Files
KanbanGit/resources/js/components/teams/TeamEditorDialog.vue
T
Twyco 1d9df6897d
CI/CD / preconditions (pull_request) Successful in 1s
CI/CD / test_and_check_app (pull_request) Successful in 6m8s
CI/CD / Publish immutable image (pull_request) Skipped
feat(KAN-47): redesign teams and membership management
# Conflicts:
#	resources/js/locales/en.ts
2026-09-16 09:51:26 +02:00

352 lines
14 KiB
Vue

<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>