Allow userdefined order and start with drag and drop resorting
Signed-off-by: Julius Härtl <jus@bitgrid.net>
This commit is contained in:
parent
55473dd2eb
commit
429049c809
|
@ -27,5 +27,6 @@ declare(strict_types=1);
|
||||||
return [
|
return [
|
||||||
'routes' => [
|
'routes' => [
|
||||||
['name' => 'dashboard#index', 'url' => '/', 'verb' => 'GET'],
|
['name' => 'dashboard#index', 'url' => '/', 'verb' => 'GET'],
|
||||||
|
['name' => 'dashboard#updateLayout', 'url' => '/layout', 'verb' => 'POST'],
|
||||||
]
|
]
|
||||||
];
|
];
|
||||||
|
|
|
@ -26,13 +26,16 @@ declare(strict_types=1);
|
||||||
|
|
||||||
namespace OCA\Dashboard\Controller;
|
namespace OCA\Dashboard\Controller;
|
||||||
|
|
||||||
|
use OCA\Dashboard\AppInfo\Application;
|
||||||
use OCA\Viewer\Event\LoadViewer;
|
use OCA\Viewer\Event\LoadViewer;
|
||||||
use OCP\AppFramework\Controller;
|
use OCP\AppFramework\Controller;
|
||||||
|
use OCP\AppFramework\Http\JSONResponse;
|
||||||
use OCP\AppFramework\Http\TemplateResponse;
|
use OCP\AppFramework\Http\TemplateResponse;
|
||||||
use OCP\Dashboard\IManager;
|
use OCP\Dashboard\IManager;
|
||||||
use OCP\Dashboard\IPanel;
|
use OCP\Dashboard\IPanel;
|
||||||
use OCP\Dashboard\RegisterPanelEvent;
|
use OCP\Dashboard\RegisterPanelEvent;
|
||||||
use OCP\EventDispatcher\IEventDispatcher;
|
use OCP\EventDispatcher\IEventDispatcher;
|
||||||
|
use OCP\IConfig;
|
||||||
use OCP\IInitialStateService;
|
use OCP\IInitialStateService;
|
||||||
use OCP\IRequest;
|
use OCP\IRequest;
|
||||||
|
|
||||||
|
@ -44,19 +47,27 @@ class DashboardController extends Controller {
|
||||||
private $eventDispatcher;
|
private $eventDispatcher;
|
||||||
/** @var IManager */
|
/** @var IManager */
|
||||||
private $dashboardManager;
|
private $dashboardManager;
|
||||||
|
/** @var IConfig */
|
||||||
|
private $config;
|
||||||
|
/** @var string */
|
||||||
|
private $userId;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
string $appName,
|
string $appName,
|
||||||
IRequest $request,
|
IRequest $request,
|
||||||
IInitialStateService $initialStateService,
|
IInitialStateService $initialStateService,
|
||||||
IEventDispatcher $eventDispatcher,
|
IEventDispatcher $eventDispatcher,
|
||||||
IManager $dashboardManager
|
IManager $dashboardManager,
|
||||||
|
IConfig $config,
|
||||||
|
$userId
|
||||||
) {
|
) {
|
||||||
parent::__construct($appName, $request);
|
parent::__construct($appName, $request);
|
||||||
|
|
||||||
$this->inititalStateService = $initialStateService;
|
$this->inititalStateService = $initialStateService;
|
||||||
$this->eventDispatcher = $eventDispatcher;
|
$this->eventDispatcher = $eventDispatcher;
|
||||||
$this->dashboardManager = $dashboardManager;
|
$this->dashboardManager = $dashboardManager;
|
||||||
|
$this->config = $config;
|
||||||
|
$this->userId = $userId;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -67,7 +78,7 @@ class DashboardController extends Controller {
|
||||||
public function index(): TemplateResponse {
|
public function index(): TemplateResponse {
|
||||||
$this->eventDispatcher->dispatchTyped(new RegisterPanelEvent($this->dashboardManager));
|
$this->eventDispatcher->dispatchTyped(new RegisterPanelEvent($this->dashboardManager));
|
||||||
|
|
||||||
$dashboardManager = $this->dashboardManager;
|
$userLayout = explode(',', $this->config->getUserValue($this->userId, 'dashboard', 'layout', 'calendar,recommendations,spreed,mail'));
|
||||||
$panels = array_map(function (IPanel $panel) {
|
$panels = array_map(function (IPanel $panel) {
|
||||||
return [
|
return [
|
||||||
'id' => $panel->getId(),
|
'id' => $panel->getId(),
|
||||||
|
@ -75,8 +86,9 @@ class DashboardController extends Controller {
|
||||||
'iconClass' => $panel->getIconClass(),
|
'iconClass' => $panel->getIconClass(),
|
||||||
'url' => $panel->getUrl()
|
'url' => $panel->getUrl()
|
||||||
];
|
];
|
||||||
}, $dashboardManager->getPanels());
|
}, $this->dashboardManager->getPanels());
|
||||||
$this->inititalStateService->provideInitialState('dashboard', 'panels', $panels);
|
$this->inititalStateService->provideInitialState('dashboard', 'panels', $panels);
|
||||||
|
$this->inititalStateService->provideInitialState('dashboard', 'layout', $userLayout);
|
||||||
|
|
||||||
if (class_exists(LoadViewer::class)) {
|
if (class_exists(LoadViewer::class)) {
|
||||||
$this->eventDispatcher->dispatchTyped(new LoadViewer());
|
$this->eventDispatcher->dispatchTyped(new LoadViewer());
|
||||||
|
@ -84,4 +96,14 @@ class DashboardController extends Controller {
|
||||||
|
|
||||||
return new TemplateResponse('dashboard', 'index');
|
return new TemplateResponse('dashboard', 'index');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @NoAdminRequired
|
||||||
|
* @param string $layout
|
||||||
|
* @return JSONResponse
|
||||||
|
*/
|
||||||
|
public function updateLayout(string $layout): JSONResponse {
|
||||||
|
$this->config->setUserValue($this->userId, 'dashboard', 'layout', $layout);
|
||||||
|
return new JSONResponse(['layout' => $layout]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,16 +2,41 @@
|
||||||
<div id="app-dashboard">
|
<div id="app-dashboard">
|
||||||
<h2>{{ greeting.icon }} {{ greeting.text }}</h2>
|
<h2>{{ greeting.icon }} {{ greeting.text }}</h2>
|
||||||
|
|
||||||
<div class="panels">
|
<Container class="panels"
|
||||||
<div v-for="panel in panels" :key="panel.id" class="panel">
|
orientation="horizontal"
|
||||||
<a :href="panel.url">
|
drag-handle-selector=".panel--header"
|
||||||
<h3 :class="panel.iconClass">
|
@drop="onDrop">
|
||||||
{{ panel.title }}
|
<Draggable v-for="panelId in layout" :key="panels[panelId].id" class="panel">
|
||||||
</h3>
|
<div class="panel--header">
|
||||||
</a>
|
<a :href="panels[panelId].url">
|
||||||
<div :ref="panel.id" :data-id="panel.id" />
|
<h3 :class="panels[panelId].iconClass">
|
||||||
|
{{ panels[panelId].title }}
|
||||||
|
</h3>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div :ref="panels[panelId].id" :data-id="panels[panelId].id" />
|
||||||
|
</Draggable>
|
||||||
|
</Container>
|
||||||
|
<a class="add-panels icon-add" @click="showModal">Add more panels</a>
|
||||||
|
<Modal v-if="modal" @close="closeModal">
|
||||||
|
<div class="modal__content">
|
||||||
|
<transition-group name="flip-list" tag="ol">
|
||||||
|
<li v-for="panel in sortedPanels" :key="panel.id">
|
||||||
|
<input :id="'panel-checkbox-' + panel.id"
|
||||||
|
type="checkbox"
|
||||||
|
class="checkbox"
|
||||||
|
:checked="isActive(panel)"
|
||||||
|
@input="updateCheckbox(panel, $event.target.checked)">
|
||||||
|
<label :for="'panel-checkbox-' + panel.id">
|
||||||
|
{{ panel.title }}
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
<li key="appstore">
|
||||||
|
<a href="/index.php/apps/settings" class="button">{{ t('dashboard', 'Get more panels from the app store') }}</a>
|
||||||
|
</li>
|
||||||
|
</transition-group>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
@ -19,17 +44,46 @@
|
||||||
import Vue from 'vue'
|
import Vue from 'vue'
|
||||||
import { loadState } from '@nextcloud/initial-state'
|
import { loadState } from '@nextcloud/initial-state'
|
||||||
import { getCurrentUser } from '@nextcloud/auth'
|
import { getCurrentUser } from '@nextcloud/auth'
|
||||||
|
import { Modal } from '@nextcloud/vue'
|
||||||
|
import { Container, Draggable } from 'vue-smooth-dnd'
|
||||||
|
import axios from '@nextcloud/axios'
|
||||||
|
import { generateUrl } from '@nextcloud/router'
|
||||||
|
|
||||||
const panels = loadState('dashboard', 'panels')
|
const panels = loadState('dashboard', 'panels')
|
||||||
|
|
||||||
|
const applyDrag = (arr, dragResult) => {
|
||||||
|
const { removedIndex, addedIndex, payload } = dragResult
|
||||||
|
if (removedIndex === null && addedIndex === null) return arr
|
||||||
|
|
||||||
|
const result = [...arr]
|
||||||
|
let itemToAdd = payload
|
||||||
|
|
||||||
|
if (removedIndex !== null) {
|
||||||
|
itemToAdd = result.splice(removedIndex, 1)[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (addedIndex !== null) {
|
||||||
|
result.splice(addedIndex, 0, itemToAdd)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'App',
|
name: 'App',
|
||||||
|
components: {
|
||||||
|
Modal,
|
||||||
|
Container,
|
||||||
|
Draggable,
|
||||||
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
timer: new Date(),
|
timer: new Date(),
|
||||||
callbacks: {},
|
callbacks: {},
|
||||||
panels,
|
panels,
|
||||||
name: getCurrentUser()?.displayName,
|
name: getCurrentUser()?.displayName,
|
||||||
|
layout: loadState('dashboard', 'layout').filter((panelId) => panels[panelId]),
|
||||||
|
modal: false,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
@ -50,22 +104,23 @@ export default {
|
||||||
}
|
}
|
||||||
return { icon: '🦉', text: t('dashboard', 'Have a night owl, {name}', { name: this.name }) }
|
return { icon: '🦉', text: t('dashboard', 'Have a night owl, {name}', { name: this.name }) }
|
||||||
},
|
},
|
||||||
|
isActive() {
|
||||||
|
return (panel) => this.layout.indexOf(panel.id) > -1
|
||||||
|
},
|
||||||
|
sortedPanels() {
|
||||||
|
return Object.values(this.panels).sort((a, b) => {
|
||||||
|
const indexA = this.layout.indexOf(a.id)
|
||||||
|
const indexB = this.layout.indexOf(b.id)
|
||||||
|
if (indexA === -1 || indexB === -1) {
|
||||||
|
return indexB - indexA || a.id - b.id
|
||||||
|
}
|
||||||
|
return indexA - indexB || a.id - b.id
|
||||||
|
})
|
||||||
|
},
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
callbacks() {
|
callbacks() {
|
||||||
for (const app in this.callbacks) {
|
this.rerenderPanels()
|
||||||
const element = this.$refs[app]
|
|
||||||
if (this.panels[app].mounted) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if (element) {
|
|
||||||
this.callbacks[app](element[0])
|
|
||||||
Vue.set(this.panels[app], 'mounted', true)
|
|
||||||
} else {
|
|
||||||
console.error('Failed to register panel in the frontend as no backend data was provided for ' + app)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
|
@ -74,9 +129,57 @@ export default {
|
||||||
}, 30000)
|
}, 30000)
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
/**
|
||||||
|
* Method to register panels that will be called by the integrating apps
|
||||||
|
*
|
||||||
|
* @param {string} app The unique app id for the widget
|
||||||
|
* @param {function} callback The callback function to register a panel which gets the DOM element passed as parameter
|
||||||
|
*/
|
||||||
register(app, callback) {
|
register(app, callback) {
|
||||||
Vue.set(this.callbacks, app, callback)
|
Vue.set(this.callbacks, app, callback)
|
||||||
},
|
},
|
||||||
|
rerenderPanels() {
|
||||||
|
for (const app in this.callbacks) {
|
||||||
|
const element = this.$refs[app]
|
||||||
|
if (this.panels[app] && this.panels[app].mounted) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (element) {
|
||||||
|
this.callbacks[app](element[0])
|
||||||
|
Vue.set(this.panels[app], 'mounted', true)
|
||||||
|
} else {
|
||||||
|
console.error('Failed to register panel in the frontend as no backend data was provided for ' + app)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
saveLayout() {
|
||||||
|
axios.post(generateUrl('/apps/dashboard/layout'), {
|
||||||
|
layout: this.layout.join(','),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onDrop(dropResult) {
|
||||||
|
this.layout = applyDrag(this.layout, dropResult)
|
||||||
|
this.saveLayout()
|
||||||
|
},
|
||||||
|
showModal() {
|
||||||
|
this.modal = true
|
||||||
|
},
|
||||||
|
closeModal() {
|
||||||
|
this.modal = false
|
||||||
|
},
|
||||||
|
updateCheckbox(panel, currentValue) {
|
||||||
|
const index = this.layout.indexOf(panel.id)
|
||||||
|
if (!currentValue && index > -1) {
|
||||||
|
this.layout.splice(index, 1)
|
||||||
|
|
||||||
|
} else {
|
||||||
|
this.layout.push(panel.id)
|
||||||
|
}
|
||||||
|
Vue.set(this.panels[panel.id], 'mounted', false)
|
||||||
|
this.saveLayout()
|
||||||
|
this.$nextTick(() => this.rerenderPanels())
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
@ -101,18 +204,30 @@ export default {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel {
|
.panel, .panels > div {
|
||||||
width: 250px;
|
width: 280px;
|
||||||
margin: 16px;
|
padding: 16px;
|
||||||
|
|
||||||
& > a {
|
.panel--header h3 {
|
||||||
|
cursor: grab;
|
||||||
|
&:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
& > .panel--header {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 50px;
|
top: 50px;
|
||||||
display: block;
|
|
||||||
background: linear-gradient(var(--color-main-background-translucent), var(--color-main-background-translucent) 80%, rgba(255, 255, 255, 0));
|
background: linear-gradient(var(--color-main-background-translucent), var(--color-main-background-translucent) 80%, rgba(255, 255, 255, 0));
|
||||||
backdrop-filter: blur(4px);
|
backdrop-filter: blur(4px);
|
||||||
|
display: flex;
|
||||||
|
a {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
h3 {
|
h3 {
|
||||||
|
display: block;
|
||||||
|
flex-grow: 1;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
|
@ -123,4 +238,35 @@ export default {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.add-panels {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 20px;
|
||||||
|
right: 20px;
|
||||||
|
padding: 10px;
|
||||||
|
padding-left: 35px;
|
||||||
|
padding-right: 15px;
|
||||||
|
background-position: 10px center;
|
||||||
|
border-radius: 100px;
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--color-background-hover);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal__content {
|
||||||
|
width: 30vw;
|
||||||
|
margin: 20px;
|
||||||
|
ol {
|
||||||
|
list-style-type: none;
|
||||||
|
}
|
||||||
|
li label {
|
||||||
|
padding: 10px;
|
||||||
|
display: block;
|
||||||
|
list-style-type: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.flip-list-move {
|
||||||
|
transition: transform 1s;
|
||||||
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
import Vue from 'vue'
|
import Vue from 'vue'
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
|
import { translate as t } from '@nextcloud/l10n'
|
||||||
|
Vue.prototype.t = t
|
||||||
|
|
||||||
const Dashboard = Vue.extend(App)
|
const Dashboard = Vue.extend(App)
|
||||||
const Instance = new Dashboard({}).$mount('#app')
|
const Instance = new Dashboard({}).$mount('#app')
|
||||||
|
|
|
@ -8798,6 +8798,11 @@
|
||||||
"is-fullwidth-code-point": "^2.0.0"
|
"is-fullwidth-code-point": "^2.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"smooth-dnd": {
|
||||||
|
"version": "0.12.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/smooth-dnd/-/smooth-dnd-0.12.1.tgz",
|
||||||
|
"integrity": "sha512-Dndj/MOG7VP83mvzfGCLGzV2HuK1lWachMtWl/Iuk6zV7noDycIBnflwaPuDzoaapEl3Pc4+ybJArkkx9sxPZg=="
|
||||||
|
},
|
||||||
"snap.js": {
|
"snap.js": {
|
||||||
"version": "2.0.9",
|
"version": "2.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/snap.js/-/snap.js-2.0.9.tgz",
|
"resolved": "https://registry.npmjs.org/snap.js/-/snap.js-2.0.9.tgz",
|
||||||
|
@ -9913,6 +9918,14 @@
|
||||||
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-3.3.4.tgz",
|
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-3.3.4.tgz",
|
||||||
"integrity": "sha512-SdKRBeoXUjaZ9R/8AyxsdTqkOfMcI5tWxPZOUX5Ie1BTL5rPSZ0O++pbiZCeYeythiZIdLEfkDiQPKIaWk5hDg=="
|
"integrity": "sha512-SdKRBeoXUjaZ9R/8AyxsdTqkOfMcI5tWxPZOUX5Ie1BTL5rPSZ0O++pbiZCeYeythiZIdLEfkDiQPKIaWk5hDg=="
|
||||||
},
|
},
|
||||||
|
"vue-smooth-dnd": {
|
||||||
|
"version": "0.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/vue-smooth-dnd/-/vue-smooth-dnd-0.8.1.tgz",
|
||||||
|
"integrity": "sha512-eZVVPTwz4A1cs0+CjXx/ihV+gAl3QBoWQnU6+23Gp59t0WBU99z7ducBQ4FvjBamqOlg8SDOE5eFHQedxwB4Wg==",
|
||||||
|
"requires": {
|
||||||
|
"smooth-dnd": "0.12.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"vue-style-loader": {
|
"vue-style-loader": {
|
||||||
"version": "4.1.2",
|
"version": "4.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/vue-style-loader/-/vue-style-loader-4.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/vue-style-loader/-/vue-style-loader-4.1.2.tgz",
|
||||||
|
|
|
@ -82,6 +82,7 @@
|
||||||
"vue-material-design-icons": "^4.8.0",
|
"vue-material-design-icons": "^4.8.0",
|
||||||
"vue-multiselect": "^2.1.6",
|
"vue-multiselect": "^2.1.6",
|
||||||
"vue-router": "^3.3.4",
|
"vue-router": "^3.3.4",
|
||||||
|
"vue-smooth-dnd": "^0.8.1",
|
||||||
"vuex": "^3.5.1",
|
"vuex": "^3.5.1",
|
||||||
"vuex-router-sync": "^5.0.0"
|
"vuex-router-sync": "^5.0.0"
|
||||||
},
|
},
|
||||||
|
|
Loading…
Reference in New Issue