From 1e2d4715f71e40ce81471ab0d828db8c967d3dc2 Mon Sep 17 00:00:00 2001 From: Qiyun Dai Date: Thu, 5 Sep 2024 00:02:36 -0500 Subject: [PATCH] [MWPW-157033] Attendee table (#201) --- .../attendee-management-table.css | 359 +++++++++++ .../attendee-management-table.js | 592 ++++++++++++++++++ ecc/blocks/ecc-dashboard/ecc-dashboard.js | 51 +- ecc/blocks/form-handler/form-handler.js | 7 +- ecc/components/filter-menu/filter-menu.css.js | 20 + ecc/components/filter-menu/filter-menu.js | 74 +++ .../searchable-picker.css.js | 39 ++ .../searchable-picker/searchable-picker.js | 138 ++++ ecc/scripts/esp-controller.js | 33 +- ecc/scripts/scripts.js | 1 + ecc/scripts/utils.js | 35 ++ 11 files changed, 1299 insertions(+), 50 deletions(-) create mode 100644 ecc/blocks/attendee-management-table/attendee-management-table.css create mode 100644 ecc/blocks/attendee-management-table/attendee-management-table.js create mode 100644 ecc/components/filter-menu/filter-menu.css.js create mode 100644 ecc/components/filter-menu/filter-menu.js create mode 100644 ecc/components/searchable-picker/searchable-picker.css.js create mode 100644 ecc/components/searchable-picker/searchable-picker.js diff --git a/ecc/blocks/attendee-management-table/attendee-management-table.css b/ecc/blocks/attendee-management-table/attendee-management-table.css new file mode 100644 index 00000000..e902ce51 --- /dev/null +++ b/ecc/blocks/attendee-management-table/attendee-management-table.css @@ -0,0 +1,359 @@ +.attendee-management-table { + font-family: var(--body-font-family); + padding: 40px; + margin: auto; +} + +.attendee-management-table .loading-screen { + position: fixed; + display: flex; + align-items: center; + justify-content: center; + gap: 16px; + height: 100%; + width: 100%; + top: 0; + left: 0; + z-index: 20; + background: var(--color-white); +} + +.attendee-management-table .loading-screen sp-field-label { + font-size: var(--type-body-s-size); +} + +.attendee-management-table .dashboard-main-container { + margin-top: 24px; + display: flex; + gap: 24px; +} + +.attendee-management-table .dashboard-main-container .dashboard-side-panel { + background-color: var(--color-gray-100); + border-radius: 8px; + padding: 16px; + max-width: 192px; + min-width: 192px; +} + +.attendee-management-table .dashboard-main-container .dashboard-side-panel .back-btn { + display: flex; + align-items: center; + font-weight: 700; + color: var(--color-black); + font-size: var(--type-body-s-size); + width: max-content; + margin-bottom: 1rem; + margin-left: -4px; +} + +.attendee-management-table .dashboard-main-container .dashboard-side-panel sp-field-label { + font-weight: 700; +} + +.attendee-management-table .dashboard-main-container .dashboard-side-panel .clear-all-wrapper.hidden { + display: none; +} + +.attendee-management-table .events-picker-wrapper { + display: flex; + flex-direction: column; +} + +.attendee-management-table sp-divider { + margin: 16px 0; +} + +.attendee-management-table .dashboard-header { + display: flex; + flex-direction: column; + gap: 16px; + justify-content: space-between; + align-items: flex-start; +} + +.attendee-management-table .dashboard-table-container { + max-width: calc(100% - 250px); + position: relative; +} + +.attendee-management-table .loading-overlay { + position: absolute; + top: 0; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + background-color: var(--highlight-background-color); + gap: 16px; +} + +.attendee-management-table .dashboard-table-wrapper { + overflow: auto; +} + +.attendee-management-table .pagination-container { + margin: 40px auto 0; + display: flex; + width: max-content; + font-size: var(--type-body-xs-size); +} + +.attendee-management-table .loading-screen, +.attendee-management-table .dashboard-header, +.attendee-management-table .dashboard-table-wrapper, +.attendee-management-table .pagination-container { + transition: opacity 0.5s; +} + +.attendee-management-table.loading .dashboard-header, +.attendee-management-table.loading .dashboard-table-wrapper, +.attendee-management-table.loading .pagination-container { + opacity: 0; +} + +.attendee-management-table:not(.loading) .loading-screen { + opacity: 0; + z-index: -1; +} + +.attendee-management-table.no-attendees .no-attendees-area { + margin: 64px; + display: flex; + flex-direction: column; + align-items: center; +} + +.attendee-management-table sp-theme sp-underlay + sp-dialog { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + z-index: 1; + background: var(--spectrum-gray-100); + min-width: 480px; +} + +.attendee-management-table sp-theme sp-underlay + sp-dialog h1 { + font-size: var(--type-heading-s-size); +} + +.attendee-management-table sp-theme sp-underlay + sp-dialog p { + font-size: var(--type-body-s-size); +} + +.attendee-management-table sp-theme sp-underlay + sp-dialog .button-container { + display: flex; + justify-content: flex-end; + gap: 16px; +} + +.attendee-management-table sp-theme sp-underlay:not([open]) + sp-dialog { + display: none; +} + +.attendee-management-table .dashboard-header-text { + display: flex; + align-items: flex-end; + gap: 16px; +} + +.attendee-management-table .dashboard-header-text h1 { + margin: 0; +} + +.attendee-management-table .dashboard-header-text p { + margin: 6px 0; +} + +.attendee-management-table .dashboard-actions-container { + display: flex; + align-items: center; + gap: 16px; +} + +.attendee-management-table .dashboard-actions-container .search-input-wrapper { + position: relative; +} + +.attendee-management-table .dashboard-actions-container .search-input-wrapper img.icon-search { + position: absolute; + display: block; + top: 50%; + transform: translateY(-50%); + right: 10px; + height: 1rem; + width: 1rem; +} + +.attendee-management-table .dashboard-actions-container input { + border-radius: 16px; + border: 2px solid var(--color-gray-500); + height: 24px; + padding: 0 16px; + width: 140px; +} + +.attendee-management-table .dashboard-actions-container input:not(:placeholder-shown) + img.icon-search { + display: none; +} + +.attendee-management-table table { + margin: auto; + border-collapse: collapse; +} + +.attendee-management-table table .table-header-row { + height: 40; + border-bottom: 2px solid var(--color-gray-600); +} + +.attendee-management-table table .table-header-row th { + padding: 0 16px; + font-weight: 700; + text-align: left; + font-size: var(--type-body-xxs-size); + color: var(--spectrum-color-gray-500); + width: 100px; + white-space: nowrap; +} + +.attendee-management-table table .table-header-row th span { + white-space: nowrap; + width: 60px; +} + +.attendee-management-table table .table-header-row th.sortable { + cursor: pointer; +} + +.attendee-management-table table .table-header-row th.active { + color: var(--color-black); +} + +.attendee-management-table table .table-header-row th .icon { + transform: translateY(4px); +} + +.attendee-management-table table .table-header-row th:not(.active) .icon, +.attendee-management-table table .table-header-row th .icon-chev-down, +.attendee-management-table table .table-header-row th.desc-sort .icon-chev-up { + display: none; +} + +.attendee-management-table table .table-header-row th.active.desc-sort .icon-chev-down { + display: inline-block; + +} + +.attendee-management-table table .attendee-row { + height: 40px; +} + +.attendee-management-table table .no-search-results-row td { + padding: 40px 0; + text-align: center; +} + +.attendee-management-table table .attendee-row:nth-of-type(even) { + background-color: var(--color-gray-100); +} + +.attendee-management-table table .attendee-row .attendee-title-link { + font-weight: 700; + text-decoration: none; +} + +.attendee-management-table table .attendee-row .thumbnail-container img { + display: block; + height: 90px; + width: 90px; + min-width: 90px; + object-fit: cover; + border-radius: 6px; + background-color: var(--color-gray-400); +} + +.attendee-management-table table .attendee-row td { + padding: 4px 16px; + position: relative; + font-size: var(--type-body-xs-size); + line-height: var(--type-body-xs-lh); + min-width: 120px; +} + +.attendee-management-table .attendee-row .dashboard-attendee-tool-box { + position: absolute; + display: flex; + flex-direction: column; + background-color: var(--color-white); + border-radius: 4px; + padding: 4px; + left: 20px; + z-index: 1; + box-shadow: 0 3px 6px 0 rgb(0 0 0 / 16%); + width: 182px; +} + +.attendee-management-table .attendee-row .dashboard-attendee-tool-box a.dash-attendee-tool { + display: flex; + align-items: center; + gap: 8px; + padding: 0 8px; + margin: 2px; + text-decoration: none; + color: var(--color-black); + font-size: var(--type-body-xxs-size); + border-radius: 8px; +} + +.attendee-management-table .attendee-row .dashboard-attendee-tool-box a.dash-attendee-tool:hover { + background-color: var(--color-gray-200); +} + +.attendee-management-table .pagination-container input { + padding: 4px 12px; + width: 16px; + margin-right: 4px; +} + +.attendee-management-table .pagination-container img.icon { + cursor: pointer; + width: 24px; + padding: 0 16px; +} + +.attendee-management-table .pagination-container img.icon.disabled { + opacity: 0.5; + pointer-events: none; +} + +.attendee-management-table table .attendee-row .attendee-status img.icon { + margin-right: 8px; +} + +.attendee-management-table .attendee-row .dashboard-attendee-tool-box a.dash-attendee-tool img.icon { + width: 16px; +} + +.attendee-management-table .attendee-row.pending { + opacity: 0.5; + pointer-events: none; +} + +.attendee-management-table sp-theme.toast-area { + position: fixed; + right: calc((100% - var(--grid-container-width)) / 2); + bottom: 80px; + display: flex; + flex-direction: column; + gap: 16px; + z-index: 9; +} + +@media screen and (min-width: 900px) { + .attendee-management-table .dashboard-header { + flex-direction: row; + } +} diff --git a/ecc/blocks/attendee-management-table/attendee-management-table.js b/ecc/blocks/attendee-management-table/attendee-management-table.js new file mode 100644 index 00000000..da21c865 --- /dev/null +++ b/ecc/blocks/attendee-management-table/attendee-management-table.js @@ -0,0 +1,592 @@ +/* eslint-disable max-len */ +import { getAllEventAttendees, getEvents } from '../../scripts/esp-controller.js'; +import { ALLOWED_ACCOUNT_TYPES } from '../../constants/constants.js'; +import { DEV_MODE, LIBS, MILO_CONFIG } from '../../scripts/scripts.js'; +import { + getIcon, + buildNoAccessScreen, + camelToSentenceCase, + readBlockConfig, +} from '../../scripts/utils.js'; +import BlockMediator from '../../scripts/deps/block-mediator.min.js'; +import { SearchablePicker } from '../../components/searchable-picker/searchable-picker.js'; +import { FilterMenu } from '../../components/filter-menu/filter-menu.js'; + +const { createTag } = await import(`${LIBS}/utils/utils.js`); + +const ATTENDEE_ATTR_KEYS = [ + 'attendeeId', + 'firstName', + 'lastName', + 'email', + 'companyName', + 'jobTitle', + 'mobilePhone', + 'industry', + 'productsOfInterest', + 'companySize', + 'age', + 'jobLevel', + 'contactMethod', +]; + +const FILTER_MAP = { + companyName: [], + jobTitle: [], + industry: [], + productsOfInterest: [], + companySize: [], + age: [], + jobLevel: [], + contactMethod: [], +}; + +function buildAllFilterMenues(props) { + const sidePanel = props.el.querySelector('.dashboard-side-panel'); + + if (!sidePanel) return null; + + const filterMenus = props.el.querySelectorAll('.filter-menu-wrapper:not(.clear-all-wrapper)'); + filterMenus.forEach((menu) => menu.remove()); + + const { currentFilters } = props; + + const menues = Object.entries(FILTER_MAP).filter(([key, val]) => { + if (!val.length) return null; + + const filterMenuWrapper = createTag('div', { class: 'filter-menu-wrapper' }, '', { parent: sidePanel }); + createTag('sp-field-label', {}, camelToSentenceCase(key), { parent: filterMenuWrapper }); + const filterMenu = createTag('filter-menu', {}, '', { parent: filterMenuWrapper }); + filterMenu.items = val; + filterMenu.type = key; + + filterMenu.addEventListener('filter-change', (e) => { + const { detail } = e; + const { type, value } = detail; + props.currentFilters[type] = value; + + props.currentFilters = currentFilters; + }); + + return filterMenu; + }); + + return menues; +} + +function buildFilters(props) { + const sidePanel = props.el.querySelector('.dashboard-side-panel'); + + if (!sidePanel) return; + + const existingFilterMenus = sidePanel.querySelectorAll('.filter-menu-wrapper'); + existingFilterMenus.forEach((menu) => menu.remove()); + + const clearAllWrapper = createTag('div', { class: 'filter-menu-wrapper clear-all-wrapper' }, '', { parent: sidePanel }); + const clearAllButton = createTag('sp-button', { variant: 'primary', size: 's' }, 'Clear all filters', { parent: clearAllWrapper }); + clearAllButton.addEventListener('click', () => { + const { currentFilters } = props; + + Object.keys(FILTER_MAP).forEach((key) => { + currentFilters[key] = []; + }); + + props.currentFilters = currentFilters; + const menues = buildAllFilterMenues(props); + clearAllWrapper.classList.toggle('hidden', !menues.length); + }); + + const menues = buildAllFilterMenues(props); + clearAllWrapper.classList.toggle('hidden', !menues.length); +} + +function updateFilterMap(props) { + Object.keys(FILTER_MAP).forEach((key) => { + FILTER_MAP[key] = [...new Set(props.data.map((e) => e[key]))].filter((e) => e); + }); +} + +function paginateData(props, config, page) { + const ps = +config['page-size']; + if (Number.isNaN(ps) || ps <= 0) { + window.lana?.log('error', 'Invalid page size'); + } + const start = (page - 1) * ps; + const end = Math.min(page * ps, props.filteredData.length); + + props.paginatedData = props.filteredData.slice(start, end); +} + +function sortData(props, config, options = {}) { + const { field, el } = props.currentSort; + + let sortAscending = true; + + if (el?.classList.contains('active')) { + if (options.resort) { + sortAscending = !el.classList.contains('desc-sort'); + } else { + sortAscending = el.classList.contains('desc-sort'); + } + el.classList.toggle('desc-sort', !sortAscending); + } else { + el?.classList.remove('desc-sort'); + } + + if (options.direction) { + sortAscending = options.direction === 'asc'; + el?.classList.toggle('desc-sort', !sortAscending); + } + + props.filteredData = props.filteredData.sort((a, b) => { + let valA; + let valB; + + if (typeof a[field] === typeof b[field] && typeof a[field] === 'number') { + valA = a[field] || 0; + valB = b[field] || 0; + return sortAscending ? valA - valB : valB - valA; + } + + valA = a[field]?.toString().toLowerCase() || ''; + valB = b[field]?.toString().toLowerCase() || ''; + return sortAscending ? valA.localeCompare(valB) : valB.localeCompare(valA); + }); + + el?.parentNode.querySelectorAll('th').forEach((header) => { + if (header !== el) { + header.classList.remove('active'); + header.classList.remove('desc-sort'); + } + }); + + props.currentPage = 1; + paginateData(props, config, 1); + el?.classList.add('active'); +} + +async function populateRow(props, index) { + const attendee = props.paginatedData[index]; + const tBody = props.el.querySelector('table.dashboard-table tbody'); + + const row = createTag('tr', { class: 'attendee-row', 'data-attendee-id': attendee.attendeeId }, '', { parent: tBody }); + + ATTENDEE_ATTR_KEYS.forEach((key) => { + createTag('td', {}, attendee[key] || '', { parent: row }); + }); +} + +function updatePaginationControl(pagination, currentPage, totalPages) { + const input = pagination.querySelector('input'); + input.value = currentPage; + const leftChevron = pagination.querySelector('.icon-chev-left'); + const rightChevron = pagination.querySelector('.icon-chev-right'); + leftChevron.classList.toggle('disabled', currentPage === 1); + rightChevron.classList.toggle('disabled', currentPage === totalPages); +} + +function decoratePagination(props, config) { + if (!props.filteredData.length) return; + + const mainContainer = props.el.querySelector('.dashboard-main-container'); + + if (!mainContainer) return; + + const totalPages = Math.ceil(props.filteredData.length / +config['page-size']); + const paginationContainer = createTag('div', { class: 'pagination-container' }); + const chevLeft = getIcon('chev-left'); + const chevRight = getIcon('chev-right'); + const paginationText = createTag('div', { class: 'pagination-text' }, `of ${totalPages} pages`); + const pageInput = createTag('input', { type: 'text', class: 'page-input' }); + + paginationText.prepend(pageInput); + paginationContainer.append(chevLeft, paginationText, chevRight); + + pageInput.addEventListener('keypress', (attendee) => { + if (attendee.key === 'Enter') { + let page = parseInt(pageInput.value, +config['page-size']); + if (page > totalPages) { + page = totalPages; + } else if (page < 1) { + page = 1; + } + + updatePaginationControl(paginationContainer, props.currentPage = page, totalPages); + paginateData(props, config, page); + } + }); + + chevLeft.addEventListener('click', () => { + if (props.currentPage > 1) { + updatePaginationControl(paginationContainer, props.currentPage -= 1, totalPages); + paginateData(props, config, props.currentPage); + } + }); + + chevRight.addEventListener('click', () => { + if (props.currentPage < totalPages) { + updatePaginationControl(paginationContainer, props.currentPage += 1, totalPages); + paginateData(props, config, props.currentPage); + } + }); + + mainContainer.querySelector('.dashboard-table-container')?.append(paginationContainer); + updatePaginationControl(paginationContainer, props.currentPage, totalPages); +} + +function initSorting(props, config) { + const thead = props.el.querySelector('thead'); + const thRow = thead.querySelector('tr'); + + ATTENDEE_ATTR_KEYS.forEach((key) => { + const val = camelToSentenceCase(key).toUpperCase(); + const thText = createTag('span', {}, val); + const th = createTag('th', {}, thText, { parent: thRow }); + + th.append(getIcon('chev-down'), getIcon('chev-up')); + th.classList.add('sortable', key); + th.addEventListener('click', () => { + if (!props.filteredData.length) return; + + thead.querySelectorAll('th').forEach((h) => { + if (th !== h) { + h.classList.remove('active'); + } + }); + th.classList.add('active'); + props.currentSort = { + el: th, + field: key, + }; + sortData(props, config); + }); + }); +} + +function buildNoResultsScreen(el, config) { + const noSearchResultsRow = createTag('tr', { class: 'no-search-results-row' }); + const noSearchResultsCol = createTag('td', { colspan: '100%' }, getIcon('empty-dashboard'), { parent: noSearchResultsRow }); + createTag('h2', {}, config['no-attendee-results-heading'], { parent: noSearchResultsCol }); + createTag('p', {}, config['no-attendee-results-text'], { parent: noSearchResultsCol }); + + el.append(noSearchResultsRow); +} + +function populateTable(props, config) { + const tBody = props.el.querySelector('table.dashboard-table tbody'); + tBody.innerHTML = ''; + + if (!props.paginatedData.length) { + buildNoResultsScreen(tBody, config); + } else { + const endOfPage = Math.min(+config['page-size'], props.paginatedData.length); + + for (let i = 0; i < endOfPage; i += 1) { + populateRow(props, i); + } + + props.el.querySelector('.pagination-container')?.remove(); + decoratePagination(props, config); + } +} + +function filterData(props, config) { + const q = props.currentQuery.toLowerCase(); + props.filteredData = props.data.filter((e) => { + const searchMatch = ATTENDEE_ATTR_KEYS.some((key) => e[key]?.toString().toLowerCase().includes(q)); + const appliedFilters = Object.entries(props.currentFilters).filter(([, val]) => val.length); + const filterMatch = appliedFilters.every(([key, val]) => val.includes(e[key])); + + return searchMatch && filterMatch; + }); + + props.currentPage = 1; + paginateData(props, config, 1); + sortData(props, config, { resort: true }); +} + +function buildDashboardHeader(props, config) { + const dashboardHeader = createTag('div', { class: 'dashboard-header' }); + const textContainer = createTag('div', { class: 'dashboard-header-text' }); + const actionsContainer = createTag('div', { class: 'dashboard-actions-container' }); + + createTag('h1', { class: 'dashboard-header-heading' }, 'All event attendees', { parent: textContainer }); + createTag('p', { class: 'dashboard-header-attendees-count' }, `(${props.data.length} attendees)`, { parent: textContainer }); + + const searchInputWrapper = createTag('div', { class: 'search-input-wrapper' }, '', { parent: actionsContainer }); + const searchInput = createTag('input', { type: 'text', placeholder: 'Search' }, '', { parent: searchInputWrapper }); + searchInputWrapper.append(getIcon('search')); + searchInput.addEventListener('input', () => { + props.currentQuery = searchInput.value; + filterData(props, config); + }); + + dashboardHeader.append(textContainer, actionsContainer); + props.el.prepend(dashboardHeader); +} + +function updateDashboardHeader(props) { + const attendeesCount = props.el.querySelector('.dashboard-header-attendees-count'); + + if (attendeesCount) attendeesCount.textContent = `(${props.data.length} attendees)`; +} + +function buildDashboardTable(props, config) { + const mainContainer = props.el.querySelector('.dashboard-main-container'); + + if (!mainContainer) return; + + const tableContainer = createTag('div', { class: 'dashboard-table-container' }, '', { parent: mainContainer }); + const tableWrapper = createTag('div', { class: 'dashboard-table-wrapper' }, '', { parent: tableContainer }); + const table = createTag('table', { class: 'dashboard-table' }, '', { parent: tableWrapper }); + const thead = createTag('thead', {}, '', { parent: table }); + createTag('tbody', {}, '', { parent: table }); + createTag('tr', { class: 'table-header-row' }, '', { parent: thead }); + initSorting(props, config); + populateTable(props, config); +} + +async function getEventsArray() { + const resp = await getEvents(); + + if (resp.error) { + return []; + } + + return resp.events; +} + +function renderTableLoadingOverlay(props) { + const tableContainer = props.el.querySelector('.dashboard-table-container'); + const loadingOverlay = createTag('div', { class: 'loading-overlay' }); + createTag('sp-progress-circle', { size: 'l', indeterminate: true }, '', { parent: loadingOverlay }); + tableContainer.append(loadingOverlay); +} + +function removeTableLoadingOverlay(props) { + const loadingOverlay = props.el.querySelector('.loading-overlay'); + loadingOverlay?.remove(); +} + +function buildEventPicker(props) { + const { events } = props; + + if (!events?.length) return; + + const sidePanel = props.el.querySelector('.dashboard-side-panel'); + const eventsPickerWrapper = createTag('div', { class: 'events-picker-wrapper' }, '', { parent: sidePanel }); + createTag('sp-field-label', {}, 'Current event', { parent: eventsPickerWrapper }); + const eventsPicker = createTag('searchable-picker', { + class: 'events-picker', + label: 'Choose an event', + }, '', { parent: eventsPickerWrapper }); + + if (props.currentEventId) { + eventsPicker.value = props.currentEventId; + const event = props.events.find((e) => e.eventId === props.currentEventId); + + if (event) eventsPicker.displayValue = event.title; + } + + eventsPicker.items = events.map((e) => ({ label: e.title, value: e.eventId })); + eventsPicker.filteredItems = eventsPicker.items; + + eventsPicker.addEventListener('picker-change', (e) => { + const { detail } = e; + props.currentEventId = detail.value; + renderTableLoadingOverlay(props); + getAllEventAttendees(props.currentEventId).then((attendees) => { + if (!attendees.error) { + props.data = attendees; + } + removeTableLoadingOverlay(props); + }); + }); +} + +function updateResetFilterBtnState(props) { + const clearAllWrapper = props.el.querySelector('.clear-all-wrapper'); + const btn = clearAllWrapper.querySelector('sp-button'); + const { currentFilters } = props; + const hasFilters = Object.values(currentFilters).some((val) => val.length); + btn.disabled = !hasFilters; +} + +function buildBackToDashboardBtn(props, config) { + const sidePanel = props.el.querySelector('.dashboard-side-panel'); + + if (!sidePanel) return; + + const url = new URL(`${window.location.origin}${config['event-dashboard-url']}`); + if (DEV_MODE) url.searchParams.set('devMode', true); + const backBtn = createTag('a', { class: 'back-btn', href: url.toString() }, 'Back', { parent: sidePanel }); + backBtn.prepend(getIcon('chev-left')); +} + +function buildDashboardSidePanel(props, config) { + const mainContainer = props.el.querySelector('.dashboard-main-container'); + + if (!mainContainer) return; + + const sidePanel = createTag('div', { class: 'dashboard-side-panel' }, '', { parent: mainContainer }); + buildBackToDashboardBtn(props, config); + buildEventPicker(props); + createTag('sp-divider', {}, '', { parent: sidePanel }); + buildFilters(props); + updateResetFilterBtnState(props); +} + +function clearActionArea(props) { + const actionArea = props.el.querySelector('.dashboard-actions-container'); + const searchInput = actionArea.querySelector('input'); + searchInput.value = ''; +} + +function resetSort(props) { + const thRow = props.el.querySelector('thead tr'); + thRow.querySelectorAll('th').forEach((th) => { + th.classList.remove('active'); + th.classList.remove('desc-sort'); + }); +} + +function initCustomLitComponents() { + customElements.define('searchable-picker', SearchablePicker); + customElements.define('filter-menu', FilterMenu); +} + +async function buildDashboard(el, config) { + const spTheme = createTag('sp-theme', { color: 'light', scale: 'medium', class: 'toast-area' }, '', { parent: el }); + createTag('sp-underlay', {}, '', { parent: spTheme }); + createTag('sp-dialog', { size: 's' }, '', { parent: spTheme }); + createTag('sp-theme', { color: 'light', scale: 'medium', class: 'dashboard-main-container' }, '', { parent: el }); + + const uspEventId = new URLSearchParams(window.location.search).get('eventId'); + const events = await getEventsArray(); + + const props = { + el, + events, + currentPage: 1, + currentSort: {}, + currentFilters: {}, + currentQuery: '', + currentEventId: uspEventId || '', + showAllAttendees: false, + }; + + let data = []; + + if (props.currentEventId) { + const resp = await getAllEventAttendees(props.currentEventId); + if (resp && !resp.error) data = resp; + } + + props.data = data; + props.filteredData = [...data]; + props.paginatedData = [...data]; + updateFilterMap(props); + + const dataHandler = { + set(target, prop, value, receiver) { + target[prop] = value; + + if (prop === 'data') { + target.filteredData = [...value]; + target.paginatedData = [...value]; + target.currentFilters = {}; + updateFilterMap(receiver); + buildFilters(receiver); + } + + if (prop === 'currentEventId') { + clearActionArea(target); + resetSort(target); + } + + if (prop === 'currentFilters') { + filterData(target, config); + } + + updateDashboardHeader(target); + populateTable(receiver, config); + updateResetFilterBtnState(target); + + return true; + }, + }; + + const proxyProps = new Proxy(props, dataHandler); + + buildDashboardSidePanel(proxyProps, config); + buildDashboardHeader(proxyProps, config); + buildDashboardTable(proxyProps, config); + initCustomLitComponents(); + setTimeout(() => { + el.classList.remove('loading'); + }, 10); +} + +function buildLoadingScreen(el) { + el.classList.add('loading'); + const loadingScreen = createTag('sp-theme', { color: 'light', scale: 'medium', class: 'loading-screen' }); + createTag('sp-progress-circle', { size: 'l', indeterminate: true }, '', { parent: loadingScreen }); + createTag('sp-field-label', {}, 'Loading Adobe Event Creation Console Attendee Management Table...', { parent: loadingScreen }); + + el.prepend(loadingScreen); +} + +export default async function init(el) { + const miloLibs = LIBS; + await Promise.all([ + import(`${miloLibs}/deps/lit-all.min.js`), + import(`${miloLibs}/features/spectrum-web-components/dist/theme.js`), + import(`${miloLibs}/features/spectrum-web-components/dist/toast.js`), + import(`${miloLibs}/features/spectrum-web-components/dist/button.js`), + import(`${miloLibs}/features/spectrum-web-components/dist/dialog.js`), + import(`${miloLibs}/features/spectrum-web-components/dist/underlay.js`), + import(`${miloLibs}/features/spectrum-web-components/dist/progress-circle.js`), + import(`${miloLibs}/features/spectrum-web-components/dist/textfield.js`), + import(`${miloLibs}/features/spectrum-web-components/dist/picker.js`), + import(`${miloLibs}/features/spectrum-web-components/dist/divider.js`), + import(`${miloLibs}/features/spectrum-web-components/dist/overlay.js`), + import(`${miloLibs}/features/spectrum-web-components/dist/popover.js`), + import(`${miloLibs}/features/spectrum-web-components/dist/link.js`), + ]); + + const { search } = window.location; + const urlParams = new URLSearchParams(search); + const devMode = urlParams.get('devMode'); + + const config = readBlockConfig(el); + el.innerHTML = ''; + buildLoadingScreen(el); + const profile = BlockMediator.get('imsProfile'); + + if (devMode === 'true' && ['stage', 'local'].includes(MILO_CONFIG.env.name)) { + buildDashboard(el, config); + return; + } + + if (profile) { + if (profile.noProfile || !ALLOWED_ACCOUNT_TYPES.includes(profile.account_type)) { + buildNoAccessScreen(el); + } else { + buildDashboard(el, config); + } + + return; + } + + if (!profile) { + const unsubscribe = BlockMediator.subscribe('imsProfile', ({ newValue }) => { + if (newValue?.noProfile || !ALLOWED_ACCOUNT_TYPES.includes(newValue.account_type)) { + buildNoAccessScreen(el); + } else { + buildDashboard(el, config); + } + + unsubscribe(); + }); + } +} diff --git a/ecc/blocks/ecc-dashboard/ecc-dashboard.js b/ecc/blocks/ecc-dashboard/ecc-dashboard.js index c15d8bef..bc81fe88 100644 --- a/ecc/blocks/ecc-dashboard/ecc-dashboard.js +++ b/ecc/blocks/ecc-dashboard/ecc-dashboard.js @@ -7,8 +7,8 @@ import { unpublishEvent, } from '../../scripts/esp-controller.js'; import { ALLOWED_ACCOUNT_TYPES } from '../../constants/constants.js'; -import { LIBS, MILO_CONFIG } from '../../scripts/scripts.js'; -import { getIcon, buildNoAccessScreen, getEventPageHost } from '../../scripts/utils.js'; +import { LIBS, MILO_CONFIG, DEV_MODE } from '../../scripts/scripts.js'; +import { getIcon, buildNoAccessScreen, getEventPageHost, readBlockConfig } from '../../scripts/utils.js'; import { quickFilter } from '../form-handler/data-handler.js'; import BlockMediator from '../../scripts/deps/block-mediator.min.js'; @@ -65,41 +65,6 @@ function showToast(props, msg, options = {}) { }); } -function toClassName(name) { - return name && typeof name === 'string' - ? name.toLowerCase().replace(/[^0-9a-z]/gi, '-') - : ''; -} - -export function readBlockConfig(block) { - return [...block.querySelectorAll(':scope>div')].reduce((config, row) => { - if (row.children) { - const cols = [...row.children]; - if (cols[1]) { - const valueEl = cols[1]; - const name = toClassName(cols[0].textContent); - if (valueEl.querySelector('a')) { - const aArr = [...valueEl.querySelectorAll('a')]; - if (aArr.length === 1) { - config[name] = aArr[0].href; - } else { - config[name] = aArr.map((a) => a.href); - } - } else if (valueEl.querySelector('p')) { - const pArr = [...valueEl.querySelectorAll('p')]; - if (pArr.length === 1) { - config[name] = pArr[0].innerHTML; - } else { - config[name] = pArr.map((p) => p.innerHTML); - } - } else config[name] = row.children[1].innerHTML; - } - } - - return config; - }, {}); -} - function formatLocaleDate(string) { const options = { year: 'numeric', @@ -423,6 +388,7 @@ function buildStatusTag(event) { function buildEventTitleTag(config, eventObj) { const url = new URL(`${window.location.origin}${config['create-form-url']}`); url.searchParams.set('eventId', eventObj.eventId); + if (DEV_MODE) url.searchParams.set('devMode', true); const eventTitleTag = createTag('a', { class: 'event-title-link', href: url.toString() }, eventObj.title); return eventTitleTag; } @@ -439,10 +405,11 @@ function buildVenueTag(eventObj) { function buildRSVPTag(config, eventObj) { const text = `${eventObj.attendeeCount} / ${eventObj.attendeeLimit}`; - const url = new URL(`${window.location.origin}${config['create-form-url']}`); + const url = new URL(`${window.location.origin}${config['attendee-dashboard-url']}`); url.searchParams.set('eventId', eventObj.eventId); + if (DEV_MODE) url.searchParams.set('devMode', true); - const rsvpTag = createTag('span', { class: 'rsvp-tag' }, text); + const rsvpTag = createTag('a', { class: 'rsvp-tag', href: url }, text); return rsvpTag; } @@ -737,16 +704,12 @@ export default async function init(el) { import(`${miloLibs}/features/spectrum-web-components/dist/progress-circle.js`), ]); - const { search } = window.location; - const urlParams = new URLSearchParams(search); - const devMode = urlParams.get('devMode'); - const config = readBlockConfig(el); el.innerHTML = ''; buildLoadingScreen(el); const profile = BlockMediator.get('imsProfile'); - if (devMode === 'true' && ['stage', 'local'].includes(MILO_CONFIG.env.name)) { + if (DEV_MODE === true && ['stage', 'local'].includes(MILO_CONFIG.env.name)) { buildDashboard(el, config); return; } diff --git a/ecc/blocks/form-handler/form-handler.js b/ecc/blocks/form-handler/form-handler.js index 47436534..eabe5b68 100644 --- a/ecc/blocks/form-handler/form-handler.js +++ b/ecc/blocks/form-handler/form-handler.js @@ -1,5 +1,5 @@ import { ALLOWED_ACCOUNT_TYPES } from '../../constants/constants.js'; -import { LIBS, MILO_CONFIG } from '../../scripts/scripts.js'; +import { LIBS, MILO_CONFIG, DEV_MODE } from '../../scripts/scripts.js'; import { getIcon, buildNoAccessScreen, @@ -823,11 +823,8 @@ export default async function init(el) { ]); const profile = BlockMediator.get('imsProfile'); - const { search } = window.location; - const urlParams = new URLSearchParams(search); - const devMode = urlParams.get('devMode'); - if (devMode === 'true' && ['stage', 'local'].includes(MILO_CONFIG.env.name)) { + if (DEV_MODE === true && ['stage', 'local'].includes(MILO_CONFIG.env.name)) { buildECCForm(el).then(() => { el.classList.remove('loading'); }); diff --git a/ecc/components/filter-menu/filter-menu.css.js b/ecc/components/filter-menu/filter-menu.css.js new file mode 100644 index 00000000..a1a2beac --- /dev/null +++ b/ecc/components/filter-menu/filter-menu.css.js @@ -0,0 +1,20 @@ +import { LIBS } from '../../scripts/scripts.js'; + +const { css } = await import(`${LIBS}/deps/lit-all.min.js`); + +// eslint-disable-next-line import/prefer-default-export +export const style = css` + :host { + display: block; + position: relative; + margin-bottom: 1rem; + } + + sp-textfield { + width: 100%; + } + + sp-menu { + width: 190px; + } +`; diff --git a/ecc/components/filter-menu/filter-menu.js b/ecc/components/filter-menu/filter-menu.js new file mode 100644 index 00000000..e372cca8 --- /dev/null +++ b/ecc/components/filter-menu/filter-menu.js @@ -0,0 +1,74 @@ +/* eslint-disable import/prefer-default-export */ +/* eslint-disable max-len */ +import { LIBS } from '../../scripts/scripts.js'; +import { camelToSentenceCase } from '../../scripts/utils.js'; +import { style } from './filter-menu.css.js'; + +const { LitElement, html, repeat } = await import(`${LIBS}/deps/lit-all.min.js`); + +export class FilterMenu extends LitElement { + static styles = style; + + static properties = { + type: { type: String }, + items: { type: Array }, + selectedItems: { type: Array }, + }; + + constructor() { + super(); + this.displayValue = 'Select filters'; + this.selectedItems = []; + } + + selectItem(e) { + const selectedItems = e.target.value; + this.selectedItems = selectedItems ? selectedItems.split(',') : []; + + this.dispatchEvent(new CustomEvent('filter-change', { + detail: { + type: this.type, + value: this.selectedItems, + }, + })); + + const typeName = camelToSentenceCase(this.type).toLowerCase(); + const filterCount = this.selectedItems.length; + this.displayValue = this.selectedItems.length ? `${filterCount} ${filterCount === 1 ? `${typeName} filter` : `${typeName} filters`} selected` : 'Select filters'; + } + + scrollIntoViewIfNeeded(index) { + const menuItem = this.shadowRoot.querySelectorAll('sp-menu-item')[index]; + if (menuItem) { + menuItem.scrollIntoView({ block: 'nearest' }); + } + } + + render() { + return html` + + + + + ${repeat(this.items, (item) => html` + + ${item} + + `)} + + + + `; + } +} diff --git a/ecc/components/searchable-picker/searchable-picker.css.js b/ecc/components/searchable-picker/searchable-picker.css.js new file mode 100644 index 00000000..a05f159a --- /dev/null +++ b/ecc/components/searchable-picker/searchable-picker.css.js @@ -0,0 +1,39 @@ +import { LIBS } from '../../scripts/scripts.js'; + +const { css } = await import(`${LIBS}/deps/lit-all.min.js`); + +// eslint-disable-next-line import/prefer-default-export +export const style = css` + :host { + display: block; + position: relative; + } + + sp-textfield { + width: 100%; + } + + .menu-overlay { + position: absolute; + background: white; + border: 1px solid #ccc; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.15); + z-index: 10; + width: 100%; + max-height: 200px; + overflow-y: auto; + display: none; + } + + .menu-overlay.open { + display: block; + } + + sp-menu { + width: 100%; + } + + sp-menu-item.focused { + background-color: var(--highcontrast-menu-item-background-color-focus, var(--mod-menu-item-background-color-down, var(--spectrum-menu-item-background-color-down))); + } +`; diff --git a/ecc/components/searchable-picker/searchable-picker.js b/ecc/components/searchable-picker/searchable-picker.js new file mode 100644 index 00000000..83a43162 --- /dev/null +++ b/ecc/components/searchable-picker/searchable-picker.js @@ -0,0 +1,138 @@ +/* eslint-disable max-len */ +import { LIBS } from '../../scripts/scripts.js'; +import { style } from './searchable-picker.css.js'; + +const { LitElement, html, repeat } = await import(`${LIBS}/deps/lit-all.min.js`); + +// eslint-disable-next-line import/prefer-default-export +export class SearchablePicker extends LitElement { + static styles = style; + + static properties = { + items: { type: Array }, + filteredItems: { type: Array }, + displayValue: { type: String }, + value: { type: String }, + menuOpen: { type: Boolean }, + focusedIndex: { type: Number }, + label: { type: String }, + }; + + constructor() { + super(); + this.displayValue = this.displayValue || ''; + this.value = ''; + this.menuOpen = false; + this.focusedIndex = -1; + this.isClickInsideMenu = false; + this.label = this.label || ''; + } + + handleInput(event) { + const filterValue = event.target.value.toLowerCase(); + this.filteredItems = this.items.filter((i) => i.label.toLowerCase().includes(filterValue)); + this.menuOpen = this.filteredItems.length > 0; + this.focusedIndex = this.menuOpen ? 0 : -1; + } + + handleFocus() { + this.menuOpen = true; + } + + handleUnfocus() { + if (!this.isClickInsideMenu) { + this.menuOpen = false; + } + this.isClickInsideMenu = false; + } + + handleItemClick(event) { + const selectedItem = event.target; + + this.dispatchEvent(new CustomEvent('picker-change', { + detail: { + value: selectedItem.value, + label: selectedItem.textContent.trim(), + }, + })); + this.value = selectedItem.value; + this.displayValue = selectedItem.textContent.trim(); + this.menuOpen = false; + } + + handleMouseDownOnItem() { + this.isClickInsideMenu = true; + } + + handleKeyDown(event) { + if (!this.menuOpen) return; + + switch (event.key) { + case 'ArrowDown': + this.focusedIndex = (this.focusedIndex + 1) % this.filteredItems.length; + this.scrollIntoViewIfNeeded(this.focusedIndex); + break; + case 'ArrowUp': + this.focusedIndex = (this.focusedIndex - 1 + this.filteredItems.length) % this.filteredItems.length; + this.scrollIntoViewIfNeeded(this.focusedIndex); + break; + case 'Enter': + if (this.focusedIndex > -1) { + this.selectItem(this.filteredItems[this.focusedIndex]); + } + break; + case 'Escape': + this.menuOpen = false; + break; + default: + break; + } + } + + selectItem(item) { + this.dispatchEvent(new CustomEvent('picker-change', { + detail: { + value: item.value, + label: item.label, + }, + })); + this.value = item.value; + this.displayValue = item.label; + this.menuOpen = false; + } + + scrollIntoViewIfNeeded(index) { + const menuItem = this.shadowRoot.querySelectorAll('sp-menu-item')[index]; + if (menuItem) { + menuItem.scrollIntoView({ block: 'nearest' }); + } + } + + render() { + return html` + + + `; + } +} diff --git a/ecc/scripts/esp-controller.js b/ecc/scripts/esp-controller.js index 30c1b6eb..dbc25b44 100644 --- a/ecc/scripts/esp-controller.js +++ b/ecc/scripts/esp-controller.js @@ -790,7 +790,7 @@ export async function deleteAttendee(eventId, attendeeId) { } } -export async function getAttendees(eventId) { +export async function getEventAttendees(eventId) { if (!eventId) return false; const { host } = getAPIConfig().esp[ECC_ENV]; @@ -812,6 +812,37 @@ export async function getAttendees(eventId) { } } +export async function getAllEventAttendees(eventId) { + const recurGetAttendees = async (fullAttendeeArr = [], nextPageToken = null) => { + const { host } = getAPIConfig().esp[ECC_ENV]; + const options = await constructRequestOptions('GET'); + const fetchUrl = nextPageToken ? `${host}/v1/events/${eventId}/attendees?nextPageToken=${nextPageToken}` : `${host}/v1/events/${eventId}/attendees`; + + return fetch(fetchUrl, options) + .then((response) => { + if (!response.ok) { + window.lana?.log(`Failed to fetch attendees for event ${eventId}. Status:`, response.status); + return { ok: response.ok, status: response.status, error: response.statusText }; + } + + return response.json(); + }) + .then((data) => { + if (data.nextPageToken) { + return recurGetAttendees(fullAttendeeArr.concat(data.attendees), data.nextPageToken); + } + + return fullAttendeeArr.concat(data.attendees || []); + }) + .catch((error) => { + window.lana?.log(`Failed to fetch attendees for event ${eventId}. Error:`, error); + return { ok: false, status: 'Network Error', error: error.message }; + }); + }; + + return recurGetAttendees(); +} + export async function getAttendee(eventId, attendeeId) { if (!eventId || !attendeeId) return false; diff --git a/ecc/scripts/scripts.js b/ecc/scripts/scripts.js index 021322a4..e8b8866e 100644 --- a/ecc/scripts/scripts.js +++ b/ecc/scripts/scripts.js @@ -167,6 +167,7 @@ export const BlockMediator = await import('./deps/block-mediator.min.js').then(( const { loadArea, setConfig, loadLana } = await import(`${LIBS}/utils/utils.js`); export const MILO_CONFIG = setConfig({ ...CONFIG, miloLibs: LIBS }); export const ECC_ENV = getECCEnv(MILO_CONFIG); +export const DEV_MODE = new URLSearchParams(window.location.search).has('devMode'); (async function loadPage() { await loadLana({ clientId: 'ecc-milo' }); diff --git a/ecc/scripts/utils.js b/ecc/scripts/utils.js index 5ba58ece..a52c60f4 100644 --- a/ecc/scripts/utils.js +++ b/ecc/scripts/utils.js @@ -226,6 +226,41 @@ export function getServiceName(link) { return url.hostname.replace('.com', '').replace('www.', ''); } +export function toClassName(name) { + return name && typeof name === 'string' + ? name.toLowerCase().replace(/[^0-9a-z]/gi, '-') + : ''; +} + +export function readBlockConfig(block) { + return [...block.querySelectorAll(':scope>div')].reduce((config, row) => { + if (row.children) { + const cols = [...row.children]; + if (cols[1]) { + const valueEl = cols[1]; + const name = toClassName(cols[0].textContent); + if (valueEl.querySelector('a')) { + const aArr = [...valueEl.querySelectorAll('a')]; + if (aArr.length === 1) { + config[name] = aArr[0].href; + } else { + config[name] = aArr.map((a) => a.href); + } + } else if (valueEl.querySelector('p')) { + const pArr = [...valueEl.querySelectorAll('p')]; + if (pArr.length === 1) { + config[name] = pArr[0].innerHTML; + } else { + config[name] = pArr.map((p) => p.innerHTML); + } + } else config[name] = row.children[1].innerHTML; + } + } + + return config; + }, {}); +} + export const fetchThrottledMemoizedText = (() => { const cache = new Map(); const pending = new Map();