Compare commits
4
Commits
v0.3.0
..
4518de5bde
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4518de5bde | ||
|
|
a028eacec2 | ||
|
|
d9de375b59 | ||
|
|
7e483d0b58 |
@@ -8,7 +8,6 @@ use App\Models\Project;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -114,41 +113,6 @@ class PermissionService
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param EloquentCollection<int, User> $users
|
||||
* @return array{directPermissions: array, teamPermissions: array, effectivePermissions: array, globalAdminUserIds: array<int, int>}
|
||||
*/
|
||||
public function projectAccessMap(EloquentCollection $users, Project $project): array
|
||||
{
|
||||
$users->loadMissing('teams');
|
||||
$directPermissions = $this->assignmentMap(User::class, $project);
|
||||
$teamPermissions = $this->assignmentMap(Team::class, $project);
|
||||
$globalPermissions = $this->assignmentMap(User::class);
|
||||
$effectivePermissions = [];
|
||||
$globalAdminUserIds = [];
|
||||
|
||||
foreach ($users as $user) {
|
||||
$permissions = $directPermissions[$user->id] ?? [];
|
||||
|
||||
foreach ($user->teams as $team) {
|
||||
$permissions = array_merge($permissions, $teamPermissions[$team->id] ?? []);
|
||||
}
|
||||
|
||||
if (in_array(Permission::GlobalAdmin->value, $globalPermissions[$user->id] ?? [], true)) {
|
||||
$permissions[] = Permission::GlobalAdmin->value;
|
||||
$globalAdminUserIds[] = $user->id;
|
||||
}
|
||||
|
||||
$expanded = $this->expandPermissions(array_map(Permission::from(...), $permissions));
|
||||
$effectivePermissions[$user->id] = Permission::values(array_values(array_filter(
|
||||
Permission::projectAssignable(),
|
||||
fn (Permission $permission): bool => in_array($permission, $expanded, true),
|
||||
)));
|
||||
}
|
||||
|
||||
return compact('directPermissions', 'teamPermissions', 'effectivePermissions', 'globalAdminUserIds');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<int, string>>
|
||||
*/
|
||||
|
||||
@@ -34,8 +34,8 @@ class BuildProjectTicketMetadataSettingsPayload
|
||||
DuplicateProjectConfiguration::COPY_TICKET_FIELDS,
|
||||
DuplicateProjectConfiguration::COPY_TICKET_LINK_TYPES,
|
||||
]),
|
||||
'ticketFieldDefinitions' => TicketFieldDefinitionResource::collection($project->ticketFieldDefinitions()->withCount('ticketFieldValues')->get())->resolve(),
|
||||
'ticketLinkTypes' => TicketLinkTypeResource::collection($project->ticketLinkTypes()->withCount('ticketLinks')->get())->resolve(),
|
||||
'ticketFieldDefinitions' => TicketFieldDefinitionResource::collection($project->ticketFieldDefinitions()->get())->resolve(),
|
||||
'ticketLinkTypes' => TicketLinkTypeResource::collection($project->ticketLinkTypes()->get())->resolve(),
|
||||
'ticketPriorities' => TicketPriorityResource::collection($project->ticketPriorities()->get())->resolve(),
|
||||
'ticketTypes' => TicketTypeResource::collection($project->ticketTypes()->withCount('tickets')->get())->resolve(),
|
||||
];
|
||||
|
||||
@@ -182,22 +182,25 @@ class ProjectController extends Controller
|
||||
403,
|
||||
);
|
||||
|
||||
$users = User::query()->orderBy('name')->get();
|
||||
|
||||
return Inertia::render('projects/Access', [
|
||||
...$this->permissionService->projectAccessMap($users, $project),
|
||||
'directPermissions' => $this->permissionService->assignmentMap(User::class, $project),
|
||||
'permissionOptions' => PermissionOptionResource::collection(
|
||||
$this->permissionService->permissionOptions(User::class, Project::class),
|
||||
)->resolve(),
|
||||
'project' => ProjectResource::make($project)->resolve(),
|
||||
'status' => session('status'),
|
||||
'teamPermissions' => $this->permissionService->assignmentMap(Team::class, $project),
|
||||
'teams' => TeamResource::collection(
|
||||
Team::query()
|
||||
->with('users')
|
||||
->orderBy('name')
|
||||
->get(),
|
||||
)->resolve(),
|
||||
'users' => UserResource::collection($users)->resolve(),
|
||||
'users' => UserResource::collection(
|
||||
User::query()
|
||||
->orderBy('name')
|
||||
->get(),
|
||||
)->resolve(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -217,9 +220,7 @@ class ProjectController extends Controller
|
||||
|
||||
$this->permissionService->sync($user, $project, $permissions);
|
||||
|
||||
return ($this->permissionService->allows($request->user(), Permission::ProjectAdmin, $project)
|
||||
? to_route('projects.access', $project)
|
||||
: to_route('projects.index'))
|
||||
return to_route('projects.access', $project)
|
||||
->with('status', "{$user->name} permissions were updated.");
|
||||
}
|
||||
|
||||
@@ -238,9 +239,7 @@ class ProjectController extends Controller
|
||||
|
||||
$this->permissionService->sync($team, $project, $permissions);
|
||||
|
||||
return ($this->permissionService->allows($request->user(), Permission::ProjectAdmin, $project)
|
||||
? to_route('projects.access', $project)
|
||||
: to_route('projects.index'))
|
||||
return to_route('projects.access', $project)
|
||||
->with('status', "{$team->name} permissions were updated.");
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,6 @@ class UpdateProjectTemplateRequest extends FormRequest
|
||||
'copy_areas' => ['required', 'array', 'min:1'],
|
||||
'copy_areas.*' => ['string', Rule::in(DuplicateProjectConfiguration::acceptedCopyAreas())],
|
||||
'confirm_linked_projects_update' => [
|
||||
Rule::excludeIf(! $requiresImpactConfirmation),
|
||||
Rule::requiredIf($requiresImpactConfirmation),
|
||||
'accepted',
|
||||
],
|
||||
|
||||
@@ -20,7 +20,6 @@ class TicketFieldDefinitionResource extends JsonResource
|
||||
'label' => $this->label,
|
||||
'position' => $this->position,
|
||||
'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(),
|
||||
'updated_at' => $this->updated_at?->toISOString(),
|
||||
];
|
||||
|
||||
@@ -20,7 +20,6 @@ class TicketLinkTypeResource extends JsonResource
|
||||
'counterpart_label' => $this->counterpart_label ?: $this->label,
|
||||
'position' => $this->position,
|
||||
'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(),
|
||||
'updated_at' => $this->updated_at?->toISOString(),
|
||||
];
|
||||
|
||||
@@ -1,369 +0,0 @@
|
||||
<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>
|
||||
@@ -1,363 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { useForm } from '@inertiajs/vue3';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import {
|
||||
destroyPriority,
|
||||
destroyType,
|
||||
storePriority,
|
||||
storeType,
|
||||
updatePriority,
|
||||
updateType,
|
||||
} 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 { 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 { TicketPriorityModel } from '@/types/models/ticket-priority-model';
|
||||
import type { TicketTypeModel } from '@/types/models/ticket-type-model';
|
||||
import type { MetadataEditorTarget } from '@/types/project-metadata';
|
||||
|
||||
const props = defineProps<{
|
||||
projectId: number;
|
||||
target: MetadataEditorTarget | null;
|
||||
types: TicketTypeModel[];
|
||||
priorities: TicketPriorityModel[];
|
||||
}>();
|
||||
const emit = defineEmits<{ close: [] }>();
|
||||
const { t } = useLocalization();
|
||||
const form = useForm({
|
||||
name: '',
|
||||
position: 1,
|
||||
is_parent: false,
|
||||
replacement_ticket_type_id: '',
|
||||
});
|
||||
const protection = useFormProtection(form, {
|
||||
isDirty: () => props.target !== null && form.isDirty,
|
||||
});
|
||||
const deletionError = ref<string | null>(null);
|
||||
const deleting = computed(() => props.target?.action === 'delete');
|
||||
const entries = computed(() =>
|
||||
props.target?.kind === 'type' ? props.types : props.priorities,
|
||||
);
|
||||
const replacements = computed(() =>
|
||||
props.types.filter((type) => type.id !== props.target?.entry?.id),
|
||||
);
|
||||
const needsReplacement = computed(
|
||||
() =>
|
||||
deleting.value &&
|
||||
props.target?.kind === 'type' &&
|
||||
(props.target.entry?.tickets_count ?? 0) > 0,
|
||||
);
|
||||
const title = computed(() => {
|
||||
const target = props.target;
|
||||
if (!target) {
|
||||
return '';
|
||||
}
|
||||
if (target.action === 'create') {
|
||||
return t(
|
||||
target.kind === 'type'
|
||||
? 'metadata.addType'
|
||||
: 'metadata.addPriority',
|
||||
);
|
||||
}
|
||||
return t(
|
||||
target.action === 'edit'
|
||||
? 'metadata.editEntry'
|
||||
: 'metadata.deleteEntry',
|
||||
{ name: target.entry.name },
|
||||
);
|
||||
});
|
||||
watch(
|
||||
() => props.target,
|
||||
(target) => {
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
form.defaults({
|
||||
name: target.entry?.name ?? '',
|
||||
position: target.entry?.position ?? 1,
|
||||
is_parent: target.entry?.is_parent ?? false,
|
||||
replacement_ticket_type_id: '',
|
||||
});
|
||||
form.resetAndClearErrors();
|
||||
protection.clearFeedback();
|
||||
deletionError.value = null;
|
||||
},
|
||||
{ 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 ||
|
||||
(deleting.value && entries.value.length <= 1)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const route =
|
||||
target.kind === 'type'
|
||||
? target.action === 'create'
|
||||
? storeType(props.projectId)
|
||||
: target.action === 'edit'
|
||||
? updateType([props.projectId, target.entry.id])
|
||||
: destroyType([props.projectId, target.entry.id])
|
||||
: target.action === 'create'
|
||||
? storePriority(props.projectId)
|
||||
: target.action === 'edit'
|
||||
? updatePriority([props.projectId, target.entry.id])
|
||||
: destroyPriority([props.projectId, target.entry.id]);
|
||||
form.transform((data) =>
|
||||
target.action === 'delete'
|
||||
? target.kind === 'type'
|
||||
? {
|
||||
replacement_ticket_type_id:
|
||||
data.replacement_ticket_type_id || null,
|
||||
}
|
||||
: {}
|
||||
: target.action === 'edit'
|
||||
? { name: data.name, position: data.position }
|
||||
: target.kind === 'type'
|
||||
? { name: data.name, is_parent: data.is_parent }
|
||||
: { name: data.name },
|
||||
);
|
||||
deletionError.value = null;
|
||||
protection.submit(route, {
|
||||
preserveScroll: true,
|
||||
onSuccess: (page) => {
|
||||
if (page.props.error) {
|
||||
deletionError.value =
|
||||
typeof page.props.status === 'string'
|
||||
? page.props.status
|
||||
: t('common.error');
|
||||
return;
|
||||
}
|
||||
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
|
||||
? target?.kind === 'type'
|
||||
? 'metadata.deleteTypeHelp'
|
||||
: 'metadata.deletePriorityHelp'
|
||||
: 'metadata.editHelp',
|
||||
)
|
||||
}}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form
|
||||
class="grid min-w-0 gap-4"
|
||||
:aria-busy="form.processing"
|
||||
@submit.prevent="submit"
|
||||
>
|
||||
<FormFeedback
|
||||
:state="deletionError ? 'idle' : protection.state.value"
|
||||
@retry="submit"
|
||||
/>
|
||||
<p
|
||||
v-if="deletionError"
|
||||
role="alert"
|
||||
class="text-destructive text-sm"
|
||||
>
|
||||
{{ deletionError }}
|
||||
</p>
|
||||
<fieldset
|
||||
:disabled="form.processing"
|
||||
class="grid min-w-0 gap-4"
|
||||
>
|
||||
<template v-if="!deleting">
|
||||
<div class="grid gap-2">
|
||||
<Label for="metadata-name">{{
|
||||
t('metadata.name')
|
||||
}}</Label>
|
||||
<Input
|
||||
id="metadata-name"
|
||||
v-model="form.name"
|
||||
name="name"
|
||||
required
|
||||
maxlength="255"
|
||||
:aria-invalid="!!form.errors.name"
|
||||
aria-describedby="metadata-name-error"
|
||||
/>
|
||||
<InputError
|
||||
id="metadata-name-error"
|
||||
:message="form.errors.name"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="target?.action === 'edit'"
|
||||
class="grid gap-2"
|
||||
>
|
||||
<Label for="metadata-position">{{
|
||||
t('workflow.position')
|
||||
}}</Label>
|
||||
<Input
|
||||
id="metadata-position"
|
||||
v-model="form.position"
|
||||
name="position"
|
||||
type="number"
|
||||
min="1"
|
||||
:max="entries.length"
|
||||
step="1"
|
||||
required
|
||||
:aria-invalid="!!form.errors.position"
|
||||
aria-describedby="metadata-position-help metadata-position-error"
|
||||
/>
|
||||
<p
|
||||
id="metadata-position-help"
|
||||
class="text-caption text-muted-foreground"
|
||||
>
|
||||
{{
|
||||
t('metadata.positionHelp', {
|
||||
count: entries.length,
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
<InputError
|
||||
id="metadata-position-error"
|
||||
:message="form.errors.position"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="target?.kind === 'type'" class="grid gap-2">
|
||||
<Label
|
||||
v-if="target.action === 'create'"
|
||||
for="metadata-parent"
|
||||
class="flex items-center gap-3"
|
||||
>
|
||||
<Checkbox
|
||||
id="metadata-parent"
|
||||
v-model="form.is_parent"
|
||||
name="is_parent"
|
||||
:invalid="!!form.errors.is_parent"
|
||||
aria-describedby="metadata-parent-help metadata-parent-error"
|
||||
/>{{ t('metadata.allowChildren') }}
|
||||
</Label>
|
||||
<p v-else class="text-sm font-medium">
|
||||
{{
|
||||
t(
|
||||
form.is_parent
|
||||
? 'metadata.parentType'
|
||||
: 'metadata.standardType',
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<p
|
||||
id="metadata-parent-help"
|
||||
class="text-caption text-muted-foreground"
|
||||
>
|
||||
{{ t('metadata.parentFixed') }}
|
||||
</p>
|
||||
<InputError
|
||||
id="metadata-parent-error"
|
||||
:message="form.errors.is_parent"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="needsReplacement" class="grid gap-2">
|
||||
<p class="text-sm">
|
||||
{{
|
||||
t('metadata.replacementHelp', {
|
||||
count: target?.entry?.tickets_count ?? 0,
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
<Label for="metadata-replacement">{{
|
||||
t('metadata.replacement')
|
||||
}}</Label>
|
||||
<select
|
||||
id="metadata-replacement"
|
||||
v-model="form.replacement_ticket_type_id"
|
||||
name="replacement_ticket_type_id"
|
||||
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.replacement_ticket_type_id
|
||||
"
|
||||
aria-describedby="metadata-replacement-help metadata-replacement-error"
|
||||
>
|
||||
<option value="">
|
||||
{{ t('metadata.chooseType') }}
|
||||
</option>
|
||||
<option
|
||||
v-for="entry in replacements"
|
||||
:key="entry.id"
|
||||
:value="entry.id"
|
||||
>
|
||||
{{ entry.name
|
||||
}}{{
|
||||
entry.is_parent
|
||||
? ` (${t('metadata.parentType')})`
|
||||
: ''
|
||||
}}
|
||||
</option>
|
||||
</select>
|
||||
<p
|
||||
id="metadata-replacement-help"
|
||||
class="text-caption text-muted-foreground"
|
||||
>
|
||||
{{ t('metadata.parentReplacement') }}
|
||||
</p>
|
||||
</div>
|
||||
<InputError
|
||||
id="metadata-replacement-error"
|
||||
:message="form.errors.replacement_ticket_type_id"
|
||||
/>
|
||||
</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"
|
||||
:disabled="deleting && entries.length <= 1"
|
||||
>{{
|
||||
deleting
|
||||
? t('workflow.confirmDelete')
|
||||
: target?.action === 'create'
|
||||
? title
|
||||
: t('common.save')
|
||||
}}</Button
|
||||
>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -1,147 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { LockKeyhole, Pencil, Plus, Trash2 } from 'lucide-vue-next';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import type {
|
||||
MetadataEditorTarget,
|
||||
MetadataEntry,
|
||||
MetadataKind,
|
||||
} from '@/types/project-metadata';
|
||||
|
||||
defineProps<{ kind: MetadataKind; entries: MetadataEntry[] }>();
|
||||
defineEmits<{ edit: [target: MetadataEditorTarget] }>();
|
||||
const { t } = useLocalization();
|
||||
</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-${kind}`"
|
||||
>
|
||||
<header class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 :id="`metadata-${kind}`" class="font-semibold">
|
||||
{{
|
||||
t(
|
||||
kind === 'type'
|
||||
? 'metadata.types'
|
||||
: 'metadata.priorities',
|
||||
)
|
||||
}}
|
||||
</h3>
|
||||
<Badge variant="secondary">{{ entries.length }}</Badge>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
class="w-full sm:w-auto"
|
||||
@click="$emit('edit', { kind, action: 'create' })"
|
||||
>
|
||||
<Plus class="size-4" aria-hidden="true" />{{
|
||||
t(
|
||||
kind === 'type'
|
||||
? 'metadata.addType'
|
||||
: 'metadata.addPriority',
|
||||
)
|
||||
}}
|
||||
</Button>
|
||||
</header>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
{{
|
||||
t(
|
||||
kind === 'type'
|
||||
? 'metadata.typesDescription'
|
||||
: 'metadata.prioritiesDescription',
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<ol
|
||||
class="divide-y"
|
||||
:aria-label="
|
||||
t(kind === 'type' ? 'metadata.types' : 'metadata.priorities')
|
||||
"
|
||||
>
|
||||
<li
|
||||
v-for="entry in entries"
|
||||
:key="entry.id"
|
||||
class="flex min-w-0 flex-wrap items-center gap-3 py-4"
|
||||
:data-metadata-entry="`${kind}-${entry.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: entry.position,
|
||||
})
|
||||
"
|
||||
>{{ entry.position }}</span
|
||||
>
|
||||
<div class="grid min-w-0 flex-1 basis-28 gap-1">
|
||||
<p class="break-words font-medium">{{ entry.name }}</p>
|
||||
<div
|
||||
v-if="kind === 'type'"
|
||||
class="text-caption text-muted-foreground flex flex-wrap items-center gap-2"
|
||||
>
|
||||
<Badge variant="outline">{{
|
||||
t(
|
||||
entry.is_parent
|
||||
? 'metadata.parentType'
|
||||
: 'metadata.standardType',
|
||||
)
|
||||
}}</Badge>
|
||||
<span>{{
|
||||
t('metadata.assignedTickets', {
|
||||
count: entry.tickets_count ?? 0,
|
||||
})
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex shrink-0 gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
:aria-label="
|
||||
t('metadata.editEntry', { name: entry.name })
|
||||
"
|
||||
@click="$emit('edit', { kind, action: 'edit', entry })"
|
||||
><Pencil class="size-4" aria-hidden="true"
|
||||
/></Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="text-destructive"
|
||||
:disabled="entries.length <= 1"
|
||||
:aria-label="
|
||||
t('metadata.deleteEntry', { name: entry.name })
|
||||
"
|
||||
:aria-describedby="
|
||||
entries.length <= 1
|
||||
? `metadata-${kind}-protection`
|
||||
: undefined
|
||||
"
|
||||
@click="
|
||||
$emit('edit', { kind, action: 'delete', entry })
|
||||
"
|
||||
><Trash2 class="size-4" aria-hidden="true"
|
||||
/></Button>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
<p
|
||||
v-if="entries.length <= 1"
|
||||
:id="`metadata-${kind}-protection`"
|
||||
class="text-caption text-muted-foreground flex items-start gap-2"
|
||||
>
|
||||
<LockKeyhole class="size-4 shrink-0" aria-hidden="true" />{{
|
||||
t(
|
||||
kind === 'type'
|
||||
? 'metadata.lastType'
|
||||
: 'metadata.lastPriority',
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,632 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { useForm } from '@inertiajs/vue3';
|
||||
import { computed, watch } from 'vue';
|
||||
import {
|
||||
destroy,
|
||||
store,
|
||||
update,
|
||||
} from '@/actions/App/Http/Controllers/ProjectNotificationSettingsController';
|
||||
import FormFeedback from '@/components/FormFeedback.vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
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 { notificationLabel } from '@/lib/notification-labels';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import {
|
||||
serializeNotificationFilters,
|
||||
supportsFilterValue,
|
||||
} from '@/lib/notification-filters';
|
||||
import type { ProjectNotificationSettingsPageProps } from '@/types';
|
||||
import type {
|
||||
NotificationEditorTarget,
|
||||
NotificationFilterFormState,
|
||||
} from '@/types/project-notifications';
|
||||
|
||||
const props = defineProps<{
|
||||
target: NotificationEditorTarget | null;
|
||||
settings: ProjectNotificationSettingsPageProps;
|
||||
}>();
|
||||
const emit = defineEmits<{ close: [] }>();
|
||||
const { t } = useLocalization();
|
||||
const form = useForm({
|
||||
name: '',
|
||||
event_type: '',
|
||||
channel: '',
|
||||
enabled: true,
|
||||
delivery_url: '',
|
||||
message_template: '',
|
||||
allowed_mentions: [] as string[],
|
||||
filters: [] as NotificationFilterFormState[],
|
||||
});
|
||||
const protection = useFormProtection(form, {
|
||||
isDirty: () => props.target !== null && form.isDirty,
|
||||
});
|
||||
const deleting = computed(() => props.target?.action === 'delete');
|
||||
const title = computed(() =>
|
||||
t(
|
||||
deleting.value
|
||||
? 'notifications.delete'
|
||||
: props.target?.action === 'edit'
|
||||
? 'notifications.edit'
|
||||
: 'notifications.create',
|
||||
),
|
||||
);
|
||||
const inputClass =
|
||||
'border-input bg-background focus-visible:ring-ring min-h-11 w-full min-w-0 rounded-md border px-3 py-2 text-sm focus-visible:ring-2';
|
||||
let nextFilterId = 0;
|
||||
watch(
|
||||
() => props.target,
|
||||
(target) => {
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
const entry = target.entry;
|
||||
const mentions = entry?.channel_configuration.allowed_mentions;
|
||||
form.defaults({
|
||||
name: entry?.name ?? '',
|
||||
event_type:
|
||||
entry?.event_type ??
|
||||
props.settings.eventTypeOptions[0]?.value ??
|
||||
'',
|
||||
channel:
|
||||
entry?.channel ??
|
||||
props.settings.channelOptions.find(
|
||||
(option) => option.value === 'webhook',
|
||||
)?.value ??
|
||||
props.settings.channelOptions[0]?.value ??
|
||||
'',
|
||||
enabled: entry?.enabled ?? true,
|
||||
delivery_url: '',
|
||||
message_template: entry?.message_template ?? '',
|
||||
allowed_mentions: Array.isArray(mentions)
|
||||
? mentions.filter(
|
||||
(value): value is string => typeof value === 'string',
|
||||
)
|
||||
: [],
|
||||
filters: (entry?.filters ?? []).map((filter) => ({
|
||||
id: nextFilterId++,
|
||||
attribute: filter.attribute,
|
||||
operator: filter.operator,
|
||||
value: Array.isArray(filter.value)
|
||||
? filter.value.join(', ')
|
||||
: (filter.value ?? ''),
|
||||
})),
|
||||
});
|
||||
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 addFilter(): void {
|
||||
form.filters.push({
|
||||
id: nextFilterId++,
|
||||
attribute: props.settings.filterAttributeOptions[0]?.value ?? '',
|
||||
operator: 'equals',
|
||||
value: '',
|
||||
});
|
||||
}
|
||||
function removeFilter(index: number): void {
|
||||
form.filters.splice(index, 1);
|
||||
form.clearErrors(
|
||||
...(Object.keys(form.errors).filter((key) =>
|
||||
key.startsWith('filters'),
|
||||
) as (keyof typeof form.errors)[]),
|
||||
);
|
||||
}
|
||||
function errorFor(prefix: string): string | undefined {
|
||||
return Object.entries(form.errors).find(
|
||||
([key]) => key === prefix || key.startsWith(`${prefix}.`),
|
||||
)?.[1];
|
||||
}
|
||||
function submit(): void {
|
||||
const target = props.target;
|
||||
if (!target || form.processing) {
|
||||
return;
|
||||
}
|
||||
const project = props.settings.project.id;
|
||||
const route =
|
||||
target.action === 'create'
|
||||
? store(project)
|
||||
: target.action === 'edit'
|
||||
? update([project, target.entry.id])
|
||||
: destroy([project, target.entry.id]);
|
||||
form.transform((data) =>
|
||||
deleting.value
|
||||
? {}
|
||||
: {
|
||||
name: data.name,
|
||||
event_type: data.event_type,
|
||||
channel: data.channel,
|
||||
enabled: data.enabled,
|
||||
delivery_url: data.delivery_url || null,
|
||||
channel_configuration:
|
||||
data.channel === 'discord'
|
||||
? { allowed_mentions: data.allowed_mentions }
|
||||
: {},
|
||||
message_template:
|
||||
data.channel === 'discord' ? data.message_template : null,
|
||||
filters: serializeNotificationFilters(data.filters),
|
||||
},
|
||||
);
|
||||
protection.submit(route, {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
form.resetAndClearErrors();
|
||||
emit('close');
|
||||
},
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog :open="target !== null" @update:open="close">
|
||||
<DialogContent
|
||||
class="sm:max-w-2xl"
|
||||
:show-close-button="!form.processing"
|
||||
@escape-key-down="form.processing && $event.preventDefault()"
|
||||
@interact-outside="form.processing && $event.preventDefault()"
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ title }}</DialogTitle>
|
||||
<DialogDescription>{{
|
||||
deleting
|
||||
? t('notifications.deleteHelp', {
|
||||
name: target?.entry?.name ?? '',
|
||||
})
|
||||
: t('notifications.editorHelp')
|
||||
}}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form
|
||||
class="grid min-w-0 gap-5"
|
||||
:aria-busy="form.processing"
|
||||
@submit.prevent="submit"
|
||||
>
|
||||
<FormFeedback :state="protection.state.value" @retry="submit" />
|
||||
<fieldset
|
||||
v-if="!deleting"
|
||||
:disabled="form.processing"
|
||||
class="grid min-w-0 gap-5"
|
||||
>
|
||||
<div class="grid gap-2">
|
||||
<Label for="notification-name">{{
|
||||
t('notifications.name')
|
||||
}}</Label>
|
||||
<Input
|
||||
id="notification-name"
|
||||
v-model="form.name"
|
||||
name="name"
|
||||
required
|
||||
maxlength="255"
|
||||
:aria-invalid="!!form.errors.name"
|
||||
aria-describedby="notification-name-error"
|
||||
/>
|
||||
<InputError
|
||||
id="notification-name-error"
|
||||
:message="form.errors.name"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid min-w-0 gap-4 sm:grid-cols-2">
|
||||
<div class="grid min-w-0 gap-2">
|
||||
<Label for="notification-event">{{
|
||||
t('notifications.event')
|
||||
}}</Label>
|
||||
<select
|
||||
id="notification-event"
|
||||
v-model="form.event_type"
|
||||
name="event_type"
|
||||
:class="inputClass"
|
||||
:aria-invalid="!!form.errors.event_type"
|
||||
aria-describedby="notification-event-error"
|
||||
>
|
||||
<option
|
||||
v-for="option in settings.eventTypeOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{
|
||||
notificationLabel(
|
||||
t,
|
||||
'event',
|
||||
option.value,
|
||||
option.label,
|
||||
)
|
||||
}}
|
||||
</option>
|
||||
</select>
|
||||
<InputError
|
||||
id="notification-event-error"
|
||||
:message="form.errors.event_type"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid min-w-0 gap-2">
|
||||
<Label for="notification-channel">{{
|
||||
t('notifications.channel')
|
||||
}}</Label>
|
||||
<select
|
||||
id="notification-channel"
|
||||
v-model="form.channel"
|
||||
name="channel"
|
||||
:class="inputClass"
|
||||
:aria-invalid="!!form.errors.channel"
|
||||
aria-describedby="notification-channel-error"
|
||||
>
|
||||
<option
|
||||
v-for="option in settings.channelOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{
|
||||
notificationLabel(
|
||||
t,
|
||||
'channel',
|
||||
option.value,
|
||||
option.label,
|
||||
)
|
||||
}}
|
||||
</option>
|
||||
</select>
|
||||
<InputError
|
||||
id="notification-channel-error"
|
||||
:message="form.errors.channel"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<section
|
||||
class="bg-muted/25 grid min-w-0 gap-3 rounded-lg border p-4"
|
||||
aria-labelledby="notification-delivery-title"
|
||||
>
|
||||
<h3
|
||||
id="notification-delivery-title"
|
||||
class="font-semibold"
|
||||
>
|
||||
{{ t('notifications.delivery') }}
|
||||
</h3>
|
||||
<p v-if="target?.action === 'edit'" class="text-sm">
|
||||
{{
|
||||
t(
|
||||
target.entry.has_delivery_url
|
||||
? 'notifications.keepUrl'
|
||||
: 'notifications.urlMissing',
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<Label for="notification-url">{{
|
||||
t(
|
||||
target?.action === 'edit'
|
||||
? 'notifications.replaceUrl'
|
||||
: 'notifications.url',
|
||||
)
|
||||
}}</Label>
|
||||
<Input
|
||||
id="notification-url"
|
||||
v-model="form.delivery_url"
|
||||
name="delivery_url"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
:spellcheck="false"
|
||||
:required="target?.action === 'create'"
|
||||
maxlength="2048"
|
||||
:aria-invalid="!!form.errors.delivery_url"
|
||||
aria-describedby="notification-url-help notification-url-error"
|
||||
/>
|
||||
<p
|
||||
id="notification-url-help"
|
||||
class="text-caption text-muted-foreground"
|
||||
>
|
||||
{{ t('notifications.secretHelp') }}
|
||||
</p>
|
||||
<InputError
|
||||
id="notification-url-error"
|
||||
:message="form.errors.delivery_url"
|
||||
/>
|
||||
</section>
|
||||
<section
|
||||
v-if="form.channel === 'discord'"
|
||||
class="grid min-w-0 gap-4"
|
||||
aria-labelledby="notification-message-title"
|
||||
>
|
||||
<div class="grid gap-2">
|
||||
<Label
|
||||
id="notification-message-title"
|
||||
for="notification-message"
|
||||
>{{ t('notifications.message') }}</Label
|
||||
>
|
||||
<textarea
|
||||
id="notification-message"
|
||||
v-model="form.message_template"
|
||||
name="message_template"
|
||||
rows="4"
|
||||
required
|
||||
maxlength="2000"
|
||||
:class="inputClass"
|
||||
:aria-invalid="!!form.errors.message_template"
|
||||
aria-describedby="notification-message-help notification-message-error"
|
||||
/>
|
||||
<p
|
||||
id="notification-message-help"
|
||||
class="text-caption text-muted-foreground"
|
||||
>
|
||||
{{ t('notifications.messageHelp') }}
|
||||
</p>
|
||||
<InputError
|
||||
id="notification-message-error"
|
||||
:message="form.errors.message_template"
|
||||
/>
|
||||
</div>
|
||||
<details class="rounded-lg border p-3">
|
||||
<summary
|
||||
class="focus-visible:ring-ring cursor-pointer rounded font-medium focus-visible:ring-2"
|
||||
>
|
||||
{{ t('notifications.variables') }}
|
||||
</summary>
|
||||
<dl class="mt-3 grid min-w-0 gap-3 text-sm">
|
||||
<div
|
||||
v-for="variable in settings.templateVariables"
|
||||
:key="variable.value"
|
||||
class="grid min-w-0 gap-1"
|
||||
>
|
||||
<dt class="break-all font-mono">
|
||||
{{ variable.value }}
|
||||
</dt>
|
||||
<dd class="text-muted-foreground">
|
||||
{{
|
||||
notificationLabel(
|
||||
t,
|
||||
'variable',
|
||||
variable.value.slice(1),
|
||||
variable.description,
|
||||
)
|
||||
}}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</details>
|
||||
<fieldset
|
||||
class="grid gap-2"
|
||||
aria-describedby="notification-mentions-help notification-mentions-error"
|
||||
>
|
||||
<legend class="mb-2 text-sm font-medium">
|
||||
{{ t('notifications.mentions') }}
|
||||
</legend>
|
||||
<label
|
||||
v-for="option in settings.discordAllowedMentionOptions"
|
||||
:key="option.value"
|
||||
class="flex min-h-11 items-center gap-3 rounded-lg border px-3 py-2 text-sm"
|
||||
>
|
||||
<input
|
||||
v-model="form.allowed_mentions"
|
||||
type="checkbox"
|
||||
:value="option.value"
|
||||
:aria-invalid="
|
||||
!!errorFor('channel_configuration')
|
||||
"
|
||||
class="accent-primary size-4"
|
||||
/>{{
|
||||
notificationLabel(
|
||||
t,
|
||||
'mention',
|
||||
option.value,
|
||||
option.label,
|
||||
)
|
||||
}}
|
||||
</label>
|
||||
<p
|
||||
id="notification-mentions-help"
|
||||
class="text-caption text-muted-foreground"
|
||||
>
|
||||
{{ t('notifications.mentionsHelp') }}
|
||||
</p>
|
||||
<InputError
|
||||
id="notification-mentions-error"
|
||||
:message="errorFor('channel_configuration')"
|
||||
/>
|
||||
</fieldset>
|
||||
</section>
|
||||
<section
|
||||
class="grid min-w-0 gap-3 border-t pt-4"
|
||||
aria-labelledby="notification-filters-title"
|
||||
>
|
||||
<h3
|
||||
id="notification-filters-title"
|
||||
class="font-semibold"
|
||||
>
|
||||
{{ t('notifications.filters') }}
|
||||
</h3>
|
||||
<p class="text-caption text-muted-foreground">
|
||||
{{ t('notifications.filtersHelp') }}
|
||||
</p>
|
||||
<p v-if="!form.filters.length" class="text-sm">
|
||||
{{ t('notifications.noFilters') }}
|
||||
</p>
|
||||
<fieldset
|
||||
v-for="(filter, index) in form.filters"
|
||||
:key="filter.id"
|
||||
class="bg-muted/25 grid min-w-0 gap-3 rounded-lg border p-3"
|
||||
>
|
||||
<legend class="px-1 text-sm font-medium">
|
||||
{{
|
||||
t('notifications.filterNumber', {
|
||||
number: index + 1,
|
||||
})
|
||||
}}
|
||||
</legend>
|
||||
<div class="grid min-w-0 gap-3 sm:grid-cols-2">
|
||||
<div class="grid min-w-0 gap-2">
|
||||
<Label
|
||||
:for="`filter-attribute-${filter.id}`"
|
||||
>{{
|
||||
t('notifications.attribute')
|
||||
}}</Label
|
||||
>
|
||||
<select
|
||||
:id="`filter-attribute-${filter.id}`"
|
||||
v-model="filter.attribute"
|
||||
:class="inputClass"
|
||||
:aria-invalid="
|
||||
!!errorFor(`filters.${index}`)
|
||||
"
|
||||
:aria-describedby="`filter-error-${filter.id}`"
|
||||
>
|
||||
<option
|
||||
v-for="option in settings.filterAttributeOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{
|
||||
notificationLabel(
|
||||
t,
|
||||
'attribute',
|
||||
option.value,
|
||||
option.label,
|
||||
)
|
||||
}}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="grid min-w-0 gap-2">
|
||||
<Label
|
||||
:for="`filter-operator-${filter.id}`"
|
||||
>{{
|
||||
t('notifications.operator')
|
||||
}}</Label
|
||||
>
|
||||
<select
|
||||
:id="`filter-operator-${filter.id}`"
|
||||
v-model="filter.operator"
|
||||
:class="inputClass"
|
||||
:aria-invalid="
|
||||
!!errorFor(`filters.${index}`)
|
||||
"
|
||||
:aria-describedby="`filter-error-${filter.id}`"
|
||||
>
|
||||
<option
|
||||
v-for="option in settings.filterOperatorOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{
|
||||
notificationLabel(
|
||||
t,
|
||||
'operator',
|
||||
option.value,
|
||||
option.label,
|
||||
)
|
||||
}}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="supportsFilterValue(filter.operator)"
|
||||
class="grid gap-2"
|
||||
>
|
||||
<Label :for="`filter-value-${filter.id}`">{{
|
||||
t(
|
||||
filter.operator === 'in'
|
||||
? 'notifications.values'
|
||||
: 'notifications.value',
|
||||
)
|
||||
}}</Label>
|
||||
<textarea
|
||||
:id="`filter-value-${filter.id}`"
|
||||
v-model="filter.value"
|
||||
rows="2"
|
||||
:class="inputClass"
|
||||
:aria-invalid="
|
||||
!!errorFor(`filters.${index}`)
|
||||
"
|
||||
:aria-describedby="`filter-error-${filter.id}`"
|
||||
/>
|
||||
</div>
|
||||
<InputError
|
||||
:id="`filter-error-${filter.id}`"
|
||||
:message="errorFor(`filters.${index}`)"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
class="justify-self-end"
|
||||
:aria-label="
|
||||
t('notifications.removeFilterNumber', {
|
||||
number: index + 1,
|
||||
})
|
||||
"
|
||||
@click="removeFilter(index)"
|
||||
>{{ t('notifications.removeFilter') }}</Button
|
||||
>
|
||||
</fieldset>
|
||||
<InputError :message="form.errors.filters" />
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
class="justify-self-start"
|
||||
@click="addFilter"
|
||||
>{{ t('notifications.addFilter') }}</Button
|
||||
>
|
||||
</section>
|
||||
<div class="grid gap-2 border-t pt-4">
|
||||
<Label
|
||||
for="notification-enabled"
|
||||
class="flex min-h-11 items-center gap-3"
|
||||
><Checkbox
|
||||
id="notification-enabled"
|
||||
v-model="form.enabled"
|
||||
:invalid="!!form.errors.enabled"
|
||||
aria-describedby="notification-enabled-help notification-enabled-error"
|
||||
/>{{ t('notifications.enabled') }}</Label
|
||||
>
|
||||
<p
|
||||
id="notification-enabled-help"
|
||||
class="text-caption text-muted-foreground"
|
||||
>
|
||||
{{ t('notifications.enabledHelp') }}
|
||||
</p>
|
||||
<InputError
|
||||
id="notification-enabled-error"
|
||||
:message="form.errors.enabled"
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
<InputError v-else :message="errorFor('notificationAction')" />
|
||||
<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('notifications.confirmDelete')
|
||||
: t('common.save')
|
||||
}}</Button
|
||||
>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -1,199 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { useForm } from '@inertiajs/vue3';
|
||||
import { computed, watch } from 'vue';
|
||||
import {
|
||||
updateTeamPermissions,
|
||||
updateUserPermissions,
|
||||
} from '@/actions/App/Http/Controllers/ProjectController';
|
||||
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 { useFormProtection } from '@/composables/useFormProtection';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import type { PermissionOption } from '@/types';
|
||||
import {
|
||||
projectPermissionDescriptions,
|
||||
projectPermissionLabels,
|
||||
} from '@/types/project-access';
|
||||
import type { ProjectAccessTarget } from '@/types/project-access';
|
||||
|
||||
const props = defineProps<{
|
||||
projectId: number;
|
||||
target: ProjectAccessTarget | null;
|
||||
permissionOptions: PermissionOption[];
|
||||
}>();
|
||||
const emit = defineEmits<{ close: [] }>();
|
||||
const { t } = useLocalization();
|
||||
const form = useForm({ permissions: [] as string[] });
|
||||
const protection = useFormProtection(form, {
|
||||
isDirty: () => props.target !== null && form.isDirty,
|
||||
});
|
||||
const selectionError = computed(
|
||||
() =>
|
||||
Object.entries(form.errors).find(([key]) =>
|
||||
key.startsWith('permissions'),
|
||||
)?.[1],
|
||||
);
|
||||
const options = computed(() =>
|
||||
props.permissionOptions
|
||||
.filter((option) => option.value in projectPermissionLabels)
|
||||
.map((option) => ({
|
||||
value: option.value,
|
||||
label: t(
|
||||
projectPermissionLabels[
|
||||
option.value as keyof typeof projectPermissionLabels
|
||||
],
|
||||
),
|
||||
description: t(
|
||||
projectPermissionDescriptions[
|
||||
option.value as keyof typeof projectPermissionDescriptions
|
||||
],
|
||||
),
|
||||
})),
|
||||
);
|
||||
watch(
|
||||
() => props.target,
|
||||
(target) => {
|
||||
form.defaults({ permissions: [...(target?.permissions ?? [])] });
|
||||
form.resetAndClearErrors();
|
||||
protection.clearFeedback();
|
||||
},
|
||||
);
|
||||
function close(open: boolean): void {
|
||||
if (open || form.processing || !protection.confirmLeave()) {
|
||||
return;
|
||||
}
|
||||
form.resetAndClearErrors();
|
||||
emit('close');
|
||||
}
|
||||
function submit(): void {
|
||||
if (!props.target || form.processing) {
|
||||
return;
|
||||
}
|
||||
const route =
|
||||
props.target.kind === 'user'
|
||||
? updateUserPermissions([props.projectId, props.target.id])
|
||||
: updateTeamPermissions([props.projectId, props.target.id]);
|
||||
protection.submit(route, {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
form.defaults();
|
||||
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">{{
|
||||
t('projectAccess.editFor', { name: target?.name ?? '' })
|
||||
}}</DialogTitle>
|
||||
<DialogDescription>{{
|
||||
t(
|
||||
target?.kind === 'team'
|
||||
? 'projectAccess.teamHelp'
|
||||
: 'projectAccess.directHelp',
|
||||
)
|
||||
}}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form class="grid min-w-0 gap-5" @submit.prevent="submit">
|
||||
<FormFeedback
|
||||
:state="protection.state.value"
|
||||
:title="
|
||||
protection.responseStatus.value === 403
|
||||
? t('projectAccess.forbidden')
|
||||
: undefined
|
||||
"
|
||||
:description="
|
||||
protection.responseStatus.value === 403
|
||||
? t('projectAccess.forbiddenHelp')
|
||||
: undefined
|
||||
"
|
||||
:retryable="protection.responseStatus.value !== 403"
|
||||
@retry="submit"
|
||||
/>
|
||||
<fieldset
|
||||
:disabled="form.processing"
|
||||
class="grid min-w-0 gap-3"
|
||||
:aria-invalid="!!selectionError"
|
||||
aria-describedby="project-permission-error"
|
||||
>
|
||||
<legend class="mb-3 text-sm font-semibold">
|
||||
{{
|
||||
t(
|
||||
target?.kind === 'team'
|
||||
? 'projectAccess.teamPermissions'
|
||||
: 'projectAccess.directPermissions',
|
||||
)
|
||||
}}
|
||||
</legend>
|
||||
<label
|
||||
v-for="option in options"
|
||||
:key="option.value"
|
||||
class="hover:bg-accent flex cursor-pointer items-start gap-3 rounded-lg border p-3"
|
||||
>
|
||||
<input
|
||||
v-model="form.permissions"
|
||||
type="checkbox"
|
||||
:value="option.value"
|
||||
:aria-label="option.label"
|
||||
class="accent-primary focus-visible:ring-ring mt-0.5 size-5 shrink-0 focus-visible:ring-2"
|
||||
/>
|
||||
<span class="grid gap-1"
|
||||
><span class="text-sm font-medium">{{
|
||||
option.label
|
||||
}}</span
|
||||
><span class="text-muted-foreground text-xs">{{
|
||||
option.description
|
||||
}}</span></span
|
||||
>
|
||||
</label>
|
||||
<InputError
|
||||
id="project-permission-error"
|
||||
:message="selectionError"
|
||||
/>
|
||||
</fieldset>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
{{ t('projectAccess.clearHelp') }}
|
||||
</p>
|
||||
<p
|
||||
v-if="target?.globalAdmin"
|
||||
class="bg-accent text-accent-foreground rounded-lg p-3 text-sm"
|
||||
>
|
||||
{{ t('projectAccess.globalHelp') }}
|
||||
</p>
|
||||
<p class="text-muted-foreground text-xs">
|
||||
{{ t('projectAccess.selfHelp') }}
|
||||
</p>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
:disabled="form.processing"
|
||||
@click="close(false)"
|
||||
>{{ t('common.cancel') }}</Button
|
||||
>
|
||||
<Button type="submit" :disabled="form.processing">{{
|
||||
t('projectAccess.save')
|
||||
}}</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -1,26 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import { projectPermissionLabels } from '@/types/project-access';
|
||||
|
||||
defineProps<{ permissions: string[]; emptyLabel: string }>();
|
||||
const { t } = useLocalization();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<template
|
||||
v-for="(label, value) in projectPermissionLabels"
|
||||
:key="value"
|
||||
>
|
||||
<Badge v-if="permissions.includes(value)" variant="secondary">{{
|
||||
t(label)
|
||||
}}</Badge>
|
||||
</template>
|
||||
<span
|
||||
v-if="permissions.length === 0"
|
||||
class="text-muted-foreground text-sm"
|
||||
>{{ emptyLabel }}</span
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,347 +0,0 @@
|
||||
<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>
|
||||
@@ -1,101 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { useForm } from '@inertiajs/vue3';
|
||||
import { watch } from 'vue';
|
||||
import { update } 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 { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useFormProtection } from '@/composables/useFormProtection';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import type { ProjectModel } from '@/types/models/project-model';
|
||||
|
||||
const props = defineProps<{ project: ProjectModel }>();
|
||||
const { t } = useLocalization();
|
||||
const form = useForm({
|
||||
show_author: props.project.show_author,
|
||||
show_story_points: props.project.show_story_points,
|
||||
});
|
||||
const protection = useFormProtection(form);
|
||||
watch(
|
||||
() => [props.project.show_author, props.project.show_story_points],
|
||||
() => {
|
||||
form.defaults({
|
||||
show_author: props.project.show_author,
|
||||
show_story_points: props.project.show_story_points,
|
||||
});
|
||||
form.resetAndClearErrors();
|
||||
},
|
||||
);
|
||||
function submit(): void {
|
||||
protection.submit(update(props.project.id), {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => form.defaults(),
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form
|
||||
class="bg-card grid min-w-0 gap-4 rounded-xl border p-4 sm:p-6"
|
||||
:aria-busy="form.processing"
|
||||
@submit.prevent="submit"
|
||||
>
|
||||
<h3 class="font-semibold">{{ t('metadata.display') }}</h3>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
{{ t('metadata.displayDescription') }}
|
||||
</p>
|
||||
<FormFeedback :state="protection.state.value" @retry="submit" />
|
||||
<fieldset :disabled="form.processing" class="grid gap-4">
|
||||
<div
|
||||
v-for="option in ['show_author', 'show_story_points'] as const"
|
||||
:key="option"
|
||||
class="grid gap-2"
|
||||
>
|
||||
<Label
|
||||
:for="option"
|
||||
class="bg-muted/35 flex min-h-14 cursor-pointer items-start gap-3 rounded-lg border p-4"
|
||||
>
|
||||
<Checkbox
|
||||
:id="option"
|
||||
v-model="form[option]"
|
||||
:name="option"
|
||||
:invalid="!!form.errors[option]"
|
||||
:aria-describedby="`${option}-help ${option}-error`"
|
||||
/>
|
||||
<span class="grid gap-1">
|
||||
<span>{{
|
||||
t(
|
||||
option === 'show_author'
|
||||
? 'metadata.author'
|
||||
: 'metadata.storyPoints',
|
||||
)
|
||||
}}</span>
|
||||
<span
|
||||
:id="`${option}-help`"
|
||||
class="text-muted-foreground text-sm font-normal"
|
||||
>{{
|
||||
t(
|
||||
option === 'show_author'
|
||||
? 'metadata.authorHelp'
|
||||
: 'metadata.storyPointsHelp',
|
||||
)
|
||||
}}</span
|
||||
>
|
||||
</span>
|
||||
</Label>
|
||||
<InputError
|
||||
:id="`${option}-error`"
|
||||
:message="form.errors[option]"
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
<Button
|
||||
type="submit"
|
||||
class="w-full sm:w-fit"
|
||||
:loading="form.processing"
|
||||
>{{ t('common.save') }}</Button
|
||||
>
|
||||
</form>
|
||||
</template>
|
||||
@@ -1,267 +0,0 @@
|
||||
<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>
|
||||
@@ -1,142 +0,0 @@
|
||||
<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>
|
||||
@@ -1,269 +0,0 @@
|
||||
<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>
|
||||
@@ -1,135 +0,0 @@
|
||||
<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>
|
||||
@@ -1,282 +1,91 @@
|
||||
<script setup lang="ts">
|
||||
import { useForm } from '@inertiajs/vue3';
|
||||
import { ArrowRightLeft } from 'lucide-vue-next';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { Form } from '@inertiajs/vue3';
|
||||
import { updateStatusTransitions } from '@/actions/App/Http/Controllers/ProjectBoardSettingsController';
|
||||
import FormFeedback from '@/components/FormFeedback.vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useFormProtection } from '@/composables/useFormProtection';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import { ticketStatusLabel } from '@/lib/tickets';
|
||||
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
|
||||
|
||||
const props = defineProps<{
|
||||
projectId: number;
|
||||
statuses: TicketStatusModel[];
|
||||
}>();
|
||||
defineProps<{ projectId: number; statuses: TicketStatusModel[] }>();
|
||||
const { t } = useLocalization();
|
||||
const sourceId = ref(props.statuses[0]?.id ?? null);
|
||||
const source = computed(() =>
|
||||
props.statuses.find((status) => status.id === sourceId.value),
|
||||
);
|
||||
const targets = computed(() =>
|
||||
props.statuses.filter((status) => status.id !== sourceId.value),
|
||||
);
|
||||
const savedTargets = computed(
|
||||
() =>
|
||||
source.value?.allowed_target_statuses
|
||||
?.map((status) => status.id)
|
||||
.sort((a, b) => a - b) ?? [],
|
||||
);
|
||||
const form = useForm({ allowed_ticket_status_ids: [...savedTargets.value] });
|
||||
const protection = useFormProtection(form);
|
||||
const saved = ref(false);
|
||||
const hasErrors = computed(() => Object.keys(form.errors).length > 0);
|
||||
|
||||
function reset(): void {
|
||||
saved.value = false;
|
||||
form.defaults({ allowed_ticket_status_ids: [...savedTargets.value] });
|
||||
form.resetAndClearErrors();
|
||||
protection.clearFeedback();
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.statuses,
|
||||
() => {
|
||||
if (!source.value) {
|
||||
sourceId.value = props.statuses[0]?.id ?? null;
|
||||
reset();
|
||||
} else if (!form.isDirty && !form.processing) {
|
||||
reset();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
function selectSource(event: Event): void {
|
||||
const select = event.target as HTMLSelectElement;
|
||||
if (form.processing || !protection.confirmLeave()) {
|
||||
select.value = String(sourceId.value);
|
||||
return;
|
||||
}
|
||||
sourceId.value = Number(select.value);
|
||||
reset();
|
||||
}
|
||||
|
||||
function toggleTarget(id: number, event: Event): void {
|
||||
saved.value = false;
|
||||
const checked = (event.target as HTMLInputElement).checked;
|
||||
form.allowed_ticket_status_ids = checked
|
||||
? [...form.allowed_ticket_status_ids, id].sort((a, b) => a - b)
|
||||
: form.allowed_ticket_status_ids.filter((target) => target !== id);
|
||||
form.clearErrors();
|
||||
protection.clearFeedback();
|
||||
}
|
||||
|
||||
function submit(): void {
|
||||
if (!source.value || !form.isDirty || form.processing) {
|
||||
return;
|
||||
}
|
||||
protection.submit(
|
||||
updateStatusTransitions([props.projectId, source.value.id]),
|
||||
{
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
saved.value = true;
|
||||
form.defaults({
|
||||
allowed_ticket_status_ids: [...savedTargets.value],
|
||||
});
|
||||
form.resetAndClearErrors();
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="bg-card min-w-0 overflow-hidden rounded-xl border"
|
||||
aria-labelledby="workflow-transitions"
|
||||
>
|
||||
<header class="grid gap-3 border-b p-5 sm:p-6">
|
||||
<div class="flex items-center gap-2">
|
||||
<ArrowRightLeft
|
||||
class="text-primary size-5 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<h3 id="workflow-transitions" class="font-semibold">
|
||||
{{ t('workflow.transitions') }}
|
||||
</h3>
|
||||
</div>
|
||||
<details class="bg-card min-w-0 rounded-xl border">
|
||||
<summary
|
||||
class="focus-visible:ring-ring/40 cursor-pointer rounded-xl p-5 font-semibold outline-none focus-visible:ring-2 sm:p-6"
|
||||
>
|
||||
{{ t('workflow.transitions') }}
|
||||
</summary>
|
||||
<div class="grid min-w-0 gap-4 border-t p-5 sm:p-6">
|
||||
<p class="text-muted-foreground text-sm">
|
||||
{{ t('workflow.transitionsDescription') }}
|
||||
</p>
|
||||
</header>
|
||||
<div class="grid min-w-0 gap-5 p-5 sm:p-6">
|
||||
<p v-if="!source" class="text-muted-foreground text-sm">
|
||||
{{ t('workflow.noStatuses') }}
|
||||
</p>
|
||||
<template v-else>
|
||||
<div class="grid min-w-0 gap-2">
|
||||
<Label for="workflow-source">{{
|
||||
t('workflow.sourceStatus')
|
||||
}}</Label>
|
||||
<select
|
||||
id="workflow-source"
|
||||
:value="sourceId"
|
||||
:disabled="form.processing"
|
||||
class="border-input bg-background focus-visible:ring-ring/40 min-h-11 w-full min-w-0 rounded-lg border px-3 py-2 text-sm outline-none focus-visible:ring-2 disabled:opacity-50"
|
||||
@change="selectSource"
|
||||
>
|
||||
<option
|
||||
v-for="status in statuses"
|
||||
:key="status.id"
|
||||
:value="status.id"
|
||||
>
|
||||
{{ ticketStatusLabel(status, '') }}
|
||||
</option>
|
||||
</select>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
{{ t('workflow.directionHelp') }}
|
||||
</p>
|
||||
</div>
|
||||
<form
|
||||
class="grid min-w-0 gap-4"
|
||||
:aria-busy="form.processing"
|
||||
@submit.prevent="submit"
|
||||
>
|
||||
<fieldset
|
||||
:disabled="form.processing"
|
||||
class="grid min-w-0 gap-3"
|
||||
aria-describedby="workflow-target-help workflow-transition-errors"
|
||||
:aria-invalid="hasErrors"
|
||||
>
|
||||
<legend class="mb-3 break-words font-medium">
|
||||
{{
|
||||
t('workflow.targetsFrom', { name: source.name })
|
||||
}}
|
||||
</legend>
|
||||
<p
|
||||
id="workflow-target-help"
|
||||
class="text-muted-foreground text-sm"
|
||||
>
|
||||
{{ t('workflow.targetHelp') }}
|
||||
</p>
|
||||
<p
|
||||
v-if="targets.length === 0"
|
||||
class="text-muted-foreground text-sm"
|
||||
>
|
||||
{{ t('workflow.noTargets') }}
|
||||
</p>
|
||||
<div v-else class="grid min-w-0 gap-2 sm:grid-cols-2">
|
||||
<label
|
||||
v-for="target in targets"
|
||||
:key="target.id"
|
||||
class="has-[:focus-visible]:ring-ring/40 flex min-h-14 min-w-0 cursor-pointer items-center gap-3 rounded-lg border p-3 has-[:focus-visible]:ring-2"
|
||||
:class="
|
||||
form.allowed_ticket_status_ids.includes(
|
||||
target.id,
|
||||
)
|
||||
? 'border-primary/40 bg-accent/40'
|
||||
: 'border-input'
|
||||
"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="allowed_ticket_status_ids[]"
|
||||
:value="target.id"
|
||||
:checked="
|
||||
form.allowed_ticket_status_ids.includes(
|
||||
target.id,
|
||||
)
|
||||
"
|
||||
class="accent-primary size-4 shrink-0"
|
||||
@change="toggleTarget(target.id, $event)"
|
||||
/>
|
||||
<span class="grid min-w-0 gap-1">
|
||||
<span
|
||||
class="break-words text-sm font-medium"
|
||||
>{{
|
||||
ticketStatusLabel(target, '')
|
||||
}}</span
|
||||
>
|
||||
<span
|
||||
class="text-muted-foreground text-xs"
|
||||
>{{
|
||||
t(
|
||||
form.allowed_ticket_status_ids.includes(
|
||||
target.id,
|
||||
)
|
||||
? 'workflow.allowed'
|
||||
: 'workflow.notAllowed',
|
||||
)
|
||||
}}</span
|
||||
>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
<Form
|
||||
v-for="source in statuses"
|
||||
:key="source.id"
|
||||
v-bind="updateStatusTransitions.form([projectId, source.id])"
|
||||
preserve-scroll
|
||||
set-defaults-on-success
|
||||
class="grid min-w-0 gap-3 rounded-lg border p-4"
|
||||
v-slot="{ errors, processing, recentlySuccessful }"
|
||||
>
|
||||
<fieldset :disabled="processing" class="grid min-w-0 gap-3">
|
||||
<legend class="mb-3 break-words font-medium">
|
||||
{{ ticketStatusLabel(source, '') }}
|
||||
</legend>
|
||||
<p
|
||||
v-if="
|
||||
targets.length > 0 &&
|
||||
form.allowed_ticket_status_ids.length === 0
|
||||
"
|
||||
v-if="statuses.length <= 1"
|
||||
class="text-muted-foreground text-sm"
|
||||
>
|
||||
{{ t('workflow.noMovesHelp') }}
|
||||
{{ t('workflow.noTargets') }}
|
||||
</p>
|
||||
<div id="workflow-transition-errors" role="alert">
|
||||
<InputError
|
||||
v-for="(message, key) in form.errors"
|
||||
:key="key"
|
||||
:message="message"
|
||||
/>
|
||||
<div v-else class="grid min-w-0 gap-2 sm:grid-cols-2">
|
||||
<label
|
||||
v-for="target in statuses.filter(
|
||||
(item) => item.id !== source.id,
|
||||
)"
|
||||
:key="target.id"
|
||||
class="border-input flex min-h-11 min-w-0 items-center gap-3 rounded-lg border px-3 py-2 text-sm"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="allowed_ticket_status_ids[]"
|
||||
:value="target.id"
|
||||
:checked="
|
||||
source.allowed_target_statuses?.some(
|
||||
(item) => item.id === target.id,
|
||||
) ?? false
|
||||
"
|
||||
class="border-input accent-primary focus-visible:ring-ring/40 size-4 shrink-0 rounded focus-visible:ring-2"
|
||||
/>
|
||||
<span class="min-w-0 break-words">{{
|
||||
ticketStatusLabel(target, '')
|
||||
}}</span>
|
||||
</label>
|
||||
</div>
|
||||
<FormFeedback
|
||||
:state="
|
||||
!saved && protection.state.value === 'success'
|
||||
? 'idle'
|
||||
: protection.state.value
|
||||
"
|
||||
@retry="submit"
|
||||
/>
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-3 border-t pt-4"
|
||||
</fieldset>
|
||||
<InputError
|
||||
v-for="(message, key) in errors"
|
||||
:key="key"
|
||||
:message="message"
|
||||
/>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
:loading="processing"
|
||||
>{{ t('common.save') }}</Button
|
||||
>
|
||||
<Badge
|
||||
:variant="form.isDirty ? 'warning' : 'secondary'"
|
||||
role="status"
|
||||
>{{
|
||||
t(
|
||||
form.isDirty
|
||||
? 'workflow.unsavedMoves'
|
||||
: 'workflow.savedMoves',
|
||||
{ count: savedTargets.length },
|
||||
)
|
||||
}}</Badge
|
||||
>
|
||||
<div
|
||||
class="flex w-full flex-wrap gap-2 sm:ml-auto sm:w-auto"
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
:disabled="!form.isDirty || form.processing"
|
||||
@click="reset"
|
||||
>{{ t('workflow.resetMoves') }}</Button
|
||||
>
|
||||
<Button
|
||||
type="submit"
|
||||
:disabled="!form.isDirty"
|
||||
:loading="form.processing"
|
||||
>{{ t('workflow.saveMoves') }}</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
<span
|
||||
v-if="recentlySuccessful"
|
||||
role="status"
|
||||
class="text-primary text-sm"
|
||||
>{{ t('feedback.saved') }}</span
|
||||
>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
</section>
|
||||
</details>
|
||||
</template>
|
||||
|
||||
@@ -1,351 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { useForm } from '@inertiajs/vue3';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import {
|
||||
store,
|
||||
updateMembers,
|
||||
updateUserPermissions,
|
||||
} from '@/actions/App/Http/Controllers/TeamController';
|
||||
import FormFeedback from '@/components/FormFeedback.vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import UserAvatar from '@/components/ui/avatar/UserAvatar.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 { TeamEditorProps } from '@/types/teams';
|
||||
|
||||
const props = defineProps<TeamEditorProps>();
|
||||
const emit = defineEmits<{ close: [] }>();
|
||||
const { t } = useLocalization();
|
||||
const search = ref('');
|
||||
const userId = ref<number | null>(null);
|
||||
const form = useForm({
|
||||
name: '',
|
||||
user_ids: [] as number[],
|
||||
permissions: [] as string[],
|
||||
});
|
||||
const protection = useFormProtection(form, {
|
||||
isDirty: () => props.target !== null && form.isDirty,
|
||||
});
|
||||
const team = computed(() =>
|
||||
props.target && props.target.kind !== 'create' ? props.target.team : null,
|
||||
);
|
||||
const visibleUsers = computed(() =>
|
||||
props.users.filter((user) =>
|
||||
`${user.name} ${user.email}`
|
||||
.toLocaleLowerCase()
|
||||
.includes(search.value.trim().toLocaleLowerCase()),
|
||||
),
|
||||
);
|
||||
const title = computed(() =>
|
||||
t(
|
||||
props.target?.kind === 'create'
|
||||
? 'teams.create'
|
||||
: props.target?.kind === 'members'
|
||||
? 'teams.manageMembers'
|
||||
: 'teams.permissions',
|
||||
),
|
||||
);
|
||||
const selectionError = computed(
|
||||
() =>
|
||||
Object.entries(form.errors).find(([key]) =>
|
||||
key.startsWith(
|
||||
props.target?.kind === 'members' ? 'user_ids' : 'permissions',
|
||||
),
|
||||
)?.[1],
|
||||
);
|
||||
const permissionLabels = {
|
||||
'team:invite': 'teams.permissionInvite',
|
||||
'team:manage': 'teams.permissionManage',
|
||||
'team:admin': 'teams.permissionAdmin',
|
||||
} as const;
|
||||
function permissionLabel(value: string, fallback: string): string {
|
||||
return value in permissionLabels
|
||||
? t(permissionLabels[value as keyof typeof permissionLabels])
|
||||
: fallback;
|
||||
}
|
||||
watch(
|
||||
() => props.target,
|
||||
() => {
|
||||
userId.value = null;
|
||||
search.value = '';
|
||||
form.defaults({
|
||||
name: '',
|
||||
user_ids: [...(team.value?.user_ids ?? [])],
|
||||
permissions: [],
|
||||
});
|
||||
form.resetAndClearErrors();
|
||||
protection.clearFeedback();
|
||||
},
|
||||
);
|
||||
function chooseUser(event: Event): void {
|
||||
const select = event.target as HTMLSelectElement;
|
||||
if (!protection.confirmLeave()) {
|
||||
select.value = String(userId.value ?? '');
|
||||
return;
|
||||
}
|
||||
userId.value = select.value ? Number(select.value) : null;
|
||||
form.defaults('permissions', [
|
||||
...(props.teamPermissions[team.value?.id ?? '']?.[userId.value ?? ''] ??
|
||||
[]),
|
||||
]);
|
||||
form.resetAndClearErrors('permissions');
|
||||
protection.clearFeedback();
|
||||
}
|
||||
function close(open: boolean): void {
|
||||
if (open || form.processing || !protection.confirmLeave()) {
|
||||
return;
|
||||
}
|
||||
form.resetAndClearErrors();
|
||||
emit('close');
|
||||
}
|
||||
function submit(): void {
|
||||
const target = props.target;
|
||||
if (
|
||||
!target ||
|
||||
form.processing ||
|
||||
(target.kind === 'permissions' && userId.value === null)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const route =
|
||||
target.kind === 'create'
|
||||
? store()
|
||||
: target.kind === 'members'
|
||||
? updateMembers(target.team.id)
|
||||
: updateUserPermissions([target.team.id, userId.value!]);
|
||||
form.transform((data) =>
|
||||
target.kind === 'create'
|
||||
? { name: data.name }
|
||||
: target.kind === 'members'
|
||||
? { user_ids: data.user_ids }
|
||||
: { permissions: data.permissions },
|
||||
);
|
||||
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>{{ title }}</DialogTitle>
|
||||
<DialogDescription class="break-words">{{
|
||||
team?.name ?? t('teams.createDescription')
|
||||
}}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form class="grid min-w-0 gap-5" @submit.prevent="submit">
|
||||
<FormFeedback
|
||||
:state="protection.state.value"
|
||||
:title="
|
||||
protection.responseStatus.value === 403
|
||||
? t('teams.forbidden')
|
||||
: undefined
|
||||
"
|
||||
:description="
|
||||
protection.responseStatus.value === 403
|
||||
? t('teams.forbiddenDescription')
|
||||
: undefined
|
||||
"
|
||||
:retryable="protection.responseStatus.value !== 403"
|
||||
@retry="submit"
|
||||
/>
|
||||
<fieldset
|
||||
:disabled="form.processing"
|
||||
class="grid min-w-0 gap-4"
|
||||
>
|
||||
<template v-if="target?.kind === 'create'">
|
||||
<div class="grid gap-2">
|
||||
<Label for="team-name">{{ t('teams.name') }}</Label>
|
||||
<Input
|
||||
id="team-name"
|
||||
v-model="form.name"
|
||||
required
|
||||
maxlength="255"
|
||||
:placeholder="t('teams.namePlaceholder')"
|
||||
:aria-invalid="!!form.errors.name"
|
||||
aria-describedby="team-name-error"
|
||||
/>
|
||||
<InputError
|
||||
id="team-name-error"
|
||||
:message="form.errors.name"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="target?.kind === 'members'">
|
||||
<p class="text-muted-foreground text-sm">
|
||||
{{ t('teams.membersHelp') }}
|
||||
</p>
|
||||
<div class="grid gap-2">
|
||||
<Label for="member-search">{{
|
||||
t('teams.searchPeople')
|
||||
}}</Label>
|
||||
<Input
|
||||
id="member-search"
|
||||
v-model="search"
|
||||
type="search"
|
||||
:placeholder="
|
||||
t('teams.searchPeoplePlaceholder')
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-muted-foreground text-sm" role="status">
|
||||
{{
|
||||
t('teams.selected', {
|
||||
count: form.user_ids.length,
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
<div
|
||||
class="grid max-h-64 gap-1 overflow-y-auto rounded-md border p-2"
|
||||
role="group"
|
||||
:aria-label="t('teams.members')"
|
||||
:aria-invalid="!!selectionError"
|
||||
aria-describedby="team-selection-error"
|
||||
>
|
||||
<label
|
||||
v-for="user in visibleUsers"
|
||||
:key="user.id"
|
||||
class="hover:bg-accent flex min-w-0 cursor-pointer items-center gap-3 rounded-md p-2"
|
||||
>
|
||||
<input
|
||||
v-model="form.user_ids"
|
||||
type="checkbox"
|
||||
:value="user.id"
|
||||
class="accent-primary focus-visible:ring-ring size-5 shrink-0 focus-visible:ring-2"
|
||||
/>
|
||||
<UserAvatar
|
||||
:user="user"
|
||||
class="size-8 shrink-0"
|
||||
/>
|
||||
<span class="grid min-w-0 gap-0.5 text-sm"
|
||||
><span class="break-words font-medium">{{
|
||||
user.name
|
||||
}}</span
|
||||
><span
|
||||
class="text-muted-foreground break-all text-xs"
|
||||
>{{ user.email }}</span
|
||||
></span
|
||||
>
|
||||
</label>
|
||||
<p
|
||||
v-if="visibleUsers.length === 0"
|
||||
class="text-muted-foreground p-3 text-sm"
|
||||
>
|
||||
{{ t('teams.noPeople') }}
|
||||
</p>
|
||||
</div>
|
||||
<InputError
|
||||
id="team-selection-error"
|
||||
:message="selectionError"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="target?.kind === 'permissions'">
|
||||
<p class="text-muted-foreground text-sm">
|
||||
{{ t('teams.permissionsHelp') }}
|
||||
</p>
|
||||
<div class="grid gap-2">
|
||||
<Label for="permission-user">{{
|
||||
t('teams.person')
|
||||
}}</Label>
|
||||
<select
|
||||
id="permission-user"
|
||||
:value="userId ?? ''"
|
||||
class="border-input bg-background focus-visible:ring-ring h-11 w-full min-w-0 rounded-md border px-3 text-sm focus-visible:ring-2"
|
||||
@change="chooseUser"
|
||||
>
|
||||
<option value="" disabled>
|
||||
{{ t('teams.choosePerson') }}
|
||||
</option>
|
||||
<option
|
||||
v-for="user in users"
|
||||
:key="user.id"
|
||||
:value="user.id"
|
||||
>
|
||||
{{ user.name }} ({{ user.email }})
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<fieldset
|
||||
v-if="userId !== null"
|
||||
class="grid gap-2"
|
||||
:aria-invalid="!!selectionError"
|
||||
aria-describedby="team-selection-error"
|
||||
>
|
||||
<legend class="mb-2 text-sm font-medium">
|
||||
{{ t('teams.directPermissions') }}
|
||||
</legend>
|
||||
<label
|
||||
v-for="option in permissionOptions"
|
||||
:key="option.value"
|
||||
class="hover:bg-accent flex cursor-pointer items-center gap-3 rounded-md border p-3 text-sm"
|
||||
>
|
||||
<input
|
||||
v-model="form.permissions"
|
||||
type="checkbox"
|
||||
:value="option.value"
|
||||
class="accent-primary focus-visible:ring-ring size-5 shrink-0 focus-visible:ring-2"
|
||||
/>
|
||||
{{
|
||||
permissionLabel(option.value, option.label)
|
||||
}}
|
||||
</label>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
{{ t('teams.permissionsClearHelp') }}
|
||||
</p>
|
||||
</fieldset>
|
||||
<InputError
|
||||
id="team-selection-error"
|
||||
:message="selectionError"
|
||||
/>
|
||||
</template>
|
||||
</fieldset>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
:disabled="form.processing"
|
||||
@click="close(false)"
|
||||
>{{ t('common.cancel') }}</Button
|
||||
>
|
||||
<Button
|
||||
type="submit"
|
||||
:disabled="
|
||||
form.processing ||
|
||||
(target?.kind === 'permissions' && userId === null)
|
||||
"
|
||||
>{{
|
||||
t(
|
||||
target?.kind === 'create'
|
||||
? 'teams.create'
|
||||
: target?.kind === 'members'
|
||||
? 'teams.saveMembers'
|
||||
: 'teams.savePermissions',
|
||||
)
|
||||
}}</Button
|
||||
>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -1,27 +0,0 @@
|
||||
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 };
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import type { NotificationActionFilterModel } from '@/types/models/notification-action-filter-model';
|
||||
import type { NotificationFilterFormState } from '@/types/project-notifications';
|
||||
|
||||
export const supportsFilterValue = (operator: string): boolean =>
|
||||
!['exists', 'not_exists'].includes(operator);
|
||||
|
||||
export function serializeNotificationFilters(
|
||||
filters: NotificationFilterFormState[],
|
||||
): NotificationActionFilterModel[] {
|
||||
return filters.map(({ attribute, operator, value }) => ({
|
||||
attribute,
|
||||
operator,
|
||||
value:
|
||||
operator === 'in'
|
||||
? value
|
||||
.split(/[\n,]/)
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean)
|
||||
: supportsFilterValue(operator)
|
||||
? value.trim()
|
||||
: null,
|
||||
}));
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import type { Localization } from '@/lib/localization';
|
||||
import { en } from '@/locales/en';
|
||||
import type { TranslationKey } from '@/types/localization';
|
||||
|
||||
export function notificationLabel(
|
||||
t: Localization['t'],
|
||||
group: string,
|
||||
value: string,
|
||||
fallback: string,
|
||||
): string {
|
||||
const key = `notifications.${group}.${value}`;
|
||||
return Object.hasOwn(en, key) ? t(key as TranslationKey) : fallback;
|
||||
}
|
||||
@@ -1,277 +1,4 @@
|
||||
export const en = {
|
||||
'projectAccess.pageTitle': '{project} · Project access',
|
||||
'projectAccess.title': 'Project access',
|
||||
'projectAccess.description':
|
||||
'Manage who can access {project} and see where their permissions come from.',
|
||||
'projectAccess.back': 'Back to project',
|
||||
'projectAccess.saved': 'Project permissions saved.',
|
||||
'projectAccess.howItWorks': 'How project access works',
|
||||
'projectAccess.additiveHelp':
|
||||
'Direct user permissions and permissions from every team are combined. Write includes read; administrator includes write and read.',
|
||||
'projectAccess.limitsHelp':
|
||||
'Project administrators can change project assignments here. Global permissions and team membership are managed separately.',
|
||||
'projectAccess.search': 'Search people or teams',
|
||||
'projectAccess.people': 'People and effective access',
|
||||
'projectAccess.effective': 'Effective permissions',
|
||||
'projectAccess.noAccess': 'No project access',
|
||||
'projectAccess.globalSource':
|
||||
'Global administrator · all project permissions',
|
||||
'projectAccess.direct': 'Direct:',
|
||||
'projectAccess.none': 'None',
|
||||
'projectAccess.viaTeam': 'Via {name}:',
|
||||
'projectAccess.editFor': 'Edit access for {name}',
|
||||
'projectAccess.editDirect': 'Edit direct access',
|
||||
'projectAccess.editTeam': 'Edit team access',
|
||||
'projectAccess.teamPermissions': 'Team permissions',
|
||||
'projectAccess.directPermissions': 'Direct permissions',
|
||||
'projectAccess.teamHelp':
|
||||
'These project permissions apply to every current and future member of the team.',
|
||||
'projectAccess.directHelp':
|
||||
'Change only this person’s direct project permissions. Team and global permissions continue to apply.',
|
||||
'projectAccess.noPeopleResults': 'No matching people',
|
||||
'projectAccess.noPeople': 'No people available',
|
||||
'projectAccess.noTeamResults': 'No matching teams',
|
||||
'projectAccess.noTeams': 'No teams yet',
|
||||
'projectAccess.noTeamsHelp':
|
||||
'Create a team and add members before assigning shared project access.',
|
||||
'projectAccess.noTeamAccess': 'No project permissions assigned',
|
||||
'projectAccess.read': 'Read',
|
||||
'projectAccess.write': 'Write',
|
||||
'projectAccess.admin': 'Administrator',
|
||||
'projectAccess.readHelp': 'View the project and its tickets.',
|
||||
'projectAccess.writeHelp': 'Create and edit tickets. Includes read access.',
|
||||
'projectAccess.adminHelp':
|
||||
'Manage project access and project administration. Includes write and read access.',
|
||||
'projectAccess.clearHelp':
|
||||
'Clear all selections to remove this assignment. Access granted by other teams, direct assignments or global administration is preserved.',
|
||||
'projectAccess.globalHelp':
|
||||
'This person is a global administrator. Changing these project assignments cannot remove their global access.',
|
||||
'projectAccess.selfHelp':
|
||||
'If you remove your own administrator access, you will return to Projects.',
|
||||
'projectAccess.save': 'Save permissions',
|
||||
'projectAccess.forbidden': 'Project access can no longer be changed',
|
||||
'projectAccess.forbiddenHelp':
|
||||
'Your administrator permission may have changed. Close this dialog and reopen the project to check your current access.',
|
||||
|
||||
'notifications.title': 'Notifications',
|
||||
'notifications.pageTitle': '{project} · Notifications',
|
||||
'notifications.description':
|
||||
'Send ticket updates to your team with webhook and Discord actions.',
|
||||
'notifications.saved': 'Notification settings saved.',
|
||||
'notifications.actions': 'Notification actions',
|
||||
'notifications.create': 'Add notification',
|
||||
'notifications.edit': 'Edit notification',
|
||||
'notifications.delete': 'Delete notification?',
|
||||
'notifications.editNamed': 'Edit {name}',
|
||||
'notifications.deleteNamed': 'Delete {name}',
|
||||
'notifications.empty': 'No notifications yet',
|
||||
'notifications.emptyHelp':
|
||||
'Add an action to send updates when a ticket event matches your rules.',
|
||||
'notifications.enabled': 'Enabled',
|
||||
'notifications.disabled': 'Disabled',
|
||||
'notifications.delivery': 'Delivery',
|
||||
'notifications.urlSaved': 'Secret URL saved',
|
||||
'notifications.urlMissing': 'No delivery URL saved',
|
||||
'notifications.filterCount': {
|
||||
one: '{count} filter · all must match',
|
||||
other: '{count} filters · all must match',
|
||||
},
|
||||
'notifications.noFiltersShort': 'All matching events',
|
||||
'notifications.noChanges': 'No saved changes yet',
|
||||
'notifications.createdAt': 'Created {date}',
|
||||
'notifications.updatedAt': 'Updated {date}',
|
||||
'notifications.deleteHelp':
|
||||
'Delete “{name}”? This action will stop sending notifications. This cannot be undone.',
|
||||
'notifications.editorHelp':
|
||||
'Choose a ticket event, a delivery channel and optional filters.',
|
||||
'notifications.name': 'Name',
|
||||
'notifications.event': 'Trigger',
|
||||
'notifications.channel': 'Channel',
|
||||
'notifications.keepUrl':
|
||||
'A secret URL is saved. Leave the field blank to keep it.',
|
||||
'notifications.replaceUrl': 'Replace delivery URL (optional)',
|
||||
'notifications.url': 'Delivery URL',
|
||||
'notifications.secretHelp':
|
||||
'URLs are stored encrypted and never shown again after saving. Enter the complete webhook URL.',
|
||||
'notifications.message': 'Discord message template',
|
||||
'notifications.messageHelp':
|
||||
'Use the variables below to include ticket details. Maximum 2,000 characters.',
|
||||
'notifications.variables': 'Available template variables',
|
||||
'notifications.mentions': 'Allowed mentions',
|
||||
'notifications.mentionsHelp':
|
||||
'Leave all unchecked to disable mentions. Only the selected mention types may notify people.',
|
||||
'notifications.filters': 'Filters',
|
||||
'notifications.filtersHelp':
|
||||
'All filters must match before this action sends a notification.',
|
||||
'notifications.noFilters':
|
||||
'No filters. This action runs for every matching event.',
|
||||
'notifications.filterNumber': 'Filter {number}',
|
||||
'notifications.attribute': 'Attribute',
|
||||
'notifications.operator': 'Operator',
|
||||
'notifications.value': 'Value',
|
||||
'notifications.values': 'Values (comma or newline separated)',
|
||||
'notifications.removeFilter': 'Remove filter',
|
||||
'notifications.removeFilterNumber': 'Remove filter {number}',
|
||||
'notifications.addFilter': 'Add filter',
|
||||
'notifications.enabledHelp':
|
||||
'Turn off this action to pause delivery while keeping its configuration.',
|
||||
'notifications.confirmDelete': 'Delete notification',
|
||||
'notifications.channel.discord': 'Discord',
|
||||
'notifications.channel.webhook': 'Webhook',
|
||||
'notifications.event.ticket_status_changed': 'Ticket status changed',
|
||||
'notifications.event.ticket_create': 'Ticket create',
|
||||
'notifications.event.ticket_assignee_changed': 'Assignee changed',
|
||||
'notifications.attribute.project.id': 'Project ID',
|
||||
'notifications.attribute.project.key': 'Project key',
|
||||
'notifications.attribute.project.name': 'Project name',
|
||||
'notifications.attribute.ticket.id': 'Ticket ID',
|
||||
'notifications.attribute.ticket.key': 'Ticket key',
|
||||
'notifications.attribute.ticket.title': 'Ticket title',
|
||||
'notifications.attribute.ticket.url': 'Ticket URL',
|
||||
'notifications.attribute.actor.id': 'Actor ID',
|
||||
'notifications.attribute.actor.name': 'Actor name',
|
||||
'notifications.attribute.assignee.from.id': 'From assignee ID',
|
||||
'notifications.attribute.assignee.from.name': 'From assignee name',
|
||||
'notifications.attribute.assignee.to.id': 'To assignee ID',
|
||||
'notifications.attribute.assignee.to.name': 'To assignee name',
|
||||
'notifications.attribute.status.from.id': 'From status ID',
|
||||
'notifications.attribute.status.from.label': 'From status label',
|
||||
'notifications.attribute.status.to.id': 'To status ID',
|
||||
'notifications.attribute.status.to.label': 'To status label',
|
||||
'notifications.operator.equals': 'Equals',
|
||||
'notifications.operator.not_equals': 'Not equals',
|
||||
'notifications.operator.exists': 'Exists',
|
||||
'notifications.operator.not_exists': 'Not exists',
|
||||
'notifications.operator.in': 'In list',
|
||||
'notifications.mention.everyone': '@everyone and @here',
|
||||
'notifications.mention.roles': 'Role mentions (@role)',
|
||||
'notifications.mention.users': 'User mentions (@username)',
|
||||
'notifications.variable.url':
|
||||
'Absolute ticket URL for the notification event.',
|
||||
'notifications.variable.ticket_key': 'Project ticket key such as CORE-42.',
|
||||
'notifications.variable.ticket_title':
|
||||
'Ticket title for the notification event.',
|
||||
'notifications.variable.project_name': 'Project name that owns the ticket.',
|
||||
'notifications.variable.status_from':
|
||||
'Origin status label for status moves; blank for ticket create and assignee changes.',
|
||||
'notifications.variable.status_to':
|
||||
'Destination or initial status label for the ticket event.',
|
||||
'notifications.variable.actor_name':
|
||||
'Actor name when the event has a known user.',
|
||||
'notifications.variable.assignee_from':
|
||||
'Previous assignee name when the event updates assignment; blank otherwise.',
|
||||
'notifications.variable.assignee_to':
|
||||
'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.',
|
||||
|
||||
'teams.eyebrow': 'People & access',
|
||||
'teams.description':
|
||||
'Bring people together in reusable teams. Manage membership and the permissions to administer each team.',
|
||||
'teams.create': 'Create team',
|
||||
'teams.createDescription':
|
||||
'Group people into a team to share project access.',
|
||||
'teams.name': 'Team name',
|
||||
'teams.namePlaceholder': 'Platform team',
|
||||
'teams.yourTeams': 'Teams you manage',
|
||||
'teams.search': 'Search teams',
|
||||
'teams.clearSearch': 'Clear search',
|
||||
'teams.empty': 'No teams to manage',
|
||||
'teams.emptyCreate': 'Create a team to start organizing your people.',
|
||||
'teams.emptyDescription':
|
||||
'No teams are available for your current permissions.',
|
||||
'teams.noResults': 'No matching teams',
|
||||
'teams.noResultsDescription': 'Try another team name or clear your search.',
|
||||
'teams.manageMembers': 'Manage members',
|
||||
'teams.manageMembersFor': 'Manage members of {name}',
|
||||
'teams.permissionsFor': 'Manage permissions for {name}',
|
||||
'teams.members': 'Members',
|
||||
'teams.noMembers': 'No members yet.',
|
||||
'teams.membersHelp':
|
||||
'Select the people who belong to this team. Changes apply when you save.',
|
||||
'teams.selected': '{count} selected',
|
||||
'teams.searchPeople': 'Find people',
|
||||
'teams.searchPeoplePlaceholder': 'Search by name or email',
|
||||
'teams.noPeople': 'No matching people.',
|
||||
'teams.saveMembers': 'Save members',
|
||||
'teams.permissions': 'Team permissions',
|
||||
'teams.permissionsHelp':
|
||||
'Direct team permissions are separate from membership. Choose any user to review or change their assignments.',
|
||||
'teams.person': 'User',
|
||||
'teams.choosePerson': 'Choose a user',
|
||||
'teams.directPermissions': 'Direct permissions',
|
||||
'teams.permissionInvite': 'Team invite',
|
||||
'teams.permissionManage': 'Team manage',
|
||||
'teams.permissionAdmin': 'Team admin',
|
||||
'teams.permissionsClearHelp':
|
||||
'Clear every checkbox to remove direct permissions. Other existing access remains in effect.',
|
||||
'teams.savePermissions': 'Save permissions',
|
||||
'teams.updated': 'Team changes saved.',
|
||||
'teams.forbidden': 'You no longer have permission',
|
||||
'teams.forbiddenDescription':
|
||||
'Your changes have not been saved. Close this dialog and reload the page to check your current access.',
|
||||
'teams.memberCount': { one: '{count} member', other: '{count} members' },
|
||||
'workflow.title': 'Board workflow',
|
||||
'workflow.pageTitle': '{project} · Board workflow',
|
||||
'workflow.description':
|
||||
@@ -319,22 +46,6 @@ export const en = {
|
||||
'workflow.transitions': 'Status moves',
|
||||
'workflow.transitionsDescription':
|
||||
'Choose exactly where each status is allowed to move next.',
|
||||
'workflow.sourceStatus': 'From status',
|
||||
'workflow.directionHelp':
|
||||
'Rules apply in one direction. Select another source to configure the return move.',
|
||||
'workflow.targetsFrom': 'Allowed destinations from {name}',
|
||||
'workflow.targetHelp':
|
||||
'Checked destinations are allowed. Unchecked destinations are forbidden. Save to apply these rules to ticket moves and board drop targets.',
|
||||
'workflow.allowed': 'Allowed',
|
||||
'workflow.notAllowed': 'Not allowed',
|
||||
'workflow.noMovesHelp':
|
||||
'With no destinations selected, tickets cannot leave this status after you save.',
|
||||
'workflow.noStatuses':
|
||||
'Add a status to configure its allowed destinations.',
|
||||
'workflow.unsavedMoves': 'Unsaved changes',
|
||||
'workflow.savedMoves': 'Saved destinations: {count}',
|
||||
'workflow.resetMoves': 'Reset changes',
|
||||
'workflow.saveMoves': 'Save moves',
|
||||
'workflow.noTargets': 'No other statuses exist yet.',
|
||||
'workflow.confirmDelete': 'Delete',
|
||||
'security.title': 'Security settings',
|
||||
@@ -427,81 +138,6 @@ export const en = {
|
||||
'common.back': 'Back',
|
||||
'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.progress': '{completed} of {total} complete',
|
||||
'criteria.empty': 'No acceptance criteria yet.',
|
||||
@@ -706,40 +342,6 @@ export const en = {
|
||||
'Inherited areas follow the linked template. Overridden areas use this project’s own configuration.',
|
||||
'projects.inheritedArea': '{name} · inherited',
|
||||
'projects.overriddenArea': '{name} · overridden',
|
||||
'welcome.metaTitle': 'Project work with Git context',
|
||||
'welcome.homeLabel': 'KanbanGit home',
|
||||
'welcome.navigation': 'Account navigation',
|
||||
'welcome.eyebrow': 'Self-hosted project management',
|
||||
'welcome.title': 'Plan the work. Keep Git context close.',
|
||||
'welcome.description':
|
||||
'Plan projects on configurable boards, keep ticket decisions visible, and connect delivery work to the repositories your team already uses.',
|
||||
'welcome.action.signIn': 'Sign in to KanbanGit',
|
||||
'welcome.action.signInShort': 'Sign in',
|
||||
'welcome.action.dashboard': 'Open dashboard',
|
||||
'welcome.action.dashboardShort': 'Dashboard',
|
||||
'welcome.actionHint': 'One workspace for boards, tickets, and Git context.',
|
||||
'welcome.preview.label': 'KanbanGit board preview',
|
||||
'welcome.preview.project': 'Product delivery board',
|
||||
'welcome.preview.connected': 'Git connected',
|
||||
'welcome.preview.backlog': 'Backlog',
|
||||
'welcome.preview.inProgress': 'In progress',
|
||||
'welcome.preview.review': 'Review',
|
||||
'welcome.preview.ticketOnboarding': 'Refine onboarding flow',
|
||||
'welcome.preview.ticketRelease': 'Plan release workflow',
|
||||
'welcome.preview.ticketRepository': 'Connect project repository',
|
||||
'welcome.preview.ticketPullRequest': 'Review pull request context',
|
||||
'welcome.features.eyebrow': 'A focused workflow',
|
||||
'welcome.features.title': 'The project context your team needs',
|
||||
'welcome.features.workflows.title': 'Configurable workflows',
|
||||
'welcome.features.workflows.description':
|
||||
'Shape project boards, backlog, statuses, and transitions around the way your team works.',
|
||||
'welcome.features.tickets.title': 'Structured ticket context',
|
||||
'welcome.features.tickets.description':
|
||||
'Keep metadata, acceptance criteria, relationships, blockers, and history with the work.',
|
||||
'welcome.features.git.title': 'Repository-aware delivery',
|
||||
'welcome.features.git.description':
|
||||
'Link project repositories and see matching branches and pull requests on the ticket.',
|
||||
'welcome.footer': 'Self-hosted project and workflow management.',
|
||||
'dashboard.eyebrow': 'Personal workspace',
|
||||
'dashboard.greeting': 'Welcome back, {name}',
|
||||
'dashboard.description':
|
||||
@@ -965,8 +567,6 @@ export const en = {
|
||||
'profile.delete.confirmAction': 'Permanently delete account',
|
||||
'common.cancel': 'Cancel',
|
||||
'common.close': 'Close',
|
||||
'common.edit': 'Edit',
|
||||
'common.remove': 'Remove',
|
||||
'common.retry': 'Try again',
|
||||
'feedback.empty': 'Nothing here yet',
|
||||
'feedback.saved': 'Changes saved',
|
||||
@@ -1258,81 +858,6 @@ export const en = {
|
||||
'inheritance.area.ticketLinkTypes': 'Ticket link types',
|
||||
'inheritance.area.repositoryConnections': 'Repository connections',
|
||||
'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':
|
||||
'Connect your personal Git account to browse repositories, branches, and pull requests you can access.',
|
||||
'gitConnections.privacy':
|
||||
@@ -1372,94 +897,4 @@ export const en = {
|
||||
'gitConnections.added': 'Git connection added.',
|
||||
'gitConnections.updated': 'Git connection updated.',
|
||||
'gitConnections.removed': 'Git connection removed.',
|
||||
'metadata.title': 'Ticket settings',
|
||||
'metadata.pageTitle': '{project} ticket settings',
|
||||
'metadata.description':
|
||||
'Manage ticket types, priorities, and the information shown on your tickets.',
|
||||
'metadata.updated': 'Ticket settings updated.',
|
||||
'metadata.types': 'Ticket types',
|
||||
'metadata.priorities': 'Priorities',
|
||||
'metadata.addType': 'Add type',
|
||||
'metadata.addPriority': 'Add priority',
|
||||
'metadata.typesDescription':
|
||||
'Define your project’s ticket types and their order. Parent types can contain child tickets.',
|
||||
'metadata.prioritiesDescription':
|
||||
'Define and order project-specific priorities. The last priority must be kept.',
|
||||
'metadata.parentType': 'Parent type',
|
||||
'metadata.standardType': 'Standard type',
|
||||
'metadata.assignedTickets': 'Assigned tickets: {count}',
|
||||
'metadata.editEntry': 'Edit {name}',
|
||||
'metadata.deleteEntry': 'Delete {name}',
|
||||
'metadata.lastType':
|
||||
'The last ticket type cannot be deleted. Add another type first.',
|
||||
'metadata.lastPriority':
|
||||
'The last priority cannot be deleted. Add another priority first.',
|
||||
'metadata.display': 'Display options',
|
||||
'metadata.displayDescription':
|
||||
'Choose which optional information is available on this project’s tickets.',
|
||||
'metadata.author': 'Show ticket author',
|
||||
'metadata.authorHelp': 'Display the author on ticket details.',
|
||||
'metadata.storyPoints': 'Enable story points',
|
||||
'metadata.storyPointsHelp': 'Allow estimates in story points on tickets.',
|
||||
'metadata.name': 'Name',
|
||||
'metadata.positionHelp':
|
||||
'Choose a position from 1 to {count}. Other entries shift automatically.',
|
||||
'metadata.allowChildren': 'Allow child tickets',
|
||||
'metadata.parentFixed':
|
||||
'Whether a type allows child tickets is fixed after creation. Its name and position remain editable.',
|
||||
'metadata.editHelp':
|
||||
'Choose a project-specific name. Changes apply to tickets using this entry.',
|
||||
'metadata.deleteTypeHelp':
|
||||
'Delete this type? Assigned tickets must be moved to a replacement type first. This action cannot be undone.',
|
||||
'metadata.deletePriorityHelp':
|
||||
'Delete this priority? All tickets assigned to this priority will also be permanently deleted. There is no replacement mapping for priorities. This action cannot be undone.',
|
||||
'metadata.replacementHelp':
|
||||
'Move {count} assigned tickets to another type before deleting this one.',
|
||||
'metadata.replacement': 'Replacement ticket type',
|
||||
'metadata.chooseType': 'Choose a type',
|
||||
'metadata.parentReplacement':
|
||||
'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;
|
||||
|
||||
+409
-218
@@ -1,245 +1,436 @@
|
||||
<script setup lang="ts">
|
||||
import { Head, Link } from '@inertiajs/vue3';
|
||||
import { ArrowRight, Columns3, GitBranch, ListChecks } from 'lucide-vue-next';
|
||||
import { computed } from 'vue';
|
||||
import AppLogo from '@/components/AppLogo.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import { dashboard, home, login } from '@/routes/index';
|
||||
|
||||
const { t } = useLocalization();
|
||||
|
||||
const features = computed(() => [
|
||||
{
|
||||
icon: Columns3,
|
||||
title: t('welcome.features.workflows.title'),
|
||||
description: t('welcome.features.workflows.description'),
|
||||
},
|
||||
{
|
||||
icon: ListChecks,
|
||||
title: t('welcome.features.tickets.title'),
|
||||
description: t('welcome.features.tickets.description'),
|
||||
},
|
||||
{
|
||||
icon: GitBranch,
|
||||
title: t('welcome.features.git.title'),
|
||||
description: t('welcome.features.git.description'),
|
||||
},
|
||||
]);
|
||||
|
||||
const previewColumns = computed(() => [
|
||||
{
|
||||
title: t('welcome.preview.backlog'),
|
||||
tickets: [
|
||||
t('welcome.preview.ticketOnboarding'),
|
||||
t('welcome.preview.ticketRelease'),
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t('welcome.preview.inProgress'),
|
||||
tickets: [t('welcome.preview.ticketRepository')],
|
||||
},
|
||||
{
|
||||
title: t('welcome.preview.review'),
|
||||
tickets: [t('welcome.preview.ticketPullRequest')],
|
||||
},
|
||||
]);
|
||||
import { dashboard, login } from '@/routes/index';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Head title="Welcome">
|
||||
<link rel="preconnect" href="https://rsms.me/" />
|
||||
<link rel="stylesheet" href="https://rsms.me/inter/inter.css" />
|
||||
</Head>
|
||||
<div
|
||||
class="bg-background text-foreground relative isolate min-h-dvh overflow-x-hidden"
|
||||
class="flex min-h-screen flex-col items-center bg-[#FDFDFC] p-6 text-[#1b1b18] lg:justify-center lg:p-8 dark:bg-[#0a0a0a]"
|
||||
>
|
||||
<Head :title="t('welcome.metaTitle')" />
|
||||
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="bg-brand/10 mask-[linear-gradient(to_bottom,black,transparent)] absolute inset-x-0 top-0 -z-10 h-[34rem]"
|
||||
/>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="bg-primary/15 absolute left-1/2 top-12 -z-10 size-80 -translate-x-1/2 rounded-full blur-3xl sm:size-[32rem]"
|
||||
/>
|
||||
|
||||
<header
|
||||
class="mx-auto flex w-full min-w-0 max-w-6xl items-center justify-between gap-3 px-4 py-4 sm:gap-4 sm:px-6 lg:px-8"
|
||||
class="not-has-[nav]:hidden mb-6 w-full max-w-[335px] text-sm lg:max-w-4xl"
|
||||
>
|
||||
<Link
|
||||
:href="home()"
|
||||
:aria-label="t('welcome.homeLabel')"
|
||||
class="focus-visible:ring-ring/35 min-w-0 rounded-md outline-none focus-visible:ring-2"
|
||||
>
|
||||
<AppLogo />
|
||||
</Link>
|
||||
|
||||
<nav :aria-label="t('welcome.navigation')" class="shrink-0">
|
||||
<Button as-child variant="outline" size="sm">
|
||||
<Link v-if="$page.props.auth.user" :href="dashboard()">
|
||||
{{ t('welcome.action.dashboardShort') }}
|
||||
<ArrowRight aria-hidden="true" />
|
||||
<nav class="flex items-center justify-end gap-4">
|
||||
<Link
|
||||
v-if="$page.props.auth.user"
|
||||
:href="dashboard()"
|
||||
class="inline-block rounded-sm border border-[#19140035] px-5 py-1.5 text-sm leading-normal text-[#1b1b18] hover:border-[#1915014a] dark:border-[#3E3E3A] dark:text-[#EDEDEC] dark:hover:border-[#62605b]"
|
||||
>
|
||||
Dashboard
|
||||
</Link>
|
||||
<template v-else>
|
||||
<Link
|
||||
:href="login()"
|
||||
class="inline-block rounded-sm border border-transparent px-5 py-1.5 text-sm leading-normal text-[#1b1b18] hover:border-[#19140035] dark:text-[#EDEDEC] dark:hover:border-[#3E3E3A]"
|
||||
>
|
||||
Log in
|
||||
</Link>
|
||||
<Link v-else :href="login()">
|
||||
{{ t('welcome.action.signInShort') }}
|
||||
<ArrowRight aria-hidden="true" />
|
||||
</Link>
|
||||
</Button>
|
||||
</template>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main class="min-w-0">
|
||||
<section
|
||||
aria-labelledby="welcome-title"
|
||||
class="mx-auto grid w-full min-w-0 max-w-6xl gap-10 px-4 pb-16 pt-12 sm:px-6 sm:pt-20 lg:px-8 lg:pt-24"
|
||||
<div
|
||||
class="duration-750 starting:opacity-0 flex w-full items-center justify-center opacity-100 transition-opacity lg:grow"
|
||||
>
|
||||
<main
|
||||
class="flex w-full max-w-[335px] flex-col-reverse overflow-hidden rounded-lg lg:max-w-4xl lg:flex-row"
|
||||
>
|
||||
<div
|
||||
class="mx-auto grid w-full min-w-0 max-w-3xl justify-items-center gap-6 text-center"
|
||||
class="flex-1 rounded-bl-lg rounded-br-lg bg-white p-6 pb-12 text-[13px] leading-[20px] shadow-[inset_0px_0px_0px_1px_rgba(26,26,0,0.16)] lg:rounded-br-none lg:rounded-tl-lg lg:p-20 dark:bg-[#161615] dark:text-[#EDEDEC] dark:shadow-[inset_0px_0px_0px_1px_#fffaed2d]"
|
||||
>
|
||||
<p
|
||||
class="border-primary/20 bg-card/80 text-primary text-label shadow-xs rounded-full border px-3 py-1.5 uppercase backdrop-blur"
|
||||
>
|
||||
{{ t('welcome.eyebrow') }}
|
||||
<h1 class="mb-1 font-medium">Let's get started</h1>
|
||||
<p class="mb-2 text-[#706f6c] dark:text-[#A1A09A]">
|
||||
Laravel has an incredibly rich ecosystem. <br />We
|
||||
suggest starting with the following.
|
||||
</p>
|
||||
<div class="grid gap-4">
|
||||
<h1
|
||||
id="welcome-title"
|
||||
class="text-balance text-4xl font-semibold leading-tight tracking-[-0.04em] sm:text-5xl lg:text-6xl"
|
||||
>
|
||||
{{ t('welcome.title') }}
|
||||
</h1>
|
||||
<p
|
||||
class="text-muted-foreground mx-auto max-w-2xl text-pretty text-base leading-7 sm:text-lg"
|
||||
>
|
||||
{{ t('welcome.description') }}
|
||||
</p>
|
||||
</div>
|
||||
<Button as-child size="lg">
|
||||
<Link v-if="$page.props.auth.user" :href="dashboard()">
|
||||
{{ t('welcome.action.dashboard') }}
|
||||
<ArrowRight aria-hidden="true" />
|
||||
</Link>
|
||||
<Link v-else :href="login()">
|
||||
{{ t('welcome.action.signIn') }}
|
||||
<ArrowRight aria-hidden="true" />
|
||||
</Link>
|
||||
</Button>
|
||||
<p class="text-caption text-muted-foreground">
|
||||
{{ t('welcome.actionHint') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<section
|
||||
:aria-label="t('welcome.preview.label')"
|
||||
class="bg-card/90 min-w-0 rounded-xl border p-3 shadow-[0_24px_80px_-32px_rgba(0,110,47,0.35)] backdrop-blur sm:p-5 dark:shadow-[0_24px_80px_-32px_rgba(74,225,118,0.22)]"
|
||||
>
|
||||
<div
|
||||
class="border-border/80 mb-3 flex min-w-0 items-center justify-between gap-3 border-b px-1 pb-3 sm:mb-5 sm:gap-4 sm:px-2 sm:pb-4"
|
||||
>
|
||||
<div class="flex shrink-0 gap-1.5" aria-hidden="true">
|
||||
<span class="bg-border size-2 rounded-full" />
|
||||
<span class="bg-border size-2 rounded-full" />
|
||||
<span class="bg-brand size-2 rounded-full" />
|
||||
</div>
|
||||
<p
|
||||
class="text-caption text-muted-foreground min-w-0 truncate"
|
||||
>
|
||||
{{ t('welcome.preview.project') }}
|
||||
</p>
|
||||
<span
|
||||
class="bg-success text-success-foreground text-label shrink-0 rounded-full px-2 py-1 uppercase"
|
||||
>
|
||||
{{ t('welcome.preview.connected') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="grid min-w-0 gap-3 md:grid-cols-3">
|
||||
<section
|
||||
v-for="column in previewColumns"
|
||||
:key="column.title"
|
||||
class="bg-muted/70 grid min-w-0 content-start gap-3 rounded-lg border p-3 sm:p-4"
|
||||
>
|
||||
<h2 class="text-caption font-semibold">
|
||||
{{ column.title }}
|
||||
</h2>
|
||||
<article
|
||||
v-for="ticket in column.tickets"
|
||||
:key="ticket"
|
||||
class="bg-card text-card-foreground shadow-xs grid min-w-0 gap-3 rounded-md border p-3"
|
||||
>
|
||||
<p class="text-body break-words font-medium">
|
||||
{{ ticket }}
|
||||
</p>
|
||||
<div
|
||||
class="flex items-center justify-between gap-3"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span
|
||||
class="bg-accent h-1.5 w-16 rounded-full"
|
||||
/>
|
||||
<span
|
||||
class="bg-brand size-5 rounded-full"
|
||||
/>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section
|
||||
aria-labelledby="welcome-features-title"
|
||||
class="border-border/80 bg-card/60 border-y"
|
||||
>
|
||||
<div
|
||||
class="mx-auto grid w-full max-w-6xl gap-8 px-4 py-14 sm:px-6 lg:px-8"
|
||||
>
|
||||
<div class="mx-auto grid max-w-2xl gap-3 text-center">
|
||||
<p class="text-label text-primary uppercase">
|
||||
{{ t('welcome.features.eyebrow') }}
|
||||
</p>
|
||||
<h2
|
||||
id="welcome-features-title"
|
||||
class="text-headline-mobile sm:text-headline"
|
||||
>
|
||||
{{ t('welcome.features.title') }}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<ul class="grid gap-4 md:grid-cols-3">
|
||||
<ul class="mb-4 flex flex-col lg:mb-6">
|
||||
<li
|
||||
v-for="feature in features"
|
||||
:key="feature.title"
|
||||
class="bg-card shadow-xs grid gap-4 rounded-lg border p-5"
|
||||
class="relative flex items-center gap-4 py-2 before:absolute before:bottom-0 before:left-[0.4rem] before:top-1/2 before:border-l before:border-[#e3e3e0] dark:before:border-[#3E3E3A]"
|
||||
>
|
||||
<span
|
||||
class="bg-accent text-primary flex size-10 items-center justify-center rounded-md"
|
||||
class="relative bg-white py-1 dark:bg-[#161615]"
|
||||
>
|
||||
<component
|
||||
:is="feature.icon"
|
||||
class="size-5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
<div class="grid gap-2">
|
||||
<h3 class="text-title">
|
||||
{{ feature.title }}
|
||||
</h3>
|
||||
<p
|
||||
class="text-muted-foreground text-pretty leading-6"
|
||||
<span
|
||||
class="flex h-3.5 w-3.5 items-center justify-center rounded-full border border-[#e3e3e0] bg-[#FDFDFC] shadow-[0px_0px_1px_0px_rgba(0,0,0,0.03),0px_1px_2px_0px_rgba(0,0,0,0.06)] dark:border-[#3E3E3A] dark:bg-[#161615]"
|
||||
>
|
||||
{{ feature.description }}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
class="h-1.5 w-1.5 rounded-full bg-[#dbdbd7] dark:bg-[#3E3E3A]"
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
<span>
|
||||
Read the
|
||||
<a
|
||||
href="https://laravel.com/docs"
|
||||
target="_blank"
|
||||
class="ml-1 inline-flex items-center space-x-1 font-medium text-[#f53003] underline underline-offset-4 dark:text-[#FF4433]"
|
||||
>
|
||||
<span>Documentation</span>
|
||||
<svg
|
||||
width="10"
|
||||
height="11"
|
||||
viewBox="0 0 10 11"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-2.5 w-2.5"
|
||||
>
|
||||
<path
|
||||
d="M7.70833 6.95834V2.79167H3.54167M2.5 8L7.5 3.00001"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="square"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</span>
|
||||
</li>
|
||||
<li
|
||||
class="relative flex items-center gap-4 py-2 before:absolute before:bottom-1/2 before:left-[0.4rem] before:top-0 before:border-l before:border-[#e3e3e0] dark:before:border-[#3E3E3A]"
|
||||
>
|
||||
<span
|
||||
class="relative bg-white py-1 dark:bg-[#161615]"
|
||||
>
|
||||
<span
|
||||
class="flex h-3.5 w-3.5 items-center justify-center rounded-full border border-[#e3e3e0] bg-[#FDFDFC] shadow-[0px_0px_1px_0px_rgba(0,0,0,0.03),0px_1px_2px_0px_rgba(0,0,0,0.06)] dark:border-[#3E3E3A] dark:bg-[#161615]"
|
||||
>
|
||||
<span
|
||||
class="h-1.5 w-1.5 rounded-full bg-[#dbdbd7] dark:bg-[#3E3E3A]"
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
<span>
|
||||
Watch video tutorials at
|
||||
<a
|
||||
href="https://laracasts.com"
|
||||
target="_blank"
|
||||
class="ml-1 inline-flex items-center space-x-1 font-medium text-[#f53003] underline underline-offset-4 dark:text-[#FF4433]"
|
||||
>
|
||||
<span>Laracasts</span>
|
||||
<svg
|
||||
width="10"
|
||||
height="11"
|
||||
viewBox="0 0 10 11"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-2.5 w-2.5"
|
||||
>
|
||||
<path
|
||||
d="M7.70833 6.95834V2.79167H3.54167M2.5 8L7.5 3.00001"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="square"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
<ul class="flex gap-3 text-sm leading-normal">
|
||||
<li>
|
||||
<a
|
||||
href="https://cloud.laravel.com"
|
||||
target="_blank"
|
||||
class="inline-block rounded-sm border border-black bg-[#1b1b18] px-5 py-1.5 text-sm leading-normal text-white hover:border-black hover:bg-black dark:border-[#eeeeec] dark:bg-[#eeeeec] dark:text-[#1C1C1A] dark:hover:border-white dark:hover:bg-white"
|
||||
>
|
||||
Deploy now
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<div
|
||||
class="relative -mb-px aspect-[335/364] w-full shrink-0 overflow-hidden rounded-t-lg bg-[#fff2f2] lg:-ml-px lg:mb-0 lg:aspect-auto lg:w-[438px] lg:rounded-r-lg lg:rounded-t-none dark:bg-[#1D0002]"
|
||||
>
|
||||
<!-- Laravel Logo -->
|
||||
<svg
|
||||
class="duration-750 starting:opacity-0 motion-safe:starting:translate-y-6 w-full max-w-none translate-y-0 text-[#F53003] opacity-100 transition-all dark:text-[#F61500]"
|
||||
viewBox="0 0 438 104"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M17.2036 -3H0V102.197H49.5189V86.7187H17.2036V-3Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M110.256 41.6337C108.061 38.1275 104.945 35.3731 100.905 33.3681C96.8667 31.3647 92.8016 30.3618 88.7131 30.3618C83.4247 30.3618 78.5885 31.3389 74.201 33.2923C69.8111 35.2456 66.0474 37.928 62.9059 41.3333C59.7643 44.7401 57.3198 48.6726 55.5754 53.1293C53.8287 57.589 52.9572 62.274 52.9572 67.1813C52.9572 72.1925 53.8287 76.8995 55.5754 81.3069C57.3191 85.7173 59.7636 89.6241 62.9059 93.0293C66.0474 96.4361 69.8119 99.1155 74.201 101.069C78.5885 103.022 83.4247 103.999 88.7131 103.999C92.8016 103.999 96.8667 102.997 100.905 100.994C104.945 98.9911 108.061 96.2359 110.256 92.7282V102.195H126.563V32.1642H110.256V41.6337ZM108.76 75.7472C107.762 78.4531 106.366 80.8078 104.572 82.8112C102.776 84.8161 100.606 86.4183 98.0637 87.6206C95.5202 88.823 92.7004 89.4238 89.6103 89.4238C86.5178 89.4238 83.7252 88.823 81.2324 87.6206C78.7388 86.4183 76.5949 84.8161 74.7998 82.8112C73.004 80.8078 71.6319 78.4531 70.6856 75.7472C69.7356 73.0421 69.2644 70.1868 69.2644 67.1821C69.2644 64.1758 69.7356 61.3205 70.6856 58.6154C71.6319 55.9102 73.004 53.5571 74.7998 51.5522C76.5949 49.5495 78.738 47.9451 81.2324 46.7427C83.7252 45.5404 86.5178 44.9396 89.6103 44.9396C92.7012 44.9396 95.5202 45.5404 98.0637 46.7427C100.606 47.9451 102.776 49.5487 104.572 51.5522C106.367 53.5571 107.762 55.9102 108.76 58.6154C109.756 61.3205 110.256 64.1758 110.256 67.1821C110.256 70.1868 109.756 73.0421 108.76 75.7472Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M242.805 41.6337C240.611 38.1275 237.494 35.3731 233.455 33.3681C229.416 31.3647 225.351 30.3618 221.262 30.3618C215.974 30.3618 211.138 31.3389 206.75 33.2923C202.36 35.2456 198.597 37.928 195.455 41.3333C192.314 44.7401 189.869 48.6726 188.125 53.1293C186.378 57.589 185.507 62.274 185.507 67.1813C185.507 72.1925 186.378 76.8995 188.125 81.3069C189.868 85.7173 192.313 89.6241 195.455 93.0293C198.597 96.4361 202.361 99.1155 206.75 101.069C211.138 103.022 215.974 103.999 221.262 103.999C225.351 103.999 229.416 102.997 233.455 100.994C237.494 98.9911 240.611 96.2359 242.805 92.7282V102.195H259.112V32.1642H242.805V41.6337ZM241.31 75.7472C240.312 78.4531 238.916 80.8078 237.122 82.8112C235.326 84.8161 233.156 86.4183 230.614 87.6206C228.07 88.823 225.251 89.4238 222.16 89.4238C219.068 89.4238 216.275 88.823 213.782 87.6206C211.289 86.4183 209.145 84.8161 207.35 82.8112C205.554 80.8078 204.182 78.4531 203.236 75.7472C202.286 73.0421 201.814 70.1868 201.814 67.1821C201.814 64.1758 202.286 61.3205 203.236 58.6154C204.182 55.9102 205.554 53.5571 207.35 51.5522C209.145 49.5495 211.288 47.9451 213.782 46.7427C216.275 45.5404 219.068 44.9396 222.16 44.9396C225.251 44.9396 228.07 45.5404 230.614 46.7427C233.156 47.9451 235.326 49.5487 237.122 51.5522C238.917 53.5571 240.312 55.9102 241.31 58.6154C242.306 61.3205 242.806 64.1758 242.806 67.1821C242.805 70.1868 242.305 73.0421 241.31 75.7472Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M438 -3H421.694V102.197H438V-3Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M139.43 102.197H155.735V48.2834H183.712V32.1665H139.43V102.197Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M324.49 32.1665L303.995 85.794L283.498 32.1665H266.983L293.748 102.197H314.242L341.006 32.1665H324.49Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M376.571 30.3656C356.603 30.3656 340.797 46.8497 340.797 67.1828C340.797 89.6597 356.094 104 378.661 104C391.29 104 399.354 99.1488 409.206 88.5848L398.189 80.0226C398.183 80.031 389.874 90.9895 377.468 90.9895C363.048 90.9895 356.977 79.3111 356.977 73.269H411.075C413.917 50.1328 398.775 30.3656 376.571 30.3656ZM357.02 61.0967C357.145 59.7487 359.023 43.3761 376.442 43.3761C393.861 43.3761 395.978 59.7464 396.099 61.0967H357.02Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<footer
|
||||
class="text-caption text-muted-foreground mx-auto flex w-full max-w-6xl flex-col gap-2 px-4 py-8 text-center sm:px-6 lg:px-8"
|
||||
>
|
||||
<p class="text-foreground font-semibold">KanbanGit</p>
|
||||
<p>{{ t('welcome.footer') }}</p>
|
||||
</footer>
|
||||
<!-- 13 -->
|
||||
<svg
|
||||
class="relative -ml-8 -mt-[6.6rem] w-[438px] max-w-none [--stroke-color:#1B1B18] lg:ml-0 dark:[--stroke-color:#FF750F]"
|
||||
viewBox="0 0 440 392"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g
|
||||
class="duration-750 starting:opacity-0 text-[#1B1B18] opacity-100 mix-blend-darken transition-all delay-300 dark:text-black dark:mix-blend-normal"
|
||||
>
|
||||
<mask
|
||||
id="path-1-mask"
|
||||
maskUnits="userSpaceOnUse"
|
||||
x="-0.328613"
|
||||
y="103"
|
||||
width="338"
|
||||
height="299"
|
||||
fill="black"
|
||||
>
|
||||
<rect
|
||||
fill="white"
|
||||
x="-0.328613"
|
||||
y="103"
|
||||
width="338"
|
||||
height="299"
|
||||
/>
|
||||
<path
|
||||
d="M234.936 400.8C204.136 400.8 178.936 392.4 159.336 375.6C140.136 358.8 130.536 337 130.536 310.2H200.736C200.736 318.2 203.736 324.8 209.736 330C215.736 335.2 223.736 337.8 233.736 337.8C243.336 337.8 251.136 335 257.136 329.4C263.536 323.8 266.736 316.6 266.736 307.8C266.736 299.8 263.936 293.2 258.336 288C252.736 282.8 245.536 280.2 236.736 280.2H199.536V218.4H236.736C243.536 218.4 249.336 216 254.136 211.2C258.936 206.4 261.336 200.4 261.336 193.2C261.336 184.8 258.736 178.2 253.536 173.4C248.336 168.6 241.736 166.2 233.736 166.2C226.536 166.2 220.336 168.4 215.136 172.8C210.336 177.2 207.936 182.8 207.936 189.6H141.336C141.336 164.8 150.136 144.6 167.736 129C185.336 113 207.936 105 235.536 105C263.136 105 285.536 112.2 302.736 126.6C320.336 141 329.136 160 329.136 183.6C329.136 200.8 324.536 214.8 315.336 225.6C306.136 236 294.336 243.2 279.936 247.2C297.136 252 310.736 260.2 320.736 271.8C331.136 283.4 336.336 298 336.336 315.6C336.336 340.4 326.936 360.8 308.136 376.8C289.336 392.8 264.936 400.8 234.936 400.8Z"
|
||||
/>
|
||||
<path
|
||||
d="M26.8714 167.6H1.67139V105.2H94.6714V400.2H26.8714V167.6Z"
|
||||
/>
|
||||
</mask>
|
||||
<path
|
||||
d="M234.936 400.8C204.136 400.8 178.936 392.4 159.336 375.6C140.136 358.8 130.536 337 130.536 310.2H200.736C200.736 318.2 203.736 324.8 209.736 330C215.736 335.2 223.736 337.8 233.736 337.8C243.336 337.8 251.136 335 257.136 329.4C263.536 323.8 266.736 316.6 266.736 307.8C266.736 299.8 263.936 293.2 258.336 288C252.736 282.8 245.536 280.2 236.736 280.2H199.536V218.4H236.736C243.536 218.4 249.336 216 254.136 211.2C258.936 206.4 261.336 200.4 261.336 193.2C261.336 184.8 258.736 178.2 253.536 173.4C248.336 168.6 241.736 166.2 233.736 166.2C226.536 166.2 220.336 168.4 215.136 172.8C210.336 177.2 207.936 182.8 207.936 189.6H141.336C141.336 164.8 150.136 144.6 167.736 129C185.336 113 207.936 105 235.536 105C263.136 105 285.536 112.2 302.736 126.6C320.336 141 329.136 160 329.136 183.6C329.136 200.8 324.536 214.8 315.336 225.6C306.136 236 294.336 243.2 279.936 247.2C297.136 252 310.736 260.2 320.736 271.8C331.136 283.4 336.336 298 336.336 315.6C336.336 340.4 326.936 360.8 308.136 376.8C289.336 392.8 264.936 400.8 234.936 400.8Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M26.8714 167.6H1.67139V105.2H94.6714V400.2H26.8714V167.6Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M234.936 400.8C204.136 400.8 178.936 392.4 159.336 375.6C140.136 358.8 130.536 337 130.536 310.2H200.736C200.736 318.2 203.736 324.8 209.736 330C215.736 335.2 223.736 337.8 233.736 337.8C243.336 337.8 251.136 335 257.136 329.4C263.536 323.8 266.736 316.6 266.736 307.8C266.736 299.8 263.936 293.2 258.336 288C252.736 282.8 245.536 280.2 236.736 280.2H199.536V218.4H236.736C243.536 218.4 249.336 216 254.136 211.2C258.936 206.4 261.336 200.4 261.336 193.2C261.336 184.8 258.736 178.2 253.536 173.4C248.336 168.6 241.736 166.2 233.736 166.2C226.536 166.2 220.336 168.4 215.136 172.8C210.336 177.2 207.936 182.8 207.936 189.6H141.336C141.336 164.8 150.136 144.6 167.736 129C185.336 113 207.936 105 235.536 105C263.136 105 285.536 112.2 302.736 126.6C320.336 141 329.136 160 329.136 183.6C329.136 200.8 324.536 214.8 315.336 225.6C306.136 236 294.336 243.2 279.936 247.2C297.136 252 310.736 260.2 320.736 271.8C331.136 283.4 336.336 298 336.336 315.6C336.336 340.4 326.936 360.8 308.136 376.8C289.336 392.8 264.936 400.8 234.936 400.8Z"
|
||||
stroke="var(--stroke-color)"
|
||||
stroke-width="2.4"
|
||||
mask="url(#path-1-mask)"
|
||||
/>
|
||||
<path
|
||||
d="M26.8714 167.6H1.67139V105.2H94.6714V400.2H26.8714V167.6Z"
|
||||
stroke="var(--stroke-color)"
|
||||
stroke-width="2.4"
|
||||
mask="url(#path-1-mask)"
|
||||
/>
|
||||
</g>
|
||||
|
||||
<g
|
||||
class="delay-400 duration-750 starting:opacity-0 motion-safe:starting:-translate-x-[26px] text-[#F3BEC7] opacity-100 transition-all dark:text-[#4B0600]"
|
||||
>
|
||||
<mask
|
||||
id="path-2-mask"
|
||||
maskUnits="userSpaceOnUse"
|
||||
x="25.3357"
|
||||
y="103"
|
||||
width="338"
|
||||
height="299"
|
||||
fill="black"
|
||||
>
|
||||
<rect
|
||||
fill="white"
|
||||
x="25.3357"
|
||||
y="103"
|
||||
width="338"
|
||||
height="299"
|
||||
/>
|
||||
<path
|
||||
d="M260.6 400.8C229.8 400.8 204.6 392.4 185 375.6C165.8 358.8 156.2 337 156.2 310.2H226.4C226.4 318.2 229.4 324.8 235.4 330C241.4 335.2 249.4 337.8 259.4 337.8C269 337.8 276.8 335 282.8 329.4C289.2 323.8 292.4 316.6 292.4 307.8C292.4 299.8 289.6 293.2 284 288C278.4 282.8 271.2 280.2 262.4 280.2H225.2V218.4H262.4C269.2 218.4 275 216 279.8 211.2C284.6 206.4 287 200.4 287 193.2C287 184.8 284.4 178.2 279.2 173.4C274 168.6 267.4 166.2 259.4 166.2C252.2 166.2 246 168.4 240.8 172.8C236 177.2 233.6 182.8 233.6 189.6H167C167 164.8 175.8 144.6 193.4 129C211 113 233.6 105 261.2 105C288.8 105 311.2 112.2 328.4 126.6C346 141 354.8 160 354.8 183.6C354.8 200.8 350.2 214.8 341 225.6C331.8 236 320 243.2 305.6 247.2C322.8 252 336.4 260.2 346.4 271.8C356.8 283.4 362 298 362 315.6C362 340.4 352.6 360.8 333.8 376.8C315 392.8 290.6 400.8 260.6 400.8Z"
|
||||
/>
|
||||
<path
|
||||
d="M52.5357 167.6H27.3357V105.2H120.336V400.2H52.5357V167.6Z"
|
||||
/>
|
||||
</mask>
|
||||
<path
|
||||
d="M260.6 400.8C229.8 400.8 204.6 392.4 185 375.6C165.8 358.8 156.2 337 156.2 310.2H226.4C226.4 318.2 229.4 324.8 235.4 330C241.4 335.2 249.4 337.8 259.4 337.8C269 337.8 276.8 335 282.8 329.4C289.2 323.8 292.4 316.6 292.4 307.8C292.4 299.8 289.6 293.2 284 288C278.4 282.8 271.2 280.2 262.4 280.2H225.2V218.4H262.4C269.2 218.4 275 216 279.8 211.2C284.6 206.4 287 200.4 287 193.2C287 184.8 284.4 178.2 279.2 173.4C274 168.6 267.4 166.2 259.4 166.2C252.2 166.2 246 168.4 240.8 172.8C236 177.2 233.6 182.8 233.6 189.6H167C167 164.8 175.8 144.6 193.4 129C211 113 233.6 105 261.2 105C288.8 105 311.2 112.2 328.4 126.6C346 141 354.8 160 354.8 183.6C354.8 200.8 350.2 214.8 341 225.6C331.8 236 320 243.2 305.6 247.2C322.8 252 336.4 260.2 346.4 271.8C356.8 283.4 362 298 362 315.6C362 340.4 352.6 360.8 333.8 376.8C315 392.8 290.6 400.8 260.6 400.8Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M52.5357 167.6H27.3357V105.2H120.336V400.2H52.5357V167.6Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M260.6 400.8C229.8 400.8 204.6 392.4 185 375.6C165.8 358.8 156.2 337 156.2 310.2H226.4C226.4 318.2 229.4 324.8 235.4 330C241.4 335.2 249.4 337.8 259.4 337.8C269 337.8 276.8 335 282.8 329.4C289.2 323.8 292.4 316.6 292.4 307.8C292.4 299.8 289.6 293.2 284 288C278.4 282.8 271.2 280.2 262.4 280.2H225.2V218.4H262.4C269.2 218.4 275 216 279.8 211.2C284.6 206.4 287 200.4 287 193.2C287 184.8 284.4 178.2 279.2 173.4C274 168.6 267.4 166.2 259.4 166.2C252.2 166.2 246 168.4 240.8 172.8C236 177.2 233.6 182.8 233.6 189.6H167C167 164.8 175.8 144.6 193.4 129C211 113 233.6 105 261.2 105C288.8 105 311.2 112.2 328.4 126.6C346 141 354.8 160 354.8 183.6C354.8 200.8 350.2 214.8 341 225.6C331.8 236 320 243.2 305.6 247.2C322.8 252 336.4 260.2 346.4 271.8C356.8 283.4 362 298 362 315.6C362 340.4 352.6 360.8 333.8 376.8C315 392.8 290.6 400.8 260.6 400.8Z"
|
||||
stroke="var(--stroke-color)"
|
||||
stroke-width="2.4"
|
||||
mask="url(#path-2-mask)"
|
||||
/>
|
||||
<path
|
||||
d="M52.5357 167.6H27.3357V105.2H120.336V400.2H52.5357V167.6Z"
|
||||
stroke="var(--stroke-color)"
|
||||
stroke-width="2.4"
|
||||
mask="url(#path-2-mask)"
|
||||
/>
|
||||
</g>
|
||||
|
||||
<g
|
||||
class="delay-400 duration-750 starting:opacity-0 motion-safe:starting:-translate-x-[51px] text-[#F8B803] opacity-100 mix-blend-color transition-all dark:text-[#391800] dark:mix-blend-hard-light"
|
||||
>
|
||||
<mask
|
||||
id="path-3-mask"
|
||||
maskUnits="userSpaceOnUse"
|
||||
x="51"
|
||||
y="103"
|
||||
width="338"
|
||||
height="299"
|
||||
fill="black"
|
||||
>
|
||||
<rect
|
||||
fill="white"
|
||||
x="51"
|
||||
y="103"
|
||||
width="338"
|
||||
height="299"
|
||||
/>
|
||||
<path
|
||||
d="M286.264 400.8C255.464 400.8 230.264 392.4 210.664 375.6C191.464 358.8 181.864 337 181.864 310.2H252.064C252.064 318.2 255.064 324.8 261.064 330C267.064 335.2 275.064 337.8 285.064 337.8C294.664 337.8 302.464 335 308.464 329.4C314.864 323.8 318.064 316.6 318.064 307.8C318.064 299.8 315.264 293.2 309.664 288C304.064 282.8 296.864 280.2 288.064 280.2H250.864V218.4H288.064C294.864 218.4 300.664 216 305.464 211.2C310.264 206.4 312.664 200.4 312.664 193.2C312.664 184.8 310.064 178.2 304.864 173.4C299.664 168.6 293.064 166.2 285.064 166.2C277.864 166.2 271.664 168.4 266.464 172.8C261.664 177.2 259.264 182.8 259.264 189.6H192.664C192.664 164.8 201.464 144.6 219.064 129C236.664 113 259.264 105 286.864 105C314.464 105 336.864 112.2 354.064 126.6C371.664 141 380.464 160 380.464 183.6C380.464 200.8 375.864 214.8 366.664 225.6C357.464 236 345.664 243.2 331.264 247.2C348.464 252 362.064 260.2 372.064 271.8C382.464 283.4 387.664 298 387.664 315.6C387.664 340.4 378.264 360.8 359.464 376.8C340.664 392.8 316.264 400.8 286.264 400.8Z"
|
||||
/>
|
||||
<path
|
||||
d="M78.2 167.6H53V105.2H146V400.2H78.2V167.6Z"
|
||||
/>
|
||||
</mask>
|
||||
<path
|
||||
d="M286.264 400.8C255.464 400.8 230.264 392.4 210.664 375.6C191.464 358.8 181.864 337 181.864 310.2H252.064C252.064 318.2 255.064 324.8 261.064 330C267.064 335.2 275.064 337.8 285.064 337.8C294.664 337.8 302.464 335 308.464 329.4C314.864 323.8 318.064 316.6 318.064 307.8C318.064 299.8 315.264 293.2 309.664 288C304.064 282.8 296.864 280.2 288.064 280.2H250.864V218.4H288.064C294.864 218.4 300.664 216 305.464 211.2C310.264 206.4 312.664 200.4 312.664 193.2C312.664 184.8 310.064 178.2 304.864 173.4C299.664 168.6 293.064 166.2 285.064 166.2C277.864 166.2 271.664 168.4 266.464 172.8C261.664 177.2 259.264 182.8 259.264 189.6H192.664C192.664 164.8 201.464 144.6 219.064 129C236.664 113 259.264 105 286.864 105C314.464 105 336.864 112.2 354.064 126.6C371.664 141 380.464 160 380.464 183.6C380.464 200.8 375.864 214.8 366.664 225.6C357.464 236 345.664 243.2 331.264 247.2C348.464 252 362.064 260.2 372.064 271.8C382.464 283.4 387.664 298 387.664 315.6C387.664 340.4 378.264 360.8 359.464 376.8C340.664 392.8 316.264 400.8 286.264 400.8Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M78.2 167.6H53V105.2H146V400.2H78.2V167.6Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M286.264 400.8C255.464 400.8 230.264 392.4 210.664 375.6C191.464 358.8 181.864 337 181.864 310.2H252.064C252.064 318.2 255.064 324.8 261.064 330C267.064 335.2 275.064 337.8 285.064 337.8C294.664 337.8 302.464 335 308.464 329.4C314.864 323.8 318.064 316.6 318.064 307.8C318.064 299.8 315.264 293.2 309.664 288C304.064 282.8 296.864 280.2 288.064 280.2H250.864V218.4H288.064C294.864 218.4 300.664 216 305.464 211.2C310.264 206.4 312.664 200.4 312.664 193.2C312.664 184.8 310.064 178.2 304.864 173.4C299.664 168.6 293.064 166.2 285.064 166.2C277.864 166.2 271.664 168.4 266.464 172.8C261.664 177.2 259.264 182.8 259.264 189.6H192.664C192.664 164.8 201.464 144.6 219.064 129C236.664 113 259.264 105 286.864 105C314.464 105 336.864 112.2 354.064 126.6C371.664 141 380.464 160 380.464 183.6C380.464 200.8 375.864 214.8 366.664 225.6C357.464 236 345.664 243.2 331.264 247.2C348.464 252 362.064 260.2 372.064 271.8C382.464 283.4 387.664 298 387.664 315.6C387.664 340.4 378.264 360.8 359.464 376.8C340.664 392.8 316.264 400.8 286.264 400.8Z"
|
||||
stroke="var(--stroke-color)"
|
||||
stroke-width="2.4"
|
||||
mask="url(#path-3-mask)"
|
||||
/>
|
||||
<path
|
||||
d="M78.2 167.6H53V105.2H146V400.2H78.2V167.6Z"
|
||||
stroke="var(--stroke-color)"
|
||||
stroke-width="2.4"
|
||||
mask="url(#path-3-mask)"
|
||||
/>
|
||||
</g>
|
||||
|
||||
<g
|
||||
class="delay-400 duration-750 starting:opacity-0 motion-safe:starting:-translate-x-[78px] text-[#F3BEC7] opacity-100 mix-blend-multiply transition-all dark:text-[#733000] dark:mix-blend-normal"
|
||||
>
|
||||
<mask
|
||||
id="path-4-mask"
|
||||
maskUnits="userSpaceOnUse"
|
||||
x="76.6643"
|
||||
y="103"
|
||||
width="338"
|
||||
height="299"
|
||||
fill="black"
|
||||
>
|
||||
<rect
|
||||
fill="white"
|
||||
x="76.6643"
|
||||
y="103"
|
||||
width="338"
|
||||
height="299"
|
||||
/>
|
||||
<path
|
||||
d="M311.929 400.8C281.129 400.8 255.929 392.4 236.329 375.6C217.129 358.8 207.529 337 207.529 310.2H277.729C277.729 318.2 280.729 324.8 286.729 330C292.729 335.2 300.729 337.8 310.729 337.8C320.329 337.8 328.129 335 334.129 329.4C340.529 323.8 343.729 316.6 343.729 307.8C343.729 299.8 340.929 293.2 335.329 288C329.729 282.8 322.529 280.2 313.729 280.2H276.529V218.4H313.729C320.529 218.4 326.329 216 331.129 211.2C335.929 206.4 338.329 200.4 338.329 193.2C338.329 184.8 335.729 178.2 330.529 173.4C325.329 168.6 318.729 166.2 310.729 166.2C303.529 166.2 297.329 168.4 292.129 172.8C287.329 177.2 284.929 182.8 284.929 189.6H218.329C218.329 164.8 227.129 144.6 244.729 129C262.329 113 284.929 105 312.529 105C340.129 105 362.529 112.2 379.729 126.6C397.329 141 406.129 160 406.129 183.6C406.129 200.8 401.529 214.8 392.329 225.6C383.129 236 371.329 243.2 356.929 247.2C374.129 252 387.729 260.2 397.729 271.8C408.129 283.4 413.329 298 413.329 315.6C413.329 340.4 403.929 360.8 385.129 376.8C366.329 392.8 341.929 400.8 311.929 400.8Z"
|
||||
/>
|
||||
<path
|
||||
d="M103.864 167.6H78.6643V105.2H171.664V400.2H103.864V167.6Z"
|
||||
/>
|
||||
</mask>
|
||||
<path
|
||||
d="M311.929 400.8C281.129 400.8 255.929 392.4 236.329 375.6C217.129 358.8 207.529 337 207.529 310.2H277.729C277.729 318.2 280.729 324.8 286.729 330C292.729 335.2 300.729 337.8 310.729 337.8C320.329 337.8 328.129 335 334.129 329.4C340.529 323.8 343.729 316.6 343.729 307.8C343.729 299.8 340.929 293.2 335.329 288C329.729 282.8 322.529 280.2 313.729 280.2H276.529V218.4H313.729C320.529 218.4 326.329 216 331.129 211.2C335.929 206.4 338.329 200.4 338.329 193.2C338.329 184.8 335.729 178.2 330.529 173.4C325.329 168.6 318.729 166.2 310.729 166.2C303.529 166.2 297.329 168.4 292.129 172.8C287.329 177.2 284.929 182.8 284.929 189.6H218.329C218.329 164.8 227.129 144.6 244.729 129C262.329 113 284.929 105 312.529 105C340.129 105 362.529 112.2 379.729 126.6C397.329 141 406.129 160 406.129 183.6C406.129 200.8 401.529 214.8 392.329 225.6C383.129 236 371.329 243.2 356.929 247.2C374.129 252 387.729 260.2 397.729 271.8C408.129 283.4 413.329 298 413.329 315.6C413.329 340.4 403.929 360.8 385.129 376.8C366.329 392.8 341.929 400.8 311.929 400.8Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M103.864 167.6H78.6643V105.2H171.664V400.2H103.864V167.6Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M311.929 400.8C281.129 400.8 255.929 392.4 236.329 375.6C217.129 358.8 207.529 337 207.529 310.2H277.729C277.729 318.2 280.729 324.8 286.729 330C292.729 335.2 300.729 337.8 310.729 337.8C320.329 337.8 328.129 335 334.129 329.4C340.529 323.8 343.729 316.6 343.729 307.8C343.729 299.8 340.929 293.2 335.329 288C329.729 282.8 322.529 280.2 313.729 280.2H276.529V218.4H313.729C320.529 218.4 326.329 216 331.129 211.2C335.929 206.4 338.329 200.4 338.329 193.2C338.329 184.8 335.729 178.2 330.529 173.4C325.329 168.6 318.729 166.2 310.729 166.2C303.529 166.2 297.329 168.4 292.129 172.8C287.329 177.2 284.929 182.8 284.929 189.6H218.329C218.329 164.8 227.129 144.6 244.729 129C262.329 113 284.929 105 312.529 105C340.129 105 362.529 112.2 379.729 126.6C397.329 141 406.129 160 406.129 183.6C406.129 200.8 401.529 214.8 392.329 225.6C383.129 236 371.329 243.2 356.929 247.2C374.129 252 387.729 260.2 397.729 271.8C408.129 283.4 413.329 298 413.329 315.6C413.329 340.4 403.929 360.8 385.129 376.8C366.329 392.8 341.929 400.8 311.929 400.8Z"
|
||||
stroke="var(--stroke-color)"
|
||||
stroke-width="2.4"
|
||||
mask="url(#path-4-mask)"
|
||||
/>
|
||||
<path
|
||||
d="M103.864 167.6H78.6643V105.2H171.664V400.2H103.864V167.6Z"
|
||||
stroke="var(--stroke-color)"
|
||||
stroke-width="2.4"
|
||||
mask="url(#path-4-mask)"
|
||||
/>
|
||||
</g>
|
||||
|
||||
<g
|
||||
class="delay-400 duration-750 starting:opacity-0 motion-safe:starting:-translate-x-[102px] text-[#F3BEC7] opacity-100 mix-blend-hard-light transition-all dark:text-[#4B0600]"
|
||||
>
|
||||
<mask
|
||||
id="path-5-mask"
|
||||
maskUnits="userSpaceOnUse"
|
||||
x="102.329"
|
||||
y="103"
|
||||
width="338"
|
||||
height="299"
|
||||
fill="black"
|
||||
>
|
||||
<rect
|
||||
fill="white"
|
||||
x="102.329"
|
||||
y="103"
|
||||
width="338"
|
||||
height="299"
|
||||
/>
|
||||
<path
|
||||
d="M337.593 400.8C306.793 400.8 281.593 392.4 261.993 375.6C242.793 358.8 233.193 337 233.193 310.2H303.393C303.393 318.2 306.393 324.8 312.393 330C318.393 335.2 326.393 337.8 336.393 337.8C345.993 337.8 353.793 335 359.793 329.4C366.193 323.8 369.393 316.6 369.393 307.8C369.393 299.8 366.593 293.2 360.993 288C355.393 282.8 348.193 280.2 339.393 280.2H302.193V218.4H339.393C346.193 218.4 351.993 216 356.793 211.2C361.593 206.4 363.993 200.4 363.993 193.2C363.993 184.8 361.393 178.2 356.193 173.4C350.993 168.6 344.393 166.2 336.393 166.2C329.193 166.2 322.993 168.4 317.793 172.8C312.993 177.2 310.593 182.8 310.593 189.6H243.993C243.993 164.8 252.793 144.6 270.393 129C287.993 113 310.593 105 338.193 105C365.793 105 388.193 112.2 405.393 126.6C422.993 141 431.793 160 431.793 183.6C431.793 200.8 427.193 214.8 417.993 225.6C408.793 236 396.993 243.2 382.593 247.2C399.793 252 413.393 260.2 423.393 271.8C433.793 283.4 438.993 298 438.993 315.6C438.993 340.4 429.593 360.8 410.793 376.8C391.993 392.8 367.593 400.8 337.593 400.8Z"
|
||||
/>
|
||||
<path
|
||||
d="M129.529 167.6H104.329V105.2H197.329V400.2H129.529V167.6Z"
|
||||
/>
|
||||
</mask>
|
||||
<path
|
||||
d="M337.593 400.8C306.793 400.8 281.593 392.4 261.993 375.6C242.793 358.8 233.193 337 233.193 310.2H303.393C303.393 318.2 306.393 324.8 312.393 330C318.393 335.2 326.393 337.8 336.393 337.8C345.993 337.8 353.793 335 359.793 329.4C366.193 323.8 369.393 316.6 369.393 307.8C369.393 299.8 366.593 293.2 360.993 288C355.393 282.8 348.193 280.2 339.393 280.2H302.193V218.4H339.393C346.193 218.4 351.993 216 356.793 211.2C361.593 206.4 363.993 200.4 363.993 193.2C363.993 184.8 361.393 178.2 356.193 173.4C350.993 168.6 344.393 166.2 336.393 166.2C329.193 166.2 322.993 168.4 317.793 172.8C312.993 177.2 310.593 182.8 310.593 189.6H243.993C243.993 164.8 252.793 144.6 270.393 129C287.993 113 310.593 105 338.193 105C365.793 105 388.193 112.2 405.393 126.6C422.993 141 431.793 160 431.793 183.6C431.793 200.8 427.193 214.8 417.993 225.6C408.793 236 396.993 243.2 382.593 247.2C399.793 252 413.393 260.2 423.393 271.8C433.793 283.4 438.993 298 438.993 315.6C438.993 340.4 429.593 360.8 410.793 376.8C391.993 392.8 367.593 400.8 337.593 400.8Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M129.529 167.6H104.329V105.2H197.329V400.2H129.529V167.6Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M337.593 400.8C306.793 400.8 281.593 392.4 261.993 375.6C242.793 358.8 233.193 337 233.193 310.2H303.393C303.393 318.2 306.393 324.8 312.393 330C318.393 335.2 326.393 337.8 336.393 337.8C345.993 337.8 353.793 335 359.793 329.4C366.193 323.8 369.393 316.6 369.393 307.8C369.393 299.8 366.593 293.2 360.993 288C355.393 282.8 348.193 280.2 339.393 280.2H302.193V218.4H339.393C346.193 218.4 351.993 216 356.793 211.2C361.593 206.4 363.993 200.4 363.993 193.2C363.993 184.8 361.393 178.2 356.193 173.4C350.993 168.6 344.393 166.2 336.393 166.2C329.193 166.2 322.993 168.4 317.793 172.8C312.993 177.2 310.593 182.8 310.593 189.6H243.993C243.993 164.8 252.793 144.6 270.393 129C287.993 113 310.593 105 338.193 105C365.793 105 388.193 112.2 405.393 126.6C422.993 141 431.793 160 431.793 183.6C431.793 200.8 427.193 214.8 417.993 225.6C408.793 236 396.993 243.2 382.593 247.2C399.793 252 413.393 260.2 423.393 271.8C433.793 283.4 438.993 298 438.993 315.6C438.993 340.4 429.593 360.8 410.793 376.8C391.993 392.8 367.593 400.8 337.593 400.8Z"
|
||||
stroke="var(--stroke-color)"
|
||||
stroke-width="2.4"
|
||||
mask="url(#path-5-mask)"
|
||||
/>
|
||||
<path
|
||||
d="M129.529 167.6H104.329V105.2H197.329V400.2H129.529V167.6Z"
|
||||
stroke="var(--stroke-color)"
|
||||
stroke-width="2.4"
|
||||
mask="url(#path-5-mask)"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
<div
|
||||
class="absolute inset-0 rounded-t-lg shadow-[inset_0px_0px_0px_1px_rgba(26,26,0,0.16)] lg:rounded-r-lg lg:rounded-t-none dark:shadow-[inset_0px_0px_0px_1px_#fffaed2d]"
|
||||
></div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<div class="h-14.5 hidden lg:block"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,39 +1,165 @@
|
||||
<script setup lang="ts">
|
||||
import { Head, router } from '@inertiajs/vue3';
|
||||
import { computed } from 'vue';
|
||||
import ManagedUserForm from '@/components/ManagedUserForm.vue';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import { Form, Head, Link } from '@inertiajs/vue3';
|
||||
import UserManagementController from '@/actions/App/Http/Controllers/Admin/UserManagementController';
|
||||
import Heading from '@/components/Heading.vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
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 { create, index } from '@/routes/admin/users';
|
||||
import type { BreadcrumbItem } from '@/types';
|
||||
|
||||
const { t } = useLocalization();
|
||||
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
||||
{ title: t('adminUsers.title'), href: index() },
|
||||
{ title: t('adminUsers.create.title'), href: create() },
|
||||
]);
|
||||
const breadcrumbs: BreadcrumbItem[] = [
|
||||
{
|
||||
title: 'User management',
|
||||
href: index(),
|
||||
},
|
||||
{
|
||||
title: 'Create user',
|
||||
href: create(),
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout :breadcrumbs="breadcrumbs">
|
||||
<Head :title="t('adminUsers.create.title')" />
|
||||
<Head title="Create user" />
|
||||
|
||||
<div
|
||||
class="gap-page md:p-page mx-auto flex w-full min-w-0 max-w-4xl flex-1 flex-col p-4"
|
||||
>
|
||||
<header class="gap-stack grid min-w-0">
|
||||
<p class="text-label text-muted-foreground uppercase">
|
||||
{{ t('adminUsers.eyebrow') }}
|
||||
</p>
|
||||
<h1 class="text-headline-mobile md:text-headline">
|
||||
{{ t('adminUsers.create.title') }}
|
||||
</h1>
|
||||
<p class="text-muted-foreground max-w-2xl">
|
||||
{{ t('adminUsers.create.description') }}
|
||||
</p>
|
||||
</header>
|
||||
<div class="mx-auto flex w-full max-w-3xl flex-1 flex-col gap-6 p-4">
|
||||
<Heading
|
||||
title="Create user"
|
||||
description="Create a user account and optionally force a password change on the next login."
|
||||
/>
|
||||
|
||||
<ManagedUserForm mode="create" @cancel="router.visit(index())" />
|
||||
<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">
|
||||
<Label for="name">Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
required
|
||||
autofocus
|
||||
autocomplete="name"
|
||||
placeholder="Jane Admin"
|
||||
/>
|
||||
<InputError :message="errors.name" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<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>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
@@ -1,60 +1,170 @@
|
||||
<script setup lang="ts">
|
||||
import { Head, router } from '@inertiajs/vue3';
|
||||
import { computed } from 'vue';
|
||||
import ManagedUserForm from '@/components/ManagedUserForm.vue';
|
||||
import UserAvatar from '@/components/ui/avatar/UserAvatar.vue';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import { Head, Link, useForm } from '@inertiajs/vue3';
|
||||
import Heading from '@/components/Heading.vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
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 { edit, index } from '@/routes/admin/users';
|
||||
import { edit, index, save } from '@/routes/admin/users';
|
||||
import type { AdminUserEditPageProps, BreadcrumbItem } from '@/types';
|
||||
|
||||
const props = defineProps<AdminUserEditPageProps>();
|
||||
const { t } = useLocalization();
|
||||
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
||||
{ title: t('adminUsers.title'), href: index() },
|
||||
|
||||
const breadcrumbs: BreadcrumbItem[] = [
|
||||
{
|
||||
title: t('adminUsers.edit.breadcrumb', { name: props.user.name }),
|
||||
title: 'User management',
|
||||
href: index(),
|
||||
},
|
||||
{
|
||||
title: 'Edit user',
|
||||
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>
|
||||
|
||||
<template>
|
||||
<AppLayout :breadcrumbs="breadcrumbs">
|
||||
<Head :title="t('adminUsers.edit.pageTitle', { name: user.name })" />
|
||||
<Head :title="`Edit ${user.name}`" />
|
||||
|
||||
<div
|
||||
class="gap-page md:p-page mx-auto flex w-full min-w-0 max-w-4xl flex-1 flex-col p-4"
|
||||
>
|
||||
<header
|
||||
class="flex min-w-0 flex-col gap-4 sm:flex-row sm:items-center"
|
||||
>
|
||||
<UserAvatar
|
||||
:user="user"
|
||||
size="xl"
|
||||
rounded
|
||||
class="ring-border shrink-0 ring-1"
|
||||
/>
|
||||
<div class="gap-stack grid min-w-0">
|
||||
<p class="text-label text-muted-foreground uppercase">
|
||||
{{ t('adminUsers.eyebrow') }}
|
||||
</p>
|
||||
<h1
|
||||
class="text-headline-mobile md:text-headline break-words"
|
||||
>
|
||||
{{ t('adminUsers.edit.title', { name: user.name }) }}
|
||||
</h1>
|
||||
<p class="text-muted-foreground break-all">
|
||||
{{ user.email }}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<ManagedUserForm
|
||||
mode="edit"
|
||||
:user="user"
|
||||
@cancel="router.visit(index())"
|
||||
<div class="mx-auto flex w-full max-w-3xl flex-1 flex-col gap-6 p-4">
|
||||
<Heading
|
||||
: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
|
||||
id="password_confirmation"
|
||||
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
|
||||
id="must_change_password_on_next_login"
|
||||
v-model="
|
||||
form.must_change_password_on_next_login
|
||||
"
|
||||
type="checkbox"
|
||||
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"
|
||||
>
|
||||
Keep the user locked to the security
|
||||
settings page until they save a new
|
||||
password.
|
||||
</span>
|
||||
</span>
|
||||
</Label>
|
||||
<InputError
|
||||
:message="
|
||||
form.errors
|
||||
.must_change_password_on_next_login
|
||||
"
|
||||
/>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
@@ -1,468 +1,267 @@
|
||||
<script setup lang="ts">
|
||||
import { Head, Link, useForm } from '@inertiajs/vue3';
|
||||
import { Form, Head, Link } from '@inertiajs/vue3';
|
||||
import {
|
||||
FolderPlus,
|
||||
KeyRound,
|
||||
Pencil,
|
||||
ShieldCheck,
|
||||
Shield,
|
||||
UserPlus,
|
||||
UsersRound,
|
||||
Users,
|
||||
} from 'lucide-vue-next';
|
||||
import { computed, ref } from '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 UserManagementController from '@/actions/App/Http/Controllers/Admin/UserManagementController';
|
||||
import Heading from '@/components/Heading.vue';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useFormProtection } from '@/composables/useFormProtection';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
import {
|
||||
create as createAdminUser,
|
||||
edit as editAdminUser,
|
||||
index as adminUsers,
|
||||
} from '@/routes/admin/users';
|
||||
import type {
|
||||
AdminUsersPageProps,
|
||||
BreadcrumbItem,
|
||||
PermissionOption,
|
||||
} from '@/types';
|
||||
import type { UserModel } from '@/types/models/user-model';
|
||||
import type { AdminUsersPageProps, BreadcrumbItem } from '@/types';
|
||||
import UserAvatar from '@/components/ui/avatar/UserAvatar.vue';
|
||||
|
||||
const props = defineProps<AdminUsersPageProps>();
|
||||
const { t } = useLocalization();
|
||||
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(() => [
|
||||
|
||||
const breadcrumbs: BreadcrumbItem[] = [
|
||||
{
|
||||
label: t('adminUsers.summary.total'),
|
||||
value: props.users.length,
|
||||
icon: UsersRound,
|
||||
title: 'User management',
|
||||
href: adminUsers(),
|
||||
},
|
||||
{
|
||||
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,
|
||||
},
|
||||
]);
|
||||
];
|
||||
|
||||
function permissionsFor(userId: number): string[] {
|
||||
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,
|
||||
});
|
||||
}
|
||||
const hasPermission = (actorId: number, permission: string) =>
|
||||
(props.directPermissions[actorId] ?? []).includes(permission);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout :breadcrumbs="breadcrumbs">
|
||||
<Head :title="t('adminUsers.title')" />
|
||||
<Head title="User management" />
|
||||
|
||||
<div class="gap-page md:p-page flex min-w-0 flex-1 flex-col p-4">
|
||||
<header
|
||||
class="flex min-w-0 flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
|
||||
>
|
||||
<div class="gap-stack grid min-w-0">
|
||||
<p class="text-label text-muted-foreground uppercase">
|
||||
{{ t('adminUsers.eyebrow') }}
|
||||
</p>
|
||||
<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()">
|
||||
<UserPlus aria-hidden="true" />
|
||||
{{ t('adminUsers.createAction') }}
|
||||
</Link>
|
||||
</Button>
|
||||
</header>
|
||||
<div class="flex flex-1 flex-col gap-6 p-4">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<Heading
|
||||
title="User management"
|
||||
description="Assign the global permissions that unlock platform-wide administration and project creation."
|
||||
/>
|
||||
|
||||
<FeedbackState
|
||||
v-if="status"
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
|
||||
<FeedbackState
|
||||
v-if="users.length === 0"
|
||||
state="empty"
|
||||
:title="t('adminUsers.empty.title')"
|
||||
:description="t('adminUsers.empty.description')"
|
||||
>
|
||||
<Button as-child variant="outline">
|
||||
<div class="flex items-center gap-3">
|
||||
<Button as-child>
|
||||
<Link :href="createAdminUser()">
|
||||
<UserPlus aria-hidden="true" />
|
||||
{{ t('adminUsers.createAction') }}
|
||||
<UserPlus class="size-4" />
|
||||
User create
|
||||
</Link>
|
||||
</Button>
|
||||
</FeedbackState>
|
||||
|
||||
<ul
|
||||
v-else
|
||||
class="grid min-w-0 gap-4 lg:grid-cols-2 2xl:grid-cols-3"
|
||||
>
|
||||
<li
|
||||
v-for="user in users"
|
||||
:key="user.id"
|
||||
class="min-w-0"
|
||||
data-managed-user
|
||||
>
|
||||
<article
|
||||
class="bg-card text-card-foreground flex h-full min-w-0 flex-col rounded-xl border"
|
||||
>
|
||||
<div class="flex min-w-0 items-start gap-4 p-5">
|
||||
<UserAvatar
|
||||
:user="user"
|
||||
size="lg"
|
||||
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 }}
|
||||
</h3>
|
||||
<p
|
||||
class="text-caption text-muted-foreground break-all"
|
||||
>
|
||||
{{ user.email }}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="outline" class="shrink-0">
|
||||
{{ t('adminUsers.managedAccount') }}
|
||||
</Badge>
|
||||
</div>
|
||||
<Badge variant="outline" class="gap-1.5 px-3 py-1">
|
||||
<Users class="size-3.5" />
|
||||
Admin-only area
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="gap-stack flex min-w-0 flex-1 flex-wrap content-start border-y px-5 py-4"
|
||||
>
|
||||
<Badge
|
||||
v-if="
|
||||
user.must_change_password_on_next_login
|
||||
"
|
||||
variant="outline"
|
||||
class="gap-1.5 whitespace-normal"
|
||||
>
|
||||
<KeyRound
|
||||
class="size-3.5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{{ t('adminUsers.passwordChangePending') }}
|
||||
</Badge>
|
||||
<Badge
|
||||
v-for="option in permissionOptions"
|
||||
v-show="
|
||||
hasPermission(user.id, option.value)
|
||||
"
|
||||
:key="option.value"
|
||||
variant="secondary"
|
||||
class="gap-1.5 whitespace-normal"
|
||||
>
|
||||
<ShieldCheck
|
||||
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>
|
||||
<span
|
||||
v-if="permissionsFor(user.id).length === 0"
|
||||
class="text-caption text-muted-foreground"
|
||||
>
|
||||
{{ t('adminUsers.permissions.none') }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="status"
|
||||
class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900 dark:border-emerald-900/70 dark:bg-emerald-950/40 dark:text-emerald-100"
|
||||
>
|
||||
{{ status }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="grid gap-2 p-4 sm:grid-cols-2"
|
||||
data-user-actions
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
@click="openPermissions(user)"
|
||||
>
|
||||
<ShieldCheck aria-hidden="true" />
|
||||
{{ t('adminUsers.permissions.action') }}
|
||||
</Button>
|
||||
<Button as-child variant="ghost">
|
||||
<Link
|
||||
:href="editAdminUser(user.id)"
|
||||
:aria-label="
|
||||
t('adminUsers.edit.action', {
|
||||
name: user.name,
|
||||
})
|
||||
"
|
||||
>
|
||||
<Pencil aria-hidden="true" />
|
||||
{{ t('adminUsers.edit.shortAction') }}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</article>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<Dialog :open="permissionsOpen" @update:open="updatePermissionsOpen">
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{{
|
||||
t('adminUsers.permissions.title', {
|
||||
name: selectedUser?.name ?? '',
|
||||
})
|
||||
}}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{{ t('adminUsers.permissions.description') }}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form
|
||||
class="gap-column grid"
|
||||
:aria-busy="permissionForm.processing"
|
||||
@submit.prevent="submitPermissions"
|
||||
>
|
||||
<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"
|
||||
:key="option.value"
|
||||
:for="'permission-' + option.value"
|
||||
class="bg-muted/35 flex cursor-pointer items-start gap-3 rounded-lg border p-4"
|
||||
>
|
||||
<Checkbox
|
||||
:id="'permission-' + option.value"
|
||||
:checked="
|
||||
permissionForm.permissions.includes(
|
||||
option.value,
|
||||
)
|
||||
"
|
||||
:value="option.value"
|
||||
:invalid="!!permissionForm.errors.permissions"
|
||||
:aria-describedby="
|
||||
'permission-help-' + option.value
|
||||
"
|
||||
@update:checked="
|
||||
(checked: boolean | 'indeterminate') =>
|
||||
updatePermission(
|
||||
option.value,
|
||||
checked === true,
|
||||
)
|
||||
"
|
||||
/>
|
||||
<span class="grid gap-1">
|
||||
<span>{{ permissionLabel(option) }}</span>
|
||||
<span
|
||||
: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>
|
||||
<Card class="gap-0 overflow-hidden">
|
||||
<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
|
||||
class="border-warning/35 bg-warning/8 text-body rounded-md border p-4"
|
||||
v-if="users.length === 0"
|
||||
class="text-muted-foreground p-6 text-sm"
|
||||
>
|
||||
{{ t('adminUsers.permissions.warning') }}
|
||||
No users exist yet.
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
:disabled="permissionForm.processing"
|
||||
@click="updatePermissionsOpen(false)"
|
||||
<div v-else class="overflow-x-auto">
|
||||
<table
|
||||
class="divide-border min-w-full divide-y text-sm"
|
||||
>
|
||||
{{ t('common.cancel') }}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
:loading="permissionForm.processing"
|
||||
data-test="confirm-user-permissions"
|
||||
>
|
||||
{{ t('adminUsers.permissions.confirm') }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<thead
|
||||
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"
|
||||
:key="user.id"
|
||||
class="bg-background align-top"
|
||||
>
|
||||
<td class="px-4 py-4">
|
||||
<div
|
||||
class="text-foreground items-medium flex items-center"
|
||||
>
|
||||
<UserAvatar
|
||||
:user="user"
|
||||
class="mr-2 size-8"
|
||||
rounded
|
||||
/>
|
||||
{{ user.name }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-muted-foreground px-4 py-4">
|
||||
{{ user.email }}
|
||||
</td>
|
||||
<td class="px-4 py-4">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Badge
|
||||
v-if="
|
||||
user.must_change_password_on_next_login
|
||||
"
|
||||
variant="outline"
|
||||
class="gap-1.5"
|
||||
>
|
||||
<KeyRound class="size-3.5" />
|
||||
Must change password
|
||||
</Badge>
|
||||
<Badge
|
||||
v-if="
|
||||
hasPermission(
|
||||
user.id,
|
||||
'global:admin',
|
||||
)
|
||||
"
|
||||
class="gap-1.5"
|
||||
>
|
||||
<Shield class="size-3.5" />
|
||||
Global admin
|
||||
</Badge>
|
||||
<Badge
|
||||
v-if="
|
||||
hasPermission(
|
||||
user.id,
|
||||
'project:create',
|
||||
)
|
||||
"
|
||||
variant="secondary"
|
||||
class="gap-1.5"
|
||||
>
|
||||
<FolderPlus class="size-3.5" />
|
||||
Project create
|
||||
</Badge>
|
||||
<span
|
||||
v-if="
|
||||
(
|
||||
directPermissions[
|
||||
user.id
|
||||
] ?? []
|
||||
).length === 0
|
||||
"
|
||||
class="text-muted-foreground"
|
||||
>
|
||||
No direct global permissions
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-4">
|
||||
<div class="flex justify-end">
|
||||
<div
|
||||
class="flex flex-wrap justify-end gap-3"
|
||||
>
|
||||
<Button
|
||||
as-child
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
<Link
|
||||
:href="
|
||||
editAdminUser(
|
||||
user.id,
|
||||
)
|
||||
"
|
||||
>
|
||||
<Pencil
|
||||
class="size-4"
|
||||
/>
|
||||
Edit
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Form
|
||||
v-bind="
|
||||
UserManagementController.update.form(
|
||||
user.id,
|
||||
)
|
||||
"
|
||||
class="flex flex-wrap justify-end gap-3"
|
||||
v-slot="{ processing }"
|
||||
>
|
||||
<label
|
||||
v-for="option in permissionOptions"
|
||||
:key="`${user.id}-${option.value}`"
|
||||
class="border-border inline-flex items-center gap-2 rounded-lg border px-3 py-2 text-sm"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="permissions[]"
|
||||
:value="
|
||||
option.value
|
||||
"
|
||||
:checked="
|
||||
hasPermission(
|
||||
user.id,
|
||||
option.value,
|
||||
)
|
||||
"
|
||||
class="border-input text-primary focus:ring-ring size-4 rounded"
|
||||
/>
|
||||
<span>{{
|
||||
option.label
|
||||
}}</span>
|
||||
</label>
|
||||
|
||||
<div
|
||||
class="w-full sm:hidden"
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
:disabled="processing"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
@@ -1,38 +1,174 @@
|
||||
<script setup lang="ts">
|
||||
import { Head } from '@inertiajs/vue3';
|
||||
import { Head, Link, useForm } from '@inertiajs/vue3';
|
||||
import { computed } from 'vue';
|
||||
import ProjectTemplateForm from '@/components/projects/ProjectTemplateForm.vue';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import Heading from '@/components/Heading.vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
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 { index, show } from '@/routes/projects';
|
||||
import { create } from '@/routes/projects/templates';
|
||||
import {
|
||||
index as projectsIndex,
|
||||
show as projectShow,
|
||||
} from '@/routes/projects/index';
|
||||
import {
|
||||
create as projectTemplateCreate,
|
||||
store as projectTemplateStore,
|
||||
} from '@/routes/projects/templates';
|
||||
import type { BreadcrumbItem, ProjectTemplatesCreatePageProps } from '@/types';
|
||||
|
||||
const props = defineProps<ProjectTemplatesCreatePageProps>();
|
||||
const { t } = useLocalization();
|
||||
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
||||
{ title: t('navigation.projects'), href: index() },
|
||||
{ title: props.project.name, href: show(props.project.id) },
|
||||
{ title: t('projects.saveTemplate'), href: create(props.project.id) },
|
||||
]);
|
||||
|
||||
const breadcrumbs: BreadcrumbItem[] = [
|
||||
{
|
||||
title: 'Projects',
|
||||
href: projectsIndex(),
|
||||
},
|
||||
{
|
||||
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>
|
||||
|
||||
<template>
|
||||
<AppLayout :breadcrumbs="breadcrumbs">
|
||||
<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">
|
||||
<header class="gap-stack grid">
|
||||
<h1 class="text-headline-mobile md:text-headline break-words">
|
||||
{{ t('templates.createTitle', { project: project.name }) }}
|
||||
</h1>
|
||||
<p class="text-muted-foreground">
|
||||
{{ t('templates.createDescription') }}
|
||||
</p>
|
||||
</header>
|
||||
<ProjectTemplateForm
|
||||
:key="project.id"
|
||||
:project="project"
|
||||
:copy-area-options="copyAreaOptions"
|
||||
/>
|
||||
<Head :title="`Save ${project.name} as new template`" />
|
||||
|
||||
<div class="mx-auto flex w-full max-w-3xl flex-1 flex-col gap-6 p-4">
|
||||
<div class="space-y-3">
|
||||
<Badge variant="outline" class="px-3 py-1">{{
|
||||
project.key
|
||||
}}</Badge>
|
||||
<Heading
|
||||
: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>
|
||||
<InputError :message="form.errors.slug" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3">
|
||||
<div>
|
||||
<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>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
@@ -1,36 +1,223 @@
|
||||
<script setup lang="ts">
|
||||
import { Head } from '@inertiajs/vue3';
|
||||
import { Head, Link, useForm } from '@inertiajs/vue3';
|
||||
import { computed } from 'vue';
|
||||
import ProjectTemplateForm from '@/components/projects/ProjectTemplateForm.vue';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import Heading from '@/components/Heading.vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
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 { index, edit } from '@/routes/project-templates';
|
||||
import {
|
||||
edit as projectTemplatesEdit,
|
||||
index as projectTemplatesIndex,
|
||||
update as projectTemplatesUpdate,
|
||||
} from '@/routes/project-templates';
|
||||
import type { BreadcrumbItem, ProjectTemplatesEditPageProps } from '@/types';
|
||||
|
||||
const props = defineProps<ProjectTemplatesEditPageProps>();
|
||||
const { t } = useLocalization();
|
||||
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
||||
{ title: t('navigation.templates'), href: index() },
|
||||
{ title: props.template.name, href: edit(props.template.id) },
|
||||
]);
|
||||
|
||||
const breadcrumbs: BreadcrumbItem[] = [
|
||||
{
|
||||
title: 'Project templates',
|
||||
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>
|
||||
|
||||
<template>
|
||||
<AppLayout :breadcrumbs="breadcrumbs">
|
||||
<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">
|
||||
<header class="gap-stack grid">
|
||||
<h1 class="text-headline-mobile md:text-headline break-words">
|
||||
{{ t('templates.editTitle', { name: template.name }) }}
|
||||
</h1>
|
||||
<p class="text-muted-foreground">
|
||||
{{ t('templates.editDescription') }}
|
||||
</p>
|
||||
</header>
|
||||
<ProjectTemplateForm
|
||||
:key="template.id"
|
||||
:template="template"
|
||||
:copy-area-options="copyAreaOptions"
|
||||
/>
|
||||
<Head :title="`Edit ${template.name}`" />
|
||||
|
||||
<div class="mx-auto flex w-full max-w-3xl flex-1 flex-col gap-6 p-4">
|
||||
<div class="space-y-3">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<Badge variant="outline" class="px-3 py-1">{{
|
||||
template.slug
|
||||
}}</Badge>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3">
|
||||
<div>
|
||||
<Label>Configuration groups</Label>
|
||||
<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>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
@@ -1,258 +1,158 @@
|
||||
<script setup lang="ts">
|
||||
import { Head, Link, useForm } from '@inertiajs/vue3';
|
||||
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 { Head, Link, router } from '@inertiajs/vue3';
|
||||
import Heading from '@/components/Heading.vue';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { useFormProtection } from '@/composables/useFormProtection';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import { useProjectTemplateAreas } from '@/composables/useProjectTemplateAreas';
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
import { destroy, edit, index } from '@/routes/project-templates';
|
||||
import {
|
||||
create as createProject,
|
||||
destroy as projectTemplatesDestroy,
|
||||
edit as projectTemplatesEdit,
|
||||
index as projectTemplatesIndex,
|
||||
} from '@/routes/project-templates';
|
||||
import {
|
||||
create as projectsCreate,
|
||||
index as projectsIndex,
|
||||
} from '@/routes/projects';
|
||||
} from '@/routes/projects/index';
|
||||
import type { BreadcrumbItem, ProjectTemplatesIndexPageProps } from '@/types';
|
||||
|
||||
import type { ProjectTemplateModel } from '@/types/models/project-template-model';
|
||||
|
||||
const props = defineProps<ProjectTemplatesIndexPageProps>();
|
||||
const { t } = useLocalization();
|
||||
const { areaText } = useProjectTemplateAreas();
|
||||
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
||||
{ title: t('navigation.templates'), href: index() },
|
||||
]);
|
||||
const templates = computed(() =>
|
||||
props.templates.filter((template) => template.is_published),
|
||||
);
|
||||
const pending = ref<ProjectTemplateModel | null>(null);
|
||||
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), {
|
||||
|
||||
const breadcrumbs: BreadcrumbItem[] = [
|
||||
{
|
||||
title: 'Project templates',
|
||||
href: projectTemplatesIndex(),
|
||||
},
|
||||
];
|
||||
|
||||
const removeTemplate = (templateId: number): void => {
|
||||
router.delete(projectTemplatesDestroy.url(templateId), {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
pending.value = null;
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout :breadcrumbs="breadcrumbs">
|
||||
<Head :title="t('navigation.templates')" />
|
||||
<div class="p-page gap-page mx-auto grid w-full min-w-0 max-w-7xl">
|
||||
<header
|
||||
class="gap-column flex flex-col sm:flex-row sm:items-start sm:justify-between"
|
||||
<Head title="Project templates" />
|
||||
|
||||
<div class="flex flex-1 flex-col gap-6 p-4">
|
||||
<div
|
||||
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
|
||||
>
|
||||
<div class="gap-stack grid min-w-0">
|
||||
<h1 class="text-headline-mobile md:text-headline">
|
||||
{{ t('navigation.templates') }}
|
||||
</h1>
|
||||
<p class="text-muted-foreground max-w-2xl">
|
||||
{{ t('templates.description') }}
|
||||
</p>
|
||||
</div>
|
||||
<Button as-child class="shrink-0"
|
||||
><Link :href="createProject()"
|
||||
><Plus aria-hidden="true" />{{
|
||||
t('projects.create')
|
||||
}}</Link
|
||||
></Button
|
||||
>
|
||||
</header>
|
||||
<FeedbackState
|
||||
v-if="status"
|
||||
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"
|
||||
>
|
||||
<LayoutTemplate
|
||||
class="text-primary size-6 shrink-0"
|
||||
aria-hidden="true"
|
||||
<Heading
|
||||
title="Project templates"
|
||||
description="Reusable published project templates. Internal project base templates stay hidden here by default."
|
||||
/>
|
||||
<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 class="flex flex-wrap gap-3">
|
||||
<Button as-child variant="outline">
|
||||
<Link :href="projectsIndex()">Back to projects</Link>
|
||||
</Button>
|
||||
|
||||
<Button as-child>
|
||||
<Link :href="projectsCreate()">Create project</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<Button as-child variant="outline" class="shrink-0"
|
||||
><Link :href="projectsIndex()"
|
||||
>{{ t('templates.browseProjects')
|
||||
}}<ArrowRight aria-hidden="true" /></Link
|
||||
></Button>
|
||||
</section>
|
||||
<FeedbackState
|
||||
v-if="templates.length === 0"
|
||||
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')"
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="status"
|
||||
class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900 dark:border-emerald-900/70 dark:bg-emerald-950/40 dark:text-emerald-100"
|
||||
>
|
||||
<li
|
||||
{{ status }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="templates.length === 0"
|
||||
class="text-muted-foreground text-sm"
|
||||
>
|
||||
No published project templates have been saved yet.
|
||||
</div>
|
||||
|
||||
<div v-else class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
<Card
|
||||
v-for="template in templates"
|
||||
:key="template.id"
|
||||
class="bg-card flex min-w-0 flex-col rounded-md border"
|
||||
:data-template="template.id"
|
||||
class="h-full"
|
||||
>
|
||||
<div class="gap-column grid min-w-0 p-5">
|
||||
<div class="gap-stack grid min-w-0">
|
||||
<h2 class="text-title break-words">
|
||||
{{ template.name }}
|
||||
</h2>
|
||||
<p
|
||||
class="text-caption text-muted-foreground break-all"
|
||||
<CardHeader>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<CardTitle>{{ template.name }}</CardTitle>
|
||||
<Badge variant="secondary">{{
|
||||
template.slug
|
||||
}}</Badge>
|
||||
<Badge
|
||||
v-if="template.linked_project_count"
|
||||
variant="outline"
|
||||
>
|
||||
{{ template.slug }}
|
||||
</p>
|
||||
{{ template.linked_project_count }} linked
|
||||
{{
|
||||
template.linked_project_count === 1
|
||||
? 'project'
|
||||
: 'projects'
|
||||
}}
|
||||
</Badge>
|
||||
</div>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
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.source_project
|
||||
? t('templates.savedFrom', {
|
||||
project: template.source_project.name,
|
||||
key: template.source_project.key,
|
||||
})
|
||||
: t('templates.sourceUnavailable')
|
||||
}}
|
||||
</p>
|
||||
<ul
|
||||
class="flex flex-wrap gap-2"
|
||||
:aria-label="t('templates.groups')"
|
||||
>
|
||||
<li v-for="area in template.copy_areas" :key="area">
|
||||
<Badge
|
||||
variant="outline"
|
||||
class="whitespace-normal"
|
||||
>{{ areaText(area, 'label') }}</Badge
|
||||
<CardDescription>
|
||||
<template v-if="template.source_project">
|
||||
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 }}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<Button as-child variant="secondary">
|
||||
<Link :href="projectTemplatesEdit(template.id)">
|
||||
Edit
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button as-child variant="outline">
|
||||
<Link
|
||||
:href="
|
||||
projectsCreate({
|
||||
query: { template: template.id },
|
||||
})
|
||||
"
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="gap-stack mt-auto flex flex-wrap border-t p-4">
|
||||
<Button as-child variant="secondary"
|
||||
><Link
|
||||
:href="
|
||||
createProject({
|
||||
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
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="text-destructive ml-auto"
|
||||
:aria-label="
|
||||
t('templates.deleteTitle', {
|
||||
name: template.name,
|
||||
})
|
||||
"
|
||||
@click="requestDelete(template)"
|
||||
><Trash2 aria-hidden="true"
|
||||
/></Button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
Use template
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
@click="removeTemplate(template.id)"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</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
|
||||
>
|
||||
<Button
|
||||
variant="destructive"
|
||||
:loading="form.processing"
|
||||
:loading-label="t('templates.deleting')"
|
||||
@click="removeTemplate"
|
||||
>{{ t('templates.delete') }}</Button
|
||||
>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
@@ -1,344 +1,308 @@
|
||||
<script setup lang="ts">
|
||||
import { Head, Link, usePage } from '@inertiajs/vue3';
|
||||
import { ArrowLeft, Search, ShieldCheck, Users } from 'lucide-vue-next';
|
||||
import { computed, ref } from 'vue';
|
||||
import FeedbackState from '@/components/FeedbackState.vue';
|
||||
import ProjectAccessEditorDialog from '@/components/projects/ProjectAccessEditorDialog.vue';
|
||||
import ProjectPermissionBadges from '@/components/projects/ProjectPermissionBadges.vue';
|
||||
import UserAvatar from '@/components/ui/avatar/UserAvatar.vue';
|
||||
import { Form, Head, Link } from '@inertiajs/vue3';
|
||||
import ProjectController from '@/actions/App/Http/Controllers/ProjectController';
|
||||
import Heading from '@/components/Heading.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 { useLocalization } from '@/composables/useLocalization';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
import { access, index, show } from '@/routes/projects';
|
||||
import { general } from '@/routes/projects/settings';
|
||||
import { access, index, show } from '@/routes/projects/index';
|
||||
import { general as generalSettings } from '@/routes/projects/settings';
|
||||
import type { BreadcrumbItem, ProjectAccessPageProps } from '@/types';
|
||||
import type { ProjectAccessTarget } from '@/types/project-access';
|
||||
|
||||
const props = defineProps<ProjectAccessPageProps>();
|
||||
const page = usePage();
|
||||
const { t } = useLocalization();
|
||||
const search = ref('');
|
||||
const target = ref<ProjectAccessTarget | null>(null);
|
||||
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
||||
{ title: t('navigation.projects'), href: index() },
|
||||
{ title: props.project.name, href: show(props.project.id) },
|
||||
{ title: t('navigation.access'), href: access(props.project.id) },
|
||||
]);
|
||||
const matches = (value: string) =>
|
||||
value.toLocaleLowerCase().includes(search.value.trim().toLocaleLowerCase());
|
||||
const visibleUsers = computed(() =>
|
||||
props.users.filter((user) => matches(`${user.name} ${user.email}`)),
|
||||
);
|
||||
const visibleTeams = computed(() =>
|
||||
props.teams.filter((team) => matches(team.name)),
|
||||
);
|
||||
const inheritedTeams = (userId: number) =>
|
||||
props.teams.filter(
|
||||
(team) =>
|
||||
team.user_ids.includes(userId) &&
|
||||
(props.teamPermissions[team.id] ?? []).length > 0,
|
||||
);
|
||||
|
||||
const breadcrumbs: BreadcrumbItem[] = [
|
||||
{
|
||||
title: 'Projects',
|
||||
href: index(),
|
||||
},
|
||||
{
|
||||
title: props.project.name,
|
||||
href: show(props.project.id),
|
||||
},
|
||||
{
|
||||
title: 'Access',
|
||||
href: access(props.project.id),
|
||||
},
|
||||
];
|
||||
|
||||
const hasPermission = (
|
||||
permissions: Record<string, string[]>,
|
||||
actorId: number,
|
||||
permission: string,
|
||||
) => (permissions[actorId] ?? []).includes(permission);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout :breadcrumbs="breadcrumbs">
|
||||
<Head
|
||||
:title="t('projectAccess.pageTitle', { project: project.name })"
|
||||
/>
|
||||
<main
|
||||
class="mx-auto flex w-full min-w-0 max-w-7xl flex-1 flex-col gap-6 p-4 sm:p-6 lg:p-8"
|
||||
>
|
||||
<header
|
||||
<Head :title="`${project.name} access`" />
|
||||
|
||||
<div class="flex flex-1 flex-col gap-6 p-4">
|
||||
<div
|
||||
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
|
||||
>
|
||||
<div class="grid min-w-0 gap-2">
|
||||
<p
|
||||
class="text-primary break-all text-xs font-semibold uppercase tracking-wider"
|
||||
>
|
||||
<div class="space-y-3">
|
||||
<Badge variant="outline" class="px-3 py-1">
|
||||
{{ project.key }}
|
||||
</p>
|
||||
<h1 class="text-headline-mobile sm:text-headline">
|
||||
{{ t('projectAccess.title') }}
|
||||
</h1>
|
||||
<p
|
||||
class="text-muted-foreground max-w-2xl break-words text-sm"
|
||||
>
|
||||
{{
|
||||
t('projectAccess.description', {
|
||||
project: project.name,
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
</Badge>
|
||||
<Heading
|
||||
:title="`${project.name} access`"
|
||||
description="Assign direct user permissions or inherited team permissions for this project."
|
||||
/>
|
||||
</div>
|
||||
<div class="flex shrink-0 flex-wrap gap-2">
|
||||
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<Button
|
||||
v-if="page.props.auth.permissions.globalAdmin"
|
||||
v-if="$page.props.auth.permissions.globalAdmin"
|
||||
as-child
|
||||
variant="outline"
|
||||
><Link :href="general(project.id)">{{
|
||||
t('navigation.general')
|
||||
}}</Link></Button
|
||||
>
|
||||
<Button as-child variant="outline"
|
||||
><Link :href="show(project.id)"
|
||||
><ArrowLeft class="size-4" aria-hidden="true" />{{
|
||||
t('projectAccess.back')
|
||||
}}</Link
|
||||
></Button
|
||||
>
|
||||
<Link :href="generalSettings(project.id)">
|
||||
General settings
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button as-child variant="outline">
|
||||
<Link :href="show(project.id)">Back to project</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
<FeedbackState
|
||||
v-if="status"
|
||||
state="success"
|
||||
:title="t('projectAccess.saved')"
|
||||
/>
|
||||
<aside
|
||||
class="bg-accent/60 flex items-start gap-3 rounded-xl border p-4"
|
||||
:aria-label="t('projectAccess.howItWorks')"
|
||||
>
|
||||
<ShieldCheck
|
||||
class="text-primary mt-0.5 size-5 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div class="grid gap-1">
|
||||
<h2 class="text-sm font-semibold">
|
||||
{{ t('projectAccess.howItWorks') }}
|
||||
</h2>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
{{ t('projectAccess.additiveHelp') }}
|
||||
</p>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
{{ t('projectAccess.limitsHelp') }}
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
<div class="relative sm:max-w-md">
|
||||
<Label for="access-search" class="sr-only">{{
|
||||
t('projectAccess.search')
|
||||
}}</Label
|
||||
><Search
|
||||
class="text-muted-foreground pointer-events-none absolute left-3 top-3 size-4"
|
||||
aria-hidden="true"
|
||||
/><Input
|
||||
id="access-search"
|
||||
v-model="search"
|
||||
type="search"
|
||||
class="pl-9"
|
||||
:placeholder="t('projectAccess.search')"
|
||||
/>
|
||||
</div>
|
||||
<section
|
||||
class="grid min-w-0 gap-4"
|
||||
aria-labelledby="people-heading"
|
||||
|
||||
<div
|
||||
v-if="status"
|
||||
class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900 dark:border-emerald-900/70 dark:bg-emerald-950/40 dark:text-emerald-100"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<h2 id="people-heading" class="text-lg font-semibold">
|
||||
{{ t('projectAccess.people') }}
|
||||
</h2>
|
||||
<Badge variant="secondary">{{ users.length }}</Badge>
|
||||
</div>
|
||||
<FeedbackState
|
||||
v-if="visibleUsers.length === 0"
|
||||
state="empty"
|
||||
:title="
|
||||
t(
|
||||
users.length
|
||||
? 'projectAccess.noPeopleResults'
|
||||
: 'projectAccess.noPeople',
|
||||
)
|
||||
"
|
||||
/>
|
||||
<ul
|
||||
v-else
|
||||
class="bg-card min-w-0 divide-y rounded-xl border"
|
||||
:aria-label="t('projectAccess.people')"
|
||||
>
|
||||
<li
|
||||
v-for="user in visibleUsers"
|
||||
:key="user.id"
|
||||
class="grid min-w-0 gap-4 p-4 sm:p-5 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.3fr)_auto] lg:items-center"
|
||||
:data-user-id="user.id"
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<UserAvatar :user="user" class="size-10 shrink-0" />
|
||||
<div class="grid min-w-0 gap-1">
|
||||
<h3 class="break-words text-sm font-semibold">
|
||||
{{ user.name }}
|
||||
</h3>
|
||||
<p
|
||||
class="text-muted-foreground break-all text-xs"
|
||||
>
|
||||
{{ user.email }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid min-w-0 gap-2">
|
||||
<p
|
||||
class="text-muted-foreground text-xs font-medium"
|
||||
>
|
||||
{{ t('projectAccess.effective') }}
|
||||
</p>
|
||||
<ProjectPermissionBadges
|
||||
:permissions="
|
||||
effectivePermissions[user.id] ?? []
|
||||
"
|
||||
:empty-label="t('projectAccess.noAccess')"
|
||||
/>
|
||||
<div
|
||||
class="text-muted-foreground grid gap-1 text-xs"
|
||||
>
|
||||
<span
|
||||
v-if="globalAdminUserIds.includes(user.id)"
|
||||
>{{ t('projectAccess.globalSource') }}</span
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span>{{ t('projectAccess.direct') }}</span
|
||||
><ProjectPermissionBadges
|
||||
:permissions="
|
||||
directPermissions[user.id] ?? []
|
||||
"
|
||||
:empty-label="t('projectAccess.none')"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-for="team in inheritedTeams(user.id)"
|
||||
:key="team.id"
|
||||
class="flex min-w-0 flex-wrap items-center gap-2"
|
||||
>
|
||||
<span class="break-words">{{
|
||||
t('projectAccess.viaTeam', {
|
||||
name: team.name,
|
||||
})
|
||||
}}</span
|
||||
><ProjectPermissionBadges
|
||||
:permissions="
|
||||
teamPermissions[team.id] ?? []
|
||||
"
|
||||
:empty-label="t('projectAccess.none')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
:aria-label="
|
||||
t('projectAccess.editFor', { name: user.name })
|
||||
"
|
||||
@click="
|
||||
target = {
|
||||
kind: 'user',
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
permissions:
|
||||
directPermissions[user.id] ?? [],
|
||||
globalAdmin: globalAdminUserIds.includes(
|
||||
user.id,
|
||||
),
|
||||
}
|
||||
"
|
||||
>{{ t('projectAccess.editDirect') }}</Button
|
||||
{{ status }}
|
||||
</div>
|
||||
|
||||
<Card class="gap-0 overflow-hidden">
|
||||
<CardHeader class="border-b">
|
||||
<CardTitle>User permissions</CardTitle>
|
||||
<CardDescription>
|
||||
Direct assignments apply only to the selected user
|
||||
inside this project.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent class="p-0">
|
||||
<div class="overflow-x-auto">
|
||||
<table
|
||||
class="divide-border min-w-full divide-y text-sm"
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
<section class="grid min-w-0 gap-4" aria-labelledby="teams-heading">
|
||||
<div class="grid gap-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<h2 id="teams-heading" class="text-lg font-semibold">
|
||||
{{ t('projectAccess.teamPermissions') }}
|
||||
</h2>
|
||||
<Badge variant="secondary">{{ teams.length }}</Badge>
|
||||
<thead
|
||||
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">
|
||||
Permissions
|
||||
</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"
|
||||
:key="user.id"
|
||||
class="bg-background align-top"
|
||||
>
|
||||
<td
|
||||
class="text-foreground px-4 py-4 font-medium"
|
||||
>
|
||||
{{ user.name }}
|
||||
</td>
|
||||
<td class="text-muted-foreground px-4 py-4">
|
||||
{{ user.email }}
|
||||
</td>
|
||||
<td class="px-4 py-4">
|
||||
<Form
|
||||
v-bind="
|
||||
ProjectController.updateUserPermissions.form(
|
||||
[project.id, user.id],
|
||||
)
|
||||
"
|
||||
class="flex flex-wrap gap-3"
|
||||
v-slot="{ processing }"
|
||||
>
|
||||
<label
|
||||
v-for="option in permissionOptions"
|
||||
:key="`${user.id}-${option.value}`"
|
||||
class="border-border inline-flex items-center gap-2 rounded-lg border px-3 py-2 text-sm"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="permissions[]"
|
||||
:value="option.value"
|
||||
:checked="
|
||||
hasPermission(
|
||||
directPermissions,
|
||||
user.id,
|
||||
option.value,
|
||||
)
|
||||
"
|
||||
class="border-input text-primary focus:ring-ring size-4 rounded"
|
||||
/>
|
||||
<span>{{ option.label }}</span>
|
||||
</label>
|
||||
|
||||
<div class="w-full sm:hidden" />
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
:disabled="processing"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Form>
|
||||
</td>
|
||||
<td
|
||||
class="text-muted-foreground px-4 py-4 text-right"
|
||||
>
|
||||
Direct
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
{{ t('projectAccess.teamHelp') }}
|
||||
</p>
|
||||
</div>
|
||||
<FeedbackState
|
||||
v-if="visibleTeams.length === 0"
|
||||
state="empty"
|
||||
:title="
|
||||
t(
|
||||
teams.length
|
||||
? 'projectAccess.noTeamResults'
|
||||
: 'projectAccess.noTeams',
|
||||
)
|
||||
"
|
||||
:description="
|
||||
teams.length
|
||||
? undefined
|
||||
: t('projectAccess.noTeamsHelp')
|
||||
"
|
||||
/>
|
||||
<ul
|
||||
v-else
|
||||
class="grid min-w-0 gap-4 lg:grid-cols-2"
|
||||
:aria-label="t('projectAccess.teamPermissions')"
|
||||
>
|
||||
<li
|
||||
v-for="team in visibleTeams"
|
||||
:key="team.id"
|
||||
class="bg-card grid min-w-0 gap-4 rounded-xl border p-5"
|
||||
:data-team-id="team.id"
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card class="gap-0 overflow-hidden">
|
||||
<CardHeader class="border-b">
|
||||
<CardTitle>Team permissions</CardTitle>
|
||||
<CardDescription>
|
||||
Team assignments are inherited by every current and
|
||||
future member of that team.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent class="p-0">
|
||||
<div
|
||||
v-if="teams.length === 0"
|
||||
class="text-muted-foreground p-6 text-sm"
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<span
|
||||
class="bg-accent text-primary flex size-10 shrink-0 items-center justify-center rounded-lg"
|
||||
><Users class="size-5" aria-hidden="true"
|
||||
/></span>
|
||||
<div class="min-w-0">
|
||||
<h3 class="break-words text-sm font-semibold">
|
||||
{{ team.name }}
|
||||
</h3>
|
||||
<p class="text-muted-foreground text-xs">
|
||||
{{
|
||||
t('teams.memberCount', {
|
||||
count: team.users.length,
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ProjectPermissionBadges
|
||||
:permissions="teamPermissions[team.id] ?? []"
|
||||
:empty-label="t('projectAccess.noTeamAccess')"
|
||||
/>
|
||||
<p class="text-muted-foreground break-words text-xs">
|
||||
{{
|
||||
team.users
|
||||
.map((user) => user.name)
|
||||
.join(', ') || t('teams.noMembers')
|
||||
}}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
:aria-label="
|
||||
t('projectAccess.editFor', { name: team.name })
|
||||
"
|
||||
@click="
|
||||
target = {
|
||||
kind: 'team',
|
||||
id: team.id,
|
||||
name: team.name,
|
||||
permissions: teamPermissions[team.id] ?? [],
|
||||
}
|
||||
"
|
||||
>{{ t('projectAccess.editTeam') }}</Button
|
||||
No teams exist yet. Create teams first to assign
|
||||
inherited permissions.
|
||||
</div>
|
||||
|
||||
<div v-else class="overflow-x-auto">
|
||||
<table
|
||||
class="divide-border min-w-full divide-y text-sm"
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
<ProjectAccessEditorDialog
|
||||
:project-id="project.id"
|
||||
:target="target"
|
||||
:permission-options="permissionOptions"
|
||||
@close="target = null"
|
||||
/>
|
||||
</main>
|
||||
<thead
|
||||
class="bg-muted/40 text-muted-foreground text-left"
|
||||
>
|
||||
<tr>
|
||||
<th class="px-4 py-3 font-medium">Team</th>
|
||||
<th class="px-4 py-3 font-medium">
|
||||
Members
|
||||
</th>
|
||||
<th class="px-4 py-3 font-medium">
|
||||
Permissions
|
||||
</th>
|
||||
<th
|
||||
class="px-4 py-3 text-right font-medium"
|
||||
>
|
||||
Action
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-border divide-y">
|
||||
<tr
|
||||
v-for="team in teams"
|
||||
:key="team.id"
|
||||
class="bg-background align-top"
|
||||
>
|
||||
<td
|
||||
class="text-foreground px-4 py-4 font-medium"
|
||||
>
|
||||
{{ team.name }}
|
||||
</td>
|
||||
<td class="px-4 py-4">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Badge
|
||||
v-for="member in team.users"
|
||||
:key="member.id"
|
||||
variant="secondary"
|
||||
>
|
||||
{{ member.name }}
|
||||
</Badge>
|
||||
<span
|
||||
v-if="team.users.length === 0"
|
||||
class="text-muted-foreground"
|
||||
>
|
||||
No members assigned
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-4">
|
||||
<Form
|
||||
v-bind="
|
||||
ProjectController.updateTeamPermissions.form(
|
||||
[project.id, team.id],
|
||||
)
|
||||
"
|
||||
class="flex flex-wrap gap-3"
|
||||
v-slot="{ processing }"
|
||||
>
|
||||
<label
|
||||
v-for="option in permissionOptions"
|
||||
:key="`${team.id}-${option.value}`"
|
||||
class="border-border inline-flex items-center gap-2 rounded-lg border px-3 py-2 text-sm"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="permissions[]"
|
||||
:value="option.value"
|
||||
:checked="
|
||||
hasPermission(
|
||||
teamPermissions,
|
||||
team.id,
|
||||
option.value,
|
||||
)
|
||||
"
|
||||
class="border-input text-primary focus:ring-ring size-4 rounded"
|
||||
/>
|
||||
<span>{{ option.label }}</span>
|
||||
</label>
|
||||
|
||||
<div class="w-full sm:hidden" />
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
:disabled="processing"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Form>
|
||||
</td>
|
||||
<td
|
||||
class="text-muted-foreground px-4 py-4 text-right"
|
||||
>
|
||||
Inherited
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
@@ -1,49 +1,121 @@
|
||||
<script setup lang="ts">
|
||||
import { Head } from '@inertiajs/vue3';
|
||||
import { computed, ref } from 'vue';
|
||||
import FeedbackState from '@/components/FeedbackState.vue';
|
||||
import MetadataEditorDialog from '@/components/projects/MetadataEditorDialog.vue';
|
||||
import MetadataOptionList from '@/components/projects/MetadataOptionList.vue';
|
||||
import TicketDisplaySettings from '@/components/projects/TicketDisplaySettings.vue';
|
||||
import TicketFieldDefinitionSettings from '@/components/projects/TicketFieldDefinitionSettings.vue';
|
||||
import TicketLinkTypeSettings from '@/components/projects/TicketLinkTypeSettings.vue';
|
||||
import { Form, Head, Link } from '@inertiajs/vue3';
|
||||
import ProjectTicketMetadataSettingsController from '@/actions/App/Http/Controllers/ProjectTicketMetadataSettingsController';
|
||||
import Heading from '@/components/Heading.vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import TemplateInheritancePanel from '@/components/projects/TemplateInheritancePanel.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 SettingsLayout from '@/layouts/projects/SettingsLayout.vue';
|
||||
import { index, show } from '@/routes/projects';
|
||||
import { metadata } from '@/routes/projects/settings';
|
||||
import { index, show } from '@/routes/projects/index';
|
||||
import {
|
||||
board as boardSettings,
|
||||
general as generalSettings,
|
||||
metadata as metadataSettings,
|
||||
notifications as notificationSettings,
|
||||
repositories as repositorySettings,
|
||||
} from '@/routes/projects/settings';
|
||||
import type {
|
||||
BreadcrumbItem,
|
||||
ProjectTicketMetadataSettingsPageProps,
|
||||
} from '@/types';
|
||||
import type { MetadataEditorTarget } from '@/types/project-metadata';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
|
||||
const props = defineProps<ProjectTicketMetadataSettingsPageProps>();
|
||||
const { t } = useLocalization();
|
||||
const target = ref<MetadataEditorTarget | null>(null);
|
||||
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
||||
{ title: t('navigation.projects'), href: index() },
|
||||
{ title: props.project.name, href: show(props.project.id) },
|
||||
{ title: t('metadata.title'), href: metadata(props.project.id) },
|
||||
]);
|
||||
|
||||
const breadcrumbs: BreadcrumbItem[] = [
|
||||
{
|
||||
title: 'Projects',
|
||||
href: index(),
|
||||
},
|
||||
{
|
||||
title: props.project.name,
|
||||
href: show(props.project.id),
|
||||
},
|
||||
{
|
||||
title: 'Ticket settings',
|
||||
href: metadataSettings(props.project.id),
|
||||
},
|
||||
];
|
||||
|
||||
const replacementTicketTypes = (ticketTypeId: number) =>
|
||||
props.ticketTypes.filter((ticketType) => ticketType.id !== ticketTypeId);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout :breadcrumbs="breadcrumbs">
|
||||
<Head :title="t('metadata.pageTitle', { project: project.name })" />
|
||||
<SettingsLayout :project-name="project.name" :project-key="project.key">
|
||||
<header class="grid gap-3">
|
||||
<h2 class="text-title">{{ t('metadata.title') }}</h2>
|
||||
<p class="text-muted-foreground">
|
||||
{{ t('metadata.description') }}
|
||||
</p>
|
||||
</header>
|
||||
<FeedbackState
|
||||
<Head :title="`${project.name} ticket settings`" />
|
||||
|
||||
<div class="flex flex-1 flex-col gap-6 p-4">
|
||||
<div
|
||||
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
|
||||
>
|
||||
<div class="space-y-3">
|
||||
<Badge variant="outline" class="px-3 py-1">
|
||||
{{ project.key }}
|
||||
</Badge>
|
||||
<Heading
|
||||
:title="`${project.name} ticket settings`"
|
||||
description="Configure project-specific ticket types, priorities, custom field definitions, and ticket link labels."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<Button
|
||||
v-if="$page.props.auth.permissions.globalAdmin"
|
||||
as-child
|
||||
variant="outline"
|
||||
>
|
||||
<Link :href="generalSettings(project.id)">
|
||||
General settings
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button as-child variant="outline">
|
||||
<Link :href="boardSettings(project.id)">
|
||||
Board settings
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button as-child variant="outline">
|
||||
<Link :href="repositorySettings(project.id)">
|
||||
Repository settings
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button as-child variant="outline">
|
||||
<Link :href="notificationSettings(project.id)">
|
||||
Notification settings
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button as-child variant="outline">
|
||||
<Link :href="show(project.id)">Back to project</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="status"
|
||||
:state="error ? 'error' : 'success'"
|
||||
:title="error ? status : t('metadata.updated')"
|
||||
/>
|
||||
:class="[
|
||||
'rounded-xl border px-4 py-3 text-sm',
|
||||
error
|
||||
? 'border-red-200/80 bg-red-50/60 text-red-800/80 dark:border-red-900/60 dark:bg-red-950/20 dark:text-red-200/80'
|
||||
: 'border-emerald-200 bg-emerald-50 text-emerald-900 dark:border-emerald-900/70 dark:bg-emerald-950/40 dark:text-emerald-100',
|
||||
]"
|
||||
>
|
||||
{{ status }}
|
||||
</div>
|
||||
|
||||
<TemplateInheritancePanel
|
||||
:areas="[
|
||||
'project_settings',
|
||||
@@ -55,34 +127,675 @@ const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
||||
:project-id="project.id"
|
||||
:template-state="templateState"
|
||||
/>
|
||||
<TicketDisplaySettings :project="project" />
|
||||
<MetadataOptionList
|
||||
kind="type"
|
||||
:entries="ticketTypes"
|
||||
@edit="target = $event"
|
||||
/>
|
||||
<MetadataOptionList
|
||||
kind="priority"
|
||||
:entries="ticketPriorities"
|
||||
@edit="target = $event"
|
||||
/>
|
||||
<TicketFieldDefinitionSettings
|
||||
:definitions="ticketFieldDefinitions"
|
||||
:field-type-options="fieldTypeOptions"
|
||||
:project-id="project.id"
|
||||
/>
|
||||
<TicketLinkTypeSettings
|
||||
:link-types="ticketLinkTypes"
|
||||
:project-id="project.id"
|
||||
/>
|
||||
|
||||
<MetadataEditorDialog
|
||||
:project-id="project.id"
|
||||
:target="target"
|
||||
:types="ticketTypes"
|
||||
:priorities="ticketPriorities"
|
||||
@close="target = null"
|
||||
/>
|
||||
</SettingsLayout>
|
||||
<div class="grid gap-6 xl:grid-cols-2">
|
||||
<Card class="gap-0 overflow-hidden">
|
||||
<CardHeader class="border-b">
|
||||
<CardTitle>General ticket settings</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent class="space-y-4 p-6">
|
||||
<Form
|
||||
v-bind="
|
||||
ProjectTicketMetadataSettingsController.update.form(
|
||||
project.id,
|
||||
)
|
||||
"
|
||||
class="border-border space-y-4 rounded-xl border border-dashed p-4"
|
||||
v-slot="{ errors, processing }"
|
||||
>
|
||||
<Label for="enable-story-points">
|
||||
<Checkbox
|
||||
id="enable-story-points"
|
||||
name="show_story_points"
|
||||
:default-value="project.show_story_points"
|
||||
value="1"
|
||||
/>
|
||||
Enable story points for tickets
|
||||
</Label>
|
||||
|
||||
<InputError :message="errors.show_story_points" />
|
||||
|
||||
<Label for="enable-author">
|
||||
<Checkbox
|
||||
id="enable-author"
|
||||
name="show_author"
|
||||
:default-value="project.show_author"
|
||||
value="1"
|
||||
/>
|
||||
Show author on ticket details
|
||||
</Label>
|
||||
|
||||
<InputError :message="errors.show_author" />
|
||||
|
||||
<Button type="submit" :disabled="processing">
|
||||
Save
|
||||
</Button>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card class="gap-0 overflow-hidden">
|
||||
<CardHeader class="border-b">
|
||||
<CardTitle>Ticket types</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent class="space-y-4 p-6">
|
||||
<Form
|
||||
v-bind="
|
||||
ProjectTicketMetadataSettingsController.storeType.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="type-name">New type name</Label>
|
||||
<Input
|
||||
id="type-name"
|
||||
name="name"
|
||||
required
|
||||
placeholder="Bug"
|
||||
/>
|
||||
<InputError :message="errors.name" />
|
||||
|
||||
<Label for="type-is-parent">
|
||||
<Checkbox
|
||||
id="type-is-parent"
|
||||
name="is_parent"
|
||||
value="1"
|
||||
/>
|
||||
Ticket type can act as a parent
|
||||
</Label>
|
||||
<InputError :message="errors.is_parent" />
|
||||
</div>
|
||||
|
||||
<Button type="submit" :disabled="processing">
|
||||
Add type
|
||||
</Button>
|
||||
</Form>
|
||||
|
||||
<div
|
||||
v-for="ticketType in ticketTypes"
|
||||
:key="ticketType.id"
|
||||
class="border-border rounded-xl border p-4"
|
||||
>
|
||||
<Form
|
||||
v-bind="
|
||||
ProjectTicketMetadataSettingsController.updateType.form(
|
||||
[project.id, ticketType.id],
|
||||
)
|
||||
"
|
||||
class="grid gap-4"
|
||||
v-slot="{ errors, processing }"
|
||||
>
|
||||
<div class="grid gap-2">
|
||||
<div
|
||||
v-if="ticketType.is_parent"
|
||||
class="flex flex-wrap items-center gap-2"
|
||||
>
|
||||
<Badge variant="secondary">
|
||||
Parent type
|
||||
</Badge>
|
||||
<span
|
||||
class="text-muted-foreground text-xs"
|
||||
>
|
||||
This flag is fixed after creation.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Label :for="`type-name-${ticketType.id}`">
|
||||
Name
|
||||
</Label>
|
||||
<Input
|
||||
:id="`type-name-${ticketType.id}`"
|
||||
name="name"
|
||||
:default-value="ticketType.name"
|
||||
required
|
||||
/>
|
||||
<InputError :message="errors.name" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label
|
||||
:for="`type-position-${ticketType.id}`"
|
||||
>
|
||||
Position
|
||||
</Label>
|
||||
<Input
|
||||
:id="`type-position-${ticketType.id}`"
|
||||
name="position"
|
||||
type="number"
|
||||
min="1"
|
||||
:max="ticketTypes.length"
|
||||
:default-value="ticketType.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.destroyType.form(
|
||||
[project.id, ticketType.id],
|
||||
)
|
||||
"
|
||||
class="mt-3 grid gap-3"
|
||||
v-slot="{ errors, processing: deleting }"
|
||||
>
|
||||
<div
|
||||
v-if="
|
||||
(ticketType.tickets_count ?? 0) > 0 &&
|
||||
ticketTypes.length > 1
|
||||
"
|
||||
class="grid gap-2"
|
||||
>
|
||||
<Label
|
||||
:for="`replacement-type-${ticketType.id}`"
|
||||
>
|
||||
Replacement type for
|
||||
{{ ticketType.tickets_count }} assigned
|
||||
ticket{{
|
||||
ticketType.tickets_count === 1
|
||||
? ''
|
||||
: 's'
|
||||
}}
|
||||
</Label>
|
||||
<select
|
||||
:id="`replacement-type-${ticketType.id}`"
|
||||
name="replacement_ticket_type_id"
|
||||
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]"
|
||||
required
|
||||
>
|
||||
<option value="">Choose type</option>
|
||||
<option
|
||||
v-for="replacementTicketType in replacementTicketTypes(
|
||||
ticketType.id,
|
||||
)"
|
||||
:key="replacementTicketType.id"
|
||||
:value="replacementTicketType.id"
|
||||
>
|
||||
{{ replacementTicketType.name }}
|
||||
</option>
|
||||
</select>
|
||||
<InputError
|
||||
:message="
|
||||
errors.replacement_ticket_type_id
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex flex-wrap items-center justify-between gap-3"
|
||||
>
|
||||
<p
|
||||
v-if="
|
||||
(ticketType.tickets_count ?? 0) > 0
|
||||
"
|
||||
class="text-muted-foreground text-sm"
|
||||
>
|
||||
Assigned tickets will be moved before
|
||||
this type is deleted.
|
||||
</p>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="destructive"
|
||||
:disabled="deleting"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card class="gap-0 overflow-hidden">
|
||||
<CardHeader class="border-b">
|
||||
<CardTitle>Priorities</CardTitle>
|
||||
<CardDescription>
|
||||
Priorities are fully project-specific and can be
|
||||
reordered or removed.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent class="space-y-4 p-6">
|
||||
<Form
|
||||
v-bind="
|
||||
ProjectTicketMetadataSettingsController.storePriority.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="priority-name"
|
||||
>New priority name</Label
|
||||
>
|
||||
<Input
|
||||
id="priority-name"
|
||||
name="name"
|
||||
required
|
||||
placeholder="Urgent"
|
||||
/>
|
||||
<InputError :message="errors.name" />
|
||||
</div>
|
||||
|
||||
<Button type="submit" :disabled="processing">
|
||||
Add priority
|
||||
</Button>
|
||||
</Form>
|
||||
|
||||
<div
|
||||
v-for="ticketPriority in ticketPriorities"
|
||||
:key="ticketPriority.id"
|
||||
class="border-border rounded-xl border p-4"
|
||||
>
|
||||
<Form
|
||||
v-bind="
|
||||
ProjectTicketMetadataSettingsController.updatePriority.form(
|
||||
[project.id, ticketPriority.id],
|
||||
)
|
||||
"
|
||||
class="grid gap-4"
|
||||
v-slot="{ errors, processing }"
|
||||
>
|
||||
<div class="grid gap-2">
|
||||
<Label
|
||||
:for="`priority-name-${ticketPriority.id}`"
|
||||
>
|
||||
Name
|
||||
</Label>
|
||||
<Input
|
||||
:id="`priority-name-${ticketPriority.id}`"
|
||||
name="name"
|
||||
:default-value="ticketPriority.name"
|
||||
required
|
||||
/>
|
||||
<InputError :message="errors.name" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label
|
||||
:for="`priority-position-${ticketPriority.id}`"
|
||||
>
|
||||
Position
|
||||
</Label>
|
||||
<Input
|
||||
:id="`priority-position-${ticketPriority.id}`"
|
||||
name="position"
|
||||
type="number"
|
||||
min="1"
|
||||
:max="ticketPriorities.length"
|
||||
:default-value="ticketPriority.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.destroyPriority.form(
|
||||
[project.id, ticketPriority.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>Custom fields</CardTitle>
|
||||
<CardDescription>
|
||||
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" />
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+335
-183
@@ -1,204 +1,356 @@
|
||||
<script setup lang="ts">
|
||||
import { Head } from '@inertiajs/vue3';
|
||||
import { Plus, Search, Shield, Users } from 'lucide-vue-next';
|
||||
import { computed, ref } from 'vue';
|
||||
import FeedbackState from '@/components/FeedbackState.vue';
|
||||
import TeamEditorDialog from '@/components/teams/TeamEditorDialog.vue';
|
||||
import UserAvatar from '@/components/ui/avatar/UserAvatar.vue';
|
||||
import { Form, Head } from '@inertiajs/vue3';
|
||||
import { Shield } from 'lucide-vue-next';
|
||||
import TeamController from '@/actions/App/Http/Controllers/TeamController';
|
||||
import Heading from '@/components/Heading.vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
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 { useLocalization } from '@/composables/useLocalization';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
import { index } from '@/routes/teams';
|
||||
import { index as teamsIndex } from '@/routes/teams';
|
||||
import type { BreadcrumbItem, TeamsIndexPageProps } from '@/types';
|
||||
import type { TeamEditorTarget } from '@/types/teams';
|
||||
|
||||
const props = defineProps<TeamsIndexPageProps>();
|
||||
const { t } = useLocalization();
|
||||
const search = ref('');
|
||||
const target = ref<TeamEditorTarget | null>(null);
|
||||
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
||||
{ title: t('navigation.teams'), href: index() },
|
||||
]);
|
||||
const visibleTeams = computed(() =>
|
||||
props.teams.filter((team) =>
|
||||
team.name
|
||||
.toLocaleLowerCase()
|
||||
.includes(search.value.trim().toLocaleLowerCase()),
|
||||
),
|
||||
);
|
||||
|
||||
const breadcrumbs: BreadcrumbItem[] = [
|
||||
{
|
||||
title: 'Teams',
|
||||
href: teamsIndex(),
|
||||
},
|
||||
];
|
||||
|
||||
const hasPermission = (
|
||||
permissions: Record<string, string[]>,
|
||||
actorId: number,
|
||||
permission: string,
|
||||
) => (permissions[actorId] ?? []).includes(permission);
|
||||
|
||||
const permissionsForTeam = (teamId: number): Record<string, string[]> =>
|
||||
props.teamPermissions[teamId] ?? {};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout :breadcrumbs="breadcrumbs">
|
||||
<Head :title="t('navigation.teams')" />
|
||||
<main
|
||||
class="mx-auto flex w-full max-w-7xl flex-1 flex-col gap-6 p-4 sm:p-6 lg:p-8"
|
||||
>
|
||||
<header
|
||||
<Head title="Teams" />
|
||||
|
||||
<div class="flex flex-1 flex-col gap-6 p-4">
|
||||
<div
|
||||
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
|
||||
>
|
||||
<div class="grid min-w-0 gap-2">
|
||||
<p
|
||||
class="text-primary text-xs font-semibold uppercase tracking-wider"
|
||||
>
|
||||
{{ t('teams.eyebrow') }}
|
||||
</p>
|
||||
<h1 class="text-headline-mobile sm:text-headline">
|
||||
{{ t('navigation.teams') }}
|
||||
</h1>
|
||||
<p class="text-muted-foreground max-w-2xl text-sm">
|
||||
{{ t('teams.description') }}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
v-if="canCreateTeams"
|
||||
type="button"
|
||||
class="shrink-0"
|
||||
@click="target = { kind: 'create' }"
|
||||
><Plus class="size-4" aria-hidden="true" />{{
|
||||
t('teams.create')
|
||||
}}</Button
|
||||
>
|
||||
</header>
|
||||
<FeedbackState
|
||||
v-if="status"
|
||||
state="success"
|
||||
:title="t('teams.updated')"
|
||||
/>
|
||||
<Heading
|
||||
title="Teams"
|
||||
description="Create reusable teams, manage memberships, and assign team-scoped permissions to users."
|
||||
/>
|
||||
|
||||
<Badge variant="outline" class="gap-1.5 px-3 py-1">
|
||||
<Shield class="size-3.5" />
|
||||
{{ canCreateTeams ? 'Global admin' : 'Team-scoped access' }}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="bg-card flex flex-col gap-4 rounded-xl border p-4 sm:flex-row sm:items-center sm:justify-between"
|
||||
v-if="status"
|
||||
class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900 dark:border-emerald-900/70 dark:bg-emerald-950/40 dark:text-emerald-100"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<Users class="text-primary size-5" aria-hidden="true" />
|
||||
<h2 class="font-semibold">{{ t('teams.yourTeams') }}</h2>
|
||||
<Badge variant="secondary">{{ teams.length }}</Badge>
|
||||
</div>
|
||||
<div class="relative sm:w-80">
|
||||
<Label for="team-search" class="sr-only">{{
|
||||
t('teams.search')
|
||||
}}</Label
|
||||
><Search
|
||||
class="text-muted-foreground pointer-events-none absolute left-3 top-3 size-4"
|
||||
aria-hidden="true"
|
||||
/><Input
|
||||
id="team-search"
|
||||
v-model="search"
|
||||
type="search"
|
||||
class="pl-9"
|
||||
:placeholder="t('teams.search')"
|
||||
/>
|
||||
{{ status }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="grid gap-6"
|
||||
:class="
|
||||
canCreateTeams
|
||||
? 'xl:grid-cols-[22rem_minmax(0,1fr)]'
|
||||
: 'xl:grid-cols-1'
|
||||
"
|
||||
>
|
||||
<Card v-if="canCreateTeams">
|
||||
<CardHeader>
|
||||
<CardTitle>Create team</CardTitle>
|
||||
<CardDescription>
|
||||
Teams group users so project permissions can be
|
||||
granted once and inherited by every member.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<Form
|
||||
v-bind="TeamController.store.form()"
|
||||
class="space-y-4"
|
||||
v-slot="{ errors, processing }"
|
||||
>
|
||||
<div class="grid gap-2">
|
||||
<Label for="name">Team name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
required
|
||||
autofocus
|
||||
placeholder="Platform Team"
|
||||
/>
|
||||
<InputError :message="errors.name" />
|
||||
</div>
|
||||
|
||||
<Button type="submit" :disabled="processing">
|
||||
Create team
|
||||
</Button>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div class="grid gap-4">
|
||||
<Card
|
||||
v-for="team in teams"
|
||||
:key="team.id"
|
||||
class="gap-0 overflow-hidden"
|
||||
>
|
||||
<CardHeader class="border-b">
|
||||
<div
|
||||
class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between"
|
||||
>
|
||||
<div>
|
||||
<CardTitle>{{ team.name }}</CardTitle>
|
||||
<CardDescription>
|
||||
{{ team.users.length }} member{{
|
||||
team.users.length === 1 ? '' : 's'
|
||||
}}
|
||||
</CardDescription>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Badge
|
||||
v-for="member in team.users"
|
||||
:key="member.id"
|
||||
variant="secondary"
|
||||
>
|
||||
{{ member.name }}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent class="space-y-6 pt-6">
|
||||
<Form
|
||||
v-bind="
|
||||
TeamController.updateMembers.form(team.id)
|
||||
"
|
||||
class="space-y-4"
|
||||
v-slot="{ processing }"
|
||||
>
|
||||
<div class="grid gap-2">
|
||||
<Label :for="`team-members-${team.id}`">
|
||||
Members
|
||||
</Label>
|
||||
<select
|
||||
:id="`team-members-${team.id}`"
|
||||
name="user_ids[]"
|
||||
multiple
|
||||
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 min-h-44 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px]"
|
||||
>
|
||||
<option
|
||||
v-for="user in users"
|
||||
:key="user.id"
|
||||
:value="user.id"
|
||||
:selected="
|
||||
team.user_ids.includes(user.id)
|
||||
"
|
||||
>
|
||||
{{ user.name }} ({{ user.email }})
|
||||
</option>
|
||||
</select>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
Hold Cmd or Ctrl to select multiple
|
||||
users.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
:disabled="processing"
|
||||
>
|
||||
Save members
|
||||
</Button>
|
||||
</Form>
|
||||
|
||||
<div class="space-y-4 border-t pt-6">
|
||||
<div class="space-y-1">
|
||||
<h3 class="text-foreground font-medium">
|
||||
Team permissions
|
||||
</h3>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
Direct user permissions on this team are
|
||||
stored separately from team membership.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table
|
||||
class="divide-border min-w-full divide-y text-sm"
|
||||
>
|
||||
<thead
|
||||
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"
|
||||
>
|
||||
Current permissions
|
||||
</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"
|
||||
:key="`${team.id}-${user.id}`"
|
||||
class="bg-background align-top"
|
||||
>
|
||||
<td class="px-4 py-4">
|
||||
<div
|
||||
class="text-foreground font-medium"
|
||||
>
|
||||
{{ user.name }}
|
||||
</div>
|
||||
<div
|
||||
class="text-muted-foreground text-xs"
|
||||
>
|
||||
{{ user.email }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-4">
|
||||
<div
|
||||
class="flex flex-wrap gap-2"
|
||||
>
|
||||
<Badge
|
||||
v-for="option in permissionOptions"
|
||||
v-show="
|
||||
hasPermission(
|
||||
permissionsForTeam(
|
||||
team.id,
|
||||
),
|
||||
user.id,
|
||||
option.value,
|
||||
)
|
||||
"
|
||||
:key="`${team.id}-${user.id}-${option.value}-badge`"
|
||||
variant="secondary"
|
||||
>
|
||||
{{ option.label }}
|
||||
</Badge>
|
||||
<span
|
||||
v-if="
|
||||
(
|
||||
permissionsForTeam(
|
||||
team.id,
|
||||
)[
|
||||
user.id
|
||||
] ?? []
|
||||
).length === 0
|
||||
"
|
||||
class="text-muted-foreground"
|
||||
>
|
||||
No direct team
|
||||
permissions
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-4">
|
||||
<div
|
||||
class="flex justify-end"
|
||||
>
|
||||
<Form
|
||||
v-bind="
|
||||
TeamController.updateUserPermissions.form(
|
||||
[
|
||||
team.id,
|
||||
user.id,
|
||||
],
|
||||
)
|
||||
"
|
||||
class="flex flex-wrap justify-end gap-3"
|
||||
v-slot="{
|
||||
processing,
|
||||
}"
|
||||
>
|
||||
<label
|
||||
v-for="option in permissionOptions"
|
||||
:key="`${team.id}-${user.id}-${option.value}`"
|
||||
class="border-border inline-flex items-center gap-2 rounded-lg border px-3 py-2 text-sm"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="permissions[]"
|
||||
:value="
|
||||
option.value
|
||||
"
|
||||
:checked="
|
||||
hasPermission(
|
||||
permissionsForTeam(
|
||||
team.id,
|
||||
),
|
||||
user.id,
|
||||
option.value,
|
||||
)
|
||||
"
|
||||
class="border-input text-primary focus:ring-ring size-4 rounded"
|
||||
/>
|
||||
<span>
|
||||
{{
|
||||
option.label
|
||||
}}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div
|
||||
class="w-full sm:hidden"
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
:disabled="
|
||||
processing
|
||||
"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card v-if="teams.length === 0">
|
||||
<CardContent class="text-muted-foreground p-6 text-sm">
|
||||
No teams are available for your current permissions.
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
<FeedbackState
|
||||
v-if="teams.length === 0"
|
||||
state="empty"
|
||||
:title="t('teams.empty')"
|
||||
:description="
|
||||
t(
|
||||
canCreateTeams
|
||||
? 'teams.emptyCreate'
|
||||
: 'teams.emptyDescription',
|
||||
)
|
||||
"
|
||||
/>
|
||||
<FeedbackState
|
||||
v-else-if="visibleTeams.length === 0"
|
||||
state="empty"
|
||||
:title="t('teams.noResults')"
|
||||
:description="t('teams.noResultsDescription')"
|
||||
><Button type="button" variant="outline" @click="search = ''">{{
|
||||
t('teams.clearSearch')
|
||||
}}</Button></FeedbackState
|
||||
>
|
||||
<ul
|
||||
v-else
|
||||
class="grid min-w-0 gap-4 lg:grid-cols-2"
|
||||
:aria-label="t('teams.yourTeams')"
|
||||
>
|
||||
<li
|
||||
v-for="team in visibleTeams"
|
||||
:key="team.id"
|
||||
class="bg-card flex min-w-0 flex-col rounded-xl border"
|
||||
:data-team-id="team.id"
|
||||
>
|
||||
<div class="flex min-w-0 items-start gap-3 p-5">
|
||||
<span
|
||||
class="bg-accent text-primary flex size-11 shrink-0 items-center justify-center rounded-lg"
|
||||
><Users class="size-5" aria-hidden="true"
|
||||
/></span>
|
||||
<div class="grid min-w-0 gap-1">
|
||||
<h3 class="break-words font-semibold">
|
||||
{{ team.name }}
|
||||
</h3>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
{{
|
||||
t('teams.memberCount', {
|
||||
count: team.users.length,
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 px-5 pb-5">
|
||||
<ul
|
||||
v-if="team.users.length"
|
||||
class="flex flex-wrap gap-2"
|
||||
:aria-label="t('teams.members')"
|
||||
>
|
||||
<li
|
||||
v-for="member in team.users"
|
||||
:key="member.id"
|
||||
class="bg-muted/60 flex min-w-0 max-w-full items-center gap-2 rounded-full py-1 pl-1 pr-3"
|
||||
>
|
||||
<UserAvatar
|
||||
:user="member"
|
||||
class="size-6 shrink-0"
|
||||
/><span class="min-w-0 break-words text-xs">{{
|
||||
member.name
|
||||
}}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else class="text-muted-foreground text-sm">
|
||||
{{ t('teams.noMembers') }}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="bg-muted/20 grid gap-2 border-t p-4 sm:grid-cols-2"
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
:aria-label="
|
||||
t('teams.manageMembersFor', { name: team.name })
|
||||
"
|
||||
@click="target = { kind: 'members', team }"
|
||||
><Users class="size-4" aria-hidden="true" />{{
|
||||
t('teams.manageMembers')
|
||||
}}</Button
|
||||
><Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
:aria-label="
|
||||
t('teams.permissionsFor', { name: team.name })
|
||||
"
|
||||
@click="target = { kind: 'permissions', team }"
|
||||
><Shield class="size-4" aria-hidden="true" />{{
|
||||
t('teams.permissions')
|
||||
}}</Button
|
||||
>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</main>
|
||||
<TeamEditorDialog
|
||||
:target="target"
|
||||
:users="users"
|
||||
:permission-options="permissionOptions"
|
||||
:team-permissions="teamPermissions"
|
||||
@close="target = null"
|
||||
/>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
@@ -4,7 +4,6 @@ export type TicketFieldDefinitionModel = {
|
||||
label: string;
|
||||
position: number;
|
||||
project_id: number;
|
||||
ticket_field_values_count?: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
@@ -4,7 +4,6 @@ export type TicketLinkTypeModel = {
|
||||
counterpart_label: string;
|
||||
position: number;
|
||||
project_id: number;
|
||||
ticket_links_count?: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
@@ -117,8 +117,6 @@ export type PermissionOption = {
|
||||
|
||||
export type ProjectAccessPageProps = {
|
||||
directPermissions: Record<string, string[]>;
|
||||
effectivePermissions: Record<string, string[]>;
|
||||
globalAdminUserIds: number[];
|
||||
permissionOptions: PermissionOption[];
|
||||
project: ProjectModel;
|
||||
status?: string | null;
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
export const projectPermissionLabels = {
|
||||
'project:read': 'projectAccess.read',
|
||||
'project:write': 'projectAccess.write',
|
||||
'project:admin': 'projectAccess.admin',
|
||||
} as const;
|
||||
|
||||
export const projectPermissionDescriptions = {
|
||||
'project:read': 'projectAccess.readHelp',
|
||||
'project:write': 'projectAccess.writeHelp',
|
||||
'project:admin': 'projectAccess.adminHelp',
|
||||
} as const;
|
||||
|
||||
export type ProjectAccessTarget = {
|
||||
kind: 'user' | 'team';
|
||||
id: number;
|
||||
name: string;
|
||||
permissions: string[];
|
||||
globalAdmin?: boolean;
|
||||
};
|
||||
@@ -1,23 +0,0 @@
|
||||
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';
|
||||
|
||||
export type MetadataKind = 'type' | 'priority';
|
||||
export type MetadataEntry = TicketPriorityModel &
|
||||
Partial<Pick<TicketTypeModel, 'is_parent' | 'tickets_count'>>;
|
||||
export type MetadataEditorTarget = { kind: MetadataKind } & (
|
||||
| { action: 'create'; entry?: never }
|
||||
| { 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 };
|
||||
@@ -1,12 +0,0 @@
|
||||
import type { NotificationActionModel } from '@/types/models/notification-action-model';
|
||||
|
||||
export type NotificationEditorTarget =
|
||||
| { action: 'create'; entry?: never }
|
||||
| { action: 'edit' | 'delete'; entry: NotificationActionModel };
|
||||
|
||||
export type NotificationFilterFormState = {
|
||||
id: number;
|
||||
attribute: string;
|
||||
operator: string;
|
||||
value: string;
|
||||
};
|
||||
@@ -1,14 +0,0 @@
|
||||
import type { PermissionOption } from '@/types';
|
||||
import type { TeamModel } from '@/types/models/team-model';
|
||||
import type { UserModel } from '@/types/models/user-model';
|
||||
|
||||
export type TeamEditorTarget =
|
||||
| { kind: 'create' }
|
||||
| { kind: 'members' | 'permissions'; team: TeamModel };
|
||||
|
||||
export type TeamEditorProps = {
|
||||
target: TeamEditorTarget | null;
|
||||
users: UserModel[];
|
||||
permissionOptions: PermissionOption[];
|
||||
teamPermissions: Record<string, Record<string, string[]>>;
|
||||
};
|
||||
@@ -1,25 +0,0 @@
|
||||
<?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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Projects;
|
||||
|
||||
use App\Actions\Permissions\PermissionService;
|
||||
use App\Enums\Permission;
|
||||
use App\Models\Project;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ProjectAccessTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_access_summary_matches_existing_permission_rules_and_excludes_other_scopes(): void
|
||||
{
|
||||
$admin = User::factory()->admin()->create();
|
||||
$writer = User::factory()->create();
|
||||
$member = User::factory()->create();
|
||||
$outsider = User::factory()->create();
|
||||
$project = Project::factory()->create();
|
||||
$otherProject = Project::factory()->create();
|
||||
$team = Team::factory()->create();
|
||||
$otherTeam = Team::factory()->create();
|
||||
$team->users()->attach([$writer->id, $member->id]);
|
||||
$otherTeam->users()->attach($writer);
|
||||
$service = app(PermissionService::class);
|
||||
$service->sync($writer, $project, [Permission::ProjectRead]);
|
||||
$service->sync($team, $project, [Permission::ProjectWrite]);
|
||||
$service->sync($otherTeam, $project, [Permission::ProjectRead]);
|
||||
$service->sync($outsider, $otherProject, [Permission::ProjectAdmin]);
|
||||
$service->sync($outsider, null, [Permission::ProjectCreate]);
|
||||
$service->sync($outsider, $team, [Permission::TeamAdmin]);
|
||||
|
||||
$map = $service->projectAccessMap(User::all(), $project);
|
||||
foreach (User::all() as $user) {
|
||||
$expected = array_values(array_filter(Permission::projectAssignable(), fn (Permission $permission): bool => $service->allows($user, $permission, $project)));
|
||||
$this->assertSame(Permission::values($expected), $map['effectivePermissions'][$user->id]);
|
||||
}
|
||||
$this->assertSame([], $map['effectivePermissions'][$outsider->id]);
|
||||
$this->assertSame(['project:read', 'project:write'], $map['effectivePermissions'][$writer->id]);
|
||||
$this->assertSame([$admin->id], $map['globalAdminUserIds']);
|
||||
$this->actingAs($admin)->get(route('projects.access', $project))->assertOk()
|
||||
->assertInertia(fn (Assert $page) => $page->component('projects/Access')
|
||||
->where("effectivePermissions.{$writer->id}", ['project:read', 'project:write'])
|
||||
->where("directPermissions.{$writer->id}", ['project:read'])
|
||||
->where("teamPermissions.{$team->id}", ['project:write'])
|
||||
->where('globalAdminUserIds', [$admin->id]));
|
||||
}
|
||||
|
||||
public function test_bulk_access_summary_uses_a_bounded_number_of_queries(): void
|
||||
{
|
||||
$project = Project::factory()->create();
|
||||
User::factory()->create();
|
||||
$service = app(PermissionService::class);
|
||||
$countQueries = function () use ($service, $project): int {
|
||||
$users = User::all();
|
||||
DB::enableQueryLog();
|
||||
DB::flushQueryLog();
|
||||
$service->projectAccessMap($users, $project);
|
||||
$count = count(DB::getQueryLog());
|
||||
DB::disableQueryLog();
|
||||
|
||||
return $count;
|
||||
};
|
||||
$initialCount = $countQueries();
|
||||
$users = User::factory()->count(10)->create();
|
||||
$team = Team::factory()->create();
|
||||
$team->users()->attach($users);
|
||||
$service->sync($team, $project, [Permission::ProjectWrite]);
|
||||
$this->assertSame($initialCount, $countQueries());
|
||||
$this->assertLessThanOrEqual(4, $initialCount);
|
||||
}
|
||||
|
||||
public function test_readers_writers_and_unrelated_admins_cannot_manage_project_access(): void
|
||||
{
|
||||
$project = Project::factory()->create();
|
||||
$other = Project::factory()->create();
|
||||
$member = User::factory()->create();
|
||||
$team = Team::factory()->create();
|
||||
$service = app(PermissionService::class);
|
||||
$service->sync($member, $project, [Permission::ProjectRead]);
|
||||
$service->sync($team, $project, [Permission::ProjectWrite]);
|
||||
foreach ([[], [Permission::ProjectRead], [Permission::ProjectWrite]] as $permissions) {
|
||||
$actor = User::factory()->create();
|
||||
$service->sync($actor, $project, $permissions);
|
||||
$service->sync($actor, $other, [Permission::ProjectAdmin]);
|
||||
$this->actingAs($actor)->get(route('projects.access', $project))->assertForbidden();
|
||||
foreach ([['users', $member], ['teams', $team]] as [$kind, $assignee]) {
|
||||
$this->patch(route("projects.permissions.{$kind}.update", [$project, $assignee]), ['permissions' => ['project:admin']])->assertForbidden();
|
||||
}
|
||||
}
|
||||
$this->assertSame(['project:read'], $service->assignmentMap(User::class, $project)[$member->id]);
|
||||
$this->assertSame(['project:write'], $service->assignmentMap(Team::class, $project)[$team->id]);
|
||||
}
|
||||
|
||||
public function test_invalid_assignments_preserve_both_user_and_team_permissions(): void
|
||||
{
|
||||
$admin = User::factory()->admin()->create();
|
||||
$project = Project::factory()->create();
|
||||
$service = app(PermissionService::class);
|
||||
foreach ([['users', User::factory()->create()], ['teams', Team::factory()->create()]] as [$kind, $assignee]) {
|
||||
$service->sync($assignee, $project, [Permission::ProjectRead]);
|
||||
foreach ([['global:admin'], ['team:admin'], ['unknown'], ['project:read', 'project:read']] as $permissions) {
|
||||
$this->actingAs($admin)->patch(route("projects.permissions.{$kind}.update", [$project, $assignee]), ['permissions' => $permissions])
|
||||
->assertSessionHasErrors('permissions.0');
|
||||
$this->assertSame(['project:read'], $service->assignmentMap($assignee::class, $project)[$assignee->id]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function test_clearing_assignments_preserves_access_from_other_sources(): void
|
||||
{
|
||||
$admin = User::factory()->admin()->create();
|
||||
$member = User::factory()->create();
|
||||
$project = Project::factory()->create();
|
||||
$team = Team::factory()->create();
|
||||
$team->users()->attach($member);
|
||||
$service = app(PermissionService::class);
|
||||
$service->sync($member, $project, [Permission::ProjectRead]);
|
||||
$service->sync($team, $project, [Permission::ProjectWrite]);
|
||||
$this->actingAs($admin)->patch(route('projects.permissions.users.update', [$project, $member]), ['permissions' => []])
|
||||
->assertRedirect(route('projects.access', $project))->assertSessionHasNoErrors();
|
||||
$this->assertTrue($service->allows($member, Permission::ProjectWrite, $project));
|
||||
$service->sync($member, $project, [Permission::ProjectRead]);
|
||||
$this->patch(route('projects.permissions.teams.update', [$project, $team]), ['permissions' => []])
|
||||
->assertRedirect(route('projects.access', $project))->assertSessionHasNoErrors();
|
||||
$this->assertTrue($service->allows($member, Permission::ProjectRead, $project));
|
||||
$this->assertFalse($service->allows($member, Permission::ProjectWrite, $project));
|
||||
}
|
||||
|
||||
public function test_removing_own_admin_access_redirects_to_projects_for_direct_and_team_grants(): void
|
||||
{
|
||||
$service = app(PermissionService::class);
|
||||
foreach (['users', 'teams'] as $kind) {
|
||||
$actor = User::factory()->create();
|
||||
$project = Project::factory()->create();
|
||||
$assignee = $actor;
|
||||
if ($kind === 'teams') {
|
||||
$assignee = Team::factory()->create();
|
||||
$assignee->users()->attach($actor);
|
||||
}
|
||||
$service->sync($assignee, $project, [Permission::ProjectAdmin]);
|
||||
$this->actingAs($actor)->patch(route("projects.permissions.{$kind}.update", [$project, $assignee]), ['permissions' => []])
|
||||
->assertRedirect(route('projects.index'))->assertSessionHasNoErrors();
|
||||
$this->assertFalse($service->allows($actor, Permission::ProjectAdmin, $project));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Projects;
|
||||
|
||||
use Symfony\Component\Process\Process;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ProjectAccessUiTest extends TestCase
|
||||
{
|
||||
public function test_access_overview_renders_sources_empty_states_and_localization(): void
|
||||
{
|
||||
$process = new Process(['node', 'tests/Fixtures/project-access.mjs'], base_path());
|
||||
$process->setTimeout(60);
|
||||
$process->run();
|
||||
|
||||
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
|
||||
$this->assertStringContainsString('Project access presentation checks passed.', $process->getOutput());
|
||||
}
|
||||
}
|
||||
@@ -298,79 +298,6 @@ class ProjectBoardSettingsControllerTest extends TestCase
|
||||
$this->assertCount(0, $sourceStatus->allowedTargetStatuses()->get());
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure saved outgoing targets reach both consumers without changing the reverse direction.
|
||||
*/
|
||||
public function test_saved_transition_subsets_and_empty_targets_are_reflected_in_ticket_and_board_responses(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$project = Project::factory()->create(['key' => 'CORE']);
|
||||
$this->syncProjectPermissions($project, $user, ['edit']);
|
||||
$column = BoardColumn::factory()->for($project)->create(['position' => 1]);
|
||||
$sourceStatus = TicketStatus::factory()->for($project)->for($column)->create([
|
||||
'name' => 'Progress',
|
||||
'position' => 1,
|
||||
]);
|
||||
$reviewStatus = TicketStatus::factory()->for($project)->create([
|
||||
'name' => 'Review',
|
||||
'position' => 2,
|
||||
]);
|
||||
$doneStatus = TicketStatus::factory()->for($project)->create([
|
||||
'name' => 'Done',
|
||||
'position' => 3,
|
||||
]);
|
||||
$ticket = Ticket::factory()->for($project)->create(['ticket_status_id' => $sourceStatus->id]);
|
||||
$sourceStatus->allowedTargetStatuses()->sync([$reviewStatus->id, $doneStatus->id]);
|
||||
$reviewStatus->allowedTargetStatuses()->sync([$sourceStatus->id]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->patch(route('projects.settings.board.statuses.transitions.update', [$project, $sourceStatus]), [
|
||||
'allowed_ticket_status_ids' => [$reviewStatus->id],
|
||||
])
|
||||
->assertRedirect(route('projects.settings.board', $project))
|
||||
->assertSessionHasNoErrors();
|
||||
|
||||
$this->assertSame([$reviewStatus->id], $sourceStatus->allowedTargetStatuses()->get()->modelKeys());
|
||||
$this->assertSame([$sourceStatus->id], $reviewStatus->allowedTargetStatuses()->get()->modelKeys());
|
||||
|
||||
$this->withApplicationKey($user)
|
||||
->getJson(route('api.projects.tickets.status-targets', [$project, $ticket]))
|
||||
->assertOk()
|
||||
->assertJsonPath('data.current_status.id', $sourceStatus->id)
|
||||
->assertJsonCount(1, 'data.target_statuses')
|
||||
->assertJsonPath('data.target_statuses.0.id', $reviewStatus->id);
|
||||
|
||||
$this->get(route('projects.board.index', $project))
|
||||
->assertOk()
|
||||
->assertInertia(fn (Assert $page) => $page
|
||||
->component('projects/Board')
|
||||
->where('boardColumns.0.tickets.0.id', $ticket->id)
|
||||
->has('boardColumns.0.tickets.0.ticket_status.allowed_target_statuses', 1)
|
||||
->where('boardColumns.0.tickets.0.ticket_status.allowed_target_statuses.0.id', $reviewStatus->id),
|
||||
);
|
||||
|
||||
$this->patch(route('projects.settings.board.statuses.transitions.update', [$project, $sourceStatus]), [
|
||||
'allowed_ticket_status_ids' => [],
|
||||
])
|
||||
->assertRedirect(route('projects.settings.board', $project))
|
||||
->assertSessionHasNoErrors();
|
||||
|
||||
$this->assertCount(0, $sourceStatus->allowedTargetStatuses()->get());
|
||||
$this->assertSame([$sourceStatus->id], $reviewStatus->allowedTargetStatuses()->get()->modelKeys());
|
||||
|
||||
$this->getJson(route('api.projects.tickets.status-targets', [$project, $ticket]))
|
||||
->assertOk()
|
||||
->assertJsonPath('data.target_statuses', []);
|
||||
|
||||
$this->get(route('projects.board.index', $project))
|
||||
->assertOk()
|
||||
->assertInertia(fn (Assert $page) => $page
|
||||
->component('projects/Board')
|
||||
->where('boardColumns.0.tickets.0.id', $ticket->id)
|
||||
->has('boardColumns.0.tickets.0.ticket_status.allowed_target_statuses', 0),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure transition updates only accept targets from the same project and never the source status itself.
|
||||
*/
|
||||
|
||||
@@ -430,66 +430,4 @@ class ProjectNotificationSettingsControllerTest extends TestCase
|
||||
->missing('notificationActions.0.delivery_url'),
|
||||
);
|
||||
}
|
||||
|
||||
public function test_editor_accepts_array_filters_and_can_clear_discord_mentions(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$project = Project::factory()->create();
|
||||
$notificationAction = NotificationAction::factory()->discord()->for($project)->create([
|
||||
'channel_configuration' => ['allowed_mentions' => ['users']],
|
||||
'delivery_url' => 'https://discord.com/api/webhooks/123456/original-secret',
|
||||
]);
|
||||
$this->syncProjectPermissions($project, $user, ['edit']);
|
||||
|
||||
$this->actingAs($user)
|
||||
->patch(route('projects.settings.notifications.update', [$project, $notificationAction]), [
|
||||
'name' => 'Paused alerts',
|
||||
'event_type' => NotificationEventType::TicketAssigneeChanged->value,
|
||||
'channel' => NotificationChannel::Discord->value,
|
||||
'enabled' => false,
|
||||
'delivery_url' => null,
|
||||
'message_template' => '$ticket_key: $url',
|
||||
'channel_configuration' => ['allowed_mentions' => []],
|
||||
'filters' => [
|
||||
['attribute' => 'ticket.key', 'operator' => 'in', 'value' => ['CORE-1', 'CORE-2']],
|
||||
['attribute' => 'actor.name', 'operator' => 'exists', 'value' => null],
|
||||
],
|
||||
])
|
||||
->assertSessionHasNoErrors()
|
||||
->assertRedirect(route('projects.settings.notifications', $project));
|
||||
|
||||
$notificationAction->refresh();
|
||||
$this->assertFalse($notificationAction->enabled);
|
||||
$this->assertSame(['allowed_mentions' => []], $notificationAction->channel_configuration);
|
||||
$this->assertSame('https://discord.com/api/webhooks/123456/original-secret', $notificationAction->delivery_url);
|
||||
$this->assertSame([
|
||||
['attribute' => 'ticket.key', 'operator' => 'in', 'value' => ['CORE-1', 'CORE-2']],
|
||||
['attribute' => 'actor.name', 'operator' => 'exists', 'value' => null],
|
||||
], $notificationAction->filters);
|
||||
}
|
||||
|
||||
public function test_invalid_editor_update_does_not_change_saved_configuration(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$project = Project::factory()->create();
|
||||
$notificationAction = NotificationAction::factory()->discord()->for($project)->create();
|
||||
$original = $notificationAction->fresh()->getRawOriginal();
|
||||
$this->syncProjectPermissions($project, $user, ['edit']);
|
||||
|
||||
$this->actingAs($user)
|
||||
->from(route('projects.settings.notifications', $project))
|
||||
->patch(route('projects.settings.notifications.update', [$project, $notificationAction]), [
|
||||
'name' => 'Invalid update',
|
||||
'event_type' => NotificationEventType::TicketCreate->value,
|
||||
'channel' => NotificationChannel::Discord->value,
|
||||
'enabled' => false,
|
||||
'delivery_url' => 'invalid-url',
|
||||
'message_template' => '',
|
||||
'channel_configuration' => ['allowed_mentions' => ['unsupported']],
|
||||
'filters' => [['attribute' => 'ticket.key', 'operator' => 'in', 'value' => []]],
|
||||
])
|
||||
->assertSessionHasErrors(['delivery_url', 'message_template', 'channel_configuration.allowed_mentions.0', 'filters.0.value']);
|
||||
|
||||
$this->assertSame($original, $notificationAction->fresh()->getRawOriginal());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Projects;
|
||||
|
||||
use Symfony\Component\Process\Process;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ProjectNotificationSettingsUiTest extends TestCase
|
||||
{
|
||||
public function test_notification_settings_render_actions_and_serialize_filter_values(): void
|
||||
{
|
||||
$process = new Process(['node', 'tests/Fixtures/project-notification-settings.mjs'], base_path());
|
||||
$process->setTimeout(60);
|
||||
$process->run();
|
||||
|
||||
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
|
||||
$this->assertStringContainsString('Notification settings checks passed.', $process->getOutput());
|
||||
}
|
||||
}
|
||||
@@ -63,58 +63,6 @@ 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.
|
||||
*/
|
||||
|
||||
@@ -163,26 +163,6 @@ class ProjectTemplateControllerTest extends TestCase
|
||||
->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
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
<?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,27 +36,8 @@ class ProjectTicketMetadataSettingsControllerTest extends TestCase
|
||||
]);
|
||||
TicketLinkType::factory()->for($project)->create([
|
||||
'label' => 'Relates to',
|
||||
'counterpart_label' => 'Related from',
|
||||
'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([
|
||||
'configuration_snapshot' => app(DuplicateProjectConfiguration::class)->export($project, [
|
||||
@@ -90,14 +71,7 @@ class ProjectTicketMetadataSettingsControllerTest extends TestCase
|
||||
->where('ticketTypes.1.name', 'Release')
|
||||
->where('ticketTypes.1.is_parent', true)
|
||||
->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.counterpart_label', 'Related from')
|
||||
->where('ticketLinkTypes.0.ticket_links_count', 1)
|
||||
->where('templateState.templateName', 'Issue Tracker Template')
|
||||
->where('templateState.overriddenAreas.0.value', DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS),
|
||||
);
|
||||
@@ -413,11 +387,6 @@ class ProjectTicketMetadataSettingsControllerTest extends TestCase
|
||||
'field_type' => 'number',
|
||||
'position' => 1,
|
||||
]);
|
||||
$ticket = Ticket::factory()->for($project)->create();
|
||||
$fieldValue = $fieldDefinition->ticketFieldValues()->create([
|
||||
'ticket_id' => $ticket->id,
|
||||
'value_number' => 5,
|
||||
]);
|
||||
|
||||
$this->syncProjectPermissions($project, $user, ['edit']);
|
||||
|
||||
@@ -443,11 +412,6 @@ class ProjectTicketMetadataSettingsControllerTest extends TestCase
|
||||
'label' => 'Risk score',
|
||||
'field_type' => 'number',
|
||||
]);
|
||||
$this->assertDatabaseHas('ticket_field_values', [
|
||||
'id' => $fieldValue->id,
|
||||
'ticket_field_definition_id' => $fieldDefinition->id,
|
||||
'value_number' => 5,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -482,15 +446,6 @@ class ProjectTicketMetadataSettingsControllerTest extends TestCase
|
||||
'counterpart_label' => 'Relates to',
|
||||
'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']);
|
||||
|
||||
@@ -522,10 +477,6 @@ class ProjectTicketMetadataSettingsControllerTest extends TestCase
|
||||
'label' => 'Depends on',
|
||||
'counterpart_label' => 'Required by',
|
||||
]);
|
||||
$this->assertDatabaseHas('ticket_links', [
|
||||
'id' => $ticketLink->id,
|
||||
'ticket_link_type_id' => $ticketLinkType->id,
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->delete(route('projects.settings.metadata.link-types.destroy', [$project, $ticketLinkType]))
|
||||
@@ -535,79 +486,5 @@ class ProjectTicketMetadataSettingsControllerTest extends TestCase
|
||||
$this->assertDatabaseMissing('ticket_link_types', [
|
||||
'id' => $ticketLinkType->id,
|
||||
]);
|
||||
$this->assertDatabaseMissing('ticket_links', [
|
||||
'id' => $ticketLink->id,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_last_priority_is_protected_from_deletion(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$project = Project::factory()->create();
|
||||
$priority = TicketPriority::factory()->for($project)->create();
|
||||
$this->syncProjectPermissions($project, $user, ['edit']);
|
||||
|
||||
$this->actingAs($user)
|
||||
->delete(route('projects.settings.metadata.priorities.destroy', [$project, $priority]))
|
||||
->assertRedirect(route('projects.settings.metadata', $project))
|
||||
->assertSessionHas('error', true)
|
||||
->assertSessionHas('status', 'The last ticket priority cannot be deleted.');
|
||||
|
||||
$this->assertModelExists($priority);
|
||||
}
|
||||
|
||||
public function test_type_order_and_usage_counts_are_returned_after_reordering(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$project = Project::factory()->create();
|
||||
app(InitializeProjectTicketMetadata::class)->handle($project);
|
||||
$types = $project->ticketTypes()->get();
|
||||
$first = $types->first();
|
||||
$last = $types->last();
|
||||
Ticket::factory()->for($project)->create([
|
||||
'ticket_type_id' => $last->id,
|
||||
'ticket_priority_id' => $project->ticketPriorities()->firstOrFail()->id,
|
||||
]);
|
||||
$this->syncProjectPermissions($project, $user, ['edit']);
|
||||
|
||||
$this->actingAs($user)
|
||||
->patch(route('projects.settings.metadata.types.update', [$project, $last]), [
|
||||
'name' => 'Customer request', 'position' => 1,
|
||||
])
|
||||
->assertSessionHasNoErrors();
|
||||
|
||||
$this->get(route('projects.settings.metadata', $project))
|
||||
->assertInertia(fn (Assert $page) => $page
|
||||
->where('ticketTypes.0.id', $last->id)
|
||||
->where('ticketTypes.0.name', 'Customer request')
|
||||
->where('ticketTypes.0.position', 1)
|
||||
->where('ticketTypes.0.tickets_count', 1)
|
||||
->where('ticketTypes.1.id', $first->id)
|
||||
->where('ticketTypes.1.position', 2)
|
||||
->where('ticketTypes.1.tickets_count', 0));
|
||||
}
|
||||
|
||||
public function test_foreign_replacement_type_is_rejected_without_changing_tickets(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$project = Project::factory()->create();
|
||||
app(InitializeProjectTicketMetadata::class)->handle($project);
|
||||
$type = $project->ticketTypes()->firstOrFail();
|
||||
$foreignType = TicketType::factory()->create();
|
||||
$ticket = Ticket::factory()->for($project)->create([
|
||||
'ticket_type_id' => $type->id,
|
||||
'ticket_priority_id' => $project->ticketPriorities()->firstOrFail()->id,
|
||||
]);
|
||||
$this->syncProjectPermissions($project, $user, ['edit']);
|
||||
|
||||
$this->actingAs($user)
|
||||
->from(route('projects.settings.metadata', $project))
|
||||
->delete(route('projects.settings.metadata.types.destroy', [$project, $type]), [
|
||||
'replacement_ticket_type_id' => $foreignType->id,
|
||||
])
|
||||
->assertSessionHasErrors('replacement_ticket_type_id');
|
||||
|
||||
$this->assertModelExists($type);
|
||||
$this->assertSame($type->id, $ticket->fresh()->ticket_type_id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Projects;
|
||||
|
||||
use Symfony\Component\Process\Process;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ProjectTicketMetadataSettingsUiTest extends TestCase
|
||||
{
|
||||
public function test_metadata_settings_show_configured_values_and_deletion_protection(): void
|
||||
{
|
||||
$process = new Process(['node', 'tests/Fixtures/project-metadata-settings.mjs'], base_path());
|
||||
$process->setTimeout(60);
|
||||
$process->run();
|
||||
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
|
||||
$this->assertStringContainsString('Metadata settings checks passed.', $process->getOutput());
|
||||
}
|
||||
}
|
||||
@@ -214,89 +214,4 @@ class TeamControllerTest extends TestCase
|
||||
'scope_type' => $team->getMorphClass(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_team_managers_only_see_their_manageable_teams(): void
|
||||
{
|
||||
$manager = User::factory()->create();
|
||||
$managed = Team::factory()->create();
|
||||
$other = Team::factory()->create();
|
||||
$other->users()->attach($manager);
|
||||
$this->syncTeamPermissions($managed, $manager, ['team:manage']);
|
||||
|
||||
$this->actingAs($manager)->get(route('teams.index'))
|
||||
->assertInertia(fn (Assert $page) => $page
|
||||
->has('teams', 1)
|
||||
->where('teams.0.id', $managed->id)
|
||||
->where('canCreateTeams', false)
|
||||
);
|
||||
|
||||
$this->actingAs($manager)->patch(route('teams.members.update', $other), ['user_ids' => []])
|
||||
->assertForbidden();
|
||||
$this->actingAs($manager)->patch(route('teams.permissions.users.update', [$other, $manager]), ['permissions' => ['team:admin']])
|
||||
->assertForbidden();
|
||||
$this->assertDatabaseHas('team_user', ['team_id' => $other->id, 'user_id' => $manager->id]);
|
||||
$this->assertDatabaseMissing('permission_assignments', ['scope_id' => $other->id, 'scope_type' => $other->getMorphClass()]);
|
||||
}
|
||||
|
||||
public function test_invalid_team_names_are_rejected(): void
|
||||
{
|
||||
$admin = User::factory()->admin()->create();
|
||||
$team = Team::factory()->create();
|
||||
|
||||
foreach (['', $team->name, str_repeat('a', 256)] as $name) {
|
||||
$this->actingAs($admin)->from(route('teams.index'))->post(route('teams.store'), ['name' => $name])
|
||||
->assertRedirect(route('teams.index'))->assertSessionHasErrors('name');
|
||||
}
|
||||
$this->assertDatabaseCount('teams', 1);
|
||||
}
|
||||
|
||||
public function test_invalid_members_preserve_existing_membership(): void
|
||||
{
|
||||
$admin = User::factory()->admin()->create();
|
||||
$member = User::factory()->create();
|
||||
$team = Team::factory()->create();
|
||||
$team->users()->attach($member);
|
||||
|
||||
foreach ([[$member->id, $member->id], [999999], ['invalid']] as $ids) {
|
||||
$this->actingAs($admin)->patch(route('teams.members.update', $team), ['user_ids' => $ids])
|
||||
->assertSessionHasErrors('user_ids.0');
|
||||
$this->assertSame([$member->id], $team->users()->pluck('users.id')->all());
|
||||
}
|
||||
}
|
||||
|
||||
public function test_membership_and_direct_permissions_can_be_cleared_independently(): void
|
||||
{
|
||||
$manager = User::factory()->create();
|
||||
$member = User::factory()->create();
|
||||
$team = Team::factory()->create();
|
||||
$team->users()->attach($member);
|
||||
$this->syncTeamPermissions($team, $manager, ['team:manage']);
|
||||
$this->syncTeamPermissions($team, $member, ['team:invite']);
|
||||
|
||||
$this->actingAs($manager)->patch(route('teams.members.update', $team), ['user_ids' => []])
|
||||
->assertRedirect(route('teams.index'))->assertSessionHasNoErrors();
|
||||
$this->assertDatabaseMissing('team_user', ['team_id' => $team->id]);
|
||||
$this->assertDatabaseHas('permission_assignments', ['assignee_id' => $member->id, 'scope_id' => $team->id, 'permission' => 'team:invite']);
|
||||
|
||||
$team->users()->attach($member);
|
||||
$this->actingAs($manager)->patch(route('teams.permissions.users.update', [$team, $member]), ['permissions' => []])
|
||||
->assertRedirect(route('teams.index'))->assertSessionHasNoErrors();
|
||||
$this->assertDatabaseHas('team_user', ['team_id' => $team->id, 'user_id' => $member->id]);
|
||||
$this->assertDatabaseMissing('permission_assignments', ['assignee_id' => $member->id, 'scope_id' => $team->id, 'scope_type' => $team->getMorphClass()]);
|
||||
}
|
||||
|
||||
public function test_invalid_permissions_preserve_existing_assignments(): void
|
||||
{
|
||||
$admin = User::factory()->admin()->create();
|
||||
$member = User::factory()->create();
|
||||
$team = Team::factory()->create();
|
||||
$this->syncTeamPermissions($team, $member, ['team:invite']);
|
||||
|
||||
foreach ([['global:admin'], ['team:manage', 'team:manage'], ['unknown']] as $permissions) {
|
||||
$this->actingAs($admin)->patch(route('teams.permissions.users.update', [$team, $member]), ['permissions' => $permissions])
|
||||
->assertSessionHasErrors('permissions.0');
|
||||
$this->assertDatabaseHas('permission_assignments', ['assignee_id' => $member->id, 'scope_id' => $team->id, 'permission' => 'team:invite']);
|
||||
$this->assertDatabaseMissing('permission_assignments', ['assignee_id' => $member->id, 'permission' => 'global:admin']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Teams;
|
||||
|
||||
use Symfony\Component\Process\Process;
|
||||
use Tests\TestCase;
|
||||
|
||||
class TeamPresentationTest extends TestCase
|
||||
{
|
||||
public function test_team_overview_respects_permissions_empty_states_and_localization(): void
|
||||
{
|
||||
$process = new Process(['node', 'tests/Fixtures/teams.mjs'], base_path());
|
||||
$process->setTimeout(60);
|
||||
$process->run();
|
||||
|
||||
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
|
||||
$this->assertStringContainsString('Teams presentation checks passed.', $process->getOutput());
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
use Symfony\Component\Process\Process;
|
||||
use Tests\TestCase;
|
||||
|
||||
class WelcomeTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_public_entry_page_is_available_to_guests(): void
|
||||
{
|
||||
$this->get(route('home'))
|
||||
->assertOk()
|
||||
->assertInertia(fn (Assert $page) => $page
|
||||
->component('Welcome')
|
||||
->where('auth.user', null));
|
||||
}
|
||||
|
||||
public function test_public_entry_page_recognizes_authenticated_users(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('home'))
|
||||
->assertOk()
|
||||
->assertInertia(fn (Assert $page) => $page
|
||||
->component('Welcome')
|
||||
->where('auth.user.id', $user->id));
|
||||
}
|
||||
|
||||
public function test_public_entry_page_presentation_and_localization(): void
|
||||
{
|
||||
$process = new Process(['node', 'tests/Fixtures/welcome.mjs'], base_path());
|
||||
$process->setTimeout(60);
|
||||
$process->run();
|
||||
|
||||
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
|
||||
$this->assertStringContainsString('Welcome behavior checks passed.', $process->getOutput());
|
||||
}
|
||||
}
|
||||
@@ -1,189 +0,0 @@
|
||||
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({ reducedMotion: 'reduce' });
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(10000);
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
const base = 'http://127.0.0.1:4198';
|
||||
const result = (value) =>
|
||||
context.addCookies([{ name: 'access_result', value, url: base }]);
|
||||
const visit = async (query = '') => {
|
||||
await result('success');
|
||||
await page.goto(base + '/projects/1/access?reset' + query);
|
||||
await page
|
||||
.getByRole('heading', { name: 'Project access', exact: true })
|
||||
.waitFor();
|
||||
};
|
||||
const button = (name) => page.getByRole('button', { name, exact: true });
|
||||
const dialog = () => page.getByRole('dialog');
|
||||
const checkbox = (name) =>
|
||||
dialog().getByRole('checkbox', { name, exact: true });
|
||||
const state = (value) =>
|
||||
dialog()
|
||||
.locator('[data-form-state="' + value + '"]')
|
||||
.waitFor({ state: 'visible' });
|
||||
const noOverflow = async () =>
|
||||
assert.equal(
|
||||
await page.evaluate(
|
||||
() => document.documentElement.scrollWidth <= innerWidth,
|
||||
),
|
||||
true,
|
||||
);
|
||||
const save = async () => {
|
||||
const request = page.waitForRequest((r) => r.method() === 'PATCH');
|
||||
await dialog()
|
||||
.getByRole('button', { name: 'Save permissions', exact: true })
|
||||
.click();
|
||||
return await request;
|
||||
};
|
||||
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 });
|
||||
await visit();
|
||||
await noOverflow();
|
||||
const trigger = button('Edit access for Sam Taylor');
|
||||
await trigger.focus();
|
||||
await page.keyboard.press('Enter');
|
||||
await dialog().waitFor();
|
||||
await noOverflow();
|
||||
assert.equal(await checkbox('Read').isChecked(), true);
|
||||
assert.equal(
|
||||
await checkbox('Write').isChecked(),
|
||||
false,
|
||||
'Inherited write must not be editable as a direct grant',
|
||||
);
|
||||
await checkbox('Write').focus();
|
||||
await page.keyboard.press('Space');
|
||||
assert.equal(await checkbox('Write').isChecked(), true);
|
||||
await page.keyboard.press('Space');
|
||||
await page.keyboard.press('Tab');
|
||||
assert.equal(
|
||||
await dialog().evaluate((el) =>
|
||||
el.contains(document.activeElement),
|
||||
),
|
||||
true,
|
||||
);
|
||||
await page.screenshot({
|
||||
path:
|
||||
'/private/tmp/kan48-dialog-' + width + '-' + theme + '.png',
|
||||
});
|
||||
await page.keyboard.press('Escape');
|
||||
await dialog().waitFor({ state: 'hidden' });
|
||||
assert.equal(
|
||||
await trigger.evaluate((el) => el === document.activeElement),
|
||||
true,
|
||||
);
|
||||
await page.screenshot({
|
||||
path: '/private/tmp/kan48-' + width + '-' + theme + '.png',
|
||||
fullPage: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
await page.locator('#access-search').fill('SAM@EXAMPLE');
|
||||
assert.equal(await page.locator('[data-user-id]').count(), 1);
|
||||
await page.getByText('No matching teams', { exact: true }).waitFor();
|
||||
await page.locator('#access-search').fill('unmatched');
|
||||
await page.getByText('No matching people', { exact: true }).waitFor();
|
||||
await visit('&empty&noUsers');
|
||||
await page.getByText('No teams yet', { exact: true }).waitFor();
|
||||
await page.getByText('No people available', { exact: true }).waitFor();
|
||||
await visit();
|
||||
await button('Edit access for Alex Morgan').click();
|
||||
await dialog()
|
||||
.getByText('This person is a global administrator.', { exact: false })
|
||||
.waitFor();
|
||||
await page.keyboard.press('Escape');
|
||||
await button('Edit access for Sam Taylor').click();
|
||||
await checkbox('Read').uncheck();
|
||||
const request = await save();
|
||||
assert.equal(
|
||||
new URL(request.url()).pathname,
|
||||
'/projects/1/permissions/users/2',
|
||||
);
|
||||
assert.deepEqual(request.postDataJSON(), { permissions: [] });
|
||||
await dialog().waitFor({ state: 'hidden' });
|
||||
await page
|
||||
.locator('[data-user-id="2"]')
|
||||
.getByText('Write', { exact: true })
|
||||
.first()
|
||||
.waitFor();
|
||||
await button('Edit access for Sam Taylor').click();
|
||||
assert.equal(await checkbox('Read').isChecked(), false);
|
||||
await page.keyboard.press('Escape');
|
||||
await button('Edit access for Platform team').click();
|
||||
await checkbox('Write').uncheck();
|
||||
await save();
|
||||
await dialog().waitFor({ state: 'hidden' });
|
||||
await page
|
||||
.locator('[data-user-id="2"]')
|
||||
.getByText('No project access', { exact: true })
|
||||
.waitFor();
|
||||
|
||||
await button('Edit access for Sam Taylor').click();
|
||||
await checkbox('Administrator').check();
|
||||
await result('validation');
|
||||
await save();
|
||||
await state('validation');
|
||||
await dialog()
|
||||
.getByText('This permission is unavailable.', { exact: true })
|
||||
.waitFor();
|
||||
assert.equal(await checkbox('Administrator').isChecked(), true);
|
||||
assert.equal(
|
||||
await dialog().locator('fieldset').getAttribute('aria-invalid'),
|
||||
'true',
|
||||
);
|
||||
await result('server');
|
||||
await save();
|
||||
await state('server');
|
||||
await result('success');
|
||||
await page.route('**/permissions/users/2', (route) =>
|
||||
route.abort('failed'),
|
||||
);
|
||||
await save();
|
||||
await state('network');
|
||||
await page.unroute('**/permissions/users/2');
|
||||
await dialog()
|
||||
.getByRole('button', { name: 'Try again', exact: true })
|
||||
.click();
|
||||
await dialog().waitFor({ state: 'hidden' });
|
||||
await page
|
||||
.locator('[data-user-id="2"]')
|
||||
.getByText('Administrator', { exact: true })
|
||||
.first()
|
||||
.waitFor();
|
||||
await button('Edit access for Sam Taylor').click();
|
||||
assert.equal(await checkbox('Administrator').isChecked(), true);
|
||||
await result('forbidden');
|
||||
await save();
|
||||
await state('server');
|
||||
await dialog()
|
||||
.getByText('Project access can no longer be changed', { exact: true })
|
||||
.waitFor();
|
||||
assert.equal(
|
||||
await dialog().getByRole('button', { name: 'Try again' }).count(),
|
||||
0,
|
||||
);
|
||||
await page.keyboard.press('Escape');
|
||||
await visit();
|
||||
await button('Edit access for Sam Taylor').click();
|
||||
await checkbox('Write').check();
|
||||
page.once('dialog', (d) => d.dismiss());
|
||||
await page.keyboard.press('Escape');
|
||||
assert.equal(await dialog().isVisible(), true);
|
||||
page.once('dialog', (d) => d.accept());
|
||||
await page.keyboard.press('Escape');
|
||||
await dialog().waitFor({ state: 'hidden' });
|
||||
assert.deepEqual(errors, []);
|
||||
console.log(
|
||||
'Project access browser checks passed: direct/team updates and clearing, effective access refresh, errors/retry, unsaved changes, keyboard, 320/390/1440px, both themes.',
|
||||
);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { createApp, h } from 'vue';
|
||||
import { createInertiaApp } from '@inertiajs/vue3';
|
||||
import '../../resources/css/app.css';
|
||||
import Access from '../../resources/js/pages/projects/Access.vue';
|
||||
import { createAppearance } from '../../resources/js/composables/useAppearance';
|
||||
import { createLocalization } from '../../resources/js/lib/localization';
|
||||
createInertiaApp({
|
||||
resolve: () => Access,
|
||||
setup: ({ el, App, props, plugin }) =>
|
||||
createApp({ render: () => h(App, props) })
|
||||
.use(plugin)
|
||||
.use(createLocalization())
|
||||
.use(createAppearance())
|
||||
.mount(el),
|
||||
});
|
||||
@@ -1,229 +0,0 @@
|
||||
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-access-fixture',
|
||||
plugins: [vue(), ...(serve ? [tailwind()] : [])],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('../../resources/js', import.meta.url)),
|
||||
},
|
||||
},
|
||||
server: serve
|
||||
? { host: '127.0.0.1', port: 4198, strictPort: true, hmr: false }
|
||||
: { middlewareMode: true, hmr: false, watch: null },
|
||||
appType: 'custom',
|
||||
});
|
||||
const users = [
|
||||
{ id: 1, name: 'Alex Morgan', email: 'alex@example.test', avatar: null },
|
||||
{ id: 2, name: 'Sam Taylor', email: 'sam@example.test', avatar: null },
|
||||
{
|
||||
id: 3,
|
||||
name: 'Jordan Longname With Several Names',
|
||||
email: 'jordan.long.email.address@example.test',
|
||||
avatar: null,
|
||||
},
|
||||
];
|
||||
function initial() {
|
||||
return {
|
||||
project: { id: 1, name: 'Core platform', key: 'CORE' },
|
||||
users,
|
||||
status: null,
|
||||
globalAdminUserIds: [1],
|
||||
directPermissions: { 2: ['project:read'] },
|
||||
teamPermissions: { 1: ['project:write'] },
|
||||
effectivePermissions: {
|
||||
1: ['project:read', 'project:write', 'project:admin'],
|
||||
2: ['project:read', 'project:write'],
|
||||
3: [],
|
||||
},
|
||||
teams: [
|
||||
{ id: 1, name: 'Platform team', user_ids: [2], users: [users[1]] },
|
||||
{ id: 2, name: 'Delivery team', user_ids: [], users: [] },
|
||||
],
|
||||
permissionOptions: ['read', 'write', 'admin'].map((name) => ({
|
||||
value: 'project:' + name,
|
||||
label: name,
|
||||
})),
|
||||
};
|
||||
}
|
||||
const makePage = (extra = {}) => ({
|
||||
component: 'Access',
|
||||
url: '/projects/1/access',
|
||||
version: null,
|
||||
clearHistory: false,
|
||||
encryptHistory: false,
|
||||
props: {
|
||||
name: 'KanbanGit',
|
||||
errors: {},
|
||||
sidebarOpen: true,
|
||||
appearance: 'system',
|
||||
auth: {
|
||||
user: users[0],
|
||||
permissions: {
|
||||
globalAdmin: false,
|
||||
projectCreate: false,
|
||||
canManageTeams: true,
|
||||
},
|
||||
},
|
||||
navigation: { project: null },
|
||||
...initial(),
|
||||
...extra,
|
||||
},
|
||||
});
|
||||
if (serve) {
|
||||
let data = initial();
|
||||
server.middlewares.use(async (request, response, next) => {
|
||||
if (!request.url.startsWith('/projects/1/')) {
|
||||
return next();
|
||||
}
|
||||
const url = new URL(request.url, 'http://localhost');
|
||||
if (url.searchParams.has('reset')) {
|
||||
data = initial();
|
||||
}
|
||||
if (url.searchParams.has('noUsers')) {
|
||||
data.users = [];
|
||||
}
|
||||
if (url.searchParams.has('empty')) {
|
||||
data.teams = [];
|
||||
}
|
||||
let errors = {};
|
||||
if (request.method !== 'GET') {
|
||||
const result =
|
||||
request.headers.cookie?.match(/access_result=(\w+)/)?.[1] ??
|
||||
'success';
|
||||
if (['server', 'forbidden'].includes(result)) {
|
||||
response.statusCode = result === 'forbidden' ? 403 : 503;
|
||||
response.end('Simulated failure');
|
||||
return;
|
||||
}
|
||||
let body = '';
|
||||
for await (const chunk of request) {
|
||||
body += chunk;
|
||||
}
|
||||
const submitted = JSON.parse(body || '{}');
|
||||
if (result === 'validation') {
|
||||
errors = { 'permissions.0': 'This permission is unavailable.' };
|
||||
} else {
|
||||
const parts = url.pathname.split('/');
|
||||
const kind = parts[4];
|
||||
const id = parts[5];
|
||||
data[
|
||||
kind === 'users' ? 'directPermissions' : 'teamPermissions'
|
||||
][id] = submitted.permissions;
|
||||
for (const user of users) {
|
||||
const grants = [
|
||||
...(data.directPermissions[user.id] ?? []),
|
||||
...data.teams
|
||||
.filter((team) => team.user_ids.includes(user.id))
|
||||
.flatMap(
|
||||
(team) => data.teamPermissions[team.id] ?? [],
|
||||
),
|
||||
];
|
||||
data.effectivePermissions[user.id] =
|
||||
data.globalAdminUserIds.includes(user.id) ||
|
||||
grants.includes('project:admin')
|
||||
? ['project:read', 'project:write', 'project:admin']
|
||||
: grants.includes('project:write')
|
||||
? ['project:read', 'project:write']
|
||||
: grants.includes('project:read')
|
||||
? ['project:read']
|
||||
: [];
|
||||
}
|
||||
}
|
||||
if (Object.keys(errors).length === 0) {
|
||||
data.status = 'Saved';
|
||||
}
|
||||
}
|
||||
const page = makePage({ ...data, errors });
|
||||
response.setHeader('Vary', 'X-Inertia');
|
||||
if (request.headers['x-inertia']) {
|
||||
response.setHeader('X-Inertia', 'true');
|
||||
response.setHeader('Content-Type', 'application/json');
|
||||
response.end(JSON.stringify(page));
|
||||
return;
|
||||
}
|
||||
const escaped = JSON.stringify(page)
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll('<', '<');
|
||||
response.setHeader('Content-Type', 'text/html');
|
||||
response.end(
|
||||
await server.transformIndexHtml(
|
||||
request.url,
|
||||
'<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"></head><body><div id="app" data-page="' +
|
||||
escaped +
|
||||
'"></div><script type="module" src="/tests/Fixtures/project-access-preview.mjs"></script></body></html>',
|
||||
),
|
||||
);
|
||||
});
|
||||
await server.listen();
|
||||
console.log('Project access 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 component = (
|
||||
await server.ssrLoadModule(
|
||||
'/resources/js/pages/projects/Access.vue',
|
||||
)
|
||||
).default;
|
||||
const render = async (extra = {}, locale = createLocalization()) =>
|
||||
(
|
||||
await createInertiaApp({
|
||||
page: makePage(extra),
|
||||
resolve: () => component,
|
||||
render: renderToString,
|
||||
setup: ({ App, props, plugin }) =>
|
||||
createSSRApp({ render: () => h(App, props) })
|
||||
.use(plugin)
|
||||
.use(locale)
|
||||
.use(createAppearance()),
|
||||
})
|
||||
).body;
|
||||
const html = await render();
|
||||
assert.match(html, /People and effective access/);
|
||||
assert.match(html, /Global administrator · all project permissions/);
|
||||
assert.match(html, /Via Platform team:/);
|
||||
assert.match(html, /No project access/);
|
||||
assert.match(html, /Edit access for Sam Taylor/);
|
||||
assert.match(html, /No project permissions assigned/);
|
||||
assert.doesNotMatch(html, /<table|type="checkbox"/);
|
||||
assert.doesNotMatch(html, /href="[^"]*settings\/general/);
|
||||
assert.match(await render({ teams: [] }), /No teams yet/);
|
||||
assert.match(await render({ users: [] }), /No people available/);
|
||||
const translated = await render(
|
||||
{},
|
||||
createLocalization(
|
||||
{ locale: 'de' },
|
||||
{
|
||||
de: {
|
||||
...en,
|
||||
'projectAccess.title': 'Projektzugriff',
|
||||
'projectAccess.read': 'Lesen',
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
assert.match(translated, /Projektzugriff/);
|
||||
assert.match(translated, /Lesen/);
|
||||
console.log('Project access presentation checks passed.');
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
}
|
||||
@@ -254,6 +254,23 @@ try {
|
||||
.getByText('Building', { exact: true })
|
||||
.waitFor();
|
||||
assert.equal(await button('Edit column In progress').count(), 0);
|
||||
await page.locator('summary').filter({ hasText: 'Status moves' }).click();
|
||||
const transition = page.locator('details form').first();
|
||||
const checkbox = transition.getByRole('checkbox').first();
|
||||
await checkbox.check();
|
||||
const move = page.waitForRequest((request) => request.method() === 'POST');
|
||||
await transition.getByRole('button', { name: 'Save', exact: true }).click();
|
||||
assert.equal(
|
||||
new URL((await move).url()).pathname,
|
||||
'/projects/1/settings/board/statuses/1/transitions',
|
||||
);
|
||||
assert.equal(
|
||||
new URL((await move).url()).searchParams.get('_method'),
|
||||
'PATCH',
|
||||
);
|
||||
assert.deepEqual((await move).postDataJSON(), {
|
||||
allowed_ticket_status_ids: [await checkbox.getAttribute('value')],
|
||||
});
|
||||
assert.deepEqual(errors, []);
|
||||
console.log(
|
||||
'Workflow browser checks passed: CRUD payloads, server refresh, order, deletion protection, validation, retries, dirty forms, keyboard, responsive widths and both themes.',
|
||||
|
||||
@@ -35,7 +35,6 @@ function initial() {
|
||||
const ready = makeStatus(2, 'Ready', 10);
|
||||
const active = makeStatus(3, 'Building', 20);
|
||||
const review = makeStatus(4, 'Review', 20, 2);
|
||||
backlog.allowed_target_statuses = [ready];
|
||||
const columns = [
|
||||
{ id: 10, name: 'Todo', position: 1, ticket_statuses: [ready] },
|
||||
{
|
||||
@@ -135,17 +134,13 @@ if (serve) {
|
||||
return;
|
||||
}
|
||||
if (result === 'validation') {
|
||||
errors = url.pathname.endsWith('/transitions')
|
||||
? {
|
||||
'allowed_ticket_status_ids.0':
|
||||
'Choose a destination from this project.',
|
||||
}
|
||||
: request.method === 'DELETE'
|
||||
? {
|
||||
ticket_status_id:
|
||||
'This group needs at least one status.',
|
||||
}
|
||||
: { name: 'Choose a different name.' };
|
||||
errors =
|
||||
request.method === 'DELETE'
|
||||
? {
|
||||
ticket_status_id:
|
||||
'This group needs at least one status.',
|
||||
}
|
||||
: { name: 'Choose a different name.' };
|
||||
} else if (result === 'position') {
|
||||
errors = { position: 'The position must be at least 1.' };
|
||||
} else {
|
||||
@@ -193,16 +188,7 @@ if (serve) {
|
||||
column.position = index + 1;
|
||||
});
|
||||
} else if (url.pathname.endsWith('/transitions')) {
|
||||
const sourceId = Number(segments.at(-2));
|
||||
const statuses = [
|
||||
...data.backlogTicketStatuses,
|
||||
...columns.flatMap((column) => column.ticket_statuses),
|
||||
];
|
||||
statuses.find(
|
||||
(status) => status.id === sourceId,
|
||||
).allowed_target_statuses = (
|
||||
submitted.allowed_ticket_status_ids ?? []
|
||||
).map((id) => ({ id: Number(id) }));
|
||||
// The browser assertion checks the preserved transition endpoint and payload.
|
||||
} else if (request.method === 'POST') {
|
||||
const group =
|
||||
submitted.board_column_id === null
|
||||
@@ -308,16 +294,6 @@ if (serve) {
|
||||
html.indexOf('data-column-id="10"') <
|
||||
html.indexOf('data-column-id="20"'),
|
||||
);
|
||||
assert.match(html, /From status/);
|
||||
assert.match(html, /Saved destinations: 1/);
|
||||
assert.match(
|
||||
await render({ ticketStatuses: [] }),
|
||||
/Add a status to configure/,
|
||||
);
|
||||
assert.match(
|
||||
await render({ ticketStatuses: [makeStatus(1, 'Backlog', null)] }),
|
||||
/No other statuses exist yet/,
|
||||
);
|
||||
const buttons = html.match(/<button\b[^>]*>[\s\S]*?<\/button>/g);
|
||||
for (const name of ['Backlog', 'Ready']) {
|
||||
assert.match(
|
||||
|
||||
@@ -1,207 +0,0 @@
|
||||
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();
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { createApp, h } from 'vue';
|
||||
import { createInertiaApp } from '@inertiajs/vue3';
|
||||
import '../../resources/css/app.css';
|
||||
import Metadata from '../../resources/js/pages/projects/settings/Metadata.vue';
|
||||
import { createAppearance } from '../../resources/js/composables/useAppearance';
|
||||
import { createLocalization } from '../../resources/js/lib/localization';
|
||||
|
||||
createInertiaApp({
|
||||
resolve: (name) =>
|
||||
name === 'Metadata'
|
||||
? Metadata
|
||||
: { render: () => h('h1', 'Destination reached') },
|
||||
setup: ({ el, App, props, plugin }) =>
|
||||
createApp({ render: () => h(App, props) })
|
||||
.use(plugin)
|
||||
.use(createLocalization())
|
||||
.use(createAppearance())
|
||||
.mount(el),
|
||||
});
|
||||
@@ -1,362 +0,0 @@
|
||||
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-metadata-settings-fixture',
|
||||
plugins: [vue(), ...(serve ? [tailwind()] : [])],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('../../resources/js', import.meta.url)),
|
||||
},
|
||||
},
|
||||
server: serve
|
||||
? { host: '127.0.0.1', port: 4189, strictPort: true, hmr: false }
|
||||
: { middlewareMode: true, hmr: false, watch: null },
|
||||
appType: 'custom',
|
||||
});
|
||||
function initial() {
|
||||
return {
|
||||
project: {
|
||||
id: 1,
|
||||
key: 'CORE',
|
||||
name: 'Core platform',
|
||||
show_author: true,
|
||||
show_story_points: false,
|
||||
},
|
||||
status: null,
|
||||
error: false,
|
||||
templateState: null,
|
||||
ticketTypes: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Task',
|
||||
position: 1,
|
||||
is_parent: false,
|
||||
tickets_count: 2,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Release',
|
||||
position: 2,
|
||||
is_parent: true,
|
||||
tickets_count: 1,
|
||||
},
|
||||
],
|
||||
ticketPriorities: [
|
||||
{ id: 3, name: 'Normal', position: 1 },
|
||||
{ id: 4, name: 'Urgent', position: 2 },
|
||||
],
|
||||
ticketFieldDefinitions: [
|
||||
{
|
||||
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(
|
||||
extra = {},
|
||||
url = '/projects/1/settings/metadata',
|
||||
component = 'Metadata',
|
||||
) {
|
||||
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: false,
|
||||
canManageTeams: false,
|
||||
},
|
||||
},
|
||||
navigation: {
|
||||
project: {
|
||||
id: 1,
|
||||
name: 'Core platform',
|
||||
key: 'CORE',
|
||||
canManage: true,
|
||||
},
|
||||
},
|
||||
...initial(),
|
||||
...extra,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (serve) {
|
||||
let data = initial();
|
||||
let nextId = 100;
|
||||
server.middlewares.use(async (request, response, next) => {
|
||||
if (!request.url.startsWith('/projects')) {
|
||||
return next();
|
||||
}
|
||||
const url = new URL(request.url, 'http://localhost');
|
||||
const mutation = request.method !== 'GET';
|
||||
if (!mutation && url.searchParams.has('reset')) {
|
||||
data = initial();
|
||||
}
|
||||
let errors = {};
|
||||
if (mutation) {
|
||||
let body = '';
|
||||
for await (const chunk of request) {
|
||||
body += chunk;
|
||||
}
|
||||
const submitted = JSON.parse(body || '{}');
|
||||
const result =
|
||||
request.headers.cookie?.match(
|
||||
/metadata_test_result=(\w+)/,
|
||||
)?.[1] ?? 'success';
|
||||
if (result === 'server') {
|
||||
response.statusCode = 500;
|
||||
response.end('Unavailable');
|
||||
return;
|
||||
}
|
||||
const kind = url.pathname.includes('/link-types')
|
||||
? 'link'
|
||||
: url.pathname.includes('/fields')
|
||||
? 'field'
|
||||
: url.pathname.includes('/types')
|
||||
? 'type'
|
||||
: 'priority';
|
||||
const entries =
|
||||
kind === 'type'
|
||||
? data.ticketTypes
|
||||
: kind === 'priority'
|
||||
? data.ticketPriorities
|
||||
: kind === 'field'
|
||||
? data.ticketFieldDefinitions
|
||||
: data.ticketLinkTypes;
|
||||
const id = Number(url.pathname.split('/').at(-1));
|
||||
if (
|
||||
result === 'validation' ||
|
||||
((submitted.name || submitted.label) &&
|
||||
entries.some(
|
||||
(entry) =>
|
||||
(entry.name ?? entry.label) ===
|
||||
(submitted.name ?? submitted.label) &&
|
||||
entry.id !== id,
|
||||
))
|
||||
) {
|
||||
errors =
|
||||
request.method === 'DELETE'
|
||||
? {
|
||||
replacement_ticket_type_id:
|
||||
'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.' };
|
||||
} else if (request.method === 'DELETE') {
|
||||
entries.splice(
|
||||
entries.findIndex((item) => item.id === id),
|
||||
1,
|
||||
);
|
||||
} else if (url.pathname.endsWith('/metadata')) {
|
||||
Object.assign(data.project, submitted);
|
||||
} else if (request.method === 'POST') {
|
||||
const created = {
|
||||
id: nextId++,
|
||||
position: entries.length + 1,
|
||||
...(kind === 'type'
|
||||
? { 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 {
|
||||
const item = entries.find((item) => item.id === id);
|
||||
entries.splice(entries.indexOf(item), 1);
|
||||
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.forEach((entry, index) => (entry.position = index + 1));
|
||||
data.status = Object.keys(errors).length ? null : 'Updated';
|
||||
}
|
||||
if (url.searchParams.has('single')) {
|
||||
data.ticketTypes = data.ticketTypes.slice(0, 1);
|
||||
data.ticketPriorities = data.ticketPriorities.slice(0, 1);
|
||||
}
|
||||
const page = makePage({ ...data, errors });
|
||||
response.setHeader('Vary', 'X-Inertia');
|
||||
if (request.headers['x-inertia']) {
|
||||
response.setHeader('X-Inertia', 'true');
|
||||
response.setHeader('Content-Type', 'application/json');
|
||||
response.end(JSON.stringify(page));
|
||||
return;
|
||||
}
|
||||
const escaped = JSON.stringify(page)
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll('<', '<');
|
||||
response.setHeader('Content-Type', 'text/html');
|
||||
response.end(
|
||||
await server.transformIndexHtml(
|
||||
request.url,
|
||||
'<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"></head><body><div id="app" data-page="' +
|
||||
escaped +
|
||||
'"></div><script type="module" src="/tests/Fixtures/project-metadata-settings-preview.mjs"></script></body></html>',
|
||||
),
|
||||
);
|
||||
});
|
||||
await server.listen();
|
||||
console.log('Metadata settings 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 component = (
|
||||
await server.ssrLoadModule(
|
||||
'/resources/js/pages/projects/settings/Metadata.vue',
|
||||
)
|
||||
).default;
|
||||
const render = async (extra = {}, locale = createLocalization()) =>
|
||||
(
|
||||
await createInertiaApp({
|
||||
page: makePage(extra),
|
||||
resolve: () => component,
|
||||
render: renderToString,
|
||||
setup: ({ App, props, plugin }) =>
|
||||
createSSRApp({ render: () => h(App, props) })
|
||||
.use(plugin)
|
||||
.use(locale)
|
||||
.use(createAppearance()),
|
||||
})
|
||||
).body;
|
||||
const html = await render();
|
||||
assert.match(html, /Ticket settings/);
|
||||
assert.match(html, /aria-current="page"/);
|
||||
assert.ok(
|
||||
html.indexOf('data-metadata-entry="type-1"') <
|
||||
html.indexOf('data-metadata-entry="type-2"'),
|
||||
);
|
||||
assert.match(html, /Assigned tickets: 2/);
|
||||
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_story_points"[^>]*aria-checked="false"/);
|
||||
const single = await render({
|
||||
ticketTypes: initial().ticketTypes.slice(0, 1),
|
||||
ticketPriorities: initial().ticketPriorities.slice(0, 1),
|
||||
});
|
||||
const buttons = single.match(/<button\b[^>]*>[\s\S]*?<\/button>/g);
|
||||
for (const name of ['Task', 'Normal']) {
|
||||
assert.match(
|
||||
buttons.find((button) =>
|
||||
button.includes('aria-label="Delete ' + name + '"'),
|
||||
),
|
||||
/aria-disabled="true"/,
|
||||
);
|
||||
}
|
||||
assert.match(single, /The last ticket type cannot be deleted/);
|
||||
assert.match(single, /The last priority cannot be deleted/);
|
||||
assert.match(
|
||||
await render(
|
||||
{},
|
||||
createLocalization(
|
||||
{ locale: 'de' },
|
||||
{ de: { ...en, 'metadata.title': 'Ticket-Einstellungen' } },
|
||||
),
|
||||
),
|
||||
/Ticket-Einstellungen/,
|
||||
);
|
||||
assert.match(
|
||||
await render({
|
||||
status: 'The last ticket type cannot be deleted.',
|
||||
error: true,
|
||||
}),
|
||||
/role="alert"/,
|
||||
);
|
||||
console.log('Metadata settings checks passed.');
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
}
|
||||
@@ -1,310 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import process from 'node:process';
|
||||
const { chromium } = await import(
|
||||
process.env.PLAYWRIGHT_MODULE ?? 'playwright'
|
||||
);
|
||||
const browser = await chromium.launch({ channel: 'chrome', headless: true });
|
||||
const base = 'http://127.0.0.1:4195';
|
||||
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: 'notification_test_result', value, url: base },
|
||||
]);
|
||||
const visit = async (query = '?reset') => {
|
||||
await result('success');
|
||||
await page.goto(base + '/projects/1/settings/notifications' + query);
|
||||
await page
|
||||
.getByRole('heading', { name: 'Notifications', exact: true })
|
||||
.waitFor();
|
||||
};
|
||||
const closed = () => dialog().waitFor({ state: 'hidden' });
|
||||
const state = (value) =>
|
||||
dialog().locator(`[data-form-state="${value}"]`).waitFor();
|
||||
const noOverflow = async () =>
|
||||
assert.equal(
|
||||
await page.evaluate(
|
||||
() => document.documentElement.scrollWidth <= window.innerWidth,
|
||||
),
|
||||
true,
|
||||
);
|
||||
const submit = async (label, method, path, 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, path);
|
||||
assert.deepEqual(request.postDataJSON(), expected);
|
||||
};
|
||||
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();
|
||||
const trigger = button('Edit Release alerts');
|
||||
await trigger.focus();
|
||||
await page.keyboard.press('Enter');
|
||||
await dialog().waitFor();
|
||||
assert.equal(
|
||||
await page.getByLabel('Name', { exact: true }).inputValue(),
|
||||
'Release alerts',
|
||||
);
|
||||
const secret = page.getByLabel('Replace delivery URL (optional)');
|
||||
assert.equal(await secret.inputValue(), '');
|
||||
assert.equal(await secret.getAttribute('type'), 'password');
|
||||
assert.equal(
|
||||
await page
|
||||
.getByLabel('Values (comma or newline separated)')
|
||||
.inputValue(),
|
||||
'CORE-1, CORE-2',
|
||||
);
|
||||
assert.equal(
|
||||
await page
|
||||
.getByLabel('User mentions (@username)', { exact: true })
|
||||
.isChecked(),
|
||||
true,
|
||||
);
|
||||
await page
|
||||
.getByText('Available template variables', { exact: true })
|
||||
.click();
|
||||
await dialog().getByText('$url', { exact: true }).waitFor();
|
||||
await noOverflow();
|
||||
assert.equal(
|
||||
await dialog().evaluate(
|
||||
(el) => el.scrollWidth <= el.clientWidth + 1,
|
||||
),
|
||||
true,
|
||||
);
|
||||
if (
|
||||
process.env.NOTIFICATION_SCREENSHOTS &&
|
||||
[1440, 390].includes(width)
|
||||
) {
|
||||
await page.screenshot({
|
||||
path: `${process.env.NOTIFICATION_SCREENSHOTS}/kan45-${width}-${theme}.png`,
|
||||
});
|
||||
}
|
||||
await page.keyboard.press('Escape');
|
||||
await closed();
|
||||
assert.equal(
|
||||
await trigger.evaluate((el) => el === document.activeElement),
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
await page.setViewportSize({ width: 1280, height: 1000 });
|
||||
await visit();
|
||||
await button('Add notification').click();
|
||||
assert.equal(
|
||||
await page.getByLabel('Channel', { exact: true }).inputValue(),
|
||||
'webhook',
|
||||
);
|
||||
assert.equal(
|
||||
await page
|
||||
.getByLabel('Discord message template', { exact: true })
|
||||
.count(),
|
||||
0,
|
||||
);
|
||||
await page.getByLabel('Name', { exact: true }).fill('New webhook');
|
||||
await page
|
||||
.getByLabel('Delivery URL', { exact: true })
|
||||
.fill('https://hooks.example.test/new-secret');
|
||||
await page
|
||||
.getByLabel('Trigger', { exact: true })
|
||||
.selectOption('ticket_assignee_changed');
|
||||
await page.getByLabel('Enabled', { exact: true }).uncheck();
|
||||
await button('Add filter').click();
|
||||
await page
|
||||
.getByLabel('Attribute', { exact: true })
|
||||
.selectOption('actor.name');
|
||||
await page.getByLabel('Operator', { exact: true }).selectOption('exists');
|
||||
assert.equal(await page.getByLabel('Value', { exact: true }).count(), 0);
|
||||
const payload = {
|
||||
name: 'New webhook',
|
||||
event_type: 'ticket_assignee_changed',
|
||||
channel: 'webhook',
|
||||
enabled: false,
|
||||
delivery_url: 'https://hooks.example.test/new-secret',
|
||||
channel_configuration: {},
|
||||
message_template: null,
|
||||
filters: [{ attribute: 'actor.name', operator: 'exists', value: null }],
|
||||
};
|
||||
await submit('Save', 'POST', '/projects/1/settings/notifications', payload);
|
||||
await closed();
|
||||
await button('Edit New webhook').waitFor();
|
||||
assert.equal(
|
||||
await page
|
||||
.locator('body')
|
||||
.textContent()
|
||||
.then((text) => text.includes('new-secret')),
|
||||
false,
|
||||
);
|
||||
await button('Edit New webhook').click();
|
||||
assert.equal(
|
||||
await page.getByLabel('Replace delivery URL (optional)').inputValue(),
|
||||
'',
|
||||
);
|
||||
await button('Remove filter 1').click();
|
||||
await page.getByLabel('Channel', { exact: true }).selectOption('discord');
|
||||
await page
|
||||
.getByRole('textbox', { name: 'Discord message template', exact: true })
|
||||
.fill('$ticket_key: $url');
|
||||
await page.getByLabel('Role mentions (@role)', { exact: true }).check();
|
||||
await button('Add filter').click();
|
||||
await page
|
||||
.getByLabel('Attribute', { exact: true })
|
||||
.selectOption('ticket.key');
|
||||
await page.getByLabel('Operator', { exact: true }).selectOption('in');
|
||||
await page
|
||||
.getByLabel('Values (comma or newline separated)')
|
||||
.fill(' CORE-1,\n CORE-3, ');
|
||||
const edited = {
|
||||
...payload,
|
||||
channel: 'discord',
|
||||
delivery_url: null,
|
||||
channel_configuration: { allowed_mentions: ['roles'] },
|
||||
message_template: '$ticket_key: $url',
|
||||
filters: [
|
||||
{
|
||||
attribute: 'ticket.key',
|
||||
operator: 'in',
|
||||
value: ['CORE-1', 'CORE-3'],
|
||||
},
|
||||
],
|
||||
};
|
||||
await result('validation');
|
||||
await submit(
|
||||
'Save',
|
||||
'PATCH',
|
||||
'/projects/1/settings/notifications/100',
|
||||
edited,
|
||||
);
|
||||
await state('validation');
|
||||
await page.getByText('Enter a valid URL.', { exact: true }).waitFor();
|
||||
await page
|
||||
.getByText('Choose at least one ticket.', { exact: true })
|
||||
.waitFor();
|
||||
await page
|
||||
.getByText('This mention type is not supported.', { exact: true })
|
||||
.waitFor();
|
||||
assert.equal(
|
||||
await page.getByLabel('Name', { exact: true }).inputValue(),
|
||||
'New webhook',
|
||||
);
|
||||
await result('server');
|
||||
await submit(
|
||||
'Save',
|
||||
'PATCH',
|
||||
'/projects/1/settings/notifications/100',
|
||||
edited,
|
||||
);
|
||||
await state('server');
|
||||
await result('forbidden');
|
||||
await submit(
|
||||
'Try again',
|
||||
'PATCH',
|
||||
'/projects/1/settings/notifications/100',
|
||||
edited,
|
||||
);
|
||||
await state('server');
|
||||
await result('success');
|
||||
await submit(
|
||||
'Try again',
|
||||
'PATCH',
|
||||
'/projects/1/settings/notifications/100',
|
||||
edited,
|
||||
);
|
||||
await closed();
|
||||
await button('Edit New webhook').click();
|
||||
await page.getByLabel('Role mentions (@role)', { exact: true }).uncheck();
|
||||
await button('Remove filter 1').click();
|
||||
await page
|
||||
.getByLabel('Replace delivery URL (optional)')
|
||||
.fill('https://hooks.example.test/replacement');
|
||||
await page.route('**/notifications/100', (route) => route.abort('failed'));
|
||||
await button('Save').click();
|
||||
await state('network');
|
||||
await page.unroute('**/notifications/100');
|
||||
await submit(
|
||||
'Try again',
|
||||
'PATCH',
|
||||
'/projects/1/settings/notifications/100',
|
||||
{
|
||||
...edited,
|
||||
delivery_url: 'https://hooks.example.test/replacement',
|
||||
channel_configuration: { allowed_mentions: [] },
|
||||
filters: [],
|
||||
},
|
||||
);
|
||||
await closed();
|
||||
await button('Edit New webhook').click();
|
||||
await page.getByLabel('Channel', { exact: true }).selectOption('webhook');
|
||||
await submit('Save', 'PATCH', '/projects/1/settings/notifications/100', {
|
||||
...edited,
|
||||
channel: 'webhook',
|
||||
channel_configuration: {},
|
||||
message_template: null,
|
||||
filters: [],
|
||||
});
|
||||
await closed();
|
||||
// Dismissing a dirty dialog asks before clearing a secret; reopening starts clean.
|
||||
await button('Edit New webhook').click();
|
||||
await page
|
||||
.getByLabel('Replace delivery URL (optional)')
|
||||
.fill('discard-this-secret');
|
||||
page.once('dialog', (prompt) => prompt.dismiss());
|
||||
await button('Cancel').click();
|
||||
assert.equal(await dialog().isVisible(), true);
|
||||
page.once('dialog', (prompt) => prompt.accept());
|
||||
await button('Cancel').click();
|
||||
await closed();
|
||||
await button('Edit Release alerts').click();
|
||||
assert.equal(
|
||||
await page.getByLabel('Replace delivery URL (optional)').inputValue(),
|
||||
'',
|
||||
);
|
||||
assert.equal(
|
||||
await page.getByLabel('Name', { exact: true }).inputValue(),
|
||||
'Release alerts',
|
||||
);
|
||||
await button('Cancel').click();
|
||||
await closed();
|
||||
const before = requests.length;
|
||||
await button('Delete New webhook').click();
|
||||
await dialog()
|
||||
.getByText(/This cannot be undone/)
|
||||
.waitFor();
|
||||
await button('Cancel').click();
|
||||
await closed();
|
||||
assert.equal(requests.length, before);
|
||||
await button('Delete New webhook').click();
|
||||
await submit(
|
||||
'Delete notification',
|
||||
'DELETE',
|
||||
'/projects/1/settings/notifications/100',
|
||||
{},
|
||||
);
|
||||
await closed();
|
||||
assert.equal(await button('Edit New webhook').count(), 0);
|
||||
await visit('?reset&empty');
|
||||
await page.getByRole('heading', { name: 'No notifications yet' }).waitFor();
|
||||
assert.deepEqual(errors, []);
|
||||
console.log(
|
||||
'Notification browser checks passed: CRUD, filters, channels, secrets, validation, retries, keyboard, responsive layouts and themes.',
|
||||
);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { createApp, h } from 'vue';
|
||||
import { createInertiaApp } from '@inertiajs/vue3';
|
||||
import '../../resources/css/app.css';
|
||||
import Notifications from '../../resources/js/pages/projects/settings/Notifications.vue';
|
||||
import { createAppearance } from '../../resources/js/composables/useAppearance';
|
||||
import { createLocalization } from '../../resources/js/lib/localization';
|
||||
|
||||
createInertiaApp({
|
||||
resolve: (name) =>
|
||||
name === 'Notifications'
|
||||
? Notifications
|
||||
: { render: () => h('h1', 'Destination reached') },
|
||||
setup: ({ el, App, props, plugin }) =>
|
||||
createApp({ render: () => h(App, props) })
|
||||
.use(plugin)
|
||||
.use(createLocalization())
|
||||
.use(createAppearance())
|
||||
.mount(el),
|
||||
});
|
||||
@@ -1,363 +0,0 @@
|
||||
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-notification-settings-fixture',
|
||||
plugins: [vue(), ...(serve ? [tailwind()] : [])],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('../../resources/js', import.meta.url)),
|
||||
},
|
||||
},
|
||||
server: serve
|
||||
? { host: '127.0.0.1', port: 4195, strictPort: true, hmr: false }
|
||||
: { middlewareMode: true, hmr: false, watch: null },
|
||||
appType: 'custom',
|
||||
});
|
||||
const options = (values) => values.map((value) => ({ value, label: value }));
|
||||
function initial() {
|
||||
return {
|
||||
project: { id: 1, key: 'CORE', name: 'Core platform' },
|
||||
status: null,
|
||||
templateState: null,
|
||||
channelOptions: options(['discord', 'webhook']),
|
||||
eventTypeOptions: options([
|
||||
'ticket_status_changed',
|
||||
'ticket_create',
|
||||
'ticket_assignee_changed',
|
||||
]),
|
||||
filterAttributeOptions: options([
|
||||
'project.id',
|
||||
'ticket.key',
|
||||
'status.to.label',
|
||||
'actor.name',
|
||||
]),
|
||||
filterOperatorOptions: options([
|
||||
'equals',
|
||||
'not_equals',
|
||||
'exists',
|
||||
'not_exists',
|
||||
'in',
|
||||
]),
|
||||
discordAllowedMentionOptions: options(['everyone', 'roles', 'users']),
|
||||
templateVariables: [
|
||||
{ value: '$ticket_key', description: 'Ticket key' },
|
||||
{ value: '$url', description: 'Ticket URL' },
|
||||
],
|
||||
notificationActions: [
|
||||
{
|
||||
id: 10,
|
||||
project_id: 1,
|
||||
name: 'Release alerts',
|
||||
enabled: true,
|
||||
event_type: 'ticket_status_changed',
|
||||
event_type_label: 'Ticket status changed',
|
||||
channel: 'discord',
|
||||
channel_label: 'Discord',
|
||||
channel_configuration: { allowed_mentions: ['users'] },
|
||||
has_delivery_url: true,
|
||||
message_template: '$ticket_key moved to $status_to: $url',
|
||||
filters: [
|
||||
{
|
||||
attribute: 'ticket.key',
|
||||
operator: 'in',
|
||||
value: ['CORE-1', 'CORE-2'],
|
||||
},
|
||||
],
|
||||
created_at: '2026-09-01T09:00:00Z',
|
||||
updated_at: '2026-09-02T12:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 20,
|
||||
project_id: 1,
|
||||
name: 'Audit webhook',
|
||||
enabled: false,
|
||||
event_type: 'ticket_create',
|
||||
event_type_label: 'Ticket create',
|
||||
channel: 'webhook',
|
||||
channel_label: 'Webhook',
|
||||
channel_configuration: {},
|
||||
has_delivery_url: false,
|
||||
message_template: null,
|
||||
filters: [],
|
||||
created_at: null,
|
||||
updated_at: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
function makePage(
|
||||
extra = {},
|
||||
url = '/projects/1/settings/notifications',
|
||||
component = 'Notifications',
|
||||
) {
|
||||
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: false,
|
||||
canManageTeams: false,
|
||||
},
|
||||
},
|
||||
navigation: {
|
||||
project: {
|
||||
id: 1,
|
||||
name: 'Core platform',
|
||||
key: 'CORE',
|
||||
canManage: true,
|
||||
},
|
||||
},
|
||||
...initial(),
|
||||
...extra,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (serve) {
|
||||
let data = initial();
|
||||
let nextId = 100;
|
||||
server.middlewares.use(async (request, response, next) => {
|
||||
if (!request.url.startsWith('/projects')) {
|
||||
return next();
|
||||
}
|
||||
const url = new URL(request.url, 'http://localhost');
|
||||
const mutation = request.method !== 'GET';
|
||||
if (!mutation && url.searchParams.has('reset')) {
|
||||
data = initial();
|
||||
nextId = 100;
|
||||
}
|
||||
let errors = {};
|
||||
if (mutation) {
|
||||
let body = '';
|
||||
for await (const chunk of request) {
|
||||
body += chunk;
|
||||
}
|
||||
const submitted = JSON.parse(body || '{}');
|
||||
const result =
|
||||
request.headers.cookie?.match(
|
||||
/notification_test_result=(\w+)/,
|
||||
)?.[1] ?? 'success';
|
||||
if (result === 'server' || result === 'forbidden') {
|
||||
response.statusCode = result === 'server' ? 500 : 403;
|
||||
response.end('Unavailable');
|
||||
return;
|
||||
}
|
||||
if (result === 'validation') {
|
||||
errors = {
|
||||
delivery_url: 'Enter a valid URL.',
|
||||
'filters.0.value': 'Choose at least one ticket.',
|
||||
'channel_configuration.allowed_mentions.0':
|
||||
'This mention type is not supported.',
|
||||
};
|
||||
} else {
|
||||
const id = Number(url.pathname.split('/').at(-1));
|
||||
if (request.method === 'DELETE') {
|
||||
data.notificationActions = data.notificationActions.filter(
|
||||
(entry) => entry.id !== id,
|
||||
);
|
||||
} else {
|
||||
const { delivery_url, ...safe } = submitted;
|
||||
if (request.method === 'POST') {
|
||||
data.notificationActions.push({
|
||||
id: nextId++,
|
||||
project_id: 1,
|
||||
...safe,
|
||||
has_delivery_url: !!delivery_url,
|
||||
created_at: '2026-09-15T10:00:00Z',
|
||||
updated_at: null,
|
||||
});
|
||||
} else {
|
||||
const entry = data.notificationActions.find(
|
||||
(entry) => entry.id === id,
|
||||
);
|
||||
Object.assign(entry, safe, {
|
||||
has_delivery_url:
|
||||
entry.has_delivery_url || !!delivery_url,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
data.status = Object.keys(errors).length ? null : 'Updated';
|
||||
}
|
||||
if (url.searchParams.has('empty')) {
|
||||
data.notificationActions = [];
|
||||
}
|
||||
const page = makePage({ ...data, errors });
|
||||
response.setHeader('Vary', 'X-Inertia');
|
||||
if (request.headers['x-inertia']) {
|
||||
response.setHeader('X-Inertia', 'true');
|
||||
response.setHeader('Content-Type', 'application/json');
|
||||
response.end(JSON.stringify(page));
|
||||
return;
|
||||
}
|
||||
const escaped = JSON.stringify(page)
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll('<', '<');
|
||||
response.setHeader('Content-Type', 'text/html');
|
||||
response.end(
|
||||
await server.transformIndexHtml(
|
||||
request.url,
|
||||
'<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"></head><body><div id="app" data-page="' +
|
||||
escaped +
|
||||
'"></div><script type="module" src="/tests/Fixtures/project-notification-settings-preview.mjs"></script></body></html>',
|
||||
),
|
||||
);
|
||||
});
|
||||
await server.listen();
|
||||
console.log('Notification settings 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 component = (
|
||||
await server.ssrLoadModule(
|
||||
'/resources/js/pages/projects/settings/Notifications.vue',
|
||||
)
|
||||
).default;
|
||||
const render = async (extra = {}, locale = createLocalization()) =>
|
||||
(
|
||||
await createInertiaApp({
|
||||
page: makePage(extra),
|
||||
resolve: () => component,
|
||||
render: renderToString,
|
||||
setup: ({ App, props, plugin }) =>
|
||||
createSSRApp({ render: () => h(App, props) })
|
||||
.use(plugin)
|
||||
.use(locale)
|
||||
.use(createAppearance()),
|
||||
})
|
||||
).body;
|
||||
const html = await render();
|
||||
assert.match(html, /Notifications/);
|
||||
assert.match(html, /aria-current="page"/);
|
||||
assert.match(html, /Release alerts/);
|
||||
assert.match(html, /Audit webhook/);
|
||||
assert.match(html, /Secret URL saved/);
|
||||
assert.match(html, /No delivery URL saved/);
|
||||
assert.match(html, /1 filter · all must match/);
|
||||
assert.match(html, /Disabled/);
|
||||
assert.match(html, /Ticket status changed/);
|
||||
assert.match(html, /No saved changes yet/);
|
||||
assert.match(
|
||||
await render({ notificationActions: [] }),
|
||||
/No notifications yet/,
|
||||
);
|
||||
assert.match(
|
||||
await render({ status: 'Saved' }),
|
||||
/Notification settings saved/,
|
||||
);
|
||||
assert.match(
|
||||
await render(
|
||||
{},
|
||||
createLocalization(
|
||||
{ locale: 'de' },
|
||||
{
|
||||
de: {
|
||||
...en,
|
||||
'notifications.title': 'Benachrichtigungen',
|
||||
},
|
||||
},
|
||||
),
|
||||
),
|
||||
/Benachrichtigungen/,
|
||||
);
|
||||
const { serializeNotificationFilters } = await server.ssrLoadModule(
|
||||
'/resources/js/lib/notification-filters.ts',
|
||||
);
|
||||
assert.deepEqual(
|
||||
serializeNotificationFilters([
|
||||
{
|
||||
id: 1,
|
||||
attribute: 'ticket.key',
|
||||
operator: 'in',
|
||||
value: ' CORE-1, \nCORE-2, ',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
attribute: 'actor.name',
|
||||
operator: 'exists',
|
||||
value: 'ignored',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
attribute: 'actor.name',
|
||||
operator: 'not_exists',
|
||||
value: 'ignored',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
attribute: 'status.to.label',
|
||||
operator: 'equals',
|
||||
value: ' Done ',
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
attribute: 'status.to.label',
|
||||
operator: 'not_equals',
|
||||
value: ' Doing ',
|
||||
},
|
||||
]),
|
||||
[
|
||||
{
|
||||
attribute: 'ticket.key',
|
||||
operator: 'in',
|
||||
value: ['CORE-1', 'CORE-2'],
|
||||
},
|
||||
{ attribute: 'actor.name', operator: 'exists', value: null },
|
||||
{
|
||||
attribute: 'actor.name',
|
||||
operator: 'not_exists',
|
||||
value: null,
|
||||
},
|
||||
{
|
||||
attribute: 'status.to.label',
|
||||
operator: 'equals',
|
||||
value: 'Done',
|
||||
},
|
||||
{
|
||||
attribute: 'status.to.label',
|
||||
operator: 'not_equals',
|
||||
value: 'Doing',
|
||||
},
|
||||
],
|
||||
);
|
||||
assert.deepEqual(serializeNotificationFilters([]), []);
|
||||
console.log('Notification settings checks passed.');
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
}
|
||||
@@ -1,260 +0,0 @@
|
||||
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();
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
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),
|
||||
});
|
||||
@@ -1,288 +0,0 @@
|
||||
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('&', '&')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll('<', '<');
|
||||
response.setHeader('Content-Type', 'text/html');
|
||||
response.end(
|
||||
await server.transformIndexHtml(
|
||||
request.url,
|
||||
'<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"></head><body><div id="app" data-page="' +
|
||||
escaped +
|
||||
'"></div><script type="module" src="/tests/Fixtures/project-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();
|
||||
}
|
||||
}
|
||||
@@ -1,200 +0,0 @@
|
||||
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({ reducedMotion: 'reduce' });
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(10000);
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
const base = 'http://127.0.0.1:4197';
|
||||
const result = (value) =>
|
||||
context.addCookies([{ name: 'teams_result', value, url: base }]);
|
||||
const visit = async (query = '') => {
|
||||
await result('success');
|
||||
await page.goto(base + '/teams?reset' + query);
|
||||
await page.getByRole('heading', { name: 'Teams', exact: true }).waitFor();
|
||||
};
|
||||
const button = (name) => page.getByRole('button', { name, exact: true });
|
||||
const dialog = () => page.getByRole('dialog');
|
||||
const state = (value) =>
|
||||
dialog()
|
||||
.locator('[data-form-state="' + value + '"]')
|
||||
.waitFor({ state: 'visible' });
|
||||
const noOverflow = async () =>
|
||||
assert.equal(
|
||||
await page.evaluate(
|
||||
() => document.documentElement.scrollWidth <= innerWidth,
|
||||
),
|
||||
true,
|
||||
);
|
||||
const save = async (label) => {
|
||||
const request = page.waitForRequest((r) =>
|
||||
['POST', 'PATCH'].includes(r.method()),
|
||||
);
|
||||
await dialog().getByRole('button', { name: label, exact: true }).click();
|
||||
return (await request).postDataJSON();
|
||||
};
|
||||
try {
|
||||
for (const width of [1440, 390, 320]) {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
for (const theme of ['light', 'dark']) {
|
||||
await page.emulateMedia({ colorScheme: theme });
|
||||
await visit();
|
||||
await noOverflow();
|
||||
await page.screenshot({
|
||||
path: '/private/tmp/kan47-' + width + '-' + theme + '.png',
|
||||
fullPage: true,
|
||||
});
|
||||
const trigger = button('Manage members of Platform team');
|
||||
await trigger.focus();
|
||||
await page.keyboard.press('Enter');
|
||||
await dialog().waitFor();
|
||||
await page.keyboard.press('Tab');
|
||||
assert.equal(
|
||||
await dialog().evaluate((el) =>
|
||||
el.contains(document.activeElement),
|
||||
),
|
||||
true,
|
||||
);
|
||||
await noOverflow();
|
||||
await page.screenshot({
|
||||
path:
|
||||
'/private/tmp/kan47-members-' +
|
||||
width +
|
||||
'-' +
|
||||
theme +
|
||||
'.png',
|
||||
fullPage: true,
|
||||
});
|
||||
await page.keyboard.press('Escape');
|
||||
await dialog().waitFor({ state: 'hidden' });
|
||||
assert.equal(
|
||||
await trigger.evaluate((el) => document.activeElement === el),
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
await visit('&manager');
|
||||
assert.equal(await button('Create team').count(), 0);
|
||||
await visit('&empty');
|
||||
await page.getByText('No teams to manage', { exact: true }).waitFor();
|
||||
await visit();
|
||||
await page.getByLabel('Search teams', { exact: true }).fill('missing');
|
||||
await page.getByText('No matching teams', { exact: true }).waitFor();
|
||||
await button('Clear search').click();
|
||||
await button('Create team').click();
|
||||
await page.getByLabel('Team name', { exact: true }).fill('New team');
|
||||
await result('validation');
|
||||
assert.deepEqual(await save('Create team'), { name: 'New team' });
|
||||
await state('validation');
|
||||
assert.equal(
|
||||
await page.getByLabel('Team name', { exact: true }).inputValue(),
|
||||
'New team',
|
||||
);
|
||||
await page
|
||||
.getByText('This team name is already taken.', { exact: true })
|
||||
.waitFor();
|
||||
await result('success');
|
||||
await save('Create team');
|
||||
await dialog().waitFor({ state: 'hidden' });
|
||||
await page
|
||||
.getByRole('heading', { name: 'New team', exact: true })
|
||||
.waitFor();
|
||||
|
||||
await button('Manage members of Platform team').click();
|
||||
const person = (name) =>
|
||||
dialog().getByRole('checkbox', { name: new RegExp(name) });
|
||||
await page.getByLabel('Find people').fill('sam@');
|
||||
await person('Sam Taylor').check();
|
||||
await page.getByLabel('Find people').fill('');
|
||||
assert.equal(await person('Alex Morgan').isChecked(), true);
|
||||
let dismissed = false;
|
||||
page.once('dialog', async (d) => {
|
||||
dismissed = true;
|
||||
await d.dismiss();
|
||||
});
|
||||
await dialog().getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||
assert.equal(dismissed, true);
|
||||
assert.equal(await dialog().isVisible(), true);
|
||||
await result('validation');
|
||||
assert.deepEqual(await save('Save members'), { user_ids: [1, 2] });
|
||||
await state('validation');
|
||||
await page
|
||||
.getByText('This user is unavailable.', { exact: true })
|
||||
.waitFor();
|
||||
assert.equal(await person('Sam Taylor').isChecked(), true);
|
||||
await page.route('**/teams/1/members', (route) => route.abort('failed'));
|
||||
await save('Save members');
|
||||
await state('network');
|
||||
await page.unroute('**/teams/1/members');
|
||||
await result('server');
|
||||
await save('Save members');
|
||||
await state('server');
|
||||
await result('success');
|
||||
await dialog()
|
||||
.getByRole('button', { name: 'Try again', exact: true })
|
||||
.click();
|
||||
await dialog().waitFor({ state: 'hidden' });
|
||||
await button('Manage members of Platform team').click();
|
||||
await person('Alex Morgan').uncheck();
|
||||
await person('Sam Taylor').uncheck();
|
||||
assert.deepEqual(await save('Save members'), { user_ids: [] });
|
||||
await dialog().waitFor({ state: 'hidden' });
|
||||
|
||||
await button('Manage permissions for Platform team').click();
|
||||
assert.equal(
|
||||
await dialog()
|
||||
.getByRole('button', { name: 'Save permissions', exact: true })
|
||||
.isDisabled(),
|
||||
true,
|
||||
);
|
||||
await page.getByLabel('User', { exact: true }).selectOption('2');
|
||||
assert.equal(await person('Team invite').isChecked(), true);
|
||||
await person('Team manage').check();
|
||||
page.once('dialog', (d) => d.dismiss());
|
||||
await page.getByLabel('User', { exact: true }).selectOption('3');
|
||||
assert.equal(
|
||||
await page.getByLabel('User', { exact: true }).inputValue(),
|
||||
'2',
|
||||
);
|
||||
await result('validation');
|
||||
assert.deepEqual(await save('Save permissions'), {
|
||||
permissions: ['team:invite', 'team:manage'],
|
||||
});
|
||||
await state('validation');
|
||||
await page
|
||||
.getByText('This permission is unavailable.', { exact: true })
|
||||
.waitFor();
|
||||
await result('forbidden');
|
||||
await save('Save permissions');
|
||||
await state('server');
|
||||
await page
|
||||
.getByText('You no longer have permission', { exact: true })
|
||||
.waitFor();
|
||||
assert.equal(
|
||||
await dialog()
|
||||
.getByRole('button', { name: 'Try again', exact: true })
|
||||
.count(),
|
||||
0,
|
||||
);
|
||||
assert.equal(await person('Team manage').isChecked(), true);
|
||||
await result('success');
|
||||
await save('Save permissions');
|
||||
await dialog().waitFor({ state: 'hidden' });
|
||||
await button('Manage permissions for Platform team').click();
|
||||
await page.getByLabel('User', { exact: true }).selectOption('2');
|
||||
assert.equal(await person('Team manage').isChecked(), true);
|
||||
await person('Team invite').uncheck();
|
||||
await person('Team manage').uncheck();
|
||||
assert.deepEqual(await save('Save permissions'), { permissions: [] });
|
||||
await dialog().waitFor({ state: 'hidden' });
|
||||
assert.deepEqual(errors, []);
|
||||
console.log(
|
||||
'Teams browser checks passed: creation, membership, permissions, empty selections, validation, network/server/403, draft protection, focus, keyboard, themes and mobile.',
|
||||
);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { createApp, h } from 'vue';
|
||||
import { createInertiaApp } from '@inertiajs/vue3';
|
||||
import '../../resources/css/app.css';
|
||||
import Teams from '../../resources/js/pages/teams/Index.vue';
|
||||
import { createAppearance } from '../../resources/js/composables/useAppearance';
|
||||
import { createLocalization } from '../../resources/js/lib/localization';
|
||||
createInertiaApp({
|
||||
resolve: () => Teams,
|
||||
setup: ({ el, App, props, plugin }) =>
|
||||
createApp({ render: () => h(App, props) })
|
||||
.use(plugin)
|
||||
.use(createLocalization())
|
||||
.use(createAppearance())
|
||||
.mount(el),
|
||||
});
|
||||
@@ -1,220 +0,0 @@
|
||||
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/teams-fixture',
|
||||
plugins: [vue(), ...(serve ? [tailwind()] : [])],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('../../resources/js', import.meta.url)),
|
||||
},
|
||||
},
|
||||
server: serve
|
||||
? { host: '127.0.0.1', port: 4197, strictPort: true, hmr: false }
|
||||
: { middlewareMode: true, hmr: false, watch: null },
|
||||
appType: 'custom',
|
||||
});
|
||||
const users = [
|
||||
{ id: 1, name: 'Alex Morgan', email: 'alex@example.test', avatar: null },
|
||||
{ id: 2, name: 'Sam Taylor', email: 'sam@example.test', avatar: null },
|
||||
{
|
||||
id: 3,
|
||||
name: 'Jordan Longname With Several Names',
|
||||
email: 'jordan.long.email.address@example.test',
|
||||
avatar: null,
|
||||
},
|
||||
];
|
||||
function initial() {
|
||||
return {
|
||||
canCreateTeams: true,
|
||||
users,
|
||||
status: null,
|
||||
teams: [
|
||||
{ id: 1, name: 'Platform team', user_ids: [1], users: [users[0]] },
|
||||
{ id: 2, name: 'Delivery team', user_ids: [], users: [] },
|
||||
],
|
||||
teamPermissions: { 1: { 2: ['team:invite'] } },
|
||||
permissionOptions: ['invite', 'manage', 'admin'].map((name) => ({
|
||||
value: 'team:' + name,
|
||||
label: 'Team ' + name,
|
||||
})),
|
||||
};
|
||||
}
|
||||
const makePage = (extra = {}) => ({
|
||||
component: 'Teams',
|
||||
url: '/teams',
|
||||
version: null,
|
||||
clearHistory: false,
|
||||
encryptHistory: false,
|
||||
props: {
|
||||
name: 'KanbanGit',
|
||||
errors: {},
|
||||
sidebarOpen: true,
|
||||
appearance: 'system',
|
||||
auth: {
|
||||
user: users[0],
|
||||
permissions: {
|
||||
globalAdmin: false,
|
||||
projectCreate: false,
|
||||
canManageTeams: true,
|
||||
},
|
||||
},
|
||||
navigation: { project: null },
|
||||
...initial(),
|
||||
...extra,
|
||||
},
|
||||
});
|
||||
if (serve) {
|
||||
let data = initial();
|
||||
server.middlewares.use(async (request, response, next) => {
|
||||
if (!request.url.startsWith('/teams')) {
|
||||
return next();
|
||||
}
|
||||
const url = new URL(request.url, 'http://localhost');
|
||||
if (url.searchParams.has('reset')) {
|
||||
data = initial();
|
||||
}
|
||||
if (url.searchParams.has('manager')) {
|
||||
data.canCreateTeams = false;
|
||||
}
|
||||
if (url.searchParams.has('empty')) {
|
||||
data.teams = [];
|
||||
}
|
||||
let errors = {};
|
||||
if (request.method !== 'GET') {
|
||||
const result =
|
||||
request.headers.cookie?.match(/teams_result=(\w+)/)?.[1] ??
|
||||
'success';
|
||||
if (['server', 'forbidden'].includes(result)) {
|
||||
response.statusCode = result === 'forbidden' ? 403 : 503;
|
||||
response.end('Simulated failure');
|
||||
return;
|
||||
}
|
||||
let body = '';
|
||||
for await (const chunk of request) {
|
||||
body += chunk;
|
||||
}
|
||||
const submitted = JSON.parse(body || '{}');
|
||||
if (result === 'validation') {
|
||||
errors = url.pathname.endsWith('/members')
|
||||
? { 'user_ids.0': 'This user is unavailable.' }
|
||||
: url.pathname.includes('/permissions/')
|
||||
? { 'permissions.0': 'This permission is unavailable.' }
|
||||
: { name: 'This team name is already taken.' };
|
||||
} else if (request.method === 'POST') {
|
||||
data.teams.push({
|
||||
id: 3,
|
||||
name: submitted.name,
|
||||
user_ids: [],
|
||||
users: [],
|
||||
});
|
||||
} else if (url.pathname.endsWith('/members')) {
|
||||
const team = data.teams.find(
|
||||
(team) => team.id === Number(url.pathname.split('/')[2]),
|
||||
);
|
||||
team.user_ids = submitted.user_ids;
|
||||
team.users = users.filter((user) =>
|
||||
team.user_ids.includes(user.id),
|
||||
);
|
||||
} else {
|
||||
const [, , teamId, , , userId] = url.pathname.split('/');
|
||||
data.teamPermissions[teamId] ??= {};
|
||||
data.teamPermissions[teamId][userId] = submitted.permissions;
|
||||
}
|
||||
if (Object.keys(errors).length === 0) {
|
||||
data.status = 'Saved';
|
||||
}
|
||||
}
|
||||
const page = makePage({ ...data, errors });
|
||||
response.setHeader('Vary', 'X-Inertia');
|
||||
if (request.headers['x-inertia']) {
|
||||
response.setHeader('X-Inertia', 'true');
|
||||
response.setHeader('Content-Type', 'application/json');
|
||||
response.end(JSON.stringify(page));
|
||||
return;
|
||||
}
|
||||
const escaped = JSON.stringify(page)
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll('<', '<');
|
||||
response.setHeader('Content-Type', 'text/html');
|
||||
response.end(
|
||||
await server.transformIndexHtml(
|
||||
request.url,
|
||||
'<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"></head><body><div id="app" data-page="' +
|
||||
escaped +
|
||||
'"></div><script type="module" src="/tests/Fixtures/teams-preview.mjs"></script></body></html>',
|
||||
),
|
||||
);
|
||||
});
|
||||
await server.listen();
|
||||
console.log('Teams 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 component = (
|
||||
await server.ssrLoadModule('/resources/js/pages/teams/Index.vue')
|
||||
).default;
|
||||
const render = async (extra = {}, locale = createLocalization()) =>
|
||||
(
|
||||
await createInertiaApp({
|
||||
page: makePage(extra),
|
||||
resolve: () => component,
|
||||
render: renderToString,
|
||||
setup: ({ App, props, plugin }) =>
|
||||
createSSRApp({ render: () => h(App, props) })
|
||||
.use(plugin)
|
||||
.use(locale)
|
||||
.use(createAppearance()),
|
||||
})
|
||||
).body;
|
||||
const html = await render();
|
||||
assert.match(html, /Platform team/);
|
||||
assert.match(html, /1 member/);
|
||||
assert.match(html, /0 members/);
|
||||
assert.match(html, /Manage members of Platform team/);
|
||||
assert.match(html, /Manage permissions for Platform team/);
|
||||
assert.match(html, /No members yet/);
|
||||
assert.match(html, /Create team/);
|
||||
assert.doesNotMatch(html, /Global admin|Hold Cmd|<select/);
|
||||
assert.doesNotMatch(
|
||||
await render({ canCreateTeams: false }),
|
||||
/Create team/,
|
||||
);
|
||||
assert.match(await render({ teams: [] }), /No teams to manage/);
|
||||
const translated = await render(
|
||||
{},
|
||||
createLocalization(
|
||||
{ locale: 'de' },
|
||||
{
|
||||
de: {
|
||||
...en,
|
||||
'navigation.teams': 'Arbeitsgruppen',
|
||||
'teams.create': 'Team anlegen',
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
assert.match(translated, /Arbeitsgruppen/);
|
||||
assert.match(translated, /Team anlegen/);
|
||||
console.log('Teams presentation checks passed.');
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
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);
|
||||
},
|
||||
});
|
||||
@@ -1,206 +0,0 @@
|
||||
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('&', '&')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll('<', '<');
|
||||
response.setHeader('Content-Type', 'text/html');
|
||||
response.end(
|
||||
await server.transformIndexHtml(
|
||||
request.url,
|
||||
'<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"></head><body><div id="app" data-page="' +
|
||||
escaped +
|
||||
'"></div><script type="module" src="/tests/Fixtures/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();
|
||||
}
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
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:4192';
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(10000);
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
|
||||
const focusWithTab = async (link) => {
|
||||
await page.locator('body').focus();
|
||||
for (let step = 0; step < 12; step++) {
|
||||
await page.keyboard.press('Tab');
|
||||
if (
|
||||
await link.evaluate((element) => element === document.activeElement)
|
||||
) {
|
||||
assert.equal(
|
||||
await link.evaluate((element) =>
|
||||
element.matches(':focus-visible'),
|
||||
),
|
||||
true,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
assert.fail('Primary welcome action was not reachable by keyboard');
|
||||
};
|
||||
|
||||
try {
|
||||
for (const width of [1440, 390, 320]) {
|
||||
await page.setViewportSize({ width, height: 1000 });
|
||||
for (const theme of ['light', 'dark']) {
|
||||
await page.emulateMedia({
|
||||
colorScheme: theme,
|
||||
reducedMotion: 'reduce',
|
||||
});
|
||||
for (const [path, action] of [
|
||||
['/', 'Sign in to KanbanGit'],
|
||||
['/?auth=1', 'Open dashboard'],
|
||||
]) {
|
||||
await page.goto(base + path);
|
||||
await page
|
||||
.getByRole('heading', {
|
||||
name: 'Plan the work. Keep Git context close.',
|
||||
exact: true,
|
||||
level: 1,
|
||||
})
|
||||
.waitFor();
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
assert.equal(
|
||||
await page
|
||||
.locator('html')
|
||||
.evaluate((element) =>
|
||||
element.classList.contains('dark'),
|
||||
),
|
||||
theme === 'dark',
|
||||
);
|
||||
assert.equal(
|
||||
await page.evaluate(
|
||||
() =>
|
||||
document.documentElement.scrollWidth <=
|
||||
window.innerWidth,
|
||||
),
|
||||
true,
|
||||
`${path} overflows at ${width}px in ${theme} mode`,
|
||||
);
|
||||
const clippedElements = await page
|
||||
.locator(
|
||||
'header a, main h1, main p, main section, main article, main li, footer p',
|
||||
)
|
||||
.evaluateAll((elements) =>
|
||||
elements
|
||||
.filter((element) => {
|
||||
const bounds = element.getBoundingClientRect();
|
||||
return (
|
||||
bounds.width > 0 &&
|
||||
(bounds.left < -0.5 ||
|
||||
bounds.right > window.innerWidth + 0.5)
|
||||
);
|
||||
})
|
||||
.map((element) => element.outerHTML.slice(0, 120)),
|
||||
);
|
||||
assert.deepEqual(
|
||||
clippedElements,
|
||||
[],
|
||||
`${path} clips content at ${width}px in ${theme} mode`,
|
||||
);
|
||||
assert.equal(await page.locator('main h1').count(), 1);
|
||||
const primaryAction = page
|
||||
.locator('main')
|
||||
.getByRole('link', { name: action, exact: true });
|
||||
assert.ok(
|
||||
(await primaryAction.evaluate(
|
||||
(element) => element.getBoundingClientRect().height,
|
||||
)) >= 44,
|
||||
);
|
||||
await page.screenshot({
|
||||
path: `/private/tmp/kan50-welcome-${path.includes('auth') ? 'authenticated' : 'guest'}-${width}-${theme}.png`,
|
||||
fullPage: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 1000 });
|
||||
await page.goto(`${base}/`);
|
||||
const signIn = page
|
||||
.locator('main')
|
||||
.getByRole('link', { name: 'Sign in to KanbanGit', exact: true });
|
||||
await focusWithTab(signIn);
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForURL('**/login');
|
||||
await page
|
||||
.getByRole('heading', { name: 'Destination reached', exact: true })
|
||||
.waitFor();
|
||||
|
||||
await page.goto(`${base}/?auth=1`);
|
||||
const dashboard = page
|
||||
.locator('main')
|
||||
.getByRole('link', { name: 'Open dashboard', exact: true });
|
||||
await focusWithTab(dashboard);
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForURL('**/dashboard');
|
||||
await page
|
||||
.getByRole('heading', { name: 'Destination reached', exact: true })
|
||||
.waitFor();
|
||||
|
||||
assert.deepEqual(errors, []);
|
||||
console.log(
|
||||
'Welcome browser checks passed: keyboard, guest and authenticated actions, 320/390/1440px and both themes.',
|
||||
);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { createApp, h } from 'vue';
|
||||
import { createInertiaApp } from '@inertiajs/vue3';
|
||||
import '../../resources/css/app.css';
|
||||
import Welcome from '../../resources/js/pages/Welcome.vue';
|
||||
import { createAppearance } from '../../resources/js/composables/useAppearance';
|
||||
import { createLocalization } from '../../resources/js/lib/localization';
|
||||
|
||||
createInertiaApp({
|
||||
resolve: (name) =>
|
||||
({ Welcome })[name] ?? {
|
||||
render: () => h('main', [h('h1', 'Destination reached')]),
|
||||
},
|
||||
setup: ({ el, App, props, plugin }) =>
|
||||
createApp({ render: () => h(App, props) })
|
||||
.use(plugin)
|
||||
.use(createLocalization())
|
||||
.use(createAppearance())
|
||||
.mount(el),
|
||||
});
|
||||
@@ -1,147 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import process from 'node:process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import tailwind from '@tailwindcss/vite';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
import { createInertiaApp } from '@inertiajs/vue3';
|
||||
import { createServer } from 'vite';
|
||||
import { createSSRApp, h } from 'vue';
|
||||
import { renderToString } from 'vue/server-renderer';
|
||||
|
||||
const serve = process.argv.includes('--serve');
|
||||
const server = await createServer({
|
||||
configFile: false,
|
||||
cacheDir: 'node_modules/.vite/welcome-fixture',
|
||||
plugins: [vue(), ...(serve ? [tailwind()] : [])],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('../../resources/js', import.meta.url)),
|
||||
},
|
||||
},
|
||||
server: serve
|
||||
? { host: '127.0.0.1', port: 4192, strictPort: true, hmr: false }
|
||||
: { middlewareMode: true, hmr: false, watch: null },
|
||||
appType: 'custom',
|
||||
});
|
||||
|
||||
const user = {
|
||||
id: 8,
|
||||
name: 'Alex Morgan',
|
||||
email: 'alex@example.test',
|
||||
};
|
||||
|
||||
function makePage(url) {
|
||||
const parsedUrl = new URL(url, 'http://localhost');
|
||||
const isWelcome = parsedUrl.pathname === '/';
|
||||
const isAuthenticated =
|
||||
isWelcome && parsedUrl.searchParams.get('auth') === '1';
|
||||
|
||||
return {
|
||||
component: isWelcome ? 'Welcome' : 'Destination',
|
||||
url,
|
||||
version: null,
|
||||
clearHistory: false,
|
||||
encryptHistory: false,
|
||||
props: {
|
||||
name: 'KanbanGit',
|
||||
errors: {},
|
||||
auth: {
|
||||
user: isAuthenticated ? user : null,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (serve) {
|
||||
server.middlewares.use(async (request, response) => {
|
||||
const page = makePage(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('&', '&')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll('<', '<');
|
||||
response.setHeader('Content-Type', 'text/html');
|
||||
response.end(
|
||||
await server.transformIndexHtml(
|
||||
request.url,
|
||||
'<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"></head><body><div id="app" data-page="' +
|
||||
escaped +
|
||||
'"></div><script type="module" src="/tests/Fixtures/welcome-preview.mjs"></script></body></html>',
|
||||
),
|
||||
);
|
||||
});
|
||||
await server.listen();
|
||||
console.log('Welcome fixture ready at http://127.0.0.1:4192/');
|
||||
} else {
|
||||
try {
|
||||
const { createAppearance } = await server.ssrLoadModule(
|
||||
'/resources/js/composables/useAppearance.ts',
|
||||
);
|
||||
const { createLocalization } = await server.ssrLoadModule(
|
||||
'/resources/js/lib/localization.ts',
|
||||
);
|
||||
const { en } = await server.ssrLoadModule(
|
||||
'/resources/js/locales/en.ts',
|
||||
);
|
||||
const { default: Welcome } = await server.ssrLoadModule(
|
||||
'/resources/js/pages/Welcome.vue',
|
||||
);
|
||||
|
||||
const render = async (url, localization = createLocalization()) => {
|
||||
const response = await createInertiaApp({
|
||||
page: makePage(url),
|
||||
resolve: () => Welcome,
|
||||
render: renderToString,
|
||||
setup: ({ App, props, plugin }) =>
|
||||
createSSRApp({ render: () => h(App, props) })
|
||||
.use(plugin)
|
||||
.use(localization)
|
||||
.use(createAppearance('light')),
|
||||
});
|
||||
return response.body;
|
||||
};
|
||||
|
||||
const guest = await render('/');
|
||||
assert.match(guest, /Plan the work\. Keep Git context close\./);
|
||||
assert.match(guest, /Configurable workflows/);
|
||||
assert.match(guest, /Structured ticket context/);
|
||||
assert.match(guest, /Repository-aware delivery/);
|
||||
assert.equal((guest.match(/href="\/login"/g) ?? []).length, 2);
|
||||
assert.doesNotMatch(guest, /href="\/dashboard"/);
|
||||
assert.doesNotMatch(guest, /Laravel|Laracasts|Deploy now/);
|
||||
|
||||
const authenticated = await render('/?auth=1');
|
||||
assert.equal(
|
||||
(authenticated.match(/href="\/dashboard"/g) ?? []).length,
|
||||
2,
|
||||
);
|
||||
assert.doesNotMatch(authenticated, /href="\/login"/);
|
||||
|
||||
const german = {
|
||||
...en,
|
||||
'welcome.title': 'Arbeit planen. Git-Kontext behalten.',
|
||||
'welcome.action.signIn': 'Bei KanbanGit anmelden',
|
||||
};
|
||||
const localized = await render(
|
||||
'/',
|
||||
createLocalization({ locale: 'de' }, { de: german }),
|
||||
);
|
||||
assert.match(localized, /Arbeit planen\. Git-Kontext behalten\./);
|
||||
assert.match(localized, /Bei KanbanGit anmelden/);
|
||||
assert.doesNotMatch(
|
||||
localized,
|
||||
/Plan the work\. Keep Git context close\./,
|
||||
);
|
||||
|
||||
console.log('Welcome behavior checks passed.');
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import process from 'node:process';
|
||||
const { chromium } = await import(
|
||||
process.env.PLAYWRIGHT_MODULE ?? 'playwright'
|
||||
);
|
||||
const browser = await chromium.launch({ channel: 'chrome', headless: true });
|
||||
const base = 'http://127.0.0.1:4188';
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(10000);
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
const editor = page.getByRole('region', { name: 'Status moves', exact: true });
|
||||
const source = () => editor.getByLabel('From status', { exact: true });
|
||||
const check = (name) =>
|
||||
editor.getByRole('checkbox', { name: new RegExp(name) });
|
||||
const button = (name) => editor.getByRole('button', { name, exact: true });
|
||||
const state = (name) => editor.locator('[data-form-state="' + name + '"]');
|
||||
const result = (value) =>
|
||||
context.addCookies([{ name: 'workflow_test_result', value, url: base }]);
|
||||
const visit = async (query = '?reset') => {
|
||||
await result('success');
|
||||
await page.goto(base + '/projects/1/settings/board' + query);
|
||||
await source().waitFor();
|
||||
};
|
||||
const noOverflow = async () =>
|
||||
assert.equal(
|
||||
await page.evaluate(
|
||||
() => document.documentElement.scrollWidth <= window.innerWidth,
|
||||
),
|
||||
true,
|
||||
);
|
||||
const save = async (id, targets) => {
|
||||
const sent = page.waitForRequest((request) => request.method() === 'PATCH');
|
||||
await button('Save moves').click();
|
||||
const request = await sent;
|
||||
assert.equal(
|
||||
new URL(request.url()).pathname,
|
||||
'/projects/1/settings/board/statuses/' + id + '/transitions',
|
||||
);
|
||||
assert.deepEqual(request.postDataJSON(), {
|
||||
allowed_ticket_status_ids: targets,
|
||||
});
|
||||
};
|
||||
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();
|
||||
assert.equal(await check('Ready').isChecked(), true);
|
||||
assert.equal(await check('Building').isChecked(), false);
|
||||
assert.equal(await check('Backlog').count(), 0);
|
||||
assert.equal(await button('Save moves').isDisabled(), true);
|
||||
await check('Building').focus();
|
||||
await page.keyboard.press('Space');
|
||||
assert.equal(await check('Building').isChecked(), true);
|
||||
await editor
|
||||
.getByText('Unsaved changes', { exact: true })
|
||||
.waitFor();
|
||||
await noOverflow();
|
||||
await button('Reset changes').click();
|
||||
assert.equal(await check('Building').isChecked(), false);
|
||||
assert.equal(await check('Ready').isChecked(), true);
|
||||
if ([1440, 390].includes(width)) {
|
||||
await editor.scrollIntoViewIfNeeded();
|
||||
await page.screenshot({
|
||||
path: '/private/tmp/KAN-41-' + width + '-' + theme + '.png',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
await visit();
|
||||
await check('Ready').uncheck();
|
||||
await check('Review').check();
|
||||
await result('validation');
|
||||
await save(1, [4]);
|
||||
await state('validation').waitFor();
|
||||
await editor
|
||||
.getByText('Choose a destination from this project.', { exact: true })
|
||||
.waitFor();
|
||||
assert.equal(await check('Review').isChecked(), true);
|
||||
assert.equal(await check('Ready').isChecked(), false);
|
||||
assert.equal(
|
||||
await editor.getByRole('group').getAttribute('aria-invalid'),
|
||||
'true',
|
||||
);
|
||||
await result('server');
|
||||
await save(1, [4]);
|
||||
await state('server').waitFor();
|
||||
await result('success');
|
||||
await button('Try again').click();
|
||||
await state('success').waitFor();
|
||||
assert.equal(await button('Save moves').isDisabled(), true);
|
||||
assert.equal(await check('Review').isChecked(), true);
|
||||
await page.reload();
|
||||
await source().waitFor();
|
||||
assert.equal(await check('Review').isChecked(), true);
|
||||
assert.equal(await check('Ready').isChecked(), false);
|
||||
await source().selectOption('4');
|
||||
assert.equal(await check('Backlog').isChecked(), false);
|
||||
assert.equal(await check('Review').count(), 0);
|
||||
await check('Backlog').check();
|
||||
page.once('dialog', (prompt) => prompt.dismiss());
|
||||
await source().selectOption('1');
|
||||
assert.equal(await source().inputValue(), '4');
|
||||
assert.equal(await check('Backlog').isChecked(), true);
|
||||
page.once('dialog', (prompt) => prompt.accept());
|
||||
await source().selectOption('1');
|
||||
assert.equal(await source().inputValue(), '1');
|
||||
assert.equal(await check('Review').isChecked(), true);
|
||||
await check('Review').uncheck();
|
||||
await editor.getByText(/With no destinations selected/).waitFor();
|
||||
await save(1, []);
|
||||
await state('success').waitFor();
|
||||
await page.reload();
|
||||
await source().waitFor();
|
||||
assert.equal(await editor.locator('input:checked').count(), 0);
|
||||
await editor.getByText('Saved destinations: 0', { exact: true }).waitFor();
|
||||
// A disconnected request retains the draft and supports a single retry.
|
||||
await check('Building').check();
|
||||
await page.route('**/statuses/1/transitions', (route) =>
|
||||
route.abort('failed'),
|
||||
);
|
||||
await save(1, [3]);
|
||||
await state('network').waitFor();
|
||||
assert.equal(await check('Building').isChecked(), true);
|
||||
await page.unroute('**/statuses/1/transitions');
|
||||
await button('Try again').click();
|
||||
await state('success').waitFor();
|
||||
// Pending saves disable inputs, source changes and duplicate submits.
|
||||
let release;
|
||||
const blocked = new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
await page.route('**/statuses/1/transitions', async (route) => {
|
||||
await blocked;
|
||||
await route.continue();
|
||||
});
|
||||
await check('Review').check();
|
||||
await save(1, [3, 4]);
|
||||
await state('loading').waitFor();
|
||||
assert.equal(await source().isDisabled(), true);
|
||||
assert.equal(await check('Building').isDisabled(), true);
|
||||
assert.equal(
|
||||
await editor.locator('button[type=submit]').isDisabled(),
|
||||
true,
|
||||
);
|
||||
release();
|
||||
await state('success').waitFor();
|
||||
await page.unroute('**/statuses/1/transitions');
|
||||
await visit('?reset&empty');
|
||||
await editor
|
||||
.getByText('No other statuses exist yet.', { exact: true })
|
||||
.waitFor();
|
||||
assert.equal(await editor.getByRole('checkbox').count(), 0);
|
||||
await noOverflow();
|
||||
assert.deepEqual(errors, []);
|
||||
console.log(
|
||||
'Transition browser checks passed: directed rules, persisted selection, clearing all targets, reset, source-change protection, validation/network/server errors, retry, pending saves, keyboard, responsive widths and both themes.',
|
||||
);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
Reference in New Issue
Block a user