Sitemap editor: Add press release button support (#2553)

Related to https://github.com/openhab/openhab-core/pull/4183.
Depends on https://github.com/openhab/openhab-core/pull/4204.

This PR implements press/release button support.

Refactor code to allow proper quoting of arguments in mappings and conditions.
Quotes are now preserved, and therefore they need to be removed in core
at usage (https://github.com/openhab/openhab-core/pull/4204).

---------

Signed-off-by: Mark Herwege <mark.herwege@telenet.be>
pull/2567/head
Mark Herwege 2024-05-07 10:44:40 +02:00 committed by GitHub
parent 02024c62b5
commit 3fa319248b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 187 additions and 56 deletions

View File

@ -44,7 +44,7 @@
comma: ',',
colon: ':',
hyphen: '-',
number: /-?[0-9]+(?:\.[0-9]*)?/,
number: /[+-]?[0-9]+(?:\.[0-9]*)?/,
string: { match: /"(?:\\["\\]|[^\n"\\])*"/, value: x => x.slice(1, -1) }
})
const requiresItem = ['Group', 'Chart', 'Switch', 'Mapview', 'Slider', 'Selection', 'Setpoint', 'Input ', 'Colorpicker', 'Default']
@ -154,17 +154,21 @@ WidgetColorAttrValue -> %lbracket _ Colors _ %rbracket
Mappings -> Mapping {% (d) => [d[0]] %}
| Mappings _ %comma _ Mapping {% (d) => d[0].concat([d[4]]) %}
Mapping -> Command _ %equals _ Label {% (d) => d[0][0].value + '=' + d[4][0].value %}
| Command _ %equals _ Label _ %equals _ WidgetIconAttrValue {% (d) => d[0][0].value + '=' + d[4][0].value + '=' + d[8].join("") %}
Mapping -> Command _ %colon _ Command _ %equals _ Label {% (d) => d[0] + ':' + d[4] + '=' + d[8] %}
| Command _ %equals _ Label {% (d) => d[0] + '=' + d[4] %}
| Command _ %colon _ Command _ %equals _ Label _ %equals _ WidgetIconAttrValue {% (d) => d[0] + ':' + d[4] + '=' + d[8] + '=' + d[12].join("") %}
| Command _ %equals _ Label _ %equals _ WidgetIconAttrValue {% (d) => d[0] + '=' + d[4] + '=' + d[8].join("") %}
Buttons -> Button {% (d) => [d[0]] %}
| Buttons _ %comma _ Button {% (d) => d[0].concat([d[4]]) %}
Button -> %number _ %colon _ %number _ %colon _ ButtonValue {% (d) => { return { 'row': parseInt(d[0].value), 'column': parseInt(d[4].value), 'command': d[8] } } %}
ButtonValue -> Command _ %equals _ Label {% (d) => d[0][0].value + '=' + d[4][0].value %}
| Command _ %equals _ Label _ %equals _ WidgetIconAttrValue {% (d) => d[0][0].value + '=' + d[4][0].value + '=' + d[8].join("") %}
ButtonValue -> Command _ %equals _ Label {% (d) => d[0][0].value + '=' + d[4] %}
| Command _ %equals _ Label _ %equals _ WidgetIconAttrValue {% (d) => d[0][0].value + '=' + d[4] + '=' + d[8].join("") %}
Command -> %number | %identifier | %string
Label -> %number | %identifier | %string
Command -> %number | %identifier {% (d) => d[0].value %}
| %string {% (d) => '"' + d[0].value + '"' %}
Label -> %number | %identifier {% (d) => d[0].value %}
| %string {% (d) => '"' + d[0].value + '"' %}
Visibilities -> Conditions {% (d) => d[0] %}
| Visibilities _ %comma _ Conditions {% (d) => d[0].concat(d[4]) %}

View File

@ -113,9 +113,9 @@ describe('dslUtil', () => {
mappings: [
'1=Morning',
'2=Evening',
'10=Cinéma',
'10="Cinéma"',
'11=TV',
'3=Bed time',
'3="Bed time"',
'4=Night=moon'
]
})
@ -132,9 +132,9 @@ describe('dslUtil', () => {
buttons: [
{ row: 1, column: 1, command: '1=Morning' },
{ row: 1, column: 2, command: '2=Evening' },
{ row: 1, column: 3, command: '10=Cinéma' },
{ row: 1, column: 3, command: '10="Cinéma"' },
{ row: 2, column: 1, command: '11=TV' },
{ row: 2, column: 2, command: '3=Bed time' },
{ row: 2, column: 2, command: '3="Bed time"' },
{ row: 2, column: 3, command: '4=Night=moon' }
]
})
@ -149,15 +149,39 @@ describe('dslUtil', () => {
addWidget(component, 'Selection', {
item: 'Echos',
mappings: [
'EchoDot1=Echo 1',
'EchoDot2=Echo 2',
'EchoDot1,EchoDot2=Alle'
'EchoDot1="Echo 1"',
'EchoDot2="Echo 2"',
'"EchoDot1,EchoDot2"=Alle'
]
})
const sitemap = dslUtil.toDsl(component).split('\n')
expect(sitemap[1]).toEqual(' Selection item=Echos mappings=[EchoDot1="Echo 1", EchoDot2="Echo 2", "EchoDot1,EchoDot2"=Alle]')
})
it('renders a widget with mappings and release command correctly', () => {
const component = createSitemapComponent('test', 'Test')
const widget = {
}
addWidget(component, 'Switch', {
item: 'pressAndRelease',
mappings: ['ON:OFF=ON']
})
const sitemap = dslUtil.toDsl(component).split('\n')
expect(sitemap[1]).toEqual(' Switch item=pressAndRelease mappings=[ON:OFF=ON]')
})
it('renders a widget with mappings and release command and string commands correctly', () => {
const component = createSitemapComponent('test', 'Test')
const widget = {
}
addWidget(component, 'Switch', {
item: 'pressAndRelease',
mappings: ['"ON command":"OFF command"="ON"']
})
const sitemap = dslUtil.toDsl(component).split('\n')
expect(sitemap[1]).toEqual(' Switch item=pressAndRelease mappings=["ON command":"OFF command"="ON"]')
})
it('renders a widget with 0 value parameter correctly', () => {
const component = createSitemapComponent('test', 'Test')
const widget = {

View File

@ -219,10 +219,10 @@ describe('SitemapCode', () => {
item: 'Scene_General',
buttons: [
{ row: 1, column: 1, command: '1=Morning' },
{ row: 1, column: 2, command: '2=Evening' },
{ row: 1, column: 3, command: '10=Cinéma' },
{ row: 1, column: 2, command: '2="Evening"' },
{ row: 1, column: 3, command: '10="Cinéma"' },
{ row: 2, column: 1, command: '11=TV' },
{ row: 2, column: 2, command: '3=Bed time' },
{ row: 2, column: 2, command: '3="Bed time"' },
{ row: 2, column: 3, command: '4=Night=moon' }
]
}
@ -259,10 +259,10 @@ describe('SitemapCode', () => {
item: 'Scene_General',
mappings: [
'1=Morning',
'2=Evening',
'10=Cinéma',
'2="Evening"',
'10="Cinéma"',
'11=TV',
'3=Bed time',
'3="Bed time"',
'4=Night=moon'
]
}
@ -298,9 +298,79 @@ describe('SitemapCode', () => {
config: {
item: 'Echos',
mappings: [
'EchoDot1=Echo 1',
'EchoDot2=Echo 2',
'EchoDot1,EchoDot2=Alle'
'EchoDot1="Echo 1"',
'EchoDot2="Echo 2"',
'"EchoDot1,EchoDot2"=Alle'
]
}
})
})
it('parses a mapping with release command', async () => {
expect(wrapper.vm.sitemapDsl).toBeDefined()
// simulate updating the sitemap in code
const sitemap = [
'sitemap test label="Test" {',
' Switch item=PressAndRelease mappings=[ON:OFF=ON]',
'}',
''
].join('\n')
wrapper.vm.updateSitemap(sitemap)
expect(wrapper.vm.sitemapDsl).toMatch(/^sitemap test label="Test"/)
expect(wrapper.vm.parsedSitemap.error).toBeFalsy()
await wrapper.vm.$nextTick()
// check whether an 'updated' event was emitted and its payload
// (should contain the parsing result for the new sitemap definition)
const events = wrapper.emitted().updated
expect(events).toBeTruthy()
expect(events.length).toBe(1)
const payload = events[0][0]
expect(payload.slots).toBeDefined()
expect(payload.slots.widgets).toBeDefined()
expect(payload.slots.widgets.length).toBe(1)
expect(payload.slots.widgets[0]).toEqual({
component: 'Switch',
config: {
item: 'PressAndRelease',
mappings: [
'ON:OFF=ON'
]
}
})
})
it('parses a mapping with release command and string commands', async () => {
expect(wrapper.vm.sitemapDsl).toBeDefined()
// simulate updating the sitemap in code
const sitemap = [
'sitemap test label="Test" {',
' Switch item=PressAndRelease mappings=["ON command":"OFF command"="ON"]',
'}',
''
].join('\n')
wrapper.vm.updateSitemap(sitemap)
expect(wrapper.vm.sitemapDsl).toMatch(/^sitemap test label="Test"/)
expect(wrapper.vm.parsedSitemap.error).toBeFalsy()
await wrapper.vm.$nextTick()
// check whether an 'updated' event was emitted and its payload
// (should contain the parsing result for the new sitemap definition)
const events = wrapper.emitted().updated
expect(events).toBeTruthy()
expect(events.length).toBe(1)
const payload = events[0][0]
expect(payload.slots).toBeDefined()
expect(payload.slots.widgets).toBeDefined()
expect(payload.slots.widgets.length).toBe(1)
expect(payload.slots.widgets[0]).toEqual({
component: 'Switch',
config: {
item: 'PressAndRelease',
mappings: [
'"ON command":"OFF command"="ON"'
]
}
})

View File

@ -29,11 +29,11 @@ function writeWidget (widget, indent) {
dsl += widget.config[key]
} else if (key === 'mappings') {
dsl += '[' + widget.config[key].filter(Boolean).map(mapping => {
return writeCommand(mapping)
return mapping
}).join(', ') + ']'
} else if (key === 'buttons') {
dsl += '[' + widget.config[key].filter(Boolean).map(button => {
return button.row + ':' + button.column + ':' + writeCommand(button.command)
return button.row + ':' + button.column + ':' + button.command
}).join(', ') + ']'
} else if (key === 'visibility') {
dsl += '[' + widget.config[key].filter(Boolean).map(rule => {
@ -61,15 +61,6 @@ function writeWidget (widget, indent) {
return dsl
}
function writeCommand (command) {
return command.split('=').map(value => {
if (/^.*\W.*$/.test(value) && /^[^"'].*[^"']$/.test(value)) {
return '"' + value + '"'
}
return value
}).join('=')
}
function writeCondition (rule, hasArgument = false) {
let argument = ''
let conditions = rule

View File

@ -343,13 +343,63 @@ describe('SitemapEdit', () => {
'2=Evening',
'10=Cinéma',
'11=TV',
'3=Bed time',
'"3 time"=Bed time',
'4=Night=moon'
])
wrapper.vm.validateWidgets()
expect(lastDialogConfig).toBeFalsy()
})
it('validates mappings with release command', async () => {
wrapper.vm.selectWidget([wrapper.vm.sitemap, null])
await wrapper.vm.$nextTick()
wrapper.vm.addWidget('Switch')
await wrapper.vm.$nextTick()
wrapper.vm.selectWidget([wrapper.vm.sitemap.slots.widgets[0], wrapper.vm.sitemap])
await wrapper.vm.$nextTick()
localVue.set(wrapper.vm.selectedWidget.config, 'item', 'Item1')
localVue.set(wrapper.vm.selectedWidget.config, 'label', 'Switch Test')
localVue.set(wrapper.vm.selectedWidget.config, 'mappings', [
'Morning'
])
// should not validate as the mapping has a syntax error
lastDialogConfig = null
wrapper.vm.validateWidgets()
expect(lastDialogConfig).toBeTruthy()
expect(lastDialogConfig.content).toMatch(/Switch widget Switch Test, syntax error in mappings: Morning/)
// configure a correct mapping and check that there are no validation errors anymore
lastDialogConfig = null
wrapper.vm.selectWidget([wrapper.vm.sitemap.slots.widgets[0], wrapper.vm.sitemap])
await wrapper.vm.$nextTick()
localVue.set(wrapper.vm.selectedWidget.config, 'mappings', [
'ON="ON"'
])
wrapper.vm.validateWidgets()
expect(lastDialogConfig).toBeFalsy()
// configure mapping for a press and release button and check that there are no validation errors
lastDialogConfig = null
wrapper.vm.selectWidget([wrapper.vm.sitemap.slots.widgets[0], wrapper.vm.sitemap])
await wrapper.vm.$nextTick()
localVue.set(wrapper.vm.selectedWidget.config, 'mappings', [
'ON:OFF="ON"'
])
wrapper.vm.validateWidgets()
expect(lastDialogConfig).toBeFalsy()
// configure mapping for a press and release button with string commands and check that there are no validation errors
lastDialogConfig = null
wrapper.vm.selectWidget([wrapper.vm.sitemap.slots.widgets[0], wrapper.vm.sitemap])
await wrapper.vm.$nextTick()
localVue.set(wrapper.vm.selectedWidget.config, 'mappings', [
'"ON command":"OFF command"=ON=icon'
])
wrapper.vm.validateWidgets()
expect(lastDialogConfig).toBeFalsy()
})
it('validates buttons', async () => {
wrapper.vm.selectWidget([wrapper.vm.sitemap, null])
await wrapper.vm.$nextTick()

View File

@ -57,11 +57,14 @@
{column: {width: '10%', type: 'number', min: 1, placeholder: 'col'}},
{command: {}}])" />
</f7-block>
<f7-block v-if="selectedWidget && ['Switch', 'Selection'].indexOf(selectedWidget.component) >= 0">
<f7-block v-if="selectedWidget && selectedWidget.component === 'Switch'">
<div><f7-block-title>Mappings</f7-block-title></div>
<attribute-details :widget="selectedWidget" attribute="mappings" placeholder="command:releaseCommand = label = icon" />
</f7-block>
<f7-block v-if="selectedWidget && selectedWidget.component === 'Selection'">
<div><f7-block-title>Mappings</f7-block-title></div>
<attribute-details :widget="selectedWidget" attribute="mappings" placeholder="command = label = icon" />
</f7-block>
<f7-block v-if="selectedWidget && selectedWidget.component !== 'Sitemap'">
</f7-block> <f7-block v-if="selectedWidget && selectedWidget.component !== 'Sitemap'">
<div><f7-block-title>Icon Rules</f7-block-title></div>
<attribute-details :widget="selectedWidget" attribute="iconrules" placeholder="item_name operator value = icon" />
</f7-block>
@ -505,7 +508,7 @@ export default {
let label = widget.config && widget.config.label ? widget.config.label : 'without label'
Object.keys(widget.config).filter(attr => ['buttons', 'mappings', 'visibility', 'valuecolor', 'labelcolor', 'iconcolor', 'iconrules'].includes(attr)).forEach(attr => {
widget.config[attr].forEach(param => {
if (((attr === 'mappings') && !this.validateMapping(param)) ||
if (((attr === 'mappings') && !this.validateMapping(widget.component, param)) ||
((attr === 'visibility') && !this.validateRule(param)) ||
((['valuecolor', 'labelcolor', 'iconcolor', 'iconrules'].includes(attr)) && !this.validateRule(param, true))) {
validationWarnings.push(widget.component + ' widget ' + label + ', syntax error in ' + attr + ': ' + param)
@ -517,7 +520,7 @@ export default {
if (!param.column || isNaN(param.column)) {
validationWarnings.push(widget.component + ' widget ' + label + ', invalid column configured: ' + param.column)
}
if (!this.validateMapping(param.command)) {
if (!this.validateMapping(widget.component, param.command)) {
validationWarnings.push(widget.component + ' widget ' + label + ', syntax error in button command: ' + param.command)
}
}
@ -542,7 +545,11 @@ export default {
return true
}
},
validateMapping (mapping) {
validateMapping (component, mapping) {
if (component === 'Switch') {
// for Switch widget, also check for releaseCommand
return /^\s*("[^\n"]*"|[^\n="]+)\s*(:\s*("[^\n"]*"|[^\n="]+)\s*)?=\s*("[^\n"]*"|[^\n="]+)\s*(=\s*("[^\n"]*"|[^\n="]+))?$/u.test(mapping)
}
return /^\s*("[^\n"]*"|[^\n="]+)\s*=\s*("[^\n"]*"|[^\n="]+)\s*(=\s*("[^\n"]*"|[^\n="]+))?$/u.test(mapping)
},
validateRule (rule, hasArgument = false) {
@ -562,10 +569,6 @@ export default {
widget.config[key] = widget.config[key].filter(Boolean)
if (key === 'buttons') {
widget.config[key].sort((value1, value2) => (value1.row - value2.row) || (value1.column - value2.column))
widget.config[key].forEach(value => this.removeQuotes(value.command))
}
if (['mappings', 'visibility', 'valuecolor', 'labelcolor', 'iconcolor', 'iconrules'].includes(key)) {
widget.config[key].forEach(this.removeQuotes)
}
}
if (!widget.config[key] && widget.config[key] !== 0) {
@ -577,17 +580,6 @@ export default {
widget.slots.widgets.forEach(this.cleanConfig)
}
},
removeQuotes (value) {
if (value) {
if (typeof value === 'string') {
value = value.replace(/"|'/g, '')
return
} else if (typeof value === 'number') {
return
}
Object.keys(value).forEach(k => this.removeQuotes(value[k]))
}
},
preProcessSitemapLoad (sitemap) {
const processed = JSON.parse(JSON.stringify(sitemap))
if (processed.slots && processed.slots.widgets) {