Invite.vue (hoppscotch-2.2.1) | : | Invite.vue (hoppscotch-3.0.0) | ||
---|---|---|---|---|
<template> | <template> | |||
<SmartModal v-if="show" :title="t('team.invite')" @close="hideModal"> | <SmartModal v-if="show" dialog :title="t('team.invite')" @close="hideModal"> | |||
<template #body> | <template #body> | |||
<div v-if="sendInvitesResult.length" class="flex flex-col px-4"> | <div v-if="sendInvitesResult.length" class="flex flex-col px-4"> | |||
<div class="flex flex-col items-center justify-center max-w-md"> | <div class="flex flex-col items-center justify-center max-w-md"> | |||
<SmartIcon class="w-6 h-6 text-accent" name="users" /> | <icon-lucide-users class="w-6 h-6 text-accent" /> | |||
<h3 class="my-2 text-lg text-center"> | <h3 class="my-2 text-lg text-center"> | |||
{{ t("team.we_sent_invite_link") }} | {{ t("team.we_sent_invite_link") }} | |||
</h3> | </h3> | |||
<p class="text-center"> | <p class="text-center"> | |||
{{ t("team.we_sent_invite_link_description") }} | {{ t("team.we_sent_invite_link_description") }} | |||
</p> | </p> | |||
</div> | </div> | |||
<div | <div | |||
class="flex flex-col p-4 mt-8 space-y-6 border rounded border-dividerL ight" | class="flex flex-col p-4 mt-8 border rounded space-y-6 border-dividerL ight" | |||
> | > | |||
<div | <div | |||
v-for="(invitee, index) in sendInvitesResult" | v-for="(invitee, index) in sendInvitesResult" | |||
:key="`invitee-${index}`" | :key="`invitee-${index}`" | |||
> | > | |||
<p class="flex items-center"> | <p class="flex items-center"> | |||
<i | <component | |||
class="mr-4 material-icons" | :is=" | |||
invitee.status === 'error' ? IconAlertTriangle : IconMailCheck | ||||
" | ||||
class="mr-4 svg-icons" | ||||
:class=" | :class=" | |||
invitee.status === 'error' ? 'text-red-500' : 'text-green-500' | invitee.status === 'error' ? 'text-red-500' : 'text-green-500' | |||
" | " | |||
> | /> | |||
{{ | ||||
invitee.status === "error" | ||||
? "error_outline" | ||||
: "mark_email_read" | ||||
}} | ||||
</i> | ||||
<span class="truncate">{{ invitee.email }}</span> | <span class="truncate">{{ invitee.email }}</span> | |||
</p> | </p> | |||
<p v-if="invitee.status === 'error'" class="mt-2 ml-8 text-red-500"> | <p v-if="invitee.status === 'error'" class="mt-2 ml-8 text-red-500"> | |||
{{ getErrorMessage(invitee.error) }} | {{ getErrorMessage(invitee.error) }} | |||
</p> | </p> | |||
</div> | </div> | |||
</div> | </div> | |||
</div> | </div> | |||
<div | <div | |||
v-else-if="sendingInvites" | v-else-if="sendingInvites" | |||
class="flex items-center justify-center p-4" | class="flex items-center justify-center p-4" | |||
> | > | |||
<SmartSpinner /> | <SmartSpinner /> | |||
</div> | </div> | |||
<div v-else class="flex flex-col px-2"> | <div v-else class="flex flex-col"> | |||
<div class="flex items-center justify-between flex-1"> | <div class="flex items-center justify-between flex-1"> | |||
<label for="memberList" class="px-4 pb-4"> | <label for="memberList" class="px-4 pb-4"> | |||
{{ t("team.pending_invites") }} | {{ t("team.pending_invites") }} | |||
</label> | </label> | |||
</div> | </div> | |||
<div class="border divide-y rounded divide-dividerLight border-divider"> | <div class="border rounded divide-y divide-dividerLight border-divider"> | |||
<div | <div | |||
v-if="pendingInvites.loading" | v-if="pendingInvites.loading" | |||
class="flex items-center justify-center p-4" | class="flex items-center justify-center p-4" | |||
> | > | |||
<SmartSpinner /> | <SmartSpinner /> | |||
</div> | </div> | |||
<div v-else> | <div v-else> | |||
<div | <div | |||
v-if="!pendingInvites.loading && E.isRight(pendingInvites.data)" | v-if="!pendingInvites.loading && E.isRight(pendingInvites.data)" | |||
> | > | |||
skipping to change at line 90 | skipping to change at line 87 | |||
class="flex flex-1 px-4 py-2 bg-transparent text-secondaryLigh t" | class="flex flex-1 px-4 py-2 bg-transparent text-secondaryLigh t" | |||
:placeholder="`${t('team.permissions')}`" | :placeholder="`${t('team.permissions')}`" | |||
:name="'value' + index" | :name="'value' + index" | |||
:value="invitee.inviteeRole" | :value="invitee.inviteeRole" | |||
readonly | readonly | |||
/> | /> | |||
<div class="flex"> | <div class="flex"> | |||
<ButtonSecondary | <ButtonSecondary | |||
v-tippy="{ theme: 'tooltip' }" | v-tippy="{ theme: 'tooltip' }" | |||
:title="t('action.remove')" | :title="t('action.remove')" | |||
svg="trash" | :icon="IconTrash" | |||
color="red" | color="red" | |||
@click.native="removeInvitee(invitee.id)" | :loading="isLoadingIndex === index" | |||
@click="removeInvitee(invitee.id, index)" | ||||
/> | /> | |||
</div> | </div> | |||
</div> | </div> | |||
</div> | </div> | |||
<div | <div | |||
v-if=" | v-if=" | |||
E.isRight(pendingInvites.data) && | E.isRight(pendingInvites.data) && | |||
pendingInvites.data.right.team.teamInvitations.length === 0 | pendingInvites.data.right.team.teamInvitations.length === 0 | |||
" | " | |||
class="flex flex-col items-center justify-center p-4 text-secondar yLight" | class="flex flex-col items-center justify-center p-4 text-secondar yLight" | |||
> | > | |||
<span class="text-center"> | <span class="text-center"> | |||
{{ t("empty.pending_invites") }} | {{ t("empty.pending_invites") }} | |||
</span> | </span> | |||
</div> | </div> | |||
<div | <div | |||
v-if="!pendingInvites.loading && E.isLeft(pendingInvites.data)" | v-if="!pendingInvites.loading && E.isLeft(pendingInvites.data)" | |||
class="flex flex-col items-center p-4" | class="flex flex-col items-center p-4" | |||
> | > | |||
<i class="mb-4 material-icons">help_outline</i> | <component :is="IconHelpCircle" class="mb-4 svg-icons" /> | |||
{{ t("error.something_went_wrong") }} | {{ t("error.something_went_wrong") }} | |||
</div> | </div> | |||
</div> | </div> | |||
</div> | </div> | |||
<div class="flex items-center justify-between flex-1 pt-4"> | <div class="flex items-center justify-between flex-1 pt-4"> | |||
<label for="memberList" class="p-4"> | <label for="memberList" class="p-4"> | |||
{{ t("team.invite_tooltip") }} | {{ t("team.invite_tooltip") }} | |||
</label> | </label> | |||
<div class="flex"> | <div class="flex"> | |||
<ButtonSecondary | <ButtonSecondary | |||
svg="plus" | :icon="IconPlus" | |||
:label="t('add.new')" | :label="t('add.new')" | |||
filled | filled | |||
@click.native="addNewInvitee" | @click="addNewInvitee" | |||
/> | /> | |||
</div> | </div> | |||
</div> | </div> | |||
<div class="border divide-y rounded divide-dividerLight border-divider"> | <div class="border rounded divide-y divide-dividerLight border-divider"> | |||
<div | <div | |||
v-for="(invitee, index) in newInvites" | v-for="(invitee, index) in newInvites" | |||
:key="`new-invitee-${index}`" | :key="`new-invitee-${index}`" | |||
class="flex divide-x divide-dividerLight" | class="flex divide-x divide-dividerLight" | |||
> | > | |||
<input | <input | |||
v-model="invitee.key" | v-model="invitee.key" | |||
class="flex flex-1 px-4 py-2 bg-transparent" | class="flex flex-1 px-4 py-2 bg-transparent" | |||
:placeholder="`${t('team.email')}`" | :placeholder="`${t('team.email')}`" | |||
:name="'invitee' + index" | :name="'invitee' + index" | |||
autofocus | autofocus | |||
/> | /> | |||
<span> | <span> | |||
<tippy | <tippy | |||
ref="newInviteeOptions" | ref="newInviteeOptions" | |||
interactive | interactive | |||
trigger="click" | trigger="click" | |||
theme="popover" | theme="popover" | |||
arrow | arrow | |||
> | > | |||
<template #trigger> | <span class="select-wrapper"> | |||
<span class="select-wrapper"> | <input | |||
<input | class="flex flex-1 px-4 py-2 bg-transparent cursor-pointer" | |||
class="flex flex-1 px-4 py-2 bg-transparent cursor-pointer | :placeholder="`${t('team.permissions')}`" | |||
" | :name="'value' + index" | |||
:placeholder="`${t('team.permissions')}`" | :value="invitee.value" | |||
:name="'value' + index" | readonly | |||
:value="invitee.value" | /> | |||
readonly | </span> | |||
<template #content="{ hide }"> | ||||
<div | ||||
class="flex flex-col" | ||||
tabindex="0" | ||||
role="menu" | ||||
@keyup.escape="hide()" | ||||
> | ||||
<SmartItem | ||||
label="OWNER" | ||||
:icon=" | ||||
invitee.value === 'OWNER' ? IconCircleDot : IconCircle | ||||
" | ||||
:active="invitee.value === 'OWNER'" | ||||
@click=" | ||||
() => { | ||||
updateNewInviteeRole(index, 'OWNER') | ||||
newInviteeOptions[index].tippy().hide() | ||||
} | ||||
" | ||||
/> | ||||
<SmartItem | ||||
label="EDITOR" | ||||
:icon=" | ||||
invitee.value === 'EDITOR' ? IconCircleDot : IconCircle | ||||
" | ||||
:active="invitee.value === 'EDITOR'" | ||||
@click=" | ||||
() => { | ||||
updateNewInviteeRole(index, 'EDITOR') | ||||
newInviteeOptions[index].tippy().hide() | ||||
} | ||||
" | ||||
/> | /> | |||
</span> | <SmartItem | |||
label="VIEWER" | ||||
:icon=" | ||||
invitee.value === 'VIEWER' ? IconCircleDot : IconCircle | ||||
" | ||||
:active="invitee.value === 'VIEWER'" | ||||
@click=" | ||||
() => { | ||||
updateNewInviteeRole(index, 'VIEWER') | ||||
newInviteeOptions[index].tippy().hide() | ||||
} | ||||
" | ||||
/> | ||||
</div> | ||||
</template> | </template> | |||
<SmartItem | ||||
label="OWNER" | ||||
:icon=" | ||||
invitee.value === 'OWNER' | ||||
? 'radio_button_checked' | ||||
: 'radio_button_unchecked' | ||||
" | ||||
:active="invitee.value === 'OWNER'" | ||||
@click.native=" | ||||
() => { | ||||
updateNewInviteeRole(index, 'OWNER') | ||||
newInviteeOptions[index].tippy().hide() | ||||
} | ||||
" | ||||
/> | ||||
<SmartItem | ||||
label="EDITOR" | ||||
:icon=" | ||||
invitee.value === 'EDITOR' | ||||
? 'radio_button_checked' | ||||
: 'radio_button_unchecked' | ||||
" | ||||
:active="invitee.value === 'EDITOR'" | ||||
@click.native=" | ||||
() => { | ||||
updateNewInviteeRole(index, 'EDITOR') | ||||
newInviteeOptions[index].tippy().hide() | ||||
} | ||||
" | ||||
/> | ||||
<SmartItem | ||||
label="VIEWER" | ||||
:icon=" | ||||
invitee.value === 'VIEWER' | ||||
? 'radio_button_checked' | ||||
: 'radio_button_unchecked' | ||||
" | ||||
:active="invitee.value === 'VIEWER'" | ||||
@click.native=" | ||||
() => { | ||||
updateNewInviteeRole(index, 'VIEWER') | ||||
newInviteeOptions[index].tippy().hide() | ||||
} | ||||
" | ||||
/> | ||||
</tippy> | </tippy> | |||
</span> | </span> | |||
<div class="flex"> | <div class="flex"> | |||
<ButtonSecondary | <ButtonSecondary | |||
id="member" | id="member" | |||
v-tippy="{ theme: 'tooltip' }" | v-tippy="{ theme: 'tooltip' }" | |||
:title="t('action.remove')" | :title="t('action.remove')" | |||
svg="trash" | :icon="IconTrash" | |||
color="red" | color="red" | |||
@click.native="removeNewInvitee(index)" | @click="removeNewInvitee(index)" | |||
/> | /> | |||
</div> | </div> | |||
</div> | </div> | |||
<div | <div | |||
v-if="newInvites.length === 0" | v-if="newInvites.length === 0" | |||
class="flex flex-col items-center justify-center p-4 text-secondaryL ight" | class="flex flex-col items-center justify-center p-4 text-secondaryL ight" | |||
> | > | |||
<img | <img | |||
:src="`/images/states/${$colorMode.value}/add_group.svg`" | :src="`/images/states/${colorMode.value}/add_group.svg`" | |||
loading="lazy" | loading="lazy" | |||
class="inline-flex flex-col object-contain object-center w-16 h-16 mb-4" | class="inline-flex flex-col object-contain object-center w-16 h-16 mb-4" | |||
:alt="`${t('empty.invites')}`" | :alt="`${t('empty.invites')}`" | |||
/> | /> | |||
<span class="pb-4 text-center"> | <span class="pb-4 text-center"> | |||
{{ t("empty.invites") }} | {{ t("empty.invites") }} | |||
</span> | </span> | |||
<ButtonSecondary | <ButtonSecondary | |||
:label="t('add.new')" | :label="t('add.new')" | |||
filled | filled | |||
@click.native="addNewInvitee" | @click="addNewInvitee" | |||
/> | /> | |||
</div> | </div> | |||
</div> | </div> | |||
<div | <div | |||
v-if="newInvites.length" | v-if="newInvites.length" | |||
class="flex flex-col items-start px-4 py-4 mt-4 border rounded border- dividerLight" | class="flex flex-col items-start px-4 py-4 mt-4 border rounded border- dividerLight" | |||
> | > | |||
<span | <span | |||
class="flex items-center justify-center px-2 py-1 mb-4 font-semibold border rounded-full bg-primaryDark border-divider" | class="flex items-center justify-center px-2 py-1 mb-4 font-semibold border rounded-full bg-primaryDark border-divider" | |||
> | > | |||
<i class="mr-2 text-secondaryLight material-icons">help_outline</i> | <component | |||
:is="IconHelpCircle" | ||||
class="mr-2 text-secondaryLight svg-icons" | ||||
/> | ||||
{{ t("profile.roles") }} | {{ t("profile.roles") }} | |||
</span> | </span> | |||
<p> | <p> | |||
<span class="text-secondaryLight"> | <span class="text-secondaryLight"> | |||
{{ t("profile.roles_description") }} | {{ t("profile.roles_description") }} | |||
</span> | </span> | |||
</p> | </p> | |||
<ul class="mt-4 space-y-4"> | <ul class="mt-4 space-y-4"> | |||
<li class="flex"> | <li class="flex"> | |||
<span | <span | |||
skipping to change at line 297 | skipping to change at line 299 | |||
</div> | </div> | |||
</div> | </div> | |||
</template> | </template> | |||
<template #footer> | <template #footer> | |||
<p | <p | |||
v-if="sendInvitesResult.length" | v-if="sendInvitesResult.length" | |||
class="flex justify-between flex-1 text-secondaryLight" | class="flex justify-between flex-1 text-secondaryLight" | |||
> | > | |||
<SmartAnchor | <SmartAnchor | |||
class="link" | class="link" | |||
:label="`← \xA0 ${t('team.invite_more')}`" | :label="t('team.invite_more')" | |||
@click.native=" | :icon="IconArrowLeft" | |||
@click=" | ||||
() => { | () => { | |||
sendInvitesResult = [] | sendInvitesResult = [] | |||
newInvites = [ | newInvites = [ | |||
{ | { | |||
key: '', | key: '', | |||
value: 'VIEWRER', | value: TeamMemberRole.Viewer, | |||
}, | }, | |||
] | ] | |||
} | } | |||
" | " | |||
/> | /> | |||
<SmartAnchor | <SmartAnchor | |||
class="link" | class="link" | |||
:label="`${t('action.dismiss')}`" | :label="`${t('action.dismiss')}`" | |||
@click.native="hideModal" | @click="hideModal" | |||
/> | /> | |||
</p> | </p> | |||
<span v-else> | <span v-else> | |||
<ButtonPrimary :label="t('team.invite')" @click.native="sendInvites" /> | <ButtonPrimary :label="t('team.invite')" @click="sendInvites" /> | |||
<ButtonSecondary | <ButtonSecondary :label="t('action.cancel')" @click="hideModal" /> | |||
:label="t('action.cancel')" | ||||
@click.native="hideModal" | ||||
/> | ||||
</span> | </span> | |||
</template> | </template> | |||
</SmartModal> | </SmartModal> | |||
</template> | </template> | |||
<script setup lang="ts"> | <script setup lang="ts"> | |||
import { watch, ref, reactive, computed } from "@nuxtjs/composition-api" | import { watch, ref, reactive, computed } from "vue" | |||
import * as T from "fp-ts/Task" | import * as T from "fp-ts/Task" | |||
import * as E from "fp-ts/Either" | import * as E from "fp-ts/Either" | |||
import * as A from "fp-ts/Array" | import * as A from "fp-ts/Array" | |||
import * as O from "fp-ts/Option" | import * as O from "fp-ts/Option" | |||
import { flow, pipe } from "fp-ts/function" | import { flow, pipe } from "fp-ts/function" | |||
import { Email, EmailCodec } from "../../helpers/backend/types/Email" | import { Email, EmailCodec } from "../../helpers/backend/types/Email" | |||
import { | import { | |||
TeamInvitationAddedDocument, | TeamInvitationAddedDocument, | |||
TeamInvitationRemovedDocument, | TeamInvitationRemovedDocument, | |||
TeamMemberRole, | TeamMemberRole, | |||
GetPendingInvitesDocument, | GetPendingInvitesDocument, | |||
GetPendingInvitesQuery, | GetPendingInvitesQuery, | |||
GetPendingInvitesQueryVariables, | GetPendingInvitesQueryVariables, | |||
} from "../../helpers/backend/graphql" | } from "../../helpers/backend/graphql" | |||
import { | import { | |||
createTeamInvitation, | createTeamInvitation, | |||
CreateTeamInvitationErrors, | CreateTeamInvitationErrors, | |||
revokeTeamInvitation, | revokeTeamInvitation, | |||
} from "../../helpers/backend/mutations/TeamInvitation" | } from "../../helpers/backend/mutations/TeamInvitation" | |||
import { GQLError, useGQLQuery } from "~/helpers/backend/GQLClient" | import { GQLError } from "~/helpers/backend/GQLClient" | |||
import { useI18n, useToast } from "~/helpers/utils/composables" | import { useGQLQuery } from "@composables/graphql" | |||
import { useI18n } from "@composables/i18n" | ||||
import { useToast } from "@composables/toast" | ||||
import { useColorMode } from "~/composables/theming" | ||||
import IconTrash from "~icons/lucide/trash" | ||||
import IconPlus from "~icons/lucide/plus" | ||||
import IconHelpCircle from "~icons/lucide/help-circle" | ||||
import IconAlertTriangle from "~icons/lucide/alert-triangle" | ||||
import IconMailCheck from "~icons/lucide/mail-check" | ||||
import IconCircleDot from "~icons/lucide/circle-dot" | ||||
import IconCircle from "~icons/lucide/circle" | ||||
import IconArrowLeft from "~icons/lucide/arrow-left" | ||||
const t = useI18n() | const t = useI18n() | |||
const toast = useToast() | const toast = useToast() | |||
const colorMode = useColorMode() | ||||
const newInviteeOptions = ref<any | null>(null) | const newInviteeOptions = ref<any | null>(null) | |||
const props = defineProps({ | const props = defineProps({ | |||
show: Boolean, | show: Boolean, | |||
editingTeamID: { type: String, default: null }, | editingTeamID: { type: String, default: null }, | |||
}) | }) | |||
const emit = defineEmits<{ | const emit = defineEmits<{ | |||
(e: "hide-modal"): void | (e: "hide-modal"): void | |||
}>() | }>() | |||
const pendingInvites = useGQLQuery< | const pendingInvites = useGQLQuery< | |||
GetPendingInvitesQuery, | GetPendingInvitesQuery, | |||
GetPendingInvitesQueryVariables, | GetPendingInvitesQueryVariables, | |||
"" | "" | |||
>({ | >({ | |||
query: GetPendingInvitesDocument, | query: GetPendingInvitesDocument, | |||
variables: reactive({ | variables: reactive({ | |||
teamID: props.editingTeamID, | teamID: props.editingTeamID, | |||
}), | }), | |||
pollDuration: 10000, | ||||
updateSubs: computed(() => | updateSubs: computed(() => | |||
!props.editingTeamID | !props.editingTeamID | |||
? [] | ? [] | |||
: [ | : [ | |||
{ | { | |||
key: 4, | key: 4, | |||
query: TeamInvitationAddedDocument, | query: TeamInvitationAddedDocument, | |||
variables: { | variables: { | |||
teamID: props.editingTeamID, | teamID: props.editingTeamID, | |||
}, | }, | |||
skipping to change at line 399 | skipping to change at line 415 | |||
variables: { | variables: { | |||
teamID: props.editingTeamID, | teamID: props.editingTeamID, | |||
}, | }, | |||
}, | }, | |||
] | ] | |||
), | ), | |||
defer: true, | defer: true, | |||
}) | }) | |||
watch( | watch( | |||
() => props.show, | ||||
(show) => { | ||||
if (!show) { | ||||
pendingInvites.pause() | ||||
} else { | ||||
pendingInvites.unpause() | ||||
} | ||||
} | ||||
) | ||||
watch( | ||||
() => props.editingTeamID, | () => props.editingTeamID, | |||
() => { | () => { | |||
if (props.editingTeamID) { | if (props.editingTeamID) { | |||
pendingInvites.execute({ | pendingInvites.execute({ | |||
teamID: props.editingTeamID, | teamID: props.editingTeamID, | |||
}) | }) | |||
} | } | |||
} | } | |||
) | ) | |||
const removeInvitee = async (id: string) => { | const isLoadingIndex = ref<null | number>(null) | |||
const removeInvitee = async (id: string, index: number) => { | ||||
isLoadingIndex.value = index | ||||
const result = await revokeTeamInvitation(id)() | const result = await revokeTeamInvitation(id)() | |||
if (E.isLeft(result)) { | if (E.isLeft(result)) { | |||
toast.error(`${t("error.something_went_wrong")}`) | toast.error(`${t("error.something_went_wrong")}`) | |||
} else { | } else { | |||
toast.success(`${t("team.member_removed")}`) | toast.success(`${t("team.member_removed")}`) | |||
} | } | |||
isLoadingIndex.value = null | ||||
} | } | |||
const newInvites = ref<Array<{ key: string; value: TeamMemberRole }>>([ | const newInvites = ref<Array<{ key: string; value: TeamMemberRole }>>([ | |||
{ | { | |||
key: "", | key: "", | |||
value: TeamMemberRole.Viewer, | value: TeamMemberRole.Viewer, | |||
}, | }, | |||
]) | ]) | |||
const addNewInvitee = () => { | const addNewInvitee = () => { | |||
End of changes. 32 change blocks. | ||||
93 lines changed or deleted | 123 lines changed or added |