Merge pull request #4638 from ietf-tools/main
chore: merge main into feat/tzaware
This commit is contained in:
commit
d55280d0b1
|
@ -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
|
||||
|
|
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.
BIN
.yarn/cache/@percy-cli-upload-npm-1.11.0-329a9874d2-7b01f7f67a.zip
vendored
Normal file
BIN
.yarn/cache/@percy-cli-upload-npm-1.11.0-329a9874d2-7b01f7f67a.zip
vendored
Normal file
Binary file not shown.
Binary file not shown.
BIN
.yarn/cache/@percy-client-npm-1.11.0-054cbe67f8-2dcb47642a.zip
vendored
Normal file
BIN
.yarn/cache/@percy-client-npm-1.11.0-054cbe67f8-2dcb47642a.zip
vendored
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
.yarn/cache/@percy-core-npm-1.11.0-6db118514e-e590983952.zip
vendored
Normal file
BIN
.yarn/cache/@percy-core-npm-1.11.0-6db118514e-e590983952.zip
vendored
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
.yarn/cache/@vitejs-plugin-vue-npm-3.1.2-f3b2868971-1da84ccfc8.zip
vendored
Normal file
BIN
.yarn/cache/@vitejs-plugin-vue-npm-3.1.2-f3b2868971-1da84ccfc8.zip
vendored
Normal file
Binary file not shown.
Binary file not shown.
BIN
.yarn/cache/@vue-compiler-core-npm-3.2.41-8f70d0e934-ff794351be.zip
vendored
Normal file
BIN
.yarn/cache/@vue-compiler-core-npm-3.2.41-8f70d0e934-ff794351be.zip
vendored
Normal file
Binary file not shown.
Binary file not shown.
BIN
.yarn/cache/@vue-compiler-dom-npm-3.2.41-1c0e991507-463f73d935.zip
vendored
Normal file
BIN
.yarn/cache/@vue-compiler-dom-npm-3.2.41-1c0e991507-463f73d935.zip
vendored
Normal file
Binary file not shown.
Binary file not shown.
BIN
.yarn/cache/@vue-compiler-sfc-npm-3.2.41-a5a9a4917f-0f13d9fa32.zip
vendored
Normal file
BIN
.yarn/cache/@vue-compiler-sfc-npm-3.2.41-a5a9a4917f-0f13d9fa32.zip
vendored
Normal file
Binary file not shown.
Binary file not shown.
BIN
.yarn/cache/@vue-compiler-ssr-npm-3.2.41-d33d233099-119913dee2.zip
vendored
Normal file
BIN
.yarn/cache/@vue-compiler-ssr-npm-3.2.41-d33d233099-119913dee2.zip
vendored
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
.yarn/cache/@vue-reactivity-npm-3.2.41-facca3f9eb-3cac74db33.zip
vendored
Normal file
BIN
.yarn/cache/@vue-reactivity-npm-3.2.41-facca3f9eb-3cac74db33.zip
vendored
Normal file
Binary file not shown.
Binary file not shown.
BIN
.yarn/cache/@vue-reactivity-transform-npm-3.2.41-c0c4b830b1-f4a1d3ea62.zip
vendored
Normal file
BIN
.yarn/cache/@vue-reactivity-transform-npm-3.2.41-c0c4b830b1-f4a1d3ea62.zip
vendored
Normal file
Binary file not shown.
Binary file not shown.
BIN
.yarn/cache/@vue-runtime-core-npm-3.2.41-ac541c4be6-d7f81d0353.zip
vendored
Normal file
BIN
.yarn/cache/@vue-runtime-core-npm-3.2.41-ac541c4be6-d7f81d0353.zip
vendored
Normal file
Binary file not shown.
Binary file not shown.
BIN
.yarn/cache/@vue-runtime-dom-npm-3.2.41-abd55753cf-3bb4c586f5.zip
vendored
Normal file
BIN
.yarn/cache/@vue-runtime-dom-npm-3.2.41-abd55753cf-3bb4c586f5.zip
vendored
Normal file
Binary file not shown.
Binary file not shown.
BIN
.yarn/cache/@vue-server-renderer-npm-3.2.41-b23e1cfd6b-34ff395947.zip
vendored
Normal file
BIN
.yarn/cache/@vue-server-renderer-npm-3.2.41-b23e1cfd6b-34ff395947.zip
vendored
Normal file
Binary file not shown.
Binary file not shown.
BIN
.yarn/cache/@vue-shared-npm-3.2.41-ff2415965e-48f13e3eef.zip
vendored
Normal file
BIN
.yarn/cache/@vue-shared-npm-3.2.41-ff2415965e-48f13e3eef.zip
vendored
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
.yarn/cache/caniuse-lite-npm-1.0.30001420-f322909669-dfa5027b2a.zip
vendored
Normal file
BIN
.yarn/cache/caniuse-lite-npm-1.0.30001420-f322909669-dfa5027b2a.zip
vendored
Normal file
Binary file not shown.
Binary file not shown.
BIN
.yarn/cache/esbuild-darwin-64-npm-0.15.11-0ccb211fdf-8.zip
vendored
Normal file
BIN
.yarn/cache/esbuild-darwin-64-npm-0.15.11-0ccb211fdf-8.zip
vendored
Normal file
Binary file not shown.
Binary file not shown.
BIN
.yarn/cache/esbuild-darwin-arm64-npm-0.15.11-cbb0a8549f-8.zip
vendored
Normal file
BIN
.yarn/cache/esbuild-darwin-arm64-npm-0.15.11-cbb0a8549f-8.zip
vendored
Normal file
Binary file not shown.
Binary file not shown.
BIN
.yarn/cache/esbuild-linux-64-npm-0.15.11-fd176c9400-8.zip
vendored
Normal file
BIN
.yarn/cache/esbuild-linux-64-npm-0.15.11-fd176c9400-8.zip
vendored
Normal file
Binary file not shown.
Binary file not shown.
BIN
.yarn/cache/esbuild-linux-arm64-npm-0.15.11-eb05503e3f-8.zip
vendored
Normal file
BIN
.yarn/cache/esbuild-linux-arm64-npm-0.15.11-eb05503e3f-8.zip
vendored
Normal file
Binary file not shown.
Binary file not shown.
BIN
.yarn/cache/esbuild-npm-0.15.11-352cc4ec35-afe5f2e6fb.zip
vendored
Normal file
BIN
.yarn/cache/esbuild-npm-0.15.11-352cc4ec35-afe5f2e6fb.zip
vendored
Normal file
Binary file not shown.
Binary file not shown.
BIN
.yarn/cache/esbuild-windows-64-npm-0.15.11-a6a42a35c8-8.zip
vendored
Normal file
BIN
.yarn/cache/esbuild-windows-64-npm-0.15.11-a6a42a35c8-8.zip
vendored
Normal file
Binary file not shown.
Binary file not shown.
BIN
.yarn/cache/esbuild-windows-arm64-npm-0.15.11-d36b5e4f06-8.zip
vendored
Normal file
BIN
.yarn/cache/esbuild-windows-arm64-npm-0.15.11-d36b5e4f06-8.zip
vendored
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
.yarn/cache/eslint-plugin-promise-npm-6.1.0-fbc1a09f9f-01c55f6c4d.zip
vendored
Normal file
BIN
.yarn/cache/eslint-plugin-promise-npm-6.1.0-fbc1a09f9f-01c55f6c4d.zip
vendored
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
.yarn/cache/html-validate-npm-7.6.0-c88dfc80a4-3e7ba99186.zip
vendored
Normal file
BIN
.yarn/cache/html-validate-npm-7.6.0-c88dfc80a4-3e7ba99186.zip
vendored
Normal file
Binary file not shown.
Binary file not shown.
BIN
.yarn/cache/moment-timezone-npm-0.5.38-6d3ab18886-ff7077de41.zip
vendored
Normal file
BIN
.yarn/cache/moment-timezone-npm-0.5.38-6d3ab18886-ff7077de41.zip
vendored
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
.yarn/cache/pinia-npm-2.0.23-17bda5a8d2-004c76d80b.zip
vendored
Normal file
BIN
.yarn/cache/pinia-npm-2.0.23-17bda5a8d2-004c76d80b.zip
vendored
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
.yarn/cache/rollup-npm-2.78.1-25ffe2a567-9034814383.zip
vendored
Normal file
BIN
.yarn/cache/rollup-npm-2.78.1-25ffe2a567-9034814383.zip
vendored
Normal file
Binary file not shown.
BIN
.yarn/cache/shepherd.js-npm-10.0.1-64acc35968-be51f42734.zip
vendored
Normal file
BIN
.yarn/cache/shepherd.js-npm-10.0.1-64acc35968-be51f42734.zip
vendored
Normal file
Binary file not shown.
BIN
.yarn/cache/smoothscroll-polyfill-npm-0.4.4-69b5bb4bf7-b99ff7d916.zip
vendored
Normal file
BIN
.yarn/cache/smoothscroll-polyfill-npm-0.4.4-69b5bb4bf7-b99ff7d916.zip
vendored
Normal file
Binary file not shown.
Binary file not shown.
BIN
.yarn/cache/vite-npm-3.1.8-6703f419ed-982696ad13.zip
vendored
Normal file
BIN
.yarn/cache/vite-npm-3.1.8-6703f419ed-982696ad13.zip
vendored
Normal file
Binary file not shown.
BIN
.yarn/cache/vue-npm-3.2.40-ee1b0f06d2-fb5ca87c16.zip
vendored
BIN
.yarn/cache/vue-npm-3.2.40-ee1b0f06d2-fb5ca87c16.zip
vendored
Binary file not shown.
BIN
.yarn/cache/vue-npm-3.2.41-cb73e74f4c-5328bf14c6.zip
vendored
Normal file
BIN
.yarn/cache/vue-npm-3.2.41-cb73e74f4c-5328bf14c6.zip
vendored
Normal file
Binary file not shown.
|
@ -13,7 +13,22 @@
|
|||
|
||||
.agenda-topnav.my-3
|
||||
meeting-navigation
|
||||
n-button.d-none.d-sm-flex(
|
||||
.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'
|
||||
)
|
||||
|
@ -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,11 +464,17 @@ if (window.location.pathname.indexOf('-utc') >= 0) {
|
|||
&-topnav {
|
||||
position: relative;
|
||||
|
||||
> button {
|
||||
&-right {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
right: 0;
|
||||
display: flex;
|
||||
|
||||
button + button {
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
> button:last-child {
|
||||
.bi {
|
||||
transition: transform 1s ease;
|
||||
}
|
||||
|
@ -437,6 +486,7 @@ if (window.location.pathname.indexOf('-utc') >= 0) {
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&-tz-selector {
|
||||
margin-right: .5rem;
|
||||
|
|
|
@ -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 () {
|
||||
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 () {
|
||||
|
|
173
client/agenda/AgendaShareModal.vue
Normal file
173
client/agenda/AgendaShareModal.vue
Normal 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>
|
|
@ -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
|
||||
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
|
||||
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
113
client/agenda/tour.js
Normal 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
|
||||
}
|
|
@ -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 => {
|
||||
|
|
19
client/shared/feature-detect.js
Normal file
19
client/shared/feature-detect.js
Normal 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
|
||||
}
|
||||
}
|
|
@ -595,7 +595,7 @@ def to_iesg(request,name):
|
|||
e.by = by
|
||||
e.doc = doc
|
||||
e.rev = doc.rev
|
||||
e.desc = "IESG process started in state <b>%s</b>" % target_state['iesg'].name
|
||||
e.desc = "Document is now in IESG state <b>%s</b>" % target_state['iesg'].name
|
||||
e.save()
|
||||
events.append(e)
|
||||
|
||||
|
@ -716,7 +716,7 @@ def edit_info(request, name):
|
|||
e.by = by
|
||||
e.doc = doc
|
||||
e.rev = doc.rev
|
||||
e.desc = "IESG process started in state <b>%s</b>" % doc.get_state("draft-iesg").name
|
||||
e.desc = "Document is now in IESG state <b>%s</b>" % doc.get_state("draft-iesg").name
|
||||
e.save()
|
||||
events.append(e)
|
||||
|
||||
|
|
|
@ -116,6 +116,9 @@ def get_person_form(*args, **kwargs):
|
|||
self.initial["ascii"] = ""
|
||||
|
||||
self.fields['pronouns_selectable'] = forms.MultipleChoiceField(label='Pronouns', choices = [(option, option) for option in ["he/him", "she/her", "they/them"]], widget=forms.CheckboxSelectMultiple, required=False)
|
||||
self.fields["pronouns_freetext"].widget.attrs.update(
|
||||
{"aria-label": "Optionally provide your personal pronouns"}
|
||||
)
|
||||
|
||||
self.unidecoded_ascii = False
|
||||
|
||||
|
|
|
@ -96,6 +96,7 @@ def preprocess_assignments_for_agenda(assignments_queryset, meeting, extra_prefe
|
|||
a.session.historic_parent
|
||||
a.session.rescheduled_to (if rescheduled)
|
||||
a.session.prefetched_active_materials
|
||||
a.session.order_number
|
||||
"""
|
||||
assignments_queryset = assignments_queryset.prefetch_related(
|
||||
'timeslot', 'timeslot__type', 'timeslot__meeting',
|
||||
|
|
|
@ -1095,8 +1095,18 @@ class Session(models.Model):
|
|||
from ietf.meeting.utils import add_event_info_to_session_qs
|
||||
if self.group.features.has_meetings:
|
||||
if not hasattr(self, "_all_meeting_sessions_for_group_cache"):
|
||||
sessions = [s for s in add_event_info_to_session_qs(self.meeting.session_set.filter(group=self.group,type=self.type)) if s.official_timeslotassignment()]
|
||||
self._all_meeting_sessions_for_group_cache = sorted(sessions, key = lambda x: x.official_timeslotassignment().timeslot.time)
|
||||
sessions = [s for s in add_event_info_to_session_qs(self.meeting.session_set.filter(group=self.group)) if s.official_timeslotassignment()]
|
||||
for s in sessions:
|
||||
s.ota = s.official_timeslotassignment()
|
||||
# Align this sort with SchedTimeSessAssignment default sort order since many views base their order on that
|
||||
self._all_meeting_sessions_for_group_cache = sorted(
|
||||
sessions, key = lambda x: (
|
||||
x.ota.timeslot.time,
|
||||
x.ota.timeslot.type.slug,
|
||||
x.ota.session.group.parent.name if x.ota.session.group.parent else None,
|
||||
x.ota.session.name
|
||||
)
|
||||
)
|
||||
return self._all_meeting_sessions_for_group_cache
|
||||
else:
|
||||
return [self]
|
||||
|
|
|
@ -210,8 +210,7 @@ def materials_document(request, document, num=None, ext=None):
|
|||
if (re.search(r'^\w+-\d+-.+-\d\d$', document) or
|
||||
re.search(r'^\w+-interim-\d+-.+-\d\d-\d\d$', document) or
|
||||
re.search(r'^\w+-interim-\d+-.+-sess[a-z]-\d\d$', document) or
|
||||
re.search(r'^minutes-interim-\d+-.+-\d\d$', document) or
|
||||
re.search(r'^slides-interim-\d+-.+-\d\d$', document)):
|
||||
re.search(r'^(minutes|slides|chatlog|polls)-interim-\d+-.+-\d\d$', document)):
|
||||
name, rev = document.rsplit('-', 1)
|
||||
else:
|
||||
name, rev = document, None
|
||||
|
@ -1640,6 +1639,8 @@ def api_get_agenda_data (request, num=None):
|
|||
# Get Floor Plans
|
||||
floors = FloorPlan.objects.filter(meeting=meeting).order_by('order')
|
||||
|
||||
#debug.show('all([(item.acronym,item.session.order_number,item.session.order_in_meeting()) for item in filtered_assignments])')
|
||||
|
||||
return JsonResponse({
|
||||
"meeting": {
|
||||
"number": schedule.meeting.number,
|
||||
|
@ -1731,7 +1732,7 @@ def agenda_extract_schedule (item):
|
|||
} if item.session.agenda() is not None else {
|
||||
"url": None
|
||||
},
|
||||
"orderInMeeting": item.session.order_in_meeting(),
|
||||
"orderInMeeting": item.session.order_number,
|
||||
"short": item.session.short if item.session.short else item.session.short_name,
|
||||
"sessionToken": item.session.docname_token_only_for_multiple(),
|
||||
"links": {
|
||||
|
|
|
@ -267,6 +267,16 @@ def status_slug_for_new_session(session, session_number):
|
|||
return 'schedw'
|
||||
|
||||
|
||||
def get_outbound_conflicts(form: SessionForm):
|
||||
"""extract wg conflict constraint data from a SessionForm"""
|
||||
outbound_conflicts = []
|
||||
for conflictname, cfield_id in form.wg_constraint_field_ids():
|
||||
conflict_groups = form.cleaned_data[cfield_id]
|
||||
if len(conflict_groups) > 0:
|
||||
outbound_conflicts.append(dict(name=conflictname, groups=conflict_groups))
|
||||
return outbound_conflicts
|
||||
|
||||
|
||||
@role_required(*AUTHORIZED_ROLES)
|
||||
def confirm(request, acronym):
|
||||
'''
|
||||
|
@ -299,12 +309,8 @@ def confirm(request, acronym):
|
|||
session_data['timeranges_display'] = [t.desc for t in form.cleaned_data['timeranges']]
|
||||
session_data['resources'] = [ ResourceAssociation.objects.get(pk=pk) for pk in request.POST.getlist('resources') ]
|
||||
|
||||
# extract wg conflict constraint data for the view
|
||||
outbound_conflicts = []
|
||||
for conflictname, cfield_id in form.wg_constraint_field_ids():
|
||||
conflict_groups = form.cleaned_data[cfield_id]
|
||||
if len(conflict_groups) > 0:
|
||||
outbound_conflicts.append(dict(name=conflictname, groups=conflict_groups))
|
||||
# extract wg conflict constraint data for the view / notifications
|
||||
outbound_conflicts = get_outbound_conflicts(form)
|
||||
|
||||
button_text = request.POST.get('submit', '')
|
||||
if button_text == 'Cancel':
|
||||
|
@ -534,11 +540,14 @@ def edit(request, acronym, num=None):
|
|||
#add_session_activity(group,'Session Request was updated',meeting,user)
|
||||
|
||||
# send notification
|
||||
outbound_conflicts = get_outbound_conflicts(form)
|
||||
session_data = form.cleaned_data.copy() # do not add things to the original cleaned_data
|
||||
session_data['outbound_conflicts'] = [f"{d['name']}: {d['groups']}" for d in outbound_conflicts]
|
||||
send_notification(
|
||||
group,
|
||||
meeting,
|
||||
login,
|
||||
form.cleaned_data,
|
||||
session_data,
|
||||
[sf.cleaned_data for sf in form.session_forms.forms_to_keep],
|
||||
'update',
|
||||
)
|
||||
|
|
|
@ -61,7 +61,7 @@
|
|||
replace: function(new_array){
|
||||
if (!new_array)
|
||||
return;
|
||||
if (!$.isArray(new_array))
|
||||
if (!Array.isArray(new_array))
|
||||
new_array = [new_array];
|
||||
this.clear();
|
||||
this.push.apply(this, new_array);
|
||||
|
@ -176,7 +176,7 @@
|
|||
},
|
||||
|
||||
_resolveDaysOfWeek: function(daysOfWeek){
|
||||
if (!$.isArray(daysOfWeek))
|
||||
if (!Array.isArray(daysOfWeek))
|
||||
daysOfWeek = daysOfWeek.split(/[,\s]*/);
|
||||
return $.map(daysOfWeek, Number);
|
||||
},
|
||||
|
@ -263,7 +263,7 @@
|
|||
o.daysOfWeekHighlighted = this._resolveDaysOfWeek(o.daysOfWeekHighlighted||[]);
|
||||
|
||||
o.datesDisabled = o.datesDisabled||[];
|
||||
if (!$.isArray(o.datesDisabled)) {
|
||||
if (!Array.isArray(o.datesDisabled)) {
|
||||
o.datesDisabled = o.datesDisabled.split(',');
|
||||
}
|
||||
o.datesDisabled = $.map(o.datesDisabled, function(d){
|
||||
|
@ -579,7 +579,7 @@
|
|||
},
|
||||
|
||||
setDates: function(){
|
||||
var args = $.isArray(arguments[0]) ? arguments[0] : arguments;
|
||||
var args = Array.isArray(arguments[0]) ? arguments[0] : arguments;
|
||||
this.update.apply(this, args);
|
||||
this._trigger('changeDate');
|
||||
this.setValue();
|
||||
|
@ -587,7 +587,7 @@
|
|||
},
|
||||
|
||||
setUTCDates: function(){
|
||||
var args = $.isArray(arguments[0]) ? arguments[0] : arguments;
|
||||
var args = Array.isArray(arguments[0]) ? arguments[0] : arguments;
|
||||
this.setDates.apply(this, $.map(args, this._utc_to_local));
|
||||
return this;
|
||||
},
|
||||
|
@ -1039,7 +1039,7 @@
|
|||
|
||||
//Check if uniqueSort exists (supported by jquery >=1.12 and >=2.2)
|
||||
//Fallback to unique function for older jquery versions
|
||||
if ($.isFunction($.uniqueSort)) {
|
||||
if (typeof $.uniqueSort === "function") {
|
||||
clsName = $.uniqueSort(clsName);
|
||||
} else {
|
||||
clsName = $.unique(clsName);
|
||||
|
|
|
@ -24,11 +24,9 @@ function replace_with_internal(table, internal_table, i) {
|
|||
}
|
||||
|
||||
function field_magic(i, e, fields) {
|
||||
if ($(e)
|
||||
.attr("colspan") === undefined &&
|
||||
(fields[i] == "num" || fields[i] == "count" ||
|
||||
if (fields[i] == "num" || fields[i] == "count" ||
|
||||
fields[i] == "percent" || fields[i] == "id" ||
|
||||
fields[i].endsWith("-num") || fields[i].endsWith("-date"))) {
|
||||
fields[i].endsWith("-num") || fields[i].endsWith("-date")) {
|
||||
$(e)
|
||||
.addClass("text-end");
|
||||
}
|
||||
|
@ -62,12 +60,21 @@ $(document)
|
|||
// get field classes from first thead row
|
||||
var fields = $(header_row)
|
||||
.find("th, td")
|
||||
.map(function () {
|
||||
return $(this)
|
||||
.attr("data-sort") ? $(this)
|
||||
.toArray()
|
||||
.map((el) => {
|
||||
let colspan = parseInt($(el)
|
||||
.attr("colspan")) || 1;
|
||||
// create a dense (non-sparse) array
|
||||
let data_sort = new Array();
|
||||
for (var i = 0; i < colspan; i++) {
|
||||
data_sort[i] = "";
|
||||
}
|
||||
data_sort[0] = $(el)
|
||||
.attr("data-sort") ? $(el)
|
||||
.attr("data-sort") : "";
|
||||
return data_sort;
|
||||
})
|
||||
.toArray();
|
||||
.flat();
|
||||
|
||||
if (fields.length == 0 || !fields.filter(field => field != "")) {
|
||||
// console.log("No table fields defined, disabling search/sort.");
|
||||
|
@ -79,10 +86,9 @@ $(document)
|
|||
$(header_row)
|
||||
.children("[data-sort]")
|
||||
.addClass("sort");
|
||||
// $(header_row)
|
||||
// .children("th, td")
|
||||
// .wrapInner('<span class="tablesorter-th"></span>');
|
||||
// // .each((i, e) => field_magic(i, e, fields));
|
||||
$(header_row)
|
||||
.children("th, td")
|
||||
.each((i, e) => field_magic(i, e, fields));
|
||||
|
||||
if ($(header_row)
|
||||
.text()
|
||||
|
|
|
@ -99,6 +99,12 @@
|
|||
Draft submission
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item {% if flavor != 'top' %}text-wrap link-primary{% endif %}"
|
||||
href="{% url 'ietf.doc.views_search.ad_workload' %}">
|
||||
IESG dashboard
|
||||
</a>
|
||||
</li>
|
||||
{% if user and user.is_authenticated %}
|
||||
<li>
|
||||
<a class="dropdown-item {% if flavor != 'top' %}text-wrap link-primary{% endif %}"
|
||||
|
|
|
@ -229,9 +229,10 @@
|
|||
</a>
|
||||
{% endif %}
|
||||
{% if liaison.state.slug == 'pending' and can_edit %}
|
||||
<button class="btn btn-primary" type="submit" name="approved">Approve</button>
|
||||
<button class="btn btn-primary" type="submit" value="Approve" name="approved">Approve</button>
|
||||
<button class="btn btn-primary"
|
||||
type="submit"
|
||||
value="Mark as Dead"
|
||||
name="dead">Mark as dead</button>
|
||||
{% endif %}
|
||||
{% if liaison.state.slug == 'posted' and user|has_role:"Secretariat" %}
|
||||
|
@ -243,6 +244,7 @@
|
|||
{% if liaison.state.slug == 'dead' and can_edit %}
|
||||
<button class="btn btn-primary"
|
||||
type="submit"
|
||||
value="Resurrect"
|
||||
name="resurrect">Resurrect</button>
|
||||
{% endif %}
|
||||
{% if liaison.state.slug == 'pending' and can_edit or liaison.state.slug == 'dead' and can_edit %}</form>{% endif %}
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
{% block nomcom_content %}
|
||||
{% origin %}
|
||||
<h2 class="mt-3">Nomination status</h2>
|
||||
<table class="table table-sm table-striped table-hover tablesorter">
|
||||
<table class="table table-sm table-striped-columns table-hover tablesorter">
|
||||
<thead class="wrap-anywhere">
|
||||
<tr>
|
||||
<th scope="col" data-sort="position">Position</th>
|
||||
|
@ -156,7 +156,7 @@
|
|||
<i class="bi bi-check"></i>
|
||||
</th>
|
||||
{% endif %}
|
||||
<th scope="col" data-sort="nominee" colspan="2">
|
||||
<th scope="col" data-sort="nominee">
|
||||
Nominee
|
||||
</th>
|
||||
<th scope="col" data-sort="position">
|
||||
|
@ -194,9 +194,7 @@
|
|||
<a href="{% url 'ietf.person.views.profile' email_or_name=np.nominee.name %}">
|
||||
{{ np.nominee.email.name_and_email }}
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a class="btn btn-primary btn-sm"
|
||||
<a class="btn btn-primary btn-sm float-end"
|
||||
href="{% url 'ietf.nomcom.views.view_feedback_nominee' year=year nominee_id=np.nominee.id %}#comment">
|
||||
View feedback
|
||||
</a>
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue