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>
|
||||
Reference in New Issue
Block a user