feat(KAN-47): redesign teams and membership management
# Conflicts: # resources/js/locales/en.ts
This commit is contained in:
@@ -0,0 +1,351 @@
|
|||||||
|
<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>
|
||||||
@@ -172,6 +172,52 @@ export const en = {
|
|||||||
'templates.deleteImpact':
|
'templates.deleteImpact':
|
||||||
'{count} linked projects will stop inheriting from this template. Their current configuration and tickets are kept.',
|
'{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.title': 'Board workflow',
|
||||||
'workflow.pageTitle': '{project} · Board workflow',
|
'workflow.pageTitle': '{project} · Board workflow',
|
||||||
'workflow.description':
|
'workflow.description':
|
||||||
|
|||||||
+166
-318
@@ -1,356 +1,204 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Form, Head } from '@inertiajs/vue3';
|
import { Head } from '@inertiajs/vue3';
|
||||||
import { Shield } from 'lucide-vue-next';
|
import { Plus, Search, Shield, Users } from 'lucide-vue-next';
|
||||||
import TeamController from '@/actions/App/Http/Controllers/TeamController';
|
import { computed, ref } from 'vue';
|
||||||
import Heading from '@/components/Heading.vue';
|
import FeedbackState from '@/components/FeedbackState.vue';
|
||||||
import InputError from '@/components/InputError.vue';
|
import TeamEditorDialog from '@/components/teams/TeamEditorDialog.vue';
|
||||||
|
import UserAvatar from '@/components/ui/avatar/UserAvatar.vue';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from '@/components/ui/card';
|
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
import AppLayout from '@/layouts/AppLayout.vue';
|
import AppLayout from '@/layouts/AppLayout.vue';
|
||||||
import { index as teamsIndex } from '@/routes/teams';
|
import { index } from '@/routes/teams';
|
||||||
import type { BreadcrumbItem, TeamsIndexPageProps } from '@/types';
|
import type { BreadcrumbItem, TeamsIndexPageProps } from '@/types';
|
||||||
|
import type { TeamEditorTarget } from '@/types/teams';
|
||||||
|
|
||||||
const props = defineProps<TeamsIndexPageProps>();
|
const props = defineProps<TeamsIndexPageProps>();
|
||||||
|
const { t } = useLocalization();
|
||||||
const breadcrumbs: BreadcrumbItem[] = [
|
const search = ref('');
|
||||||
{
|
const target = ref<TeamEditorTarget | null>(null);
|
||||||
title: 'Teams',
|
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
||||||
href: teamsIndex(),
|
{ title: t('navigation.teams'), href: index() },
|
||||||
},
|
]);
|
||||||
];
|
const visibleTeams = computed(() =>
|
||||||
|
props.teams.filter((team) =>
|
||||||
const hasPermission = (
|
team.name
|
||||||
permissions: Record<string, string[]>,
|
.toLocaleLowerCase()
|
||||||
actorId: number,
|
.includes(search.value.trim().toLocaleLowerCase()),
|
||||||
permission: string,
|
),
|
||||||
) => (permissions[actorId] ?? []).includes(permission);
|
);
|
||||||
|
|
||||||
const permissionsForTeam = (teamId: number): Record<string, string[]> =>
|
|
||||||
props.teamPermissions[teamId] ?? {};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppLayout :breadcrumbs="breadcrumbs">
|
<AppLayout :breadcrumbs="breadcrumbs">
|
||||||
<Head title="Teams" />
|
<Head :title="t('navigation.teams')" />
|
||||||
|
<main
|
||||||
<div class="flex flex-1 flex-col gap-6 p-4">
|
class="mx-auto flex w-full max-w-7xl flex-1 flex-col gap-6 p-4 sm:p-6 lg:p-8"
|
||||||
<div
|
>
|
||||||
|
<header
|
||||||
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
|
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
|
||||||
>
|
>
|
||||||
<Heading
|
<div class="grid min-w-0 gap-2">
|
||||||
title="Teams"
|
<p
|
||||||
description="Create reusable teams, manage memberships, and assign team-scoped permissions to users."
|
class="text-primary text-xs font-semibold uppercase tracking-wider"
|
||||||
/>
|
|
||||||
|
|
||||||
<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
|
|
||||||
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 }}
|
{{ t('teams.eyebrow') }}
|
||||||
</div>
|
</p>
|
||||||
|
<h1 class="text-headline-mobile sm:text-headline">
|
||||||
<div
|
{{ t('navigation.teams') }}
|
||||||
class="grid gap-6"
|
</h1>
|
||||||
:class="
|
<p class="text-muted-foreground max-w-2xl text-sm">
|
||||||
canCreateTeams
|
{{ t('teams.description') }}
|
||||||
? '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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
v-if="canCreateTeams"
|
||||||
variant="outline"
|
type="button"
|
||||||
:disabled="processing"
|
class="shrink-0"
|
||||||
|
@click="target = { kind: 'create' }"
|
||||||
|
><Plus class="size-4" aria-hidden="true" />{{
|
||||||
|
t('teams.create')
|
||||||
|
}}</Button
|
||||||
>
|
>
|
||||||
Save members
|
</header>
|
||||||
</Button>
|
<FeedbackState
|
||||||
</Form>
|
v-if="status"
|
||||||
|
state="success"
|
||||||
<div class="space-y-4 border-t pt-6">
|
:title="t('teams.updated')"
|
||||||
<div class="space-y-1">
|
/>
|
||||||
<h3 class="text-foreground font-medium">
|
<div
|
||||||
Team permissions
|
class="bg-card flex flex-col gap-4 rounded-xl border p-4 sm:flex-row sm:items-center sm:justify-between"
|
||||||
|
>
|
||||||
|
<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')"
|
||||||
|
/>
|
||||||
|
</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>
|
</h3>
|
||||||
<p class="text-muted-foreground text-sm">
|
<p class="text-muted-foreground text-sm">
|
||||||
Direct user permissions on this team are
|
{{
|
||||||
stored separately from team membership.
|
t('teams.memberCount', {
|
||||||
|
count: team.users.length,
|
||||||
|
})
|
||||||
|
}}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
||||||
<div
|
<div class="flex-1 px-5 pb-5">
|
||||||
class="text-muted-foreground text-xs"
|
<ul
|
||||||
>
|
v-if="team.users.length"
|
||||||
{{ user.email }}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-4">
|
|
||||||
<div
|
|
||||||
class="flex flex-wrap gap-2"
|
class="flex flex-wrap gap-2"
|
||||||
|
:aria-label="t('teams.members')"
|
||||||
>
|
>
|
||||||
<Badge
|
<li
|
||||||
v-for="option in permissionOptions"
|
v-for="member in team.users"
|
||||||
v-show="
|
:key="member.id"
|
||||||
hasPermission(
|
class="bg-muted/60 flex min-w-0 max-w-full items-center gap-2 rounded-full py-1 pl-1 pr-3"
|
||||||
permissionsForTeam(
|
|
||||||
team.id,
|
|
||||||
),
|
|
||||||
user.id,
|
|
||||||
option.value,
|
|
||||||
)
|
|
||||||
"
|
|
||||||
:key="`${team.id}-${user.id}-${option.value}-badge`"
|
|
||||||
variant="secondary"
|
|
||||||
>
|
>
|
||||||
{{ option.label }}
|
<UserAvatar
|
||||||
</Badge>
|
:user="member"
|
||||||
<span
|
class="size-6 shrink-0"
|
||||||
v-if="
|
/><span class="min-w-0 break-words text-xs">{{
|
||||||
(
|
member.name
|
||||||
permissionsForTeam(
|
}}</span>
|
||||||
team.id,
|
</li>
|
||||||
)[
|
</ul>
|
||||||
user.id
|
<p v-else class="text-muted-foreground text-sm">
|
||||||
] ?? []
|
{{ t('teams.noMembers') }}
|
||||||
).length === 0
|
</p>
|
||||||
"
|
|
||||||
class="text-muted-foreground"
|
|
||||||
>
|
|
||||||
No direct team
|
|
||||||
permissions
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
|
||||||
<td class="px-4 py-4">
|
|
||||||
<div
|
<div
|
||||||
class="flex justify-end"
|
class="bg-muted/20 grid gap-2 border-t p-4 sm:grid-cols-2"
|
||||||
>
|
>
|
||||||
<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
|
<Button
|
||||||
type="submit"
|
type="button"
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
variant="outline"
|
||||||
:disabled="
|
:aria-label="
|
||||||
processing
|
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
|
||||||
>
|
>
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</main>
|
||||||
|
<TeamEditorDialog
|
||||||
|
:target="target"
|
||||||
|
:users="users"
|
||||||
|
:permission-options="permissionOptions"
|
||||||
|
:team-permissions="teamPermissions"
|
||||||
|
@close="target = null"
|
||||||
|
/>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
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[]>>;
|
||||||
|
};
|
||||||
@@ -214,4 +214,89 @@ class TeamControllerTest extends TestCase
|
|||||||
'scope_type' => $team->getMorphClass(),
|
'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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
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),
|
||||||
|
});
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user