Skip to content

Commit c691af4

Browse files
authored
Merge pull request #62624 from nextcloud/enh/noid/vue3-dashboard
Migrate the dashboard app to Vue 3
2 parents 0fb0c68 + ab7a14e commit c691af4

434 files changed

Lines changed: 694 additions & 705 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/dashboard/lib/Controller/DashboardController.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ public function __construct(
5151
#[NoAdminRequired]
5252
#[FrontpageRoute(verb: 'GET', url: '/')]
5353
public function index(): TemplateResponse {
54+
Util::addStyle('dashboard', 'main');
5455
Util::addStyle('dashboard', 'dashboard');
5556
Util::addScript('dashboard', 'main', 'theming');
5657

apps/dashboard/src/DashboardApp.vue

Lines changed: 143 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -10,45 +10,45 @@
1010
v-for="status in sortedRegisteredStatus"
1111
:id="'status-' + status"
1212
:key="status">
13-
<div :ref="'status-' + status" />
13+
<div :ref="(element) => setStatusElement(status, element)" />
1414
</li>
1515
</ul>
1616

1717
<Draggable
1818
v-model="layout"
19-
class="panels"
2019
v-bind="{ swapThreshold: 0.30, delay: 500, delayOnTouchOnly: true, touchStartThreshold: 3 }"
20+
class="panels"
21+
:itemKey="(element) => element"
2122
handle=".panel--header"
2223
@end="saveLayout">
23-
<template v-for="panelId in layout">
24-
<div
25-
v-if="isApiWidgetV2(panels[panelId].id)"
26-
:key="`${panels[panelId].id}-v2`"
27-
class="panel">
28-
<div class="panel--header">
29-
<h2>
30-
<img v-if="apiWidgets[panels[panelId].id].icon_url" :src="apiWidgets[panels[panelId].id].icon_url" alt="">
31-
<span v-else :class="apiWidgets[panels[panelId].id].icon_class" aria-hidden="true" />
32-
{{ apiWidgets[panels[panelId].id].title }}
33-
</h2>
34-
</div>
35-
<div class="panel--content">
36-
<ApiDashboardWidget
37-
:widget="apiWidgets[panels[panelId].id]"
38-
:data="apiWidgetItems[panels[panelId].id]"
39-
:loading="loadingItems" />
40-
</div>
41-
</div>
42-
<div v-else :key="panels[panelId].id" class="panel">
43-
<div class="panel--header">
44-
<h2>
45-
<span :class="panels[panelId].iconClass" aria-hidden="true" />
46-
{{ panels[panelId].title }}
47-
</h2>
48-
</div>
49-
<div class="panel--content" :class="{ loading: !panels[panelId].mounted }">
50-
<div :ref="panels[panelId].id" :data-id="panels[panelId].id" />
51-
</div>
24+
<template #item="{ element: panelId }">
25+
<div :key="panelId" class="panel">
26+
<template v-if="isApiWidgetV2(panels[panelId].id)">
27+
<div class="panel--header">
28+
<h2>
29+
<img v-if="apiWidgets[panels[panelId].id].icon_url" :src="apiWidgets[panels[panelId].id].icon_url" alt="">
30+
<span v-else :class="apiWidgets[panels[panelId].id].icon_class" aria-hidden="true" />
31+
{{ apiWidgets[panels[panelId].id].title }}
32+
</h2>
33+
</div>
34+
<div class="panel--content">
35+
<ApiDashboardWidget
36+
:widget="apiWidgets[panels[panelId].id]"
37+
:data="apiWidgetItems[panels[panelId].id]"
38+
:loading="loadingItems" />
39+
</div>
40+
</template>
41+
<template v-else>
42+
<div class="panel--header">
43+
<h2>
44+
<span :class="panels[panelId].iconClass" aria-hidden="true" />
45+
{{ panels[panelId].title }}
46+
</h2>
47+
</div>
48+
<div class="panel--content" :class="{ loading: !panels[panelId].mounted }">
49+
<div :ref="panels[panelId].id" :data-id="panels[panelId].id" />
50+
</div>
51+
</template>
5252
</div>
5353
</template>
5454
</Draggable>
@@ -62,7 +62,11 @@
6262
</NcButton>
6363
</div>
6464

65-
<NcModal v-if="modal" size="large" @close="closeModal">
65+
<NcModal
66+
v-if="modal"
67+
size="large"
68+
:closeOnClickOutside="true"
69+
@close="closeModal">
6670
<div class="modal__content">
6771
<h2>{{ t('dashboard', 'Edit widgets') }}</h2>
6872
<ol class="panels">
@@ -81,27 +85,31 @@
8185
</li>
8286
</ol>
8387
<Draggable
84-
v-model="layout"
88+
v-bind="{ swapThreshold: 0.30, delay: 500, delayOnTouchOnly: true, touchStartThreshold: 3 }"
8589
class="panels"
8690
tag="ol"
87-
v-bind="{ swapThreshold: 0.30, delay: 500, delayOnTouchOnly: true, touchStartThreshold: 3 }"
91+
:itemKey="(panel) => panel.id"
8892
handle=".draggable"
89-
@end="saveLayout">
90-
<li v-for="panel in sortedPanels" :key="panel.id" :class="'panel-' + panel.id">
91-
<input
92-
:id="'panel-checkbox-' + panel.id"
93-
type="checkbox"
94-
class="checkbox"
95-
:checked="isActive(panel)"
96-
@input="updateCheckbox(panel, $event.target.checked)">
97-
<label :for="'panel-checkbox-' + panel.id" :class="{ draggable: isActive(panel) }">
98-
<img v-if="panel.iconUrl" alt="" :src="panel.iconUrl">
99-
<span v-else :class="panel.iconClass" aria-hidden="true" />
100-
{{ panel.title }}
101-
</label>
102-
</li>
93+
:modelValue="modalPanelList"
94+
@update:modelValue="onModalPanelListUpdate"
95+
@start="onModalDragStart"
96+
@end="onModalDragEnd">
97+
<template #item="{ element: panel }">
98+
<li :key="panel.id" :class="'panel-' + panel.id">
99+
<input
100+
:id="'panel-checkbox-' + panel.id"
101+
type="checkbox"
102+
class="checkbox"
103+
:checked="isActive(panel)"
104+
@input="updateCheckbox(panel, $event.target.checked)">
105+
<label :for="'panel-checkbox-' + panel.id" :class="{ draggable: isActive(panel) }">
106+
<img v-if="panel.iconUrl" alt="" :src="panel.iconUrl">
107+
<span v-else :class="panel.iconClass" aria-hidden="true" />
108+
{{ panel.title }}
109+
</label>
110+
</li>
111+
</template>
103112
</Draggable>
104-
105113
<a v-if="isAdmin && appStoreEnabled" :href="appStoreUrl" class="button">{{ t('dashboard', 'Get more widgets from the App Store') }}</a>
106114

107115
<div v-if="statuses.weather && isStatusActive('weather')">
@@ -124,9 +132,11 @@
124132
import { getCurrentUser } from '@nextcloud/auth'
125133
import axios from '@nextcloud/axios'
126134
import { loadState } from '@nextcloud/initial-state'
135+
import { t } from '@nextcloud/l10n'
127136
import { generateOcsUrl, generateUrl } from '@nextcloud/router'
128-
import Vue from 'vue'
129-
import Draggable from 'vuedraggable'
137+
// import this directly so Vite builds it and we don't end up using the UMD bundle which
138+
// tries to run "new Function(...)" (for no good reason) which is blocked by our CSP
139+
import Draggable from 'vuedraggable/src/vuedraggable.js'
130140
import NcButton from '@nextcloud/vue/components/NcButton'
131141
import NcModal from '@nextcloud/vue/components/NcModal'
132142
import NcUserStatusIcon from '@nextcloud/vue/components/NcUserStatusIcon'
@@ -166,6 +176,7 @@ export default {
166176
167177
setup() {
168178
return {
179+
t,
169180
productName: window.OC.theme.productName,
170181
}
171182
},
@@ -176,15 +187,17 @@ export default {
176187
timer: new Date(),
177188
registeredStatus: [],
178189
callbacks: {},
179-
callbacksStatus: {},
180190
allCallbacksStatus: {},
191+
statusElements: {},
181192
statusInfo,
182193
enabledStatuses: loadState('dashboard', 'statuses'),
183194
panels,
184195
firstRun,
185196
displayName: getCurrentUser()?.displayName,
186197
uid: getCurrentUser()?.uid,
187198
layout: loadState('dashboard', 'layout').filter((panelId) => panels[panelId]),
199+
modalPanelList: [],
200+
isModalDragging: false,
188201
modal: false,
189202
appStoreUrl: generateUrl('/settings/apps/dashboard'),
190203
appStoreEnabled: loadState('dashboard', 'appStoreEnabled', true),
@@ -263,39 +276,19 @@ export default {
263276
return Object.keys(this.allCallbacksStatus).slice().sort(this.sortStatuses)
264277
},
265278
266-
sortedPanels() {
267-
return Object.values(this.panels).sort((a, b) => {
268-
const indexA = this.layout.indexOf(a.id)
269-
const indexB = this.layout.indexOf(b.id)
270-
if (indexA === -1 || indexB === -1) {
271-
return indexB - indexA || a.id - b.id
272-
}
273-
return indexA - indexB || a.id - b.id
274-
})
275-
},
276-
277279
sortedRegisteredStatus() {
278280
return this.registeredStatus.slice().sort(this.sortStatuses)
279281
},
280282
},
281283
282284
watch: {
283285
callbacks() {
284-
this.rerenderPanels()
286+
this.$nextTick(() => this.rerenderPanels())
285287
},
286288
287-
callbacksStatus() {
288-
for (const app in this.callbacksStatus) {
289-
const element = this.$refs['status-' + app]
290-
if (this.statuses[app] && this.statuses[app].mounted) {
291-
continue
292-
}
293-
if (element) {
294-
this.callbacksStatus[app](element[0])
295-
Vue.set(this.statuses, app, { mounted: true })
296-
} else {
297-
logger.error('Failed to register panel in the frontend as no backend data was provided for ' + app)
298-
}
289+
layout() {
290+
if (this.modal && !this.isModalDragging) {
291+
this.modalPanelList = this.getSortedPanelObjects()
299292
}
300293
},
301294
},
@@ -320,6 +313,8 @@ export default {
320313
}, widget.reload_interval * 1000)
321314
}
322315
}
316+
317+
this.$nextTick(() => this.rerenderPanels())
323318
},
324319
325320
mounted() {
@@ -335,7 +330,7 @@ export default {
335330
}
336331
},
337332
338-
destroyed() {
333+
unmounted() {
339334
window.removeEventListener('scroll', this.handleScroll)
340335
},
341336
@@ -347,18 +342,40 @@ export default {
347342
* @param {Function} callback The callback function to register a panel which gets the DOM element passed as parameter
348343
*/
349344
register(app, callback) {
350-
Vue.set(this.callbacks, app, callback)
345+
this.callbacks[app] = callback
351346
},
352347
353348
registerStatus(app, callback) {
354349
// always save callbacks in case user enables the status later
355-
Vue.set(this.allCallbacksStatus, app, callback)
350+
this.allCallbacksStatus[app] = callback
356351
// register only if status is enabled or missing from config
357352
if (this.isStatusActive(app)) {
358-
this.registeredStatus.push(app)
359-
this.$nextTick(() => {
360-
Vue.set(this.callbacksStatus, app, callback)
361-
})
353+
if (!this.registeredStatus.includes(app)) {
354+
this.registeredStatus.push(app)
355+
}
356+
this.$nextTick(() => this.mountStatus(app))
357+
}
358+
},
359+
360+
setStatusElement(app, element) {
361+
if (element) {
362+
this.statusElements[app] = element
363+
this.mountStatus(app)
364+
} else {
365+
delete this.statusElements[app]
366+
}
367+
},
368+
369+
mountStatus(app) {
370+
if (this.statuses[app]?.mounted) {
371+
return
372+
}
373+
374+
const element = this.statusElements[app]
375+
const callback = this.allCallbacksStatus[app]
376+
if (element && callback) {
377+
callback(element)
378+
this.statuses[app] = { mounted: true }
362379
}
363380
},
364381
@@ -377,10 +394,12 @@ export default {
377394
continue
378395
}
379396
if (element) {
380-
this.callbacks[app](element[0], {
397+
// In Vue 3, refs in v-for are arrays
398+
const el = Array.isArray(element) ? element[0] : element
399+
this.callbacks[app](el, {
381400
widget: this.panels[app],
382401
})
383-
Vue.set(this.panels[app], 'mounted', true)
402+
this.panels[app].mounted = true
384403
} else {
385404
logger.error('Failed to register panel in the frontend as no backend data was provided for ' + app)
386405
}
@@ -402,12 +421,40 @@ export default {
402421
showModal() {
403422
this.modal = true
404423
this.firstRun = false
424+
this.modalPanelList = this.getSortedPanelObjects()
405425
},
406426
407427
closeModal() {
408428
this.modal = false
409429
},
410430
431+
getSortedPanelObjects() {
432+
return Object.values(this.panels).sort((a, b) => {
433+
const indexA = this.layout.indexOf(a.id)
434+
const indexB = this.layout.indexOf(b.id)
435+
if (indexA === -1 || indexB === -1) {
436+
return indexB - indexA || a.id - b.id
437+
}
438+
return indexA - indexB || a.id - b.id
439+
})
440+
},
441+
442+
onModalPanelListUpdate(newList) {
443+
this.modalPanelList = newList
444+
},
445+
446+
onModalDragStart() {
447+
this.isModalDragging = true
448+
},
449+
450+
onModalDragEnd() {
451+
this.isModalDragging = false
452+
this.layout = this.modalPanelList
453+
.filter((panel) => this.layout.includes(panel.id))
454+
.map((panel) => panel.id)
455+
this.saveLayout()
456+
},
457+
411458
updateCheckbox(panel, currentValue) {
412459
const index = this.layout.indexOf(panel.id)
413460
if (!currentValue && index > -1) {
@@ -418,7 +465,7 @@ export default {
418465
this.fetchApiWidgetItems([panel.id], true)
419466
}
420467
}
421-
Vue.set(this.panels[panel.id], 'mounted', false)
468+
this.panels[panel.id].mounted = false
422469
this.saveLayout()
423470
this.$nextTick(() => this.rerenderPanels())
424471
},
@@ -457,10 +504,7 @@ export default {
457504
const j = this.registeredStatus.findIndex((s) => s === app)
458505
if (j !== -1) {
459506
this.registeredStatus.splice(j, 1)
460-
Vue.set(this.statuses, app, { mounted: false })
461-
this.$nextTick(() => {
462-
Vue.delete(this.callbacksStatus, app)
463-
})
507+
this.statuses[app] = { mounted: false }
464508
}
465509
this.saveStatuses()
466510
},
@@ -517,6 +561,11 @@ export default {
517561
</script>
518562
519563
<style lang="scss" scoped>
564+
:global(#content.app-dashboard) {
565+
margin-inline: 0;
566+
width: 100%;
567+
}
568+
520569
#app-dashboard {
521570
width: 100%;
522571
min-height: 100%;
@@ -803,4 +852,8 @@ html, body {
803852
/* Scrollbar sits on the background image — use plain-text color for contrast */
804853
scrollbar-color: var(--color-background-plain-text) transparent;
805854
}
855+
856+
#app-content-vue {
857+
width: 100%;
858+
}
806859
</style>

0 commit comments

Comments
 (0)