352 lines
14 KiB
Vue
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>
|