Allow userdefined order and start with drag and drop resorting

Signed-off-by: Julius Härtl <jus@bitgrid.net>
This commit is contained in:
Julius Härtl 2020-06-15 08:18:50 +02:00
parent 55473dd2eb
commit 429049c809
No known key found for this signature in database
GPG Key ID: 4C614C6ED2CDE6DF
6 changed files with 215 additions and 30 deletions

View File

@ -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'],
] ]
]; ];

View File

@ -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]);
}
} }

View File

@ -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>

View File

@ -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')

13
package-lock.json generated
View File

@ -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",

View File

@ -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"
}, },