diff --git a/front/src/components/boxs/scene/EditSceneBox.jsx b/front/src/components/boxs/scene/EditSceneBox.jsx index 94541784c6..fa500e2547 100644 --- a/front/src/components/boxs/scene/EditSceneBox.jsx +++ b/front/src/components/boxs/scene/EditSceneBox.jsx @@ -1,10 +1,12 @@ import { Component } from 'preact'; import { Localizer, Text } from 'preact-i18n'; +import update from 'immutability-helper'; import BaseEditBox from '../baseEditBox'; import withIntlAsProp from '../../../utils/withIntlAsProp'; import { connect } from 'unistore/preact'; import Select from 'react-select'; import { RequestStatus } from '../../../utils/consts'; +import { SceneListWithDragAndDrop } from '../../drag-and-drop/SceneListWithDragAndDrop'; class EditSceneBox extends Component { updateScenes = selectedSceneOptions => { @@ -22,6 +24,33 @@ class EditSceneBox extends Component { }); }; + updateCustomOrder = e => { + const customOrder = e.target.checked; + const newBoxConfig = { + scene_custom_order: customOrder + }; + if (customOrder) { + // The currently displayed (alphabetical) order becomes + // the starting point of the custom order. + newBoxConfig.scenes = (this.state.selectedSceneOptions || []).map(option => option.value); + } + this.props.updateBoxConfig(this.props.x, this.props.y, newBoxConfig); + }; + + moveScene = (currentIndex, newIndex) => { + const scenes = (this.state.selectedSceneOptions || []).map(option => option.value); + const movedScene = scenes[currentIndex]; + const scenesWithoutMovedScene = update(scenes, { + $splice: [[currentIndex, 1]] + }); + const newScenes = update(scenesWithoutMovedScene, { + $splice: [[newIndex, 0, movedScene]] + }); + this.props.updateBoxConfig(this.props.x, this.props.y, { + scenes: newScenes + }); + }; + getScenes = async () => { try { this.setState({ status: RequestStatus.Getting }); @@ -54,11 +83,22 @@ class EditSceneBox extends Component { refreshSelectedOptions = async props => { const selectedSceneOptions = []; if (this.state.sceneOptions) { - this.state.sceneOptions.forEach(sceneOption => { - if (props.box.scenes && props.box.scenes.indexOf(sceneOption.value) !== -1) { - selectedSceneOptions.push(sceneOption); - } - }); + if (props.box.scene_custom_order && props.box.scenes) { + // The scenes are displayed in the order chosen by the user + props.box.scenes.forEach(sceneSelector => { + const sceneOption = this.state.sceneOptions.find(option => option.value === sceneSelector); + if (sceneOption) { + selectedSceneOptions.push(sceneOption); + } + }); + } else { + // By default, the scenes are displayed in alphabetical order + this.state.sceneOptions.forEach(sceneOption => { + if (props.box.scenes && props.box.scenes.indexOf(sceneOption.value) !== -1) { + selectedSceneOptions.push(sceneOption); + } + }); + } } await this.setState({ selectedSceneOptions }); }; @@ -68,14 +108,17 @@ class EditSceneBox extends Component { }; componentWillReceiveProps(nextProps) { - if (nextProps.box && nextProps.box.scenes) { - if (!this.props.box || !this.props.box.scenes || nextProps.box.scenes !== this.props.box.scenes) { + const currentBox = this.props.box || {}; + const nextBox = nextProps.box || {}; + if (nextBox.scenes) { + if (nextBox.scenes !== currentBox.scenes || nextBox.scene_custom_order !== currentBox.scene_custom_order) { this.refreshSelectedOptions(nextProps); } } } render(props, { status, selectedSceneOptions, sceneOptions }) { const loading = status === RequestStatus.Getting && !status; + const displayScenesOrder = props.box.scene_custom_order && selectedSceneOptions && selectedSceneOptions.length > 0; return (
@@ -112,6 +155,33 @@ class EditSceneBox extends Component { />
)} + {sceneOptions && ( +
+ + + + +
+ )} + {displayScenesOrder && ( +
+ + +
+ )}
diff --git a/front/src/components/boxs/scene/SceneBox.jsx b/front/src/components/boxs/scene/SceneBox.jsx index 1de708fac9..3289ec39c9 100644 --- a/front/src/components/boxs/scene/SceneBox.jsx +++ b/front/src/components/boxs/scene/SceneBox.jsx @@ -111,9 +111,25 @@ class SceneBoxComponent extends Component { } } + // The API returns the scenes sorted alphabetically. + // If the user chose a custom order in the widget configuration, + // the scenes are displayed in the order of the box configuration. + getOrderedScenes = scenes => { + const { box } = this.props; + if (!scenes || !box.scene_custom_order || !box.scenes) { + return scenes; + } + const getScenePosition = selector => { + const position = box.scenes.indexOf(selector); + return position === -1 ? box.scenes.length : position; + }; + return [...scenes].sort((a, b) => getScenePosition(a.selector) - getScenePosition(b.selector)); + }; + render(props, { scenes, status, runningScenes, now }) { const boxTitle = props.box.name; const loading = status === RequestStatus.Getting && !status; + const orderedScenes = this.getOrderedScenes(scenes); return (
@@ -132,8 +148,8 @@ class SceneBoxComponent extends Component {
- {scenes && - scenes.map(scene => ( + {orderedScenes && + orderedScenes.map(scene => ( { + const ref = useRef(null); + const [{ isDragging }, drag, preview] = useDrag( + () => ({ + type: SCENE_TYPE, + item: () => { + return { index }; + }, + collect: monitor => ({ + isDragging: !!monitor.isDragging() + }) + }), + // the rows have a stable key, so a row keeps its instance when the order changes: + // the drag spec must be re-created when the index of the row changes + [index] + ); + const [{ isActive }, drop] = useDrop({ + accept: SCENE_TYPE, + collect: monitor => ({ + isActive: monitor.canDrop() && monitor.isOver() + }), + drop(item) { + if (!ref.current) { + return; + } + moveScene(item.index, index); + } + }); + preview(drop(ref)); + + return ( +
+
+
+ +
+
+ {selectedSceneOption.label} +
+
+
+ ); +}; + +const { backend: dragAndDropBackend, options: dragAndDropBackendOptions } = getDragAndDropBackend(); + +const SceneListWithDragAndDrop = ({ selectedSceneOptions, moveScene }) => ( + + {selectedSceneOptions.map((selectedSceneOption, index) => ( + + ))} + +); + +export { SceneListWithDragAndDrop }; diff --git a/front/src/components/drag-and-drop/style.css b/front/src/components/drag-and-drop/style.css index abcb9ce1a4..c3ee0245a4 100644 --- a/front/src/components/drag-and-drop/style.css +++ b/front/src/components/drag-and-drop/style.css @@ -46,4 +46,24 @@ .deviceListRemoveButton { z-index: 0; +} + +.sceneListDragAndDrop { + cursor: pointer; + user-select: none; +} + +.sceneListDragAndDropDragging { + opacity: 0.5; +} + +.sceneListDragAndDropActive { + background-color: #ecf0f1; +} + +.sceneListDragAndDropLabel { + height: auto; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } \ No newline at end of file diff --git a/front/src/config/i18n/de.json b/front/src/config/i18n/de.json index d44f29a73c..eaee822006 100644 --- a/front/src/config/i18n/de.json +++ b/front/src/config/i18n/de.json @@ -610,7 +610,10 @@ "scene": { "editNameLabel": "Widget-Name (optional)", "editNamePlaceholder": "Name auf dem Dashboard angezeigt", - "editSceneLabel": "Wähle die Szene aus, die hier angezeigt werden soll." + "editSceneLabel": "Wähle die Szene aus, die hier angezeigt werden soll.", + "customOrderLabel": "Benutzerdefinierte Reihenfolge", + "customOrderDescription": "Szenen werden standardmäßig in alphabetischer Reihenfolge angezeigt. Aktiviere diese Option, um sie nach deinen Wünschen zu sortieren.", + "orderSceneLabel": "Ziehe die Szenen per Drag & Drop, um ihre Reihenfolge zu ändern" }, "link": { "editTitleLabel": "Titel", diff --git a/front/src/config/i18n/en.json b/front/src/config/i18n/en.json index 45877e8876..21ba148908 100644 --- a/front/src/config/i18n/en.json +++ b/front/src/config/i18n/en.json @@ -629,7 +629,10 @@ "scene": { "editNameLabel": "Widget Name (optional)", "editNamePlaceholder": "Name displayed on the dashboard", - "editSceneLabel": "Select the scene you want to display here." + "editSceneLabel": "Select the scene you want to display here.", + "customOrderLabel": "Custom order", + "customOrderDescription": "By default, scenes are displayed in alphabetical order. Enable this option to sort them the way you want.", + "orderSceneLabel": "Drag and drop the scenes to change their order" }, "link": { "editTitleLabel": "Title", diff --git a/front/src/config/i18n/fr.json b/front/src/config/i18n/fr.json index 729f9b9335..8d6a8f6420 100644 --- a/front/src/config/i18n/fr.json +++ b/front/src/config/i18n/fr.json @@ -595,7 +595,10 @@ "scene": { "editNameLabel": "Nom du widget (optionnel)", "editNamePlaceholder": "Nom affiché sur le tableau de bord", - "editSceneLabel": "Sélectionnez la scène que vous souhaitez afficher ici." + "editSceneLabel": "Sélectionnez la scène que vous souhaitez afficher ici.", + "customOrderLabel": "Ordre personnalisé", + "customOrderDescription": "Par défaut, les scènes sont affichées par ordre alphabétique. Activez cette option pour les trier comme vous le souhaitez.", + "orderSceneLabel": "Glissez-déposez les scènes pour modifier leur ordre" }, "link": { "editTitleLabel": "Titre", diff --git a/server/models/dashboard.js b/server/models/dashboard.js index c8be178723..b825cf8c23 100644 --- a/server/models/dashboard.js +++ b/server/models/dashboard.js @@ -34,6 +34,9 @@ const boxesSchema = Joi.array().items( camera_latency: Joi.string(), camera_live_auto_start: Joi.boolean(), scenes: Joi.array().items(Joi.string()), + // scene box: when true, the scenes are displayed in the order of the "scenes" array + // instead of the default alphabetical order + scene_custom_order: Joi.boolean(), humidity_use_custom_value: Joi.boolean(), humidity_min: Joi.number(), humidity_max: Joi.number(), diff --git a/server/test/lib/dashboard/dashboard.create.test.js b/server/test/lib/dashboard/dashboard.create.test.js index 37ce0bb9bd..4b450e6a14 100644 --- a/server/test/lib/dashboard/dashboard.create.test.js +++ b/server/test/lib/dashboard/dashboard.create.test.js @@ -23,6 +23,28 @@ describe('dashboard.create', () => { // selector should be the slug of the name + a dash + 4 random characters expect(newDashboard.selector).to.match(/^my-new-dashboard-[a-z0-9]{4}$/); }); + it('should create a dashboard with a scene box with a custom scene order', async () => { + const newDashboard = await dashboard.create('0cd30aef-9c4e-4a23-88e3-3547971296e5', { + name: 'My dashboard with a scene box', + type: DASHBOARD_TYPE.MAIN, + position: 0, + visibility: DASHBOARD_VISIBILITY.PRIVATE, + boxes: [ + [ + { + type: DASHBOARD_BOX_TYPE.SCENE, + scenes: ['my-second-scene', 'my-first-scene'], + scene_custom_order: true, + }, + ], + ], + }); + expect(newDashboard.boxes[0][0]).to.deep.equal({ + type: DASHBOARD_BOX_TYPE.SCENE, + scenes: ['my-second-scene', 'my-first-scene'], + scene_custom_order: true, + }); + }); it('should create a dashboard with the selector given', async () => { const newDashboard = await dashboard.create('0cd30aef-9c4e-4a23-88e3-3547971296e5', { name: 'My dashboard with a custom selector',