refactor: consolidated admin dashboard improvements (#3790)

Co-authored-by: jamesgeorge007 <jamesgeorge998001@gmail.com>
This commit is contained in:
Joel Jacob Stephen
2024-02-02 15:17:25 +05:30
committed by GitHub
parent aab76f1358
commit 3d6adcc39d
20 changed files with 763 additions and 716 deletions

View File

@@ -72,13 +72,14 @@ onMounted(async () => {
success = await resetInfraConfigs(resetInfraConfigsMutation);
if (!success) return;
} else {
const infraResult = await updateInfraConfigs(updateInfraConfigsMutation);
if (!infraResult) return;
const authResult = await updateAuthProvider(
updateAllowedAuthProviderMutation
);
const infraResult = await updateInfraConfigs(updateInfraConfigsMutation);
success = authResult && infraResult;
if (!success) return;
if (!authResult) return;
}
restart.value = true;

View File

@@ -3,7 +3,7 @@
v-if="show"
dialog
:title="t('teams.create_team')"
@close="$emit('hide-modal')"
@close="emit('hide-modal')"
>
<template #body>
<div class="flex flex-col space-y-4 relative">
@@ -16,12 +16,16 @@
class="flex-1 !flex"
:source="allUsersEmail"
:spellcheck="true"
placeholder=""
:placeholder="t('teams.email')"
@input="(email: string) => getOwnerEmail(email)"
/>
</div>
<label for="teamName"> {{ t('teams.name') }} </label>
<HoppSmartInput v-model="teamName" placeholder="" class="!my-2" />
<HoppSmartInput
v-model="teamName"
:placeholder="t('teams.name')"
class="!my-2"
/>
</div>
</template>
<template #footer>
@@ -44,11 +48,10 @@
<script setup lang="ts">
import { ref, watchEffect } from 'vue';
import { useToast } from '~/composables/toast';
import { useI18n } from '~/composables/i18n';
import { useToast } from '~/composables/toast';
const t = useI18n();
const toast = useToast();
const props = withDefaults(
@@ -82,11 +85,11 @@ const getOwnerEmail = (email: string) => (ownerEmail.value = email);
const createTeam = () => {
if (teamName.value.trim() === '') {
toast.error(`${t('teams.valid_name')}`);
toast.error(t('teams.valid_name'));
return;
}
if (ownerEmail.value.trim() === '') {
toast.error(`${t('teams.valid_owner_email')}`);
toast.error(t('teams.valid_owner_email'));
return;
}
emit('create-team', teamName.value, ownerEmail.value);

View File

@@ -1,7 +1,7 @@
<template>
<div class="flex flex-col">
<div v-if="team" class="flex flex-col">
<div class="flex flex-col space-y-8">
<div v-if="team.id" class="flex flex-col space-y-3">
<div class="flex flex-col space-y-3">
<label class="text-accentContrast" for="username"
>{{ t('teams.id') }}
</label>
@@ -10,33 +10,33 @@
</div>
</div>
<div v-if="teamName" class="flex flex-col space-y-3">
<div class="flex flex-col space-y-3">
<label class="text-accentContrast" for="teamname"
>{{ t('teams.name') }}
</label>
<div
class="flex bg-divider rounded-md items-stretch flex-1 border border-divider"
:class="{
'!border-accent': showRenameInput,
'!border-accent': isTeamNameBeingEdited,
}"
>
<HoppSmartInput
v-model="newTeamName"
v-model="updatedTeamName"
styles="bg-transparent flex-1 rounded-md !rounded-r-none disabled:select-none border-r-0 disabled:cursor-default disabled:opacity-50"
placeholder="Team Name"
:disabled="!showRenameInput"
:disabled="!isTeamNameBeingEdited"
>
<template #button>
<HoppButtonPrimary
class="!rounded-l-none"
filled
:icon="showRenameInput ? IconSave : IconEdit"
:icon="isTeamNameBeingEdited ? IconSave : IconEdit"
:label="
showRenameInput
isTeamNameBeingEdited
? `${t('teams.rename')}`
: `${t('teams.edit')}`
"
@click="handleNameEdit()"
@click="handleTeamNameEdit"
/>
</template>
</HoppSmartInput>
@@ -58,7 +58,7 @@
class="!bg-red-600 !hover:opacity-80"
filled
:label="t('teams.delete_team')"
@click="team && $emit('delete-team', team.id)"
@click="emit('delete-team', team.id)"
:icon="IconTrash"
/>
</div>
@@ -66,45 +66,89 @@
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useMutation } from '@urql/vue';
import { useVModel } from '@vueuse/core';
import { computed, onMounted, ref } from 'vue';
import { useI18n } from '~/composables/i18n';
import { useToast } from '~/composables/toast';
import { TeamInfoQuery } from '~/helpers/backend/graphql';
import { RenameTeamDocument, TeamInfoQuery } from '~/helpers/backend/graphql';
import IconEdit from '~icons/lucide/edit';
import IconSave from '~icons/lucide/save';
import IconTrash from '~icons/lucide/trash-2';
import { useI18n } from '~/composables/i18n';
const t = useI18n();
const toast = useToast();
const props = defineProps<{
team: TeamInfoQuery['infra']['teamInfo'];
teamName: string;
showRenameInput: boolean;
}>();
const emit = defineEmits<{
(event: 'delete-team', teamID: string): void;
(event: 'rename-team', teamName: string): void;
(event: 'update:showRenameInput', showRenameInput: boolean): void;
(event: 'update:team', team: TeamInfoQuery['infra']['teamInfo']): void;
(event: 'delete-team', teamId: string): void;
}>();
const newTeamName = ref(props.teamName);
const team = useVModel(props, 'team', emit);
const handleNameEdit = () => {
if (props.showRenameInput) {
renameTeam();
// Contains the actual team name
const teamName = computed({
get: () => team.value.name,
set: (value) => {
team.value.name = value;
},
});
// Contains the stored team name from the actual team name before being edited
const currentTeamName = ref('');
// Contains the team name that is being edited
const updatedTeamName = computed({
get: () => currentTeamName.value,
set: (value) => {
currentTeamName.value = value;
},
});
// Set the current team name to the actual team name
onMounted(() => {
currentTeamName.value = teamName.value;
});
// Rename the team name
const isTeamNameBeingEdited = ref(false);
const teamRename = useMutation(RenameTeamDocument);
const handleTeamNameEdit = () => {
if (isTeamNameBeingEdited.value) {
// If the team name is not changed, then return control
if (teamName.value !== updatedTeamName.value) {
renameTeamName();
} else isTeamNameBeingEdited.value = false;
} else {
emit('update:showRenameInput', true);
isTeamNameBeingEdited.value = true;
}
};
const renameTeam = () => {
if (newTeamName.value.trim() === '') {
toast.error(`${t('teams.empty_name')}`);
const renameTeamName = async () => {
if (updatedTeamName.value.trim() === '') {
toast.error(t('teams.empty_name'));
return;
}
emit('rename-team', newTeamName.value);
if (updatedTeamName.value.length < 6) {
toast.error(t('state.team_name_too_short'));
return;
}
const variables = { uid: team.value.id, name: updatedTeamName.value };
const result = await teamRename.executeMutation(variables);
if (result.error) {
toast.error(t('state.rename_team_failure'));
} else {
isTeamNameBeingEdited.value = false;
toast.success(t('state.rename_team_success'));
teamName.value = updatedTeamName.value;
}
};
</script>

View File

@@ -1,16 +1,23 @@
<template>
<HoppSmartModal v-if="show" dialog title="Add Member" @close="hideModal">
<HoppSmartModal
v-if="show"
dialog
:title="t('teams.add_member')"
@close="hideModal"
>
<template #body>
<div v-if="addingUserToTeam" class="flex items-center justify-center p-4">
<HoppSmartSpinner />
</div>
<div v-else class="flex flex-col">
<div class="flex items-center justify-between flex-1 pt-4">
<label for="memberList" class="p-4"> Add members </label>
<label for="memberList" class="p-4">
{{ t('teams.add_members') }}
</label>
<div class="flex">
<HoppButtonSecondary
:icon="IconPlus"
label="Add new"
:label="t('teams.add_new')"
filled
@click="addNewMember"
/>
@@ -23,8 +30,8 @@
class="flex divide-x divide-dividerLight"
>
<HoppSmartAutoComplete
v-model="member.key"
placeholder="Email"
:value="member.key"
:placeholder="t('state.email')"
:source="allUsersEmail"
:name="'member' + index"
:spellcheck="true"
@@ -44,7 +51,7 @@
<HoppSmartSelectWrapper>
<input
class="flex flex-1 px-4 py-2 bg-transparent cursor-pointer"
placeholder="Permissions"
:placeholder="t('teams.permissions')"
:name="'value' + index"
:value="member.value"
readonly
@@ -58,7 +65,7 @@
@keyup.escape="hide()"
>
<HoppSmartItem
label="OWNER"
:label="t('teams.owner')"
:icon="
member.value === 'OWNER' ? IconCircleDot : IconCircle
"
@@ -71,7 +78,7 @@
"
/>
<HoppSmartItem
label="EDITOR"
:label="t('teams.editor')"
:icon="
member.value === 'EDITOR' ? IconCircleDot : IconCircle
"
@@ -84,7 +91,7 @@
"
/>
<HoppSmartItem
label="VIEWER"
:label="t('teams.viewer')"
:icon="
member.value === 'VIEWER' ? IconCircleDot : IconCircle
"
@@ -104,7 +111,7 @@
<HoppButtonSecondary
id="member"
v-tippy="{ theme: 'tooltip' }"
title="Remove"
:title="t('teams.remove')"
:icon="IconTrash"
color="red"
@click="removeNewMember(index)"
@@ -113,13 +120,13 @@
</div>
<HoppSmartPlaceholder
v-if="newMembersList.length === 0"
:src="`/images/states/dark/add_group.svg`"
alt="No invites"
text="No invites"
:src="addGroupImagePath"
:alt="t('teams.no_members')"
:text="t('teams.no_members')"
>
<template #body>
<HoppButtonSecondary
label="Add new"
:label="t('teams.add_new')"
filled
@click="addNewMember"
/>
@@ -136,11 +143,12 @@
<icon-lucide-help-circle
class="mr-2 text-secondaryLight svg-icons"
/>
Roles
{{ t('teams.roles') }}
</span>
<p>
<span class="text-secondaryLight">
Roles are used to control access to the shared collections.
{{ t('teams.roles_description') }}
</span>
</p>
<ul class="mt-4 space-y-4">
@@ -148,31 +156,30 @@
<span
class="w-1/4 font-semibold uppercase truncate text-secondaryDark max-w-[4rem]"
>
Owner
{{ t('teams.owner') }}
</span>
<span class="flex flex-1">
Owners can add, edit, and delete requests, collections and team
members.
{{ t('teams.owner_description') }}
</span>
</li>
<li class="flex">
<span
class="w-1/4 font-semibold uppercase truncate text-secondaryDark max-w-[4rem]"
>
Editor
{{ t('teams.editor') }}
</span>
<span class="flex flex-1">
Editors can add, edit, and delete requests.
{{ t('teams.editor_description') }}
</span>
</li>
<li class="flex">
<span
class="w-1/4 font-semibold uppercase truncate text-secondaryDark max-w-[4rem]"
>
Viewer
{{ t('teams.viewer') }}
</span>
<span class="flex flex-1">
Viewers can only view and use requests.
{{ t('teams.viewer_description') }}
</span>
</li>
</ul>
@@ -183,41 +190,61 @@
<template #footer>
<span class="flex space-x-2">
<HoppButtonPrimary
label="Add Member"
:label="t('teams.add_member')"
outline
@click="addUserasTeamMember"
/>
<HoppButtonSecondary label="Cancel" outline filled @click="hideModal" />
<HoppButtonSecondary
:label="t('teams.cancel')"
outline
filled
@click="hideModal"
/>
</span>
</template>
</HoppSmartModal>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useMutation, useQuery } from '@urql/vue';
import * as A from 'fp-ts/Array';
import * as O from 'fp-ts/Option';
import { pipe } from 'fp-ts/function';
import { computed, ref } from 'vue';
import { useI18n } from '~/composables/i18n';
import { useToast } from '~/composables/toast';
import { usePagedQuery } from '~/composables/usePagedQuery';
import { Email, EmailCodec } from '~/helpers/Email';
import IconCircle from '~icons/lucide/circle';
import IconCircleDot from '~icons/lucide/circle-dot';
import IconPlus from '~icons/lucide/plus';
import IconTrash from '~icons/lucide/trash';
import {
AddUserToTeamByAdminDocument,
TeamMemberRole,
MetricsDocument,
TeamMemberRole,
UsersListDocument,
} from '../../helpers/backend/graphql';
import { useToast } from '~/composables/toast';
import { useMutation, useQuery } from '@urql/vue';
import { Email, EmailCodec } from '~/helpers/Email';
import IconTrash from '~icons/lucide/trash';
import IconPlus from '~icons/lucide/plus';
import IconCircleDot from '~icons/lucide/circle-dot';
import IconCircle from '~icons/lucide/circle';
import { computed } from 'vue';
import { usePagedQuery } from '~/composables/usePagedQuery';
import { useI18n } from '~/composables/i18n';
const t = useI18n();
const toast = useToast();
const tippyActions = ref<any | null>(null);
// Get Users List
const props = defineProps<{
show: boolean;
editingTeamID: string;
}>();
const emit = defineEmits<{
(e: 'hide-modal'): void;
(e: 'member'): void;
}>();
const addGroupImagePath = `${
import.meta.env.VITE_ADMIN_URL
}/assets/images/add_group.svg`;
// Get Users List to extract email ids of all users
const { data } = useQuery({ query: MetricsDocument });
const usersPerPage = computed(() => data.value?.infra.usersCount || 10000);
@@ -231,21 +258,6 @@ const { list: usersList } = usePagedQuery(
const allUsersEmail = computed(() => usersList.value.map((user) => user.email));
const toast = useToast();
// Template refs
const tippyActions = ref<any | null>(null);
const props = defineProps({
show: Boolean,
editingTeamID: { type: String, default: null },
});
const emit = defineEmits<{
(e: 'hide-modal'): void;
(e: 'member'): void;
}>();
const newMembersList = ref<Array<{ key: string; value: TeamMemberRole }>>([
{
key: '',
@@ -264,12 +276,13 @@ const updateNewMemberRole = (index: number, role: TeamMemberRole) => {
newMembersList.value[index].value = role;
};
const removeNewMember = (id: number) => {
newMembersList.value.splice(id, 1);
const removeNewMember = (index: number) => {
newMembersList.value.splice(index, 1);
};
const addingUserToTeam = ref<boolean>(false);
// Checks if the member invites are of valid email format and then adds the users to the team
const addUserasTeamMember = async () => {
addingUserToTeam.value = true;
@@ -293,7 +306,7 @@ const addUserasTeamMember = async () => {
if (O.isNone(validationResult)) {
// Error handling for no validation
toast.error(`${t('users.invalid_user')}`);
toast.error(t('users.invalid_user'));
addingUserToTeam.value = false;
return;
}
@@ -320,20 +333,18 @@ const addUserToTeam = async (
) => {
const variables = { userEmail: email, role: userRole, teamID: teamID };
const res = await addUserToTeamMutation
.executeMutation(variables)
.then((result) => {
if (result.error) {
if (result.error.toString() == '[GraphQL] user/not_found') {
toast.error(`${t('state.user_not_found')}`);
} else {
toast.error(`${t('state.add_user_failure')}`);
}
} else {
toast.success(`${t('state.add_user_success')}`);
emit('member');
}
});
return res;
const result = await addUserToTeamMutation.executeMutation(variables);
if (result.error) {
if (result.error.toString() == '[GraphQL] user/not_found') {
toast.error(t('state.user_not_found'));
} else {
toast.error(t('state.add_user_failure'));
}
} else {
toast.success(t('state.add_user_success'));
emit('member');
}
return result;
};
</script>

View File

@@ -13,7 +13,7 @@
<div class="border rounded border-divider my-8">
<HoppSmartPlaceholder
v-if="team?.teamMembers?.length === 0"
text="No members in this team. Add members to this team to collaborate"
:text="t('teams.no_members')"
>
<template #body>
<HoppButtonSecondary
@@ -35,7 +35,7 @@
>
<input
class="flex flex-1 px-4 py-3 bg-transparent"
placeholder="Email"
:placeholder="t('teams.email_title')"
:name="'param' + index"
:value="member.email"
readonly
@@ -50,7 +50,7 @@
<span class="relative">
<input
class="flex flex-1 px-4 py-3 bg-transparent cursor-pointer"
placeholder="Permissions"
:placeholder="t('teams.permissions')"
:name="'value' + index"
:value="member.role"
readonly
@@ -69,7 +69,7 @@
@keyup.escape="hide()"
>
<HoppSmartItem
label="OWNER"
:label="t('teams.owner')"
:icon="
member.role === 'OWNER' ? IconCircleDot : IconCircle
"
@@ -82,7 +82,7 @@
"
/>
<HoppSmartItem
label="EDITOR"
:label="t('teams.editor')"
:icon="
member.role === 'EDITOR' ? IconCircleDot : IconCircle
"
@@ -98,7 +98,7 @@
"
/>
<HoppSmartItem
label="VIEWER"
:label="t('teams.viewer')"
:icon="
member.role === 'VIEWER' ? IconCircleDot : IconCircle
"
@@ -131,7 +131,7 @@
</div>
</div>
</div>
<div v-if="!fetching && !team" class="flex flex-col items-center">
<div v-if="!team" class="flex flex-col items-center">
<icon-lucide-help-circle class="mb-4 svg-icons" />
{{ t('teams.error') }}
</div>
@@ -139,13 +139,15 @@
<div class="flex">
<HoppButtonPrimary
:label="t('teams.save')"
v-if="areRolesUpdated"
:label="t('teams.save_changes')"
outline
@click="saveUpdatedTeam"
/>
</div>
<TeamsInvite
:show="showInvite"
:team="team"
:editingTeamID="route.params.id.toString()"
@member="updateMembers"
@hide-modal="
@@ -158,102 +160,101 @@
</template>
<script setup lang="ts">
import IconCircleDot from '~icons/lucide/circle-dot';
import IconCircle from '~icons/lucide/circle';
import IconUserPlus from '~icons/lucide/user-plus';
import IconUserMinus from '~icons/lucide/user-minus';
import IconChevronDown from '~icons/lucide/chevron-down';
import { useClientHandle, useMutation } from '@urql/vue';
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { useMutation } from '@urql/vue';
import { useVModel } from '@vueuse/core';
import { cloneDeep, isEqual } from 'lodash-es';
import { computed, onMounted, ref } from 'vue';
import { useRoute } from 'vue-router';
import { useI18n } from '~/composables/i18n';
import { useToast } from '~/composables/toast';
import { useClientHandler } from '~/composables/useClientHandler';
import IconChevronDown from '~icons/lucide/chevron-down';
import IconCircle from '~icons/lucide/circle';
import IconCircleDot from '~icons/lucide/circle-dot';
import IconUserMinus from '~icons/lucide/user-minus';
import IconUserPlus from '~icons/lucide/user-plus';
import {
ChangeUserRoleInTeamByAdminDocument,
TeamInfoDocument,
TeamMemberRole,
RemoveUserFromTeamByAdminDocument,
TeamInfoDocument,
TeamInfoQuery,
TeamMemberRole,
} from '../../helpers/backend/graphql';
import { HoppButtonPrimary, HoppButtonSecondary } from '@hoppscotch/ui';
import { useI18n } from '~/composables/i18n';
const t = useI18n();
const toast = useToast();
const route = useRoute();
const props = defineProps<{
team: TeamInfoQuery['infra']['teamInfo'];
}>();
const emit = defineEmits<{
(e: 'update-team'): void;
(event: 'update:team', team: TeamInfoQuery['infra']['teamInfo']): void;
}>();
const teamDetails = useVModel(props, 'team', emit);
// Used to Invoke the Invite Members Modal
const showInvite = ref(false);
// Get Team Details
const team = ref<TeamInfoQuery['infra']['teamInfo'] | undefined>();
const fetching = ref(true);
const route = useRoute();
const { client } = useClientHandle();
const getTeamInfo = async () => {
fetching.value = true;
const result = await client
.query(TeamInfoDocument, { teamID: route.params.id.toString() })
.toPromise();
if (result.error) {
return toast.error(`${t('teams.load_info_error')}`);
const { fetchData: getTeamInfo, data: teamInfo } = useClientHandler(
TeamInfoDocument,
{
teamID: route.params.id.toString(),
}
if (result.data?.infra.teamInfo) {
team.value = result.data.infra.teamInfo;
}
fetching.value = false;
};
);
onMounted(async () => await getTeamInfo());
onUnmounted(() => emit('update-team'));
onMounted(async () => {
await getTeamInfo();
});
const team = computed(() => teamInfo.value?.infra.teamInfo);
// Update members tab after a change in the members list or member roles
const updateMembers = () => {
getTeamInfo();
emit('update-team');
const updateMembers = async () => {
if (!team.value) return;
await getTeamInfo();
teamDetails.value = team.value;
};
// Template refs
const tippyActions = ref<any | null>(null);
const roleUpdates = ref<
// Roles of the members in the team
const currentMemberRoles = ref<
{
userID: string;
role: TeamMemberRole;
}[]
>([]);
watch(
() => team.value,
(teamDetails) => {
const members = teamDetails?.teamMembers ?? [];
// Roles of the members in the team after the updates but before saving
const updatedMemberRoles = ref<
{
userID: string;
role: TeamMemberRole;
}[]
>(cloneDeep(currentMemberRoles.value));
// Remove deleted members
roleUpdates.value = roleUpdates.value.filter(
(update) =>
members.findIndex(
(y: { user: { uid: string } }) => y.user.uid === update.userID
) !== -1
);
}
// Check if the roles of the members have been updated
const areRolesUpdated = computed(() =>
currentMemberRoles.value && updatedMemberRoles.value
? !isEqual(currentMemberRoles.value, updatedMemberRoles.value)
: false
);
// Update the role of the member selected in the UI
const updateMemberRole = (userID: string, role: TeamMemberRole) => {
const updateIndex = roleUpdates.value.findIndex(
const updateIndex = updatedMemberRoles.value.findIndex(
(item) => item.userID === userID
);
if (updateIndex !== -1) {
// Role Update exists
roleUpdates.value[updateIndex].role = role;
updatedMemberRoles.value[updateIndex].role = role;
} else {
// Role Update does not exist
roleUpdates.value.push({
updatedMemberRoles.value.push({
userID,
role,
});
@@ -264,7 +265,7 @@ const updateMemberRole = (userID: string, role: TeamMemberRole) => {
const membersList = computed(() => {
if (!team.value) return [];
const members = (team.value.teamMembers ?? []).map((member) => {
const updatedRole = roleUpdates.value.find(
const updatedRole = updatedMemberRoles.value.find(
(update) => update.userID === member.user.uid
);
@@ -299,19 +300,31 @@ const isLoading = ref(false);
const saveUpdatedTeam = async () => {
isLoading.value = true;
roleUpdates.value.forEach(async (update) => {
const isOwnerPresent = membersList.value.some(
(member) => member.role === TeamMemberRole.Owner
);
if (!isOwnerPresent) {
toast.error(t('state.owner_not_present'));
isLoading.value = false;
return;
}
updatedMemberRoles.value.forEach(async (update) => {
if (!team.value) return;
const updateMemberRoleResult = await changeUserRoleInTeam(
update.userID,
team.value.id,
update.role
);
if (updateMemberRoleResult.error) {
toast.error(`${t('state.role_update_failed')}`);
roleUpdates.value = [];
toast.error(t('state.role_update_failed'));
} else {
toast.success(`${t('state.role_update_success')}`);
roleUpdates.value = [];
toast.success(t('state.role_update_success'));
currentMemberRoles.value = updatedMemberRoles.value;
updatedMemberRoles.value = cloneDeep(currentMemberRoles.value);
}
isLoading.value = false;
});
@@ -340,14 +353,14 @@ const removeExistingTeamMember = async (userID: string, index: number) => {
team.value.id
)();
if (removeTeamMemberResult.error) {
toast.error(`${t('state.remove_member_failure')}`);
toast.error(t('state.remove_member_failure'));
} else {
team.value.teamMembers = team.value.teamMembers?.filter(
(member: any) => member.user.uid !== userID
);
toast.success(`${t('state.remove_member_success')}`);
teamDetails.value = team.value;
toast.success(t('state.remove_member_success'));
}
isLoadingIndex.value = null;
emit('update-team');
};
</script>

View File

@@ -1,103 +1,76 @@
<template>
<div class="border rounded divide-y divide-dividerLight border-divider my-8">
<div v-if="fetching" class="flex items-center justify-center p-4">
<HoppSmartSpinner />
</div>
<div v-else>
<div v-if="team" class="divide-y divide-dividerLight">
<div
v-for="(invitee, index) in pendingInvites"
:key="`invitee-${index}`"
class="flex divide-x divide-dividerLight"
>
<input
v-if="invitee"
class="flex flex-1 px-4 py-2 bg-transparent text-secondaryLight"
placeholder="Email"
:name="'param' + index"
:value="invitee.inviteeEmail"
readonly
<HoppSmartPlaceholder
v-if="team && pendingInvites?.length === 0"
text="No pending invites"
/>
<div v-else class="divide-y divide-dividerLight">
<div
v-for="(invitee, index) in pendingInvites"
:key="`invitee-${index}`"
class="flex divide-x divide-dividerLight"
>
<input
v-if="invitee"
class="flex flex-1 px-4 py-2 bg-transparent text-secondaryLight"
:placeholder="t('teams.email_title')"
:name="'param' + index"
:value="invitee.inviteeEmail"
readonly
/>
<input
class="flex flex-1 px-4 py-2 bg-transparent text-secondaryLight"
:placeholder="t('teams.permission')"
:name="'value' + index"
:value="invitee.inviteeRole"
readonly
/>
<div class="flex">
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('teams.remove')"
:icon="IconTrash"
color="red"
:loading="isLoadingIndex === index"
@click="removeInvitee(invitee.id, index)"
/>
<input
class="flex flex-1 px-4 py-2 bg-transparent text-secondaryLight"
placeholder="Permissions"
:name="'value' + index"
:value="invitee.inviteeRole"
readonly
/>
<div class="flex">
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('teams.remove')"
:icon="IconTrash"
color="red"
:loading="isLoadingIndex === index"
@click="removeInvitee(invitee.id, index)"
/>
</div>
</div>
</div>
<HoppSmartPlaceholder
v-if="team && pendingInvites?.length === 0"
text="No pending invites"
>
<template #body>
<div v-if="!fetching && error" class="flex flex-col items-center p-4">
<icon-lucide-help-circle class="mb-4 svg-icons" />
{{ t('error.something_went_wrong') }}
</div>
</template>
</HoppSmartPlaceholder>
</div>
</div>
</template>
<script setup lang="ts">
import IconTrash from '~icons/lucide/trash';
import { useMutation, useClientHandle } from '@urql/vue';
import { ref, onMounted } from 'vue';
import { useMutation } from '@urql/vue';
import { useVModel } from '@vueuse/core';
import { computed, ref } from 'vue';
import { useI18n } from '~/composables/i18n';
import { useToast } from '~/composables/toast';
import {
RevokeTeamInvitationDocument,
TeamInfoDocument,
TeamInfoQuery,
} from '~/helpers/backend/graphql';
import { useToast } from '~/composables/toast';
import { useRoute } from 'vue-router';
import { useI18n } from '~/composables/i18n';
import IconTrash from '~icons/lucide/trash';
const t = useI18n();
const toast = useToast();
// Get details of the team
const fetching = ref(true);
const error = ref(false);
const { client } = useClientHandle();
const route = useRoute();
const team = ref<TeamInfoQuery['infra']['teamInfo'] | undefined>();
const pendingInvites = ref<
TeamInfoQuery['infra']['teamInfo']['teamInvitations'] | undefined
>();
const props = defineProps<{
team: TeamInfoQuery['infra']['teamInfo'];
}>();
const getTeamInfo = async () => {
fetching.value = true;
const result = await client
.query(TeamInfoDocument, { teamID: route.params.id.toString() })
.toPromise();
const emit = defineEmits<{
(event: 'update:team', team: TeamInfoQuery['infra']['teamInfo']): void;
}>();
if (result.error) {
error.value = true;
return toast.error(`${t('teams.load_info_error')}`);
}
if (result.data?.infra.teamInfo) {
team.value = result.data.infra.teamInfo;
pendingInvites.value = team.value.teamInvitations;
}
fetching.value = false;
};
onMounted(async () => await getTeamInfo());
const team = useVModel(props, 'team', emit);
const pendingInvites = computed({
get: () => team.value?.teamInvitations,
set: (value) => {
team.value.teamInvitations = value;
},
});
// Remove Invitation
const isLoadingIndex = ref<null | number>(null);
@@ -110,7 +83,7 @@ const removeInvitee = async (id: string, index: number) => {
isLoadingIndex.value = index;
const result = await revokeTeamInvitation(id);
if (result.error) {
toast.error(`${t('state.remove_invitee_failure')}`);
toast.error(t('state.remove_invitee_failure'));
} else {
if (pendingInvites.value) {
pendingInvites.value = pendingInvites.value.filter(
@@ -118,7 +91,7 @@ const removeInvitee = async (id: string, index: number) => {
return invite.id !== id;
}
);
toast.success(`${t('state.remove_invitee_success')}`);
toast.success(t('state.remove_invitee_success'));
}
}
isLoadingIndex.value = null;

View File

@@ -1,6 +1,6 @@
<template>
<div class="rounded-md">
<div class="grid gap-6 mt-4">
<div class="grid gap-6">
<div
class="relative"
:class="
@@ -71,18 +71,14 @@
<script setup lang="ts">
import { format } from 'date-fns';
import { useI18n } from '~/composables/i18n';
import { useToast } from '~/composables/toast';
import { UserInfoQuery } from '~/helpers/backend/graphql';
import IconTrash from '~icons/lucide/trash';
import IconUserCheck from '~icons/lucide/user-check';
import IconUserMinus from '~icons/lucide/user-minus';
import { UserInfoQuery } from '~/helpers/backend/graphql';
const t = useI18n();
const toast = useToast();
const props = defineProps<{

View File

@@ -3,7 +3,7 @@
v-if="show"
dialog
:title="t('users.invite_user')"
@close="$emit('hide-modal')"
@close="emit('hide-modal')"
>
<template #body>
<HoppSmartInput
@@ -16,7 +16,6 @@
<span class="flex space-x-2">
<HoppButtonPrimary
:label="t('users.send_invite')"
:loading="loadingState"
@click="sendInvite"
/>
<HoppButtonSecondary label="Cancel" outline filled @click="hideModal" />
@@ -27,21 +26,18 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useToast } from '~/composables/toast';
import { useI18n } from '~/composables/i18n';
import { useToast } from '~/composables/toast';
const t = useI18n();
const toast = useToast();
withDefaults(
defineProps<{
show: boolean;
loadingState: boolean;
}>(),
{
show: false,
loadingState: false,
}
);
@@ -54,7 +50,7 @@ const email = ref('');
const sendInvite = () => {
if (email.value.trim() === '') {
toast.error(`${t('users.valid_email')}`);
toast.error(t('users.valid_email'));
return;
}
emit('send-invite', email.value);

View File

@@ -1,116 +1,116 @@
<template>
<div>
<div class="px-4">
<div v-if="fetching" class="flex justify-center">
<HoppSmartSpinner />
</div>
<div v-else-if="error">{{ t('shared_requests.load_list_error') }}</div>
<div v-else-if="sharedRequests.length === 0" class="ml-3 mt-5 text-lg">
<div v-else-if="sharedRequests.length === 0" class="mt-5">
{{ t('users.no_shared_requests') }}
</div>
<div v-else class="mt-10">
<HoppSmartTable :list="sharedRequests">
<template #head>
<tr
class="text-secondary border-b border-dividerDark text-sm text-left bg-primaryLight"
>
<th class="px-6 py-2">{{ t('shared_requests.id') }}</th>
<th class="px-6 py-2 w-30">{{ t('shared_requests.url') }}</th>
<th class="px-6 py-2">{{ t('shared_requests.created_on') }}</th>
<!-- Empty Heading for the Action Button -->
<th class="px-6 py-2 text-center">Actions</th>
</tr>
</template>
<template #body="{ list: sharedRequests }">
<tr
v-for="request in sharedRequests"
:key="request.id"
class="text-secondaryDark hover:bg-divider hover:cursor-pointer rounded-xl"
>
<td class="flex py-4 px-7 max-w-50">
<span class="truncate">
{{ request.id }}
</span>
</td>
<HoppSmartTable v-else class="mt-8" :list="sharedRequests">
<template #head>
<tr
class="text-secondary border-b border-dividerDark text-sm text-left bg-primaryLight"
>
<th class="px-6 py-2">{{ t('shared_requests.id') }}</th>
<th class="px-6 py-2 w-30">{{ t('shared_requests.url') }}</th>
<th class="px-6 py-2">{{ t('shared_requests.created_on') }}</th>
<!-- Empty Heading for the Action Button -->
<th class="px-6 py-2 text-center">
{{ t('shared_requests.action') }}
</th>
</tr>
</template>
<template #body="{ list: sharedRequests }">
<tr
v-for="request in sharedRequests"
:key="request.id"
class="text-secondaryDark hover:bg-divider hover:cursor-pointer rounded-xl"
>
<td class="flex py-4 px-7 max-w-50">
<span class="truncate">
{{ request.id }}
</span>
</td>
<td class="py-4 px-7 w-96">
{{ sharedRequestURL(request.request) }}
</td>
<td class="py-4 px-7 w-96">
{{ sharedRequestURL(request.request) }}
</td>
<td class="py-2 px-7">
{{ getCreatedDate(request.createdOn) }}
<div class="text-gray-400 text-tiny">
{{ getCreatedTime(request.createdOn) }}
</div>
</td>
<td class="py-2 px-7">
{{ getCreatedDate(request.createdOn) }}
<div class="text-gray-400 text-tiny">
{{ getCreatedTime(request.createdOn) }}
</div>
</td>
<td class="flex justify-center">
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('shared_requests.open_request')"
:to="`${shortcodeBaseURL}/r/${request.id}`"
:blank="true"
:icon="IconExternalLink"
class="px-3 text-emerald-500 hover:text-accent"
/>
<td class="flex justify-center">
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('shared_requests.open_request')"
:to="`${shortcodeBaseURL}/r/${request.id}`"
:blank="true"
:icon="IconExternalLink"
class="px-3 text-emerald-500 hover:text-accent"
/>
<UiAutoResetIcon
:title="t('shared_requests.copy')"
:icon="{ default: IconCopy, temporary: IconCheck }"
@click="copySharedRequest(request.id)"
/>
<UiAutoResetIcon
:title="t('shared_requests.copy')"
:icon="{ default: IconCopy, temporary: IconCheck }"
@click="copySharedRequest(request.id)"
/>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('shared_requests.delete')"
:icon="IconTrash"
color="red"
class="px-3"
@click="deleteSharedRequest(request.id)"
/>
</td>
</tr>
</template>
</HoppSmartTable>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('shared_requests.delete')"
:icon="IconTrash"
color="red"
class="px-3"
@click="deleteSharedRequest(request.id)"
/>
</td>
</tr>
</template>
</HoppSmartTable>
<!-- Pagination -->
<div
v-if="hasNextPage && sharedRequests.length >= sharedRequestsPerPage"
class="flex items-center w-28 px-3 py-2 mt-5 mx-auto font-semibold text-secondaryDark bg-divider hover:bg-dividerDark rounded-3xl cursor-pointer"
@click="fetchNextSharedRequests"
>
<span class="mr-2">{{ t('shared_requests.show_more') }}</span>
<icon-lucide-chevron-down />
</div>
<!-- Pagination -->
<div
v-if="hasNextPage && sharedRequests.length >= sharedRequestsPerPage"
class="flex items-center w-28 px-3 py-2 mt-5 mx-auto font-semibold text-secondaryDark bg-divider hover:bg-dividerDark rounded-3xl cursor-pointer"
@click="fetchNextSharedRequests"
>
<span class="mr-2">{{ t('shared_requests.show_more') }}</span>
<icon-lucide-chevron-down />
</div>
</div>
<HoppSmartConfirmModal
:show="confirmDeletion"
:title="t('shared_requests.confirm_request_deletion')"
@hide-modal="confirmDeletion = false"
@resolve="deleteSharedRequestMutation(deleteSharedRequestID)"
/>
<HoppSmartConfirmModal
:show="confirmDeletion"
:title="t('shared_requests.confirm_request_deletion')"
@hide-modal="confirmDeletion = false"
@resolve="deleteSharedRequestMutation(deleteSharedRequestID)"
/>
</div>
</template>
<script setup lang="ts">
import { useMutation } from '@urql/vue';
import { format } from 'date-fns';
import { ref } from 'vue';
import { useMutation } from '@urql/vue';
import {
SharedRequestsDocument,
RevokeShortcodeByAdminDocument,
} from '../../helpers/backend/graphql';
import { usePagedQuery } from '~/composables/usePagedQuery';
import { useToast } from '~/composables/toast';
import { useI18n } from '~/composables/i18n';
import { useToast } from '~/composables/toast';
import { usePagedQuery } from '~/composables/usePagedQuery';
import { copyToClipboard } from '~/helpers/utils/clipboard';
import IconTrash from '~icons/lucide/trash';
import IconCopy from '~icons/lucide/copy';
import IconCheck from '~icons/lucide/check';
import IconCopy from '~icons/lucide/copy';
import IconExternalLink from '~icons/lucide/external-link';
import IconTrash from '~icons/lucide/trash';
import {
RevokeShortcodeByAdminDocument,
SharedRequestsDocument,
} from '~/helpers/backend/graphql';
const t = useI18n();
const toast = useToast();
@@ -154,7 +154,7 @@ const shortcodeBaseURL =
// Copy Shared Request to Clipboard
const copySharedRequest = (requestID: string) => {
copyToClipboard(`${shortcodeBaseURL}/r/${requestID}`);
toast.success(`${t('state.copied_to_clipboard')}`);
toast.success(t('state.copied_to_clipboard'));
};
// Shared Request Deletion
@@ -170,19 +170,19 @@ const deleteSharedRequest = (id: string) => {
const deleteSharedRequestMutation = async (id: string | null) => {
if (!id) {
confirmDeletion.value = false;
toast.error(`${t('state.delete_request_failure')}`);
toast.error(t('state.delete_request_failure'));
return;
}
const variables = { codeID: id };
await sharedRequestDeletion.executeMutation(variables).then((result) => {
if (result.error) {
toast.error(`${t('state.delete_request_failure')}`);
toast.error(t('state.delete_request_failure'));
} else {
sharedRequests.value = sharedRequests.value.filter(
(request) => request.id !== id
);
refetch();
toast.success(`${t('state.delete_request_success')}`);
toast.success(t('state.delete_request_success'));
}
});
confirmDeletion.value = false;