diff --git a/front/cypress/e2e/routes/scene/Scene.cy.js b/front/cypress/e2e/routes/scene/Scene.cy.js index cfe135bd5e..2e735ec28a 100644 --- a/front/cypress/e2e/routes/scene/Scene.cy.js +++ b/front/cypress/e2e/routes/scene/Scene.cy.js @@ -1,41 +1,7 @@ describe('Scene view', () => { - before(() => { - cy.login(); - const serverUrl = Cypress.env('serverUrl'); - cy.request({ - method: 'GET', - url: `${serverUrl}/api/v1/room` - }).then(res => { - const device = { - name: 'One device', - external_id: 'one-device', - selector: 'one-device', - room_id: res.body[0].id, - features: [ - { - name: 'Multilevel', - category: 'light', - type: 'temperature', - external_id: 'light-temperature', - selector: 'light-temperature', - read_only: false, - keep_history: true, - has_feedback: true, - min: 0, - max: 1 - } - ] - }; - cy.createDevice(device, 'mqtt'); - }); - }); beforeEach(() => { cy.login(); }); - after(() => { - // Delete all Bluetooth devices - cy.deleteDevices('mqtt'); - }); it('Should create new scene', () => { cy.visit('/dashboard/scene'); cy.contains('scene.newButton') @@ -57,14 +23,23 @@ describe('Scene view', () => { cy.url().should('eq', `${Cypress.config().baseUrl}/dashboard/scene/my-scene`); }); - it('Should edit the scene description', () => { + it('Should edit the scene settings', () => { cy.visit('/dashboard/scene/my-scene'); - cy.contains('editScene.editDescriptionPlaceholder').click(); + cy.get('div[class*="card-header"]') + .contains('editScene.settings') + .should('have.class', 'card-title') + .click(); - cy.get('input:visible').then(inputs => { - // Zone name - cy.wrap(inputs[0]).type('My scene description'); + cy.get('div[class*="form-group"]').then(inputs => { + cy.wrap(inputs[0]) + .find('input') + .clear() + .type('My scene name'); + cy.wrap(inputs[1]) + .find('input') + .type('My scene description'); + cy.wrap(inputs[2]).type('My tag 1{enter}{enter}'); }); // I don't know why, but I'm unable to get this button with @@ -73,6 +48,7 @@ describe('Scene view', () => { cy.wrap(buttons[0]).click(); }); }); + it('Should add new condition house empty', () => { cy.visit('/dashboard/scene/my-scene'); cy.contains('editScene.addActionButton') @@ -81,12 +57,14 @@ describe('Scene view', () => { const i18n = Cypress.env('i18n'); - cy.get('div[class*="-control"]') - .click(0, 0, { force: true }) - .get('[class*="-menu"]') - .find('[class*="-option"]') - .filter(`:contains("${i18n.editScene.actions.house['is-empty']}")`) - .click(0, 0, { force: true }); + cy.get('div[class*="-control"]').then(inputs => { + cy.wrap(inputs[1]) + .click(0, 0, { force: true }) + .get('[class*="-menu"]') + .find('[class*="-option"]') + .filter(`:contains("${i18n.editScene.actions.house['is-empty']}")`) + .click(0, 0, { force: true }); + }); // I don't know why, but I'm unable to get this button with // the text. Using the class but it's not recommended otherwise!! @@ -94,14 +72,56 @@ describe('Scene view', () => { cy.wrap(buttons[1]).click(); }); - cy.get('div[class*="-control"]') - .click(0, 0, { force: true }) - .get('[class*="-menu"]') - .find('[class*="-option"]') - .filter(`:contains("My House")`) - .click(0, 0, { force: true }); + cy.get('div[class*="-control"]').then(inputs => { + cy.wrap(inputs[1]) + .click(0, 0, { force: true }) + .get('[class*="-menu"]') + .find('[class*="-option"]') + .filter(`:contains("My House")`) + .click(0, 0, { force: true }); + }); }); + it('Should add new condition device set value', () => { + const serverUrl = Cypress.env('serverUrl'); + cy.intercept( + { + method: 'GET', + url: `${serverUrl}/api/v1/room?expand=devices` + }, + [ + { + id: 'd63ce677-f5f8-47e1-816d-7aa227c863e4', + house_id: '6c1c78f0-1c26-4944-9149-77188e25d00d', + name: 'Living Room', + selector: 'living-room', + created_at: '2023-10-03T12:21:39.551Z', + updated_at: '2023-10-03T12:21:39.551Z', + devices: [ + { + name: 'One device', + selector: 'one-device', + features: [ + { + name: 'Multilevel', + selector: 'light-temperature', + category: 'light', + type: 'temperature', + read_only: false, + unit: null, + min: 0, + max: 1, + last_value: null, + last_value_changed: null + } + ], + service: { id: '123d4d56-6cbd-4020-991f-2a0a8e0ac3e0', name: 'mqtt' } + } + ] + } + ] + ).as('loadDevices'); + cy.visit('/dashboard/scene/my-scene'); cy.contains('editScene.addActionButton') .should('have.class', 'btn-outline-primary') @@ -109,12 +129,14 @@ describe('Scene view', () => { const i18n = Cypress.env('i18n'); - cy.get('div[class*="-control"]') - .click(0, 0, { force: true }) - .get('[class*="-menu"]') - .find('[class*="-option"]') - .filter(`:contains("${i18n.editScene.actions.device['set-value']}")`) - .click(0, 0, { force: true }); + cy.get('div[class*="-control"]').then(inputs => { + cy.wrap(inputs[1]) + .click(0, 0, { force: true }) + .get('[class*="-menu"]') + .find('[class*="-option"]') + .filter(`:contains("${i18n.editScene.actions.device['set-value']}")`) + .click(0, 0, { force: true }); + }); // I don't know why, but I'm unable to get this button with // the text. Using the class but it's not recommended otherwise!! @@ -122,13 +144,21 @@ describe('Scene view', () => { cy.wrap(buttons[1]).click(); }); - cy.get('div[class*="-control"]') - .click(0, 0, { force: true }) - .get('[class*="-menu"]') - .find('[class*="-option"]') - .filter(`:contains("Multilevel")`) - .click(0, 0, { force: true }); + cy.wait('@loadDevices'); + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(100); + + cy.get('div[class*="-control"]').then(inputs => { + cy.wrap(inputs[1]) + .click(0, 0, { force: true }) + .get('[class*="-menu"]') + .find('[class*="-option"]') + .filter(`:contains("Multilevel")`) + .click(0, 0, { force: true }); + cy.log('4'); + }); }); + it('Should add new calendar event trigger', () => { cy.visit('/dashboard/scene/my-scene'); cy.contains('editScene.addNewTriggerButton') @@ -137,12 +167,14 @@ describe('Scene view', () => { const i18n = Cypress.env('i18n'); - cy.get('div[class*="-control"]') - .click(0, 0, { force: true }) - .get('[class*="-menu"]') - .find('[class*="-option"]') - .filter(`:contains("${i18n.editScene.triggers.calendar['event-is-coming']}")`) - .click(0, 0, { force: true }); + cy.get('div[class*="-control"]').then(inputs => { + cy.wrap(inputs[1]) + .click(0, 0, { force: true }) + .get('[class*="-menu"]') + .find('[class*="-option"]') + .filter(`:contains("${i18n.editScene.triggers.calendar['event-is-coming']}")`) + .click(0, 0, { force: true }); + }); // I don't know why, but I'm unable to get this button with // the text. Using the class but it's not recommended otherwise!! @@ -156,6 +188,7 @@ describe('Scene view', () => { cy.wrap(selects[2]).select('minute'); }); }); + it('Should disable scene', () => { cy.visit('/dashboard/scene'); @@ -202,6 +235,7 @@ describe('Scene view', () => { cy.url().should('eq', `${Cypress.config().baseUrl}/dashboard/scene/my-duplicated-scene`); }); + it('Should delete existing scene', () => { cy.login(); cy.visit('/dashboard/scene/my-scene'); diff --git a/front/src/actions/createScene.js b/front/src/actions/createScene.js index bb68f40a00..db969a197f 100644 --- a/front/src/actions/createScene.js +++ b/front/src/actions/createScene.js @@ -54,7 +54,8 @@ function createActions(store) { newScene: { name: '', icon: null, - actions: [[]] + actions: [[]], + tags: [] }, newSceneErrors: null, createSceneStatus: null diff --git a/front/src/components/layout/CardFilter.jsx b/front/src/components/layout/CardFilter.jsx index be8c67d289..47c9cccb22 100644 --- a/front/src/components/layout/CardFilter.jsx +++ b/front/src/components/layout/CardFilter.jsx @@ -11,17 +11,12 @@ const CardFilter = ({ changeOrderDir, orderValue = 'asc', search, searchValue, s +
- +
); diff --git a/front/src/config/i18n/en.json b/front/src/config/i18n/en.json index ea119531f1..96c9bf8274 100644 --- a/front/src/config/i18n/en.json +++ b/front/src/config/i18n/en.json @@ -1422,9 +1422,15 @@ } }, "editScene": { + "settings": "Settings", + "nameTitle": "Scene name", "editNamePlaceholder": "Enter a scene name", "descriptionTitle": "Description", + "iconLabel": "Icon", "editDescriptionPlaceholder": "Enter a scene description", + "tagsTitle": "Tags", + "editTagsPlaceholder": "Enter a scene tags", + "createTag": "Create tag: '{{tagName}}'", "startButton": "Start", "saveButton": "Save", "deleteButton": "Delete", @@ -2091,7 +2097,8 @@ "newButton": "New", "editButton": "Edit", "startButton": "Start", - "searchPlaceholder": "Search scenes" + "searchPlaceholder": "Search scenes", + "filterTagsName": "Filter by tags" }, "gateway": { "instanceConfiguredTitle": "Gladys Plus", diff --git a/front/src/config/i18n/fr.json b/front/src/config/i18n/fr.json index 567b38fb1f..1e888344e5 100644 --- a/front/src/config/i18n/fr.json +++ b/front/src/config/i18n/fr.json @@ -1423,9 +1423,15 @@ } }, "editScene": { + "settings": "Configuration", + "nameTitle": "Nom de scène", "editNamePlaceholder": "Entrez un nom de scène", "descriptionTitle": "Description", + "iconLabel": "Icône", "editDescriptionPlaceholder": "Entrez une description pour la scène", + "createTag": "Créer le tag : '{{tagName}}'", + "tagsTitle": "Tags", + "editTagsPlaceholder": "Entrez un tag pour la scène", "startButton": "Démarrer", "saveButton": "Sauvegarder", "deleteButton": "Supprimer", @@ -2092,7 +2098,8 @@ "newButton": "Nouveau", "editButton": "Editer", "startButton": "Démarrer", - "searchPlaceholder": "Chercher une scène" + "searchPlaceholder": "Chercher une scène", + "filterTagsName": "Filtrer par tags" }, "gateway": { "instanceConfiguredTitle": "Gladys Plus", diff --git a/front/src/routes/scene/SceneCard.jsx b/front/src/routes/scene/SceneCard.jsx index 6e20e17d04..b4fd0fb058 100644 --- a/front/src/routes/scene/SceneCard.jsx +++ b/front/src/routes/scene/SceneCard.jsx @@ -3,6 +3,7 @@ import { Component } from 'preact'; import { Link } from 'preact-router/match'; import cx from 'classnames'; import style from './style.css'; +import { MAX_LENGTH_TAG } from './constant'; class SceneCard extends Component { startScene = async () => { @@ -34,7 +35,7 @@ class SceneCard extends Component {
-
+
@@ -53,6 +54,16 @@ class SceneCard extends Component {

{props.scene.name}

{props.scene.description}
+
+ {props.scene.tags && + props.scene.tags.map(tag => ( + + {tag.name.length > MAX_LENGTH_TAG + ? `${tag.name.substring(0, MAX_LENGTH_TAG - 3)}...` + : tag.name} + + ))} +
diff --git a/front/src/routes/scene/SceneTagFilter.jsx b/front/src/routes/scene/SceneTagFilter.jsx new file mode 100644 index 0000000000..e7bc39bfc2 --- /dev/null +++ b/front/src/routes/scene/SceneTagFilter.jsx @@ -0,0 +1,113 @@ +import { Component } from 'preact'; +import cx from 'classnames'; +import { Text } from 'preact-i18n'; +import { MAX_LENGTH_TAG } from './constant'; + +class SceneTagFilter extends Component { + constructor(props) { + super(props); + + this.state = { + tagFilterDropdownOpened: false + }; + } + + setDropdownRef = dropdownRef => { + this.dropdownRef = dropdownRef; + }; + + componentWillReceiveProps(nextProps) { + let tagsStatus = {}; + if (nextProps.tags) { + tagsStatus = nextProps.tags.reduce( + (tags, tag) => ({ + ...tags, + [tag.name]: false + }), + {} + ); + } + if (nextProps.sceneTagSearch) { + nextProps.sceneTagSearch.forEach(tagName => { + tagsStatus[tagName] = true; + }); + } + this.setState({ + tagsStatus + }); + } + + toggleTagFilterDropdown = () => { + this.setState({ + tagFilterDropdownOpened: !this.state.tagFilterDropdownOpened + }); + }; + + closeTagFilterDropdown = e => { + if (this.dropdownRef && this.dropdownRef.contains(e.target)) { + return; + } + this.setState({ tagFilterDropdownOpened: false }); + }; + + selectedTags = async tagName => { + await this.setState({ + tagsStatus: { + ...this.state.tagsStatus, + [tagName]: !this.state.tagsStatus[tagName] + } + }); + const selectedTags = Object.keys(this.state.tagsStatus).filter(tagName => this.state.tagsStatus[tagName]); + this.props.searchTags(selectedTags); + }; + + componentDidMount() { + document.addEventListener('click', this.closeTagFilterDropdown, true); + } + + componentWillUnmount() { + document.removeEventListener('click', this.closeTagFilterDropdown, true); + } + + render(props, { tagFilterDropdownOpened, tagsStatus }) { + return ( +
+ +
+ {props.tags && + props.tags.map(tag => ( +
+
  • this.selectedTags(tag.name)}> +
    + this.selectedTags(tag.name)} + checked={tagsStatus[tag.name]} + /> + +
    +
  • +
    + ))} +
    +
    + ); + } +} + +export default SceneTagFilter; diff --git a/front/src/routes/scene/constant.js b/front/src/routes/scene/constant.js new file mode 100644 index 0000000000..8416093cf8 --- /dev/null +++ b/front/src/routes/scene/constant.js @@ -0,0 +1,3 @@ +const MAX_LENGTH_TAG = 30; + +module.exports.MAX_LENGTH_TAG = MAX_LENGTH_TAG; diff --git a/front/src/routes/scene/edit-scene/EditScenePage.jsx b/front/src/routes/scene/edit-scene/EditScenePage.jsx index faa20abfca..9526285b5f 100644 --- a/front/src/routes/scene/edit-scene/EditScenePage.jsx +++ b/front/src/routes/scene/edit-scene/EditScenePage.jsx @@ -1,4 +1,3 @@ -import { Text, Localizer } from 'preact-i18n'; import update from 'immutability-helper'; import cx from 'classnames'; @@ -7,6 +6,8 @@ import ActionGroup from './ActionGroup'; import SceneActionsDropdown from './SceneActionsDropdown'; import TriggerGroup from './TriggerGroup'; import style from './style.css'; +import Settings from './Settings'; +import { Text } from 'preact-i18n'; const ACTION_CARD_TYPE = 'ACTION_CARD_TYPE'; const ACTION_GROUP_TYPE = 'ACTION_GROUP_TYPE'; @@ -21,87 +22,31 @@ const EditScenePage = ({ children, ...props }) => (

    - {props.isNameEditable ? ( -
    -
    - - } - /> - -
    - -
    -
    -
    - ) : ( - - {props.scene.name} - - - - - )} -

    - - {props.isDescriptionEditable ? ( -
    -
    - - } - /> - -
    - -
    -
    -
    - ) : ( - - {props.scene.description ? ( - {props.scene.description} - ) : ( - - )} - - - )} + {props.scene.description && {props.scene.description}}
    +
    - -
    )} +
    + +
    +
    (
    + {props.scene.actions.map((parallelActions, index) => (
    diff --git a/front/src/routes/scene/edit-scene/Settings.jsx b/front/src/routes/scene/edit-scene/Settings.jsx new file mode 100644 index 0000000000..ff47c36437 --- /dev/null +++ b/front/src/routes/scene/edit-scene/Settings.jsx @@ -0,0 +1,138 @@ +import cx from 'classnames'; +import { Localizer, Text } from 'preact-i18n'; +import CreatableSelect from 'react-select/creatable'; +import { Component } from 'preact'; +import styles from './style.css'; +import iconList from '../../../../../server/config/icons.json'; + +class Settings extends Component { + constructor(props) { + super(props); + this.state = { + cardOpened: false + }; + } + + openCloseCard = () => { + this.setState({ + cardOpened: !this.state.cardOpened + }); + }; + + render(props, { cardOpened }) { + return ( +
    +
    +
    +
    + +

    + +

    +
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    + + } + /> + +
    +
    +
    + +
    + + } + /> + +
    +
    +
    + +
    + + ({ value: tag.name, label: tag.name }))} + closeMenuOnSelect={false} + isMulti + options={props.tags && props.tags.map(tag => ({ value: tag.name, label: tag.name }))} + onChange={tags => props.setTags(tags.map(tag => tag.value))} + formatCreateLabel={inputValue => ( + + )} + /> + +
    +
    +
    +
    + +
    + {iconList.map(icon => ( +
    +
    + +
    +
    + ))} +
    +
    +
    +
    +
    +
    +
    +
    + ); + } +} + +export default Settings; diff --git a/front/src/routes/scene/edit-scene/index.js b/front/src/routes/scene/edit-scene/index.js index af441bf5ec..406bcafa1a 100644 --- a/front/src/routes/scene/edit-scene/index.js +++ b/front/src/routes/scene/edit-scene/index.js @@ -92,7 +92,6 @@ class EditScene extends Component { this.setState({ saving: true, error: false }); try { await this.props.httpClient.patch(`/api/v1/scene/${this.props.scene_selector}`, this.state.scene); - this.setState({ isNameEditable: false, isDescriptionEditable: false }); } catch (e) { console.error(e); this.setState({ error: true }); @@ -220,6 +219,7 @@ class EditScene extends Component { }); }, 500); }; + deleteScene = async () => { this.setState({ saving: true }); try { @@ -309,35 +309,6 @@ class EditScene extends Component { }); }; - toggleIsNameEditable = async () => { - await this.setState(prevState => ({ isNameEditable: !prevState.isNameEditable, isDescriptionEditable: false })); - if (this.state.isNameEditable) { - this.nameInput.focus(); - } - }; - - setNameInputRef = nameInput => { - this.nameInput = nameInput; - }; - - toggleIsDescriptionEditable = async () => { - await this.setState(prevState => ({ - isDescriptionEditable: !prevState.isDescriptionEditable, - isNameEditable: false - })); - if (this.state.isDescriptionEditable) { - this.descriptionInput.focus(); - } - }; - - closeEdition = () => { - this.setState({ isNameEditable: false, isDescriptionEditable: false }); - }; - - setDescriptionInputRef = descriptionInput => { - this.descriptionInput = descriptionInput; - }; - updateSceneName = e => { this.setState(prevState => { const newState = update(prevState, { @@ -364,9 +335,24 @@ class EditScene extends Component { }); }; + updateSceneIcon = e => { + console.log('updateSceneIcon', e.target.value); + this.setState(prevState => { + const newState = update(prevState, { + scene: { + icon: { + $set: e.target.value + } + } + }); + return newState; + }); + }; + duplicateScene = () => { route(`/dashboard/scene/${this.props.scene_selector}/duplicate`); }; + moveCard = async (originalX, originalY, destX, destY) => { // incorrect coordinates if (destX < 0 || destY < 0) { @@ -445,20 +431,43 @@ class EditScene extends Component { await this.setState(newState); }; + setTags = tags => { + this.setState(prevState => { + const newState = update(prevState, { + scene: { + tags: { + $set: tags.map(tag => ({ name: tag })) + } + } + }); + return newState; + }); + }; + + getTags = async () => { + try { + const tags = await this.props.httpClient.get(`/api/v1/tag_scene`); + this.setState({ + tags + }); + } catch (e) { + console.error(e); + } + }; + constructor(props) { super(props); this.isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0 || navigator.msMaxTouchPoints > 0; this.state = { scene: null, variables: {}, - triggersVariables: [], - isNameEditable: false + triggersVariables: [] }; } componentDidMount() { - document.addEventListener('click', this.closeEdition, true); this.getSceneBySelector(); + this.getTags(); this.props.session.dispatcher.addListener('scene.executing-action', payload => this.highlighCurrentlyExecutedAction(payload) ); @@ -471,43 +480,42 @@ class EditScene extends Component { document.removeEventListener('click', this.closeEdition, true); } - render(props, { saving, error, variables, scene, isNameEditable, isDescriptionEditable, triggersVariables }) { + render(props, { saving, error, variables, scene, triggersVariables, tags }) { return ( scene && ( - - - +
    + + + +
    ) ); } diff --git a/front/src/routes/scene/edit-scene/style.css b/front/src/routes/scene/edit-scene/style.css index 6843b1df63..956e51c66e 100644 --- a/front/src/routes/scene/edit-scene/style.css +++ b/front/src/routes/scene/edit-scene/style.css @@ -31,3 +31,47 @@ left: auto; } } + + +.settings { + opacity: 0; + max-height: 0; + visibility: hidden; + transition: opacity 0.3s ease, max-height 0.3s ease; + padding: 0rem +} + +.settingsOpen { + visibility: visible; + opacity: 1; + max-height: 1000px; + padding: 1.5rem 1.5rem; +} + +.iconContainer { + margin-top: 1rem; + height: 10rem; + overflow: scroll; +} + +.iconDiv { + padding: 5px; + width: 35px; + margin-bottom: 8px; +} + +.iconDivChecked { + background-color: #f5f7fb; + border-radius: 4px; +} + +.iconLabel { + cursor: pointer; + margin-bottom: 0; +} + +.iconInput { + position: absolute; + z-index: -1; + opacity: 0; +} diff --git a/front/src/routes/scene/index.js b/front/src/routes/scene/index.js index ee6b54fff3..b695a7b59d 100644 --- a/front/src/routes/scene/index.js +++ b/front/src/routes/scene/index.js @@ -18,6 +18,9 @@ class Scene extends Component { if (this.state.sceneSearch && this.state.sceneSearch.length) { params.search = this.state.sceneSearch; } + if (this.state.sceneTagSearch && this.state.sceneTagSearch.length) { + params.searchTags = this.state.sceneTagSearch.join(','); + } const scenes = await this.props.httpClient.get('/api/v1/scene', params); this.setState({ scenes, @@ -31,12 +34,29 @@ class Scene extends Component { }); } }; + getTags = async () => { + try { + const tags = await this.props.httpClient.get(`/api/v1/tag_scene`); + this.setState({ + tags + }); + } catch (e) { + console.error(e); + } + }; search = async e => { await this.setState({ sceneSearch: e.target.value }); await this.getScenes(); }; + searchTags = async tags => { + await this.setState({ + sceneTagSearch: tags + }); + await this.getScenes(); + }; + changeOrderDir = async e => { await this.setState({ getScenesOrderDir: e.target.value @@ -88,6 +108,7 @@ class Scene extends Component { getScenesOrderDir: 'asc', scenes: [], sceneSearch: null, + sceneTagSearch: null, loading: true }; this.debouncedSearch = debounce(this.search.bind(this), 200); @@ -95,9 +116,10 @@ class Scene extends Component { componentWillMount() { this.getScenes(); + this.getTags(); } - render(props, { scenes, loading, getError }) { + render(props, { scenes, loading, getError, tags, sceneTagSearch }) { return ( ); } diff --git a/front/src/routes/scene/new-scene/NewScenePage.jsx b/front/src/routes/scene/new-scene/NewScenePage.jsx index e8fde77d66..14290b7bf2 100644 --- a/front/src/routes/scene/new-scene/NewScenePage.jsx +++ b/front/src/routes/scene/new-scene/NewScenePage.jsx @@ -42,6 +42,7 @@ const NewScenePage = ({ children, ...props }) => (
    +