Edit.vue 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423
  1. <template>
  2. <SmartModal v-if="show" dialog :title="t('team.edit')" @close="hideModal">
  3. <template #body>
  4. <div class="flex flex-col px-2">
  5. <div class="relative flex">
  6. <input
  7. id="selectLabelTeamEdit"
  8. v-model="name"
  9. v-focus
  10. class="input floating-input"
  11. placeholder=" "
  12. type="text"
  13. autocomplete="off"
  14. @keyup.enter="saveTeam"
  15. />
  16. <label for="selectLabelTeamEdit">
  17. {{ t("action.label") }}
  18. </label>
  19. </div>
  20. <div class="flex items-center justify-between flex-1 pt-4">
  21. <label for="memberList" class="p-4">
  22. {{ t("team.members") }}
  23. </label>
  24. <div class="flex">
  25. <ButtonSecondary
  26. svg="user-plus"
  27. :label="t('team.invite')"
  28. filled
  29. @click.native="
  30. () => {
  31. $emit('invite-team')
  32. }
  33. "
  34. />
  35. </div>
  36. </div>
  37. <div
  38. v-if="teamDetails.loading"
  39. class="flex flex-col items-center justify-center"
  40. >
  41. <SmartSpinner class="mb-4" />
  42. <span class="text-secondaryLight">{{ t("state.loading") }}</span>
  43. </div>
  44. <div
  45. v-if="
  46. !teamDetails.loading &&
  47. E.isRight(teamDetails.data) &&
  48. teamDetails.data.right.team.teamMembers
  49. "
  50. class="border rounded divide-y divide-dividerLight border-divider"
  51. >
  52. <div
  53. v-if="teamDetails.data.right.team.teamMembers === 0"
  54. class="flex flex-col items-center justify-center p-4 text-secondaryLight"
  55. >
  56. <img
  57. :src="`/images/states/${$colorMode.value}/add_group.svg`"
  58. loading="lazy"
  59. class="inline-flex flex-col object-contain object-center w-16 h-16 my-4"
  60. :alt="`${t('empty.members')}`"
  61. />
  62. <span class="pb-4 text-center">
  63. {{ t("empty.members") }}
  64. </span>
  65. <ButtonSecondary
  66. svg="user-plus"
  67. :label="t('team.invite')"
  68. @click.native="
  69. () => {
  70. emit('invite-team')
  71. }
  72. "
  73. />
  74. </div>
  75. <div v-else>
  76. <div
  77. v-for="(member, index) in membersList"
  78. :key="`member-${index}`"
  79. class="flex divide-x divide-dividerLight"
  80. >
  81. <input
  82. class="flex flex-1 px-4 py-2 bg-transparent"
  83. :placeholder="`${t('team.email')}`"
  84. :name="'param' + index"
  85. :value="member.email"
  86. readonly
  87. />
  88. <span>
  89. <tippy
  90. ref="memberOptions"
  91. interactive
  92. trigger="click"
  93. theme="popover"
  94. arrow
  95. >
  96. <template #trigger>
  97. <span class="select-wrapper">
  98. <input
  99. class="flex flex-1 px-4 py-2 bg-transparent cursor-pointer"
  100. :placeholder="`${t('team.permissions')}`"
  101. :name="'value' + index"
  102. :value="member.role"
  103. readonly
  104. />
  105. </span>
  106. </template>
  107. <div class="flex flex-col" role="menu">
  108. <SmartItem
  109. label="OWNER"
  110. :icon="
  111. member.role === 'OWNER'
  112. ? 'radio_button_checked'
  113. : 'radio_button_unchecked'
  114. "
  115. :active="member.role === 'OWNER'"
  116. @click.native="
  117. () => {
  118. updateMemberRole(member.userID, 'OWNER')
  119. memberOptions[index].tippy().hide()
  120. }
  121. "
  122. />
  123. <SmartItem
  124. label="EDITOR"
  125. :icon="
  126. member.role === 'EDITOR'
  127. ? 'radio_button_checked'
  128. : 'radio_button_unchecked'
  129. "
  130. :active="member.role === 'EDITOR'"
  131. @click.native="
  132. () => {
  133. updateMemberRole(member.userID, 'EDITOR')
  134. memberOptions[index].tippy().hide()
  135. }
  136. "
  137. />
  138. <SmartItem
  139. label="VIEWER"
  140. :icon="
  141. member.role === 'VIEWER'
  142. ? 'radio_button_checked'
  143. : 'radio_button_unchecked'
  144. "
  145. :active="member.role === 'VIEWER'"
  146. @click.native="
  147. () => {
  148. updateMemberRole(member.userID, 'VIEWER')
  149. memberOptions[index].tippy().hide()
  150. }
  151. "
  152. />
  153. </div>
  154. </tippy>
  155. </span>
  156. <div class="flex">
  157. <ButtonSecondary
  158. id="member"
  159. v-tippy="{ theme: 'tooltip' }"
  160. :title="t('action.remove')"
  161. svg="user-minus"
  162. color="red"
  163. :loading="isLoadingIndex === index"
  164. @click.native="removeExistingTeamMember(member.userID, index)"
  165. />
  166. </div>
  167. </div>
  168. </div>
  169. </div>
  170. <div
  171. v-if="!teamDetails.loading && E.isLeft(teamDetails.data)"
  172. class="flex flex-col items-center"
  173. >
  174. <i class="mb-4 material-icons">help_outline</i>
  175. {{ t("error.something_went_wrong") }}
  176. </div>
  177. </div>
  178. </template>
  179. <template #footer>
  180. <span>
  181. <ButtonPrimary
  182. :label="t('action.save')"
  183. :loading="isLoading"
  184. @click.native="saveTeam"
  185. />
  186. <ButtonSecondary
  187. :label="t('action.cancel')"
  188. @click.native="hideModal"
  189. />
  190. </span>
  191. </template>
  192. </SmartModal>
  193. </template>
  194. <script setup lang="ts">
  195. import { computed, ref, toRef, watch } from "@nuxtjs/composition-api"
  196. import * as E from "fp-ts/Either"
  197. import {
  198. GetTeamDocument,
  199. GetTeamQuery,
  200. GetTeamQueryVariables,
  201. TeamMemberAddedDocument,
  202. TeamMemberRemovedDocument,
  203. TeamMemberRole,
  204. TeamMemberUpdatedDocument,
  205. } from "~/helpers/backend/graphql"
  206. import {
  207. removeTeamMember,
  208. renameTeam,
  209. updateTeamMemberRole,
  210. } from "~/helpers/backend/mutations/Team"
  211. import { TeamNameCodec } from "~/helpers/backend/types/TeamName"
  212. import { useGQLQuery } from "~/helpers/backend/GQLClient"
  213. import { useI18n, useToast } from "~/helpers/utils/composables"
  214. const t = useI18n()
  215. const emit = defineEmits<{
  216. (e: "hide-modal"): void
  217. (e: "refetch-teams"): void
  218. }>()
  219. const memberOptions = ref<any | null>(null)
  220. const props = defineProps<{
  221. show: boolean
  222. editingTeam: {
  223. name: string
  224. }
  225. editingTeamID: string
  226. }>()
  227. const toast = useToast()
  228. const name = toRef(props.editingTeam, "name")
  229. watch(
  230. () => props.editingTeam.name,
  231. (newName: string) => {
  232. name.value = newName
  233. }
  234. )
  235. watch(
  236. () => props.editingTeamID,
  237. (teamID: string) => {
  238. teamDetails.execute({ teamID })
  239. }
  240. )
  241. const teamDetails = useGQLQuery<GetTeamQuery, GetTeamQueryVariables, "">({
  242. query: GetTeamDocument,
  243. variables: {
  244. teamID: props.editingTeamID,
  245. },
  246. pollDuration: 10000,
  247. defer: true,
  248. updateSubs: computed(() => {
  249. if (props.editingTeamID) {
  250. return [
  251. {
  252. key: 1,
  253. query: TeamMemberAddedDocument,
  254. variables: {
  255. teamID: props.editingTeamID,
  256. },
  257. },
  258. {
  259. key: 2,
  260. query: TeamMemberUpdatedDocument,
  261. variables: {
  262. teamID: props.editingTeamID,
  263. },
  264. },
  265. {
  266. key: 3,
  267. query: TeamMemberRemovedDocument,
  268. variables: {
  269. teamID: props.editingTeamID,
  270. },
  271. },
  272. ]
  273. } else return []
  274. }),
  275. })
  276. watch(
  277. () => props.show,
  278. (show) => {
  279. if (!show) {
  280. teamDetails.pause()
  281. } else {
  282. teamDetails.unpause()
  283. }
  284. }
  285. )
  286. const roleUpdates = ref<
  287. {
  288. userID: string
  289. role: TeamMemberRole
  290. }[]
  291. >([])
  292. watch(
  293. () => teamDetails,
  294. () => {
  295. if (teamDetails.loading) return
  296. const data = teamDetails.data
  297. if (E.isRight(data)) {
  298. const members = data.right.team?.teamMembers ?? []
  299. // Remove deleted members
  300. roleUpdates.value = roleUpdates.value.filter(
  301. (update) =>
  302. members.findIndex((y) => y.user.uid === update.userID) !== -1
  303. )
  304. }
  305. }
  306. )
  307. const updateMemberRole = (userID: string, role: TeamMemberRole) => {
  308. const updateIndex = roleUpdates.value.findIndex(
  309. (item) => item.userID === userID
  310. )
  311. if (updateIndex !== -1) {
  312. // Role Update exists
  313. roleUpdates.value[updateIndex].role = role
  314. } else {
  315. // Role Update does not exist
  316. roleUpdates.value.push({
  317. userID,
  318. role,
  319. })
  320. }
  321. }
  322. const membersList = computed(() => {
  323. if (teamDetails.loading) return []
  324. const data = teamDetails.data
  325. if (E.isLeft(data)) return []
  326. if (E.isRight(data)) {
  327. const members = (data.right.team?.teamMembers ?? []).map((member) => {
  328. const updatedRole = roleUpdates.value.find(
  329. (update) => update.userID === member.user.uid
  330. )
  331. return {
  332. userID: member.user.uid,
  333. email: member.user.email!,
  334. role: updatedRole?.role ?? member.role,
  335. }
  336. })
  337. return members
  338. }
  339. return []
  340. })
  341. const isLoadingIndex = ref<null | number>(null)
  342. const removeExistingTeamMember = async (userID: string, index: number) => {
  343. isLoadingIndex.value = index
  344. const removeTeamMemberResult = await removeTeamMember(
  345. userID,
  346. props.editingTeamID
  347. )()
  348. if (E.isLeft(removeTeamMemberResult)) {
  349. toast.error(`${t("error.something_went_wrong")}`)
  350. } else {
  351. toast.success(`${t("team.member_removed")}`)
  352. emit("refetch-teams")
  353. teamDetails.execute({ teamID: props.editingTeamID })
  354. }
  355. isLoadingIndex.value = null
  356. }
  357. const isLoading = ref(false)
  358. const saveTeam = async () => {
  359. isLoading.value = true
  360. if (name.value !== "") {
  361. if (TeamNameCodec.is(name.value)) {
  362. const updateTeamNameResult = await renameTeam(
  363. props.editingTeamID,
  364. name.value
  365. )()
  366. if (E.isLeft(updateTeamNameResult)) {
  367. toast.error(`${t("error.something_went_wrong")}`)
  368. } else {
  369. roleUpdates.value.forEach(async (update) => {
  370. const updateMemberRoleResult = await updateTeamMemberRole(
  371. update.userID,
  372. props.editingTeamID,
  373. update.role
  374. )()
  375. if (E.isLeft(updateMemberRoleResult)) {
  376. toast.error(`${t("error.something_went_wrong")}`)
  377. console.error(updateMemberRoleResult.left.error)
  378. }
  379. })
  380. }
  381. hideModal()
  382. toast.success(`${t("team.saved")}`)
  383. } else {
  384. toast.error(`${t("team.name_length_insufficient")}`)
  385. }
  386. } else {
  387. toast.error(`${t("empty.team_name")}`)
  388. }
  389. isLoading.value = false
  390. }
  391. const hideModal = () => {
  392. emit("hide-modal")
  393. }
  394. </script>