feat(agenda): various agenda improvements and fixes (#4613)

* chore: update dependencies + add jsconfig

* fix(agenda): handle localStorage being disabled

* feat: agenda share modal

* feat: agenda tour

* feat: agenda share filters + picked sessions + fixes

* test: fix agenda tests

* test: add agenda share dialog test

* test: remove agenda only flag
This commit is contained in:
Nicolas Giard 2022-10-21 17:04:32 -04:00 committed by GitHub
parent d08815d8da
commit 395f110df2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
93 changed files with 1287 additions and 788 deletions

View file

@ -34,7 +34,7 @@ indent_size = 2
[dev/**.js]
indent_size = 2
[{package.json,.eslintrc.js,.yarnrc.yml,vite.config.js,cypress.config.js}]
[{package.json,.eslintrc.js,.yarnrc.yml,vite.config.js,jsconfig.json}]
indent_size = 2
# Settings for cypress tests

847
.pnp.cjs generated

File diff suppressed because it is too large Load diff

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -13,13 +13,28 @@
.agenda-topnav.my-3
meeting-navigation
n-button.d-none.d-sm-flex(
quaternary
@click='toggleSettings'
)
template(#icon)
i.bi.bi-gear
span Settings
.agenda-topnav-right.d-none.d-md-flex
n-button(
quaternary
@click='startTour'
)
template(#icon)
i.bi.bi-question-square
span Help
n-button(
quaternary
@click='toggleShare'
)
template(#icon)
i.bi.bi-share
span Share
n-button(
quaternary
@click='toggleSettings'
)
template(#icon)
i.bi.bi-gear
span Settings
.row
.col
@ -137,6 +152,7 @@
agenda-quick-access
agenda-mobile-bar
agenda-share-modal(v-model:shown='state.shareModalShown')
</template>
<script setup>
@ -159,10 +175,12 @@ import AgendaScheduleList from './AgendaScheduleList.vue'
import AgendaScheduleCalendar from './AgendaScheduleCalendar.vue'
import AgendaQuickAccess from './AgendaQuickAccess.vue'
import AgendaSettings from './AgendaSettings.vue'
import AgendaShareModal from './AgendaShareModal.vue'
import AgendaMobileBar from './AgendaMobileBar.vue'
import MeetingNavigation from './MeetingNavigation.vue'
import timezones from '../shared/timezones'
import { initTour } from './tour'
import { useAgendaStore } from './store'
import { useSiteStore } from '../shared/store'
@ -187,6 +205,7 @@ const route = useRoute()
const state = reactive({
searchText: '',
shareModalShown: false
})
// REFS
@ -219,8 +238,19 @@ watch(() => agendaStore.meetingDays, () => {
})
watch(() => agendaStore.isLoaded, () => {
let resetQuery = false
if (route.query.filters) {
// Handle ?filters= parameter
const keywords = route.query.filters.split(',').map(k => k.trim()).filter(k => !!k)
if (keywords?.length > 0) {
agendaStore.$patch({
selectedCatSubs: keywords
})
}
resetQuery = true
}
if (route.query.show) {
// Handle legacy ?show= parameter
// Handle ?show= parameter
const keywords = route.query.show.split(',').map(k => k.trim()).filter(k => !!k)
if (keywords?.length > 0) {
const pickedIds = []
@ -235,13 +265,23 @@ watch(() => agendaStore.isLoaded, () => {
pickerModeView: true,
pickedEvents: pickedIds
})
agendaStore.persistMeetingPreferences()
}
}
resetQuery = true
}
if (route.query.pick) {
// Handle legacy /personalize path (open picker mode)
agendaStore.$patch({ pickerMode: true })
resetQuery = true
}
if (route.query.tz) {
// Handle tz param
agendaStore.$patch({ timezone: route.query.tz })
resetQuery = true
}
if (resetQuery) {
agendaStore.persistMeetingPreferences()
router.replace({ query: null })
}
@ -313,6 +353,18 @@ function toggleSettings () {
})
}
function toggleShare () {
state.shareModalShown = !state.shareModalShown
}
function startTour () {
const tour = initTour({
mobileMode: siteStore.viewport < 990,
pickerMode: agendaStore.pickerMode
})
tour.start()
}
// -> Go to current meeting if not provided
function handleCurrentMeetingRedirect () {
if (!route.params.meetingNumber && agendaStore.meeting.number) {
@ -394,15 +446,6 @@ onMounted(() => {
}
})
// CREATED
// -> Handle loading tab directly based on URL
if (window.location.pathname.indexOf('-utc') >= 0) {
agendaStore.$patch({ timezone: 'UTC' })
} else if (window.location.pathname.indexOf('personalize') >= 0) {
// state.currentTab = 'personalize'
}
</script>
<style lang="scss">
@ -421,18 +464,25 @@ if (window.location.pathname.indexOf('-utc') >= 0) {
&-topnav {
position: relative;
> button {
&-right {
position: absolute;
top: 5px;
right: 0;
display: flex;
.bi {
transition: transform 1s ease;
button + button {
margin-left: 5px;
}
&:hover {
> button:last-child {
.bi {
transform: rotate(180deg);
transition: transform 1s ease;
}
&:hover {
.bi {
transform: rotate(180deg);
}
}
}
}

View file

@ -64,7 +64,7 @@ n-drawer(v-model:show='state.isShown', placement='bottom', :height='state.drawer
</template>
<script setup>
import { reactive, ref, unref, watch } from 'vue'
import { nextTick, reactive, ref, unref, watch } from 'vue'
import intersection from 'lodash/intersection'
import difference from 'lodash/difference'
import union from 'lodash/union'
@ -113,8 +113,15 @@ function cancelFilter () {
}
function saveFilter () {
agendaStore.$patch({ selectedCatSubs: state.pendingSelection })
state.isShown = false
const applyLoadingMsg = message.create('Applying filters...', { type: 'loading', duration: 0 })
setTimeout(() => {
agendaStore.$patch({ selectedCatSubs: state.pendingSelection })
agendaStore.persistMeetingPreferences()
state.isShown = false
nextTick(() => {
applyLoadingMsg.destroy()
})
}, 500)
}
function clearFilter () {

View file

@ -0,0 +1,173 @@
<template lang="pug">
n-modal(v-model:show='modalShown')
n-card.agenda-share(
:bordered='false'
segmented
role='dialog'
aria-modal='true'
)
template(#header-extra)
.agenda-share-header
n-button.ms-4.agenda-share-close(
ghost
color='gray'
strong
@click='modalShown = false'
)
i.bi.bi-x
template(#header)
.agenda-share-header
i.bi.bi-share
span Share this view
.agenda-share-content
.text-muted.pb-2 Use the following URL for sharing the current view #[em (including any active filters)] with other users:
n-input-group
n-input(
ref='filteredUrlIpt'
size='large'
readonly
v-model:value='state.filteredUrl'
)
n-button(
type='primary'
primary
strong
size='large'
@click='copyFilteredUrl'
)
template(#icon)
i.bi.bi-clipboard-check.me-1
span Copy
</template>
<script setup>
import { computed, reactive, ref, watch } from 'vue'
import { find } from 'lodash-es'
import {
NButton,
NCard,
NModal,
NInputGroup,
NInput,
useMessage
} from 'naive-ui'
import { useAgendaStore } from './store'
// PROPS
const props = defineProps({
shown: {
type: Boolean,
required: true,
default: false
}
})
// MESSAGE PROVIDER
const message = useMessage()
// STORES
const agendaStore = useAgendaStore()
// EMIT
const emit = defineEmits(['update:shown'])
// STATE
const state = reactive({
isLoading: false,
filteredUrl: window.location.href
})
const filteredUrlIpt = ref(null)
// COMPUTED
const modalShown = computed({
get () {
return props.shown
},
set(value) {
emit('update:shown', value)
}
})
// WATCHERS
watch(() => props.shown, (newValue) => {
if (newValue) {
generateUrl()
}
})
// METHODS
function generateUrl () {
const newUrl = new URL(window.location.href)
const queryParams = []
if (agendaStore.selectedCatSubs.length > 0 ) {
queryParams.push(`filters=${agendaStore.selectedCatSubs.join(',')}`)
}
if (agendaStore.pickerMode && agendaStore.pickedEvents.length > 0 ) {
const kwds = []
for (const id of agendaStore.pickedEvents) {
const session = find(agendaStore.scheduleAdjusted, ['id', id])
if (session) {
const suffix = session.sessionToken ? `-${session.sessionToken}` : ''
kwds.push(`${session.acronym}${suffix}`)
}
}
queryParams.push(`show=${kwds.join(',')}`)
}
newUrl.search = queryParams.length > 0 ? `?${queryParams.join('&')}` : ''
state.filteredUrl = newUrl.toString()
}
async function copyFilteredUrl () {
filteredUrlIpt.value?.select()
try {
if (navigator.clipboard) {
await navigator.clipboard.writeText(state.filteredUrl)
} else {
if (!document.execCommand('copy')) {
throw new Error('Copy failed')
}
}
message.success('URL copied to clipboard successfully.')
} catch (err) {
message.error('Failed to copy URL to clipboard.')
}
}
</script>
<style lang="scss">
@import "bootstrap/scss/functions";
@import "bootstrap/scss/variables";
.agenda-share {
width: 90vw;
max-width: 1000px;
&-header {
font-size: 20px;
display: flex;
align-items: center;
> .bi {
margin-right: 12px;
font-size: 20px;
color: $indigo;
}
}
&-close .bi {
font-size: 20px;
color: inherit;
}
}
</style>

View file

@ -4,6 +4,7 @@ import uniqBy from 'lodash/uniqBy'
import murmur from 'murmurhash-js/murmurhash3_gc'
import { useSiteStore } from '../shared/store'
import { storageAvailable } from '../shared/feature-detect'
const urlRe = /http[s]?:\/\/(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\(\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+/
const conferenceDomains = ['webex.com', 'zoom.us', 'jitsi.org', 'meetecho.com', 'gather.town']
@ -147,7 +148,11 @@ export const useAgendaStore = defineStore('agenda', {
const agendaData = await resp.json()
// -> Switch to meeting timezone
this.timezone = window.localStorage.getItem(`agenda.${agendaData.meeting.number}.timezone`) || agendaData.meeting.timezone
if (storageAvailable('localStorage')) {
this.timezone = window.localStorage.getItem(`agenda.${agendaData.meeting.number}.timezone`) || agendaData.meeting.timezone
} else {
this.timezone = agendaData.meeting.timezone
}
// -> Load meeting data
this.categories = agendaData.categories
@ -161,9 +166,17 @@ export const useAgendaStore = defineStore('agenda', {
this.infoNoteHash = murmur(agendaData.meeting.infoNote, 0).toString()
// -> Load meeting-specific preferences
this.infoNoteShown = !(window.localStorage.getItem(`agenda.${agendaData.meeting.number}.hideInfo`) === this.infoNoteHash)
this.colorAssignments = JSON.parse(window.localStorage.getItem(`agenda.${agendaData.meeting.number}.colorAssignments`) || '{}')
this.pickedEvents = JSON.parse(window.localStorage.getItem(`agenda.${agendaData.meeting.number}.pickedEvents`) || '[]')
if (storageAvailable('localStorage')) {
this.infoNoteShown = !(window.localStorage.getItem(`agenda.${agendaData.meeting.number}.hideInfo`) === this.infoNoteHash)
this.colorAssignments = JSON.parse(window.localStorage.getItem(`agenda.${agendaData.meeting.number}.colorAssignments`) || '{}')
this.selectedCatSubs = JSON.parse(window.localStorage.getItem(`agenda.${agendaData.meeting.number}.filters`) || '[]')
this.pickedEvents = JSON.parse(window.localStorage.getItem(`agenda.${agendaData.meeting.number}.pickedEvents`) || '[]')
} else {
this.infoNoteShown = true
this.colorAssignments = {}
this.selectedCatSubs = []
this.pickedEvents = []
}
this.isLoaded = true
} catch (err) {
@ -177,12 +190,15 @@ export const useAgendaStore = defineStore('agenda', {
this.hideLoadingScreen()
},
persistMeetingPreferences () {
if (!storageAvailable('localStorage')) { return }
if (this.infoNoteShown) {
window.localStorage.removeItem(`agenda.${this.meeting.number}.hideInfo`)
} else {
window.localStorage.setItem(`agenda.${this.meeting.number}.hideInfo`, this.infoNoteHash)
}
window.localStorage.setItem(`agenda.${this.meeting.number}.colorAssignments`, JSON.stringify(this.colorAssignments))
window.localStorage.setItem(`agenda.${this.meeting.number}.filters`, JSON.stringify(this.selectedCatSubs))
window.localStorage.setItem(`agenda.${this.meeting.number}.pickedEvents`, JSON.stringify(this.pickedEvents))
window.localStorage.setItem(`agenda.${this.meeting.number}.timezone`, this.timezone)
},
@ -221,10 +237,10 @@ export const useAgendaStore = defineStore('agenda', {
}
},
persist: {
enabled: true,
enabled: storageAvailable('localStorage'),
strategies: [
{
storage: localStorage,
storage: storageAvailable('localStorage') ? localStorage : null,
paths: [
'areaIndicatorsShown',
'bolderText',

113
client/agenda/tour.js Normal file
View file

@ -0,0 +1,113 @@
import Shepherd from 'shepherd.js'
import 'shepherd.js/dist/css/shepherd.css'
export function initTour ({ mobileMode, pickerMode }) {
const tour = new Shepherd.Tour({
useModalOverlay: true,
defaultStepOptions: {
classes: 'shepherd-theme-custom',
scrollTo: false,
modalOverlayOpeningPadding: 8,
modalOverlayOpeningRadius: 4,
popperOptions: {
modifiers: [
{
name: 'offset',
options: {
offset: [0,20]
}
}
]
}
}
})
const defaultButtons = [
{
text: 'Exit',
action: tour.cancel,
secondary: true
},
{
text: 'Next',
action: tour.next
}
]
// STEPS
tour.addSteps([
{
title: 'Filter Areas + Groups',
text: 'You can filter the list of sessions by areas or working groups you\'re interested in. The filters you select here also apply to the <strong>Calendar View</strong> and persist even if you come back to this page later.',
attachTo: {
element: mobileMode ? '.agenda-mobile-bar > button:first-child' : '#agenda-quickaccess-filterbyareagroups-btn',
on: mobileMode ? 'top' : 'left'
},
buttons: defaultButtons
},
{
title: 'Pick Sessions',
text: 'Alternatively select <strong>individual sessions</strong> from the list to build your own schedule.',
attachTo: {
element: pickerMode ? '.agenda-quickaccess-btnrow' : '#agenda-quickaccess-picksessions-btn',
on: 'left'
},
buttons: defaultButtons,
showOn: () => !mobileMode
},
{
title: 'Calendar View',
text: 'View the current list of sessions in a <strong>calendar view</strong>, by week or by day. The filters you selected above also apply in this view.',
attachTo: {
element: mobileMode ? '.agenda-mobile-bar > button:nth-child(2)' : '#agenda-quickaccess-calview-btn',
on: mobileMode ? 'top' : 'left'
},
buttons: defaultButtons
},
{
title: 'Add to your calendar',
text: 'Add the current list of sessions to your personal calendar application, in either <strong>webcal</strong> or <strong>ics</strong> format.',
attachTo: {
element: mobileMode ? '.agenda-mobile-bar > button:nth-child(3)' : '#agenda-quickaccess-addtocal-btn',
on: mobileMode ? 'top' : 'left'
},
buttons: defaultButtons
},
{
title: 'Search Events',
text: 'Filter the list of sessions by searching for <strong>specific keywords</strong> in the title, location, acronym, notes or group name. Click the button again to close the search and discard its filtering.',
attachTo: {
element: '.agenda-table-search',
on: 'top'
},
buttons: defaultButtons
},
{
title: 'Assign Colors to Events',
text: 'Assign colors to individual events to keep track of those you find interesting, wish to attend or define your own colors / descriptions from the <strong>Settings</strong> panel.',
attachTo: {
element: '.agenda-table-colorpicker',
on: 'top'
},
buttons: defaultButtons
},
{
title: 'Sessions',
text: 'View the session materials by either clicking on its title or using the <strong>Show meeting materials</strong> button on the right. You can locate the room holding this event on the floor plan by clicking on the location name.',
attachTo: {
element: () => document.querySelector('.agenda-table-display-event'),
on: 'top'
},
buttons: [
{
text: 'Finish',
action: tour.next
}
],
modalOverlayOpeningPadding: 0,
modalOverlayOpeningRadius: 2
}
])
return tour
}

View file

@ -29,6 +29,12 @@ export default createRouter({
return { name: 'agenda' }
}
},
{
path: '/meeting/:meetingNumber(\\d+)?/agenda-utc',
redirect: to => {
return { name: 'agenda', query: { ...to.query, tz: 'UTC' } }
}
},
{
path: '/meeting/:meetingNumber(\\d+)?/agenda/personalize',
redirect: to => {

View file

@ -0,0 +1,19 @@
const cache = {}
export function storageAvailable(type) {
if (Object.prototype.hasOwnProperty.call(cache, type)) {
return cache[type]
}
try {
let storage = window[type]
const x = '__storage_test__'
storage.setItem(x, x)
storage.removeItem(x)
cache[type] = true
return true
}
catch (e) {
cache[type] = false
return false
}
}

21
jsconfig.json Normal file
View file

@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES6",
},
"exclude": [
"node_modules",
".yarn",
".vite"
],
"include": [
"client/**/*",
"playwright/**/*"
],
"vueCompilerOptions": {
"target": 3,
"plugins": [
"@volar/vue-language-plugin-pug"
]
}
}

View file

@ -24,8 +24,8 @@
"@popperjs/core": "2.11.6",
"bootstrap": "5.2.2",
"bootstrap-icons": "1.9.1",
"browser-fs-access": "0.31.0",
"caniuse-lite": "1.0.30001414",
"browser-fs-access": "0.31.1",
"caniuse-lite": "1.0.30001420",
"d3": "7.6.1",
"file-saver": "2.0.5",
"highcharts": "10.2.1",
@ -37,47 +37,48 @@
"lodash-es": "4.17.21",
"luxon": "3.0.4",
"moment": "2.29.4",
"moment-timezone": "0.5.37",
"moment-timezone": "0.5.38",
"ms": "2.1.3",
"murmurhash-js": "1.0.0",
"naive-ui": "2.33.3",
"pinia": "2.0.22",
"naive-ui": "2.33.5",
"pinia": "2.0.23",
"pinia-plugin-persist": "1.0.0",
"select2": "4.1.0-rc.0",
"select2-bootstrap-5-theme": "1.3.0",
"send": "0.18.0",
"shepherd.js": "10.0.1",
"slugify": "1.6.5",
"sortablejs": "1.15.0",
"vue": "3.2.40",
"vue": "3.2.41",
"vue-router": "4.1.5",
"zxcvbn": "4.4.2"
},
"devDependencies": {
"@faker-js/faker": "7.5.0",
"@faker-js/faker": "7.6.0",
"@parcel/transformer-sass": "2.7.0",
"@percy/cli": "1.10.4",
"@percy/cli": "1.11.0",
"@percy/cypress": "3.1.2",
"@vitejs/plugin-vue": "2.3.4",
"@vitejs/plugin-vue": "3.1.2",
"@vue/test-utils": "2.1.0",
"browserlist": "latest",
"c8": "7.12.0",
"cypress": "10.9.0",
"cypress-real-events": "1.7.1",
"eslint": "8.24.0",
"eslint": "8.25.0",
"eslint-config-standard": "17.0.0",
"eslint-plugin-cypress": "2.12.1",
"eslint-plugin-import": "2.26.0",
"eslint-plugin-n": "15.3.0",
"eslint-plugin-node": "11.1.0",
"eslint-plugin-promise": "6.0.1",
"eslint-plugin-vue": "9.5.1",
"html-validate": "7.5.0",
"eslint-plugin-promise": "6.1.0",
"eslint-plugin-vue": "9.6.0",
"html-validate": "7.6.0",
"jquery-migrate": "3.4.0",
"parcel": "2.7.0",
"pug": "3.0.2",
"sass": "1.55.0",
"seedrandom": "3.0.5",
"vite": "2.9.15"
"vite": "3.1.8"
},
"targets": {
"ietf": {

View file

@ -81,9 +81,15 @@ test.describe('past - desktop', () => {
await expect(navLocator.last()).toContainText('Plaintext')
})
// SETTINGS BUTTON
// RIGHT-SIDE BUTTONS
await expect(page.locator('.agenda .meeting-nav + button')).toContainText('Settings')
await test.step('has the correct right side buttons', async () => {
const btnsLocator = page.locator('.agenda .agenda-topnav-right > button')
await expect(btnsLocator).toHaveCount(3)
await expect(btnsLocator.first()).toContainText('Help')
await expect(btnsLocator.nth(1)).toContainText('Share')
await expect(btnsLocator.last()).toContainText('Settings')
})
})
test('agenda schedule list header', async ({ page }) => {
@ -773,7 +779,7 @@ test.describe('past - desktop', () => {
test('agenda settings', async ({ page, browserName }) => {
// Open dialog
await page.locator('.meeting-nav + button').click()
await page.locator('.agenda-topnav-right > button:last-child').click()
await expect(page.locator('.agenda-settings')).toBeVisible()
// Check header elements
await expect(page.locator('.agenda-settings .n-drawer-header__main > span')).toContainText('Agenda Settings')
@ -898,6 +904,22 @@ test.describe('past - desktop', () => {
await expect(page.locator('.agenda-settings')).not.toBeVisible()
})
// -> SHARE DIALOG
test('agenda share dialog', async ({ page }) => {
// Open dialog
await page.locator('.agenda-topnav-right > button:nth-child(2)').click()
await expect(page.locator('.agenda-share')).toBeVisible()
// Check header elements
await expect(page.locator('.agenda-share .n-card-header__main > .agenda-share-header > .bi')).toBeVisible()
await expect(page.locator('.agenda-share .n-card-header__main > .agenda-share-header > .bi + span')).toContainText('Share this view')
// Check input URL
await expect(page.locator('.agenda-share .agenda-share-content input[type=text]')).toHaveValue(`http://localhost:3000/meeting/${meetingData.meeting.number}/agenda`)
// Clicking X should close the dialog
await page.locator('.agenda-share .n-card-header__extra > .agenda-share-header > button').click()
await expect(page.locator('.agenda-share')).not.toBeVisible()
})
// -> ADD TO CALENDAR
test('agenda add to calendar', async ({ page }) => {
@ -1012,7 +1034,7 @@ test.describe('past - desktop', () => {
// Change color legend from settings dialog
// ----------------------------------------
// Open dialog
await page.locator('.meeting-nav + button').click()
await page.locator('.agenda-topnav-right > button:last-child').click()
await expect(page.locator('.agenda-settings')).toBeVisible()
// Toggle color legend switch
await colorLgdSwitchLocator.click()
@ -1051,7 +1073,7 @@ test.describe('past - desktop', () => {
// No colored dots should appear
await expect(page.locator('.agenda .agenda-table-display-event .agenda-table-colorindicator')).toHaveCount(0)
// Clear all colors from Settings menu
await page.locator('.meeting-nav + button').click()
await page.locator('.agenda-topnav-right > button:last-child').click()
await expect(page.locator('.agenda-settings')).toBeVisible()
await page.locator('.agenda-settings .agenda-settings-actions > button').first().click()
await page.locator('.n-dropdown-option:has-text("Clear Color")').click()
@ -1295,7 +1317,7 @@ test.describe('live - desktop', () => {
test('live red line toggle', async ({ page }) => {
// Open settings dialog
await page.locator('.meeting-nav + button').click()
await page.locator('.agenda-topnav-right > button:last-child').click()
await expect(page.locator('.agenda-settings')).toBeVisible()
// Toggle red line switch
const redlineSwitchLocator = page.locator('#agenda-settings-tgl-redline div[role=switch]')

694
yarn.lock

File diff suppressed because it is too large Load diff