From de2e4aa0f1db87afc9762c512f09aee02a76a9aa Mon Sep 17 00:00:00 2001 From: Vaibhav Singh Date: Sat, 9 Nov 2024 19:32:38 +0530 Subject: [PATCH] feat: UI for deleting discord groups by super users (#909) * UI changes for delete group feature under feature flag * chore: Test cases * fix: issue is no group found --- __tests__/groups/group.test.js | 75 ++++++++++++++++++++++- groups/assets/delete.svg | 8 +++ groups/createElements.js | 67 ++++++++++++++++++++- groups/index.html | 1 + groups/render.js | 35 ++++++++++- groups/script.js | 79 +++++++++++++++++++++--- groups/style.css | 107 +++++++++++++++++++++++++++++++++ groups/utils.js | 29 ++++++++- 8 files changed, 386 insertions(+), 15 deletions(-) create mode 100644 groups/assets/delete.svg diff --git a/__tests__/groups/group.test.js b/__tests__/groups/group.test.js index 9830f377..828758d5 100644 --- a/__tests__/groups/group.test.js +++ b/__tests__/groups/group.test.js @@ -1,10 +1,21 @@ const puppeteer = require('puppeteer'); -const { allUsersData } = require('../../mock-data/users'); +const { allUsersData, superUserData } = require('../../mock-data/users'); const { discordGroups, GroupRoleData } = require('../../mock-data/groups'); const BASE_URL = 'https://api.realdevsquad.com'; const PAGE_URL = 'http://localhost:8000'; +function setSuperUserPermission() { + allUsersData.users[0] = superUserData; +} + +function resetUserPermission() { + allUsersData.users[0] = { + ...allUsersData.users[0], + roles: { archived: false }, + }; +} + describe('Discord Groups Page', () => { let browser; let page; @@ -297,4 +308,66 @@ describe('Discord Groups Page', () => { const repoLinkStyle = await page.evaluate((el) => el.style, repoLink); expect(repoLinkStyle).toBeTruthy(); }); + + test('Should display delete button for super users', async () => { + setSuperUserPermission(); + await page.goto(`${PAGE_URL}/groups?dev=true`); + await page.waitForNetworkIdle(); + await page.waitForTimeout(1000); + + const deleteButtons = await page.$$('.delete-group'); + const cards = await page.$$('.card'); + expect(deleteButtons.length).toBe(cards.length); + expect(deleteButtons.length).toBeGreaterThan(0); + }); + + test('Should not display delete button when user is normal user', async () => { + resetUserPermission(); + await page.goto(`${PAGE_URL}/groups?dev=true`); + await page.waitForNetworkIdle(); + + const deleteButtons = await page.$$('.delete-group'); + expect(deleteButtons.length).toBe(0); + }); + + test('Should not display delete button when dev=false', async () => { + setSuperUserPermission(); + await page.goto(`${PAGE_URL}/groups`); + await page.waitForNetworkIdle(); + + const deleteButtons = await page.$$('.delete-group'); + expect(deleteButtons.length).toBe(0); + }); + + test('Should display delete confirmation modal on click of delete button', async () => { + setSuperUserPermission(); + await page.goto(`${PAGE_URL}/groups?dev=true`); + await page.waitForNetworkIdle(); + await page.waitForTimeout(1000); + + const deleteButton = await page.$('.delete-group'); + await deleteButton.click(); + + const deleteConfirmationModal = await page.waitForSelector( + '.delete-confirmation-modal', + ); + + expect(deleteConfirmationModal).toBeTruthy(); + }); + + test('Should close delete confirmation modal when cancel button is clicked', async () => { + setSuperUserPermission(); + await page.goto(`${PAGE_URL}/groups?dev=true`); + await page.waitForNetworkIdle(); + await page.waitForTimeout(1000); + + const deleteButton = await page.$('.delete-group'); + await deleteButton.click(); + + const cancelButton = await page.waitForSelector('#cancel-delete'); + await cancelButton.click(); + + const modalClosed = await page.$('.delete-confirmation-modal'); + expect(modalClosed).toBeFalsy(); + }); }); diff --git a/groups/assets/delete.svg b/groups/assets/delete.svg new file mode 100644 index 00000000..1ca5a7a1 --- /dev/null +++ b/groups/assets/delete.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/groups/createElements.js b/groups/createElements.js index 44254e8a..3a7c0270 100644 --- a/groups/createElements.js +++ b/groups/createElements.js @@ -1,4 +1,9 @@ -const createCard = (rawGroup, onClick = () => {}) => { +const createCard = ( + rawGroup, + onClick = () => {}, + onDelete = () => {}, + isSuperUser = false, +) => { const group = { ...rawGroup, description: @@ -10,7 +15,17 @@ const createCard = (rawGroup, onClick = () => {}) => { cardElement.className = 'card'; cardElement.id = `group-${group.id}`; cardElement.innerHTML = ` -
+
+
+ ${ + isSuperUser + ? ` + ` + : '' + } +

@@ -36,6 +51,15 @@ const createCard = (rawGroup, onClick = () => {}) => { .querySelector('.card__btn') .addEventListener('click', () => group.isUpdating || onClick()); + if (isSuperUser) { + cardElement + .querySelector('.delete-group') + .addEventListener('click', (e) => { + e.stopPropagation(); + onDelete(rawGroup.id, rawGroup.roleId); + }); + } + return cardElement; }; @@ -214,6 +238,44 @@ const createGroupCreationModal = (onClose = () => {}, onSubmit = () => {}) => { return backdropElement; }; +const createDeleteConfirmationModal = ( + onClose = () => {}, + onConfirm = () => {}, +) => { + const backdropElement = document.createElement('div'); + backdropElement.className = 'backdrop'; + + const modalElement = document.createElement('div'); + modalElement.className = 'delete-confirmation-modal'; + modalElement.innerHTML = ` +
+

Confirm Delete

+ +
+
+

Are you sure you want to delete this group?

+
+ +
+ + +
+ `; + + modalElement.querySelector('#close-button').onclick = onClose; + modalElement.querySelector('#cancel-delete').onclick = onClose; + modalElement.querySelector('#confirm-delete').onclick = onConfirm; + + backdropElement.appendChild(modalElement); + backdropElement.onclick = (e) => { + if (e.target === backdropElement) onClose(); + }; + + return backdropElement; +}; + export { createCard, createLoadingCard, @@ -222,4 +284,5 @@ export { createNavbarProfileLoading, createNavbarProfileSignin, createGroupCreationModal, + createDeleteConfirmationModal, }; diff --git a/groups/index.html b/groups/index.html index b58fcdcf..a2e188ab 100644 --- a/groups/index.html +++ b/groups/index.html @@ -10,6 +10,7 @@ + diff --git a/groups/render.js b/groups/render.js index c8513b2a..9647b07d 100644 --- a/groups/render.js +++ b/groups/render.js @@ -6,6 +6,7 @@ import { createNavbarProfile, createNavbarProfileLoading, createNavbarProfileSignin, + createDeleteConfirmationModal, } from './createElements.js'; const renderNotAuthenticatedPage = () => { @@ -86,8 +87,13 @@ const removeLoadingCards = () => { loadingCards.forEach((card) => mainContainer.removeChild(card)); }; -const renderGroupById = ({ group, cardOnClick = () => {} }) => { - const card = createCard(group, cardOnClick); +const renderGroupById = ({ + group, + cardOnClick = () => {}, + onDelete = () => {}, + isSuperUser = false, +}) => { + const card = createCard(group, cardOnClick, onDelete, isSuperUser); const mainContainer = document.querySelector('.group-container'); const groupElement = document.getElementById(`group-${group.id}`); if (groupElement) { @@ -105,6 +111,29 @@ const renderNoGroupFound = () => { mainContainer.append(noGroupContainer); }; +const renderDeleteConfirmationModal = ({ + onClose = () => {}, + onConfirm = () => {}, +}) => { + const container = document.querySelector('body'); + const existingBackdrop = document.querySelector('.backdrop'); + + if (existingBackdrop) { + container.removeChild(existingBackdrop); + } + + const modal = createDeleteConfirmationModal(onClose, onConfirm); + container.appendChild(modal); +}; + +const removeDeleteConfirmationModal = () => { + const container = document.querySelector('body'); + const backdrop = document.querySelector('.backdrop'); + if (backdrop) { + container.removeChild(backdrop); + } +}; + export { renderNotAuthenticatedPage, renderGroupCreationModal, @@ -117,4 +146,6 @@ export { removeLoadingCards, renderGroupById, renderNoGroupFound, + renderDeleteConfirmationModal, + removeDeleteConfirmationModal, }; diff --git a/groups/script.js b/groups/script.js index 9ac12657..7b42e819 100644 --- a/groups/script.js +++ b/groups/script.js @@ -11,7 +11,10 @@ import { renderNavbarProfile, renderNavbarProfileSignin, renderNotAuthenticatedPage, + renderDeleteConfirmationModal, + removeDeleteConfirmationModal, } from './render.js'; + import { addGroupRoleToMember, createDiscordGroupRole, @@ -22,11 +25,14 @@ import { getDiscordGroupIdsFromSearch, getParamValueFromURL, setParamValueInURL, + deleteDiscordGroupRole, } from './utils.js'; const QUERY_PARAM_KEY = { + DEV_FEATURE_FLAG: 'dev', GROUP_SEARCH: 'name', }; +const isDev = getParamValueFromURL(QUERY_PARAM_KEY.DEV_FEATURE_FLAG) === 'true'; const handler = { set: (obj, prop, value) => { @@ -42,11 +48,16 @@ const handler = { .filter( (ng) => JSON.stringify(oldGroups?.[ng.id]) !== JSON.stringify(ng), ) - .filter((ng) => dataStore.filteredGroupsIds.includes(ng.id)); + .filter((ng) => dataStore.filteredGroupsIds.includes(ng.id)) + .filter((ng) => !ng.isDeleted); diffGroups.forEach((group) => renderGroupById({ - group, + group: { + ...dataStore.groups[group.id], + roleId: dataStore.groups[group.id].roleid, + }, cardOnClick: () => groupCardOnAction(group.id), + isSuperUser: dataStore.isSuperUser, }), ); break; @@ -99,6 +110,9 @@ const handler = { case 'discordId': obj[prop] = value; break; + case 'isSuperUser': + obj[prop] = value; + break; default: throw new Error('Invalid property set'); } @@ -114,6 +128,7 @@ const dataStore = new Proxy( search: getParamValueFromURL(QUERY_PARAM_KEY.GROUP_SEARCH), discordId: null, isCreateGroupModalOpen: false, + isSuperUser: false, }, handler, ); @@ -155,10 +170,13 @@ const onCreate = () => { }; const afterAuthentication = async () => { renderNavbarProfile({ profile: dataStore.userSelf }); + dataStore.isSuperUser = await checkUserIsSuperUser(); + await Promise.all([getDiscordGroups(), getUserGroupRoles()]).then( ([groups, roleData]) => { - dataStore.filteredGroupsIds = groups.map((group) => group.id); - dataStore.groups = groups.reduce((acc, group) => { + const nonDeletedGroups = groups.filter((group) => !group.isDeleted); + dataStore.filteredGroupsIds = nonDeletedGroups.map((group) => group.id); + dataStore.groups = nonDeletedGroups.reduce((acc, group) => { let title = group.rolename .replace('group-', '') .split('-') @@ -180,6 +198,9 @@ const afterAuthentication = async () => { dataStore.search, ); dataStore.discordId = roleData.userId; + renderAllGroups({ + cardOnClick: groupCardOnAction, + }); }, ); }; @@ -254,12 +275,52 @@ function groupCardOnAction(id) { function renderAllGroups({ cardOnClick }) { const mainContainer = document.querySelector('.group-container'); mainContainer.innerHTML = ''; - dataStore.filteredGroupsIds.forEach((id) => - renderGroupById({ - group: dataStore.groups[id], - cardOnClick: () => cardOnClick(id), - }), + const nonDeletedGroups = dataStore.filteredGroupsIds.filter( + (id) => !dataStore.groups[id].isDeleted, ); + if (nonDeletedGroups.length === 0) { + renderNoGroupFound(); + } else { + nonDeletedGroups.forEach((id) => { + const group = dataStore.groups[id]; + if (!group.isDeleted) { + renderGroupById({ + group: group, + cardOnClick: () => cardOnClick(id), + onDelete: isDev ? showDeleteModal : undefined, + isSuperUser: dataStore.isSuperUser && isDev, + }); + } + }); + } +} + +function showDeleteModal(groupId, roleId) { + if (!isDev) return; + renderDeleteConfirmationModal({ + onClose: () => { + removeDeleteConfirmationModal(); + }, + onConfirm: async () => { + try { + await deleteDiscordGroupRole(groupId, roleId); + showToaster('Group deleted successfully'); + + updateGroup(groupId, { isDeleted: true }); + + dataStore.filteredGroupsIds = dataStore.filteredGroupsIds.filter( + (id) => id !== groupId, + ); + renderAllGroups({ + cardOnClick: groupCardOnAction, + }); + } catch (error) { + showToaster(error.message || 'Failed to delete group'); + } finally { + removeDeleteConfirmationModal(); + } + }, + }); } onCreate(); diff --git a/groups/style.css b/groups/style.css index 054e47b4..b9b966f8 100644 --- a/groups/style.css +++ b/groups/style.css @@ -127,6 +127,12 @@ body { grid-template-columns: repeat(4, 1fr); } +@media screen and (max-width: 400px) { + .group-header { + gap: 0.8rem; + } +} + .spacer { flex-grow: 1; } @@ -345,6 +351,32 @@ body { width: 100%; } +.card__header { + display: flex; + align-items: center; + justify-content: space-between; +} + +.delete-group { + background-color: transparent; + border: none; + padding: 0; + cursor: pointer; +} + +.delete-group__icon { + height: 1.2em; + opacity: 0.5; + transition: transform 0.2s ease-in-out, opacity 0.2s ease-in-out, + scale 0.2s ease-in-out; +} + +.delete-group:hover .delete-group__icon { + transform: rotate(10deg); + opacity: 1; + scale: 1.2; +} + .card__title { font-size: 1.1rem; font-weight: 600; @@ -592,3 +624,78 @@ body { gap: 0; } } + +/* +For Delete Confirmation Modal +*/ + +.delete-confirmation-modal { + background-color: var(--color-white); + border-radius: 8px; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + max-width: 400px; + width: 90%; + padding: 24px; + position: relative; +} + +.delete-modal__header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; +} + +.delete-modal__title { + font-size: 24px; + font-weight: bold; + color: #333; + margin: 0; +} + +.delete-modal__close { + background: none; + border: none; + cursor: pointer; + padding: 0.8rem 1rem; + margin: -1rem -0.8rem 0.8rem 0.4rem; +} + +.delete-modal__content { + margin-bottom: 24px; +} + +.delete-modal__msg { + font-size: 16px; + color: #666; + line-height: 1.5; +} + +.delete-modal__buttons { + display: flex; + justify-content: flex-end; + gap: 12px; +} + +.delete-modal-button { + padding: 10px 20px; + border-radius: 4px; + font-size: 16px; + font-weight: bold; + cursor: pointer; + transition: background-color 0.3s ease; +} + +.button--secondary:hover { + background-color: var(--color-group-btn-background); +} + +.button--danger { + background-color: #dc3545; + color: #fff; + border: none; +} + +.button--danger:hover { + background-color: #c82333; +} diff --git a/groups/utils.js b/groups/utils.js index 4dfeff83..e6437a0e 100644 --- a/groups/utils.js +++ b/groups/utils.js @@ -1,5 +1,4 @@ const BASE_URL = window.API_BASE_URL; // REPLACE WITH YOUR LOCALHOST URL FOR TESTING LOCAL BACKEND - async function getMembers() { try { const res = await fetch(`${BASE_URL}/users/`, { @@ -117,6 +116,33 @@ async function removeRoleFromMember(roleId, discordId) { } } +async function deleteDiscordGroupRole(groupId, roleId) { + try { + const res = await fetch( + `${BASE_URL}/discord-actions/groups/${groupId}?dev=true`, + { + method: 'DELETE', + credentials: 'include', + headers: { + 'Content-type': 'application/json', + }, + body: JSON.stringify({ roleid: roleId }), + }, + ); + + if (!res.ok) { + const errorResponse = await res.json(); + throw new Error( + `Failed to delete group role: ${JSON.stringify(errorResponse.error)}`, + ); + } + + return await res.json(); + } catch (err) { + throw err; + } +} + function removeGroupKeywordFromDiscordRoleName(groupName) { if (/^group.*/.test(groupName)) { const splitNames = groupName.split('-'); @@ -164,6 +190,7 @@ export { createDiscordGroupRole, addGroupRoleToMember, removeRoleFromMember, + deleteDiscordGroupRole, removeGroupKeywordFromDiscordRoleName, getDiscordGroupIdsFromSearch, getParamValueFromURL,