Skip to content

Commit

Permalink
Merge pull request #1273 from FlowFuse/1244-number-input-range-min-ma…
Browse files Browse the repository at this point in the history
…x-limits-not-working-as-expected

Number input range min max limits not working as expected
  • Loading branch information
joepavitt authored Sep 16, 2024
2 parents ee4508a + b72ad84 commit 40d360a
Show file tree
Hide file tree
Showing 5 changed files with 223 additions and 30 deletions.
27 changes: 27 additions & 0 deletions docs/nodes/widgets/ui-number-input.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,18 @@ props:
Label:
description: The number shown within the number input field.
dynamic: true
Min:
description: Defines the minimum allowable value for the number input field.
dynamic: true
Max:
description: Defines the maximum allowable value for the number input field.
dynamic: true
Step:
description: Sets the increment/decrement step for adjusting the number value in the input field.
dynamic: true
Spinner:
description: Sets the layout of the spinners either as inline or stacked.
dynamic: true
Tooltip:
description: The number shown when hovering over the number input field.
Passthrough: If this node receives a msg in Node-RED, should it be passed through to the output as if a new value was inserted to the input?
Expand Down Expand Up @@ -42,9 +54,24 @@ dynamic:
Icon Position:
payload: msg.ui_update.iconPosition
structure: ["String"]
examples: ["left", "right"]
Icon Inner Position:
payload: msg.ui_update.iconInnerPosition
structure: ["String"]
examples: ["inside", "outside"]
Min:
payload: msg.ui_update.min
structure: ["Number"]
Max:
payload: msg.ui_update.max
structure: ["Number"]
Step:
payload: msg.ui_update.step
structure: ["Number"]
Spinner:
payload: msg.ui_update.spinner
structure: ["String"]

---

<script setup>
Expand Down
31 changes: 21 additions & 10 deletions nodes/widgets/locales/en-US/ui_number_input.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,26 +3,37 @@
Adds a single number input row to your dashboard
</p>
<h3>Dynamic Properties (Inputs)</h3>
<p>Any of the following can be appended to a <code>msg.</code> in order to override or set properties on this node at runtime.</p>
<p>Any of the following can be appended to <code>msg.ui_update</code> in order to override or set properties on this node at runtime.</p>
<dl class="message-properties">
<dt class="optional">class <span class="property-type">string</span></dt>
<dd>Add a CSS class, or more, to the Button at runtime.</dd>
<dd>Range <span class="property-type">number</span></dd>
<dd>
min - the minimum value the slider can be changed to; max - the maximum value the
slider can be changed to; step - the increment/decrement value when the slider is moved.
</dd>
<dd>Add a CSS class, or more, to the number input at runtime.</dd>
</dl>
<dl class="message-properties">
<dt class="optional">min <span class="property-type">number</span></dt>
<dd>The minimum value available on the slider.</dd>
<dd>Override the minimum value available on the number input.</dd>
</dl>
<dl class="message-properties">
<dt class="optional">step <span class="property-type">number</span></dt>
<dd>The step size to allow a user to select from between the <code>min</code> and <code>max</code> values.</dd>
<dd>Override the step size to allow a user to select from between the <code>min</code> and <code>max</code> values.</dd>
</dl>
<dl class="message-properties">
<dt class="optional">max <span class="property-type">number</span></dt>
<dd>The maximum value available on the slider.</dd>
<dd>Override the maximum value available on the number input.</dd>
</dl>
<dl class="message-properties">
<dt class="optional">label <span class="property-type">string</span></dt>
<dd>Override the label displayed on the number input.</dd>
</dl>
<dl class="message-properties">
<dt class="optional">icon <span class="property-type">string</span></dt>
<dd>Override the icon defined in the initial configuration.</dd>
</dl>
<dl class="message-properties">
<dt class="optional">clearable <span class="property-type">boolean</span></dt>
<dd>Controls whether an "x" appears number on input as a user types in order to quickly clear any added numbers.</dd>
</dl>
<dl class="message-properties">
<dt class="optional">spinner <span class="property-type">string</span></dt>
<dd>Set the spinner layout as <code>inline</code> or <code>stacked</code> to control how the spinners appear in the number input.</dd>
</dl>
</script>
23 changes: 21 additions & 2 deletions nodes/widgets/ui_number_input.html
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,14 @@
topicType: { value: 'msg' },
min: { value: 0, required: true, validate: RED.validators.number() },
max: { value: 10, required: true, validate: RED.validators.number() },
step: { value: 1 },
step: {
value: 1,
validate: function (v) {
const isValid = RED.validators.number()(v) && v > 0
$('#node-input-step').toggleClass('input-error', !isValid)
return isValid
}
},
tooltip: { value: '' },
passthru: { value: true },
sendOnBlur: { value: true },
Expand All @@ -36,7 +43,8 @@
clearable: { value: false },
icon: { value: '' },
iconPosition: { value: 'left' },
iconInnerPosition: { value: 'inside' }
iconInnerPosition: { value: 'inside' },
spinner: { value: 'default' }
},
inputs: 1,
outputs: 1,
Expand Down Expand Up @@ -101,6 +109,10 @@
if (!this.iconInnerPosition) {
$('#node-input-iconInnerPosition').val('inside')
}

if (!this.spinner) {
$('#node-input-spinner').val('default')
}
},
label: function () {
return this.name || (~this.label.indexOf('{' + '{') ? null : this.label) || this.mode + ' input'
Expand Down Expand Up @@ -164,6 +176,13 @@
</div>
</div>
</div>
<div class="form-row">
<label for="node-input-spinner">Spinner</label>
<select id="node-input-spinner">
<option value="default">Inline</option>
<option value="stacked">Stacked</option>
</select>
</div>
<div class="form-row">
<label for="node-input-className"><i class="fa fa-code"></i> Class</label>
<div style="display: inline;">
Expand Down
33 changes: 33 additions & 0 deletions nodes/widgets/ui_number_input.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
const datastore = require('../store/data.js')
const statestore = require('../store/state.js')
const { appendTopic } = require('../utils/index.js')

module.exports = function (RED) {
function NumberInputNode (config) {
Expand All @@ -15,6 +17,37 @@ module.exports = function (RED) {

const evts = {
onChange: true,
beforeSend: async function (msg) {
const updates = msg.ui_update
if (updates) {
if (typeof updates.label !== 'undefined') {
// dynamically set "label" property
statestore.set(group.getBase(), node, msg, 'label', updates.label)
}
if (typeof updates.clearable !== 'undefined') {
// dynamically set "clearable" property
statestore.set(group.getBase(), node, msg, 'clearable', updates.clearable)
}
if (typeof updates.icon !== 'undefined') {
// dynamically set "icon" property
statestore.set(group.getBase(), node, msg, 'icon', updates.icon)
}
if (typeof updates.iconPosition !== 'undefined') {
// dynamically set "iconPosition" property
statestore.set(group.getBase(), node, msg, 'iconPosition', updates.iconPosition)
}
if (typeof updates.iconInnerPosition !== 'undefined') {
// dynamically set "iconInnerPosition" property
statestore.set(group.getBase(), node, msg, 'iconInnerPosition', updates.iconInnerPosition)
}
if (typeof updates.spinner !== 'undefined') {
// dynamically set "spinner" property
statestore.set(group.getBase(), node, msg, 'spinner', updates.spinner)
}
}
msg = await appendTopic(RED, config, node, msg)
return msg
},
onInput: function (msg, send) {
// store the latest msg passed to node
datastore.save(group.getBase(), node, msg)
Expand Down
139 changes: 121 additions & 18 deletions ui/src/widgets/ui-number-input/UINumberInput.vue
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
<template>
<v-tooltip :text="tooltip" :disabled="!tooltip?.length" location="bottom">
<!-- eslint-disable-next-line vue/no-template-shadow -->
<template #activator="{ props }">
<v-number-input
v-model="value" :reverse="false" controlVariant="default" :hideInput="false" :inset="false"
v-bind="props" :disabled="!state.enabled" class="nrdb-ui-number-field" :label="label"
:rules="validation" :clearable="clearable" variant="outlined" hide-details="auto"
:prepend-icon="prependIcon" :append-icon="appendIcon" :append-inner-icon="appendInnerIcon"
:prepend-inner-icon="prependInnerIcon" @update:model-value="onChange" @keyup.enter="onEnter" @blur="onBlur" @click:clear="onClear"
/>
</template>
</v-tooltip>
<div ref="container" class="nrdb-ui-number-field">
<v-tooltip :text="tooltip" :disabled="!tooltip?.length" location="bottom">
<!-- eslint-disable-next-line vue/no-template-shadow -->
<template #activator="{ props }">
<v-number-input
v-model="value" :class="{'compressed': isCompressed, 'stacked-spinner': spinner === 'stacked'}" :reverse="false" :controlVariant="spinner" :hideInput="false" :inset="false"
v-bind="props" :disabled="!state.enabled" :label="label"
:rules="validation" :clearable="clearable" variant="outlined" hide-details="auto"
:prepend-icon="prependIcon" :append-icon="appendIcon" :append-inner-icon="appendInnerIcon"
:prepend-inner-icon="prependInnerIcon" :min="min" :max="max" :step="step" @update:model-value="onChange" @keyup.enter="onEnter" @blur="onBlur" @click:clear="onClear"
/>
</template>
</v-tooltip>
</div>
</template>

<script>
Expand All @@ -33,7 +35,8 @@ export default {
return {
delayTimer: null,
textValue: null,
previousValue: null
previousValue: null,
isCompressed: false
}
},
computed: {
Expand Down Expand Up @@ -85,9 +88,25 @@ export default {
iconInnerPosition () {
return this.getProperty('iconInnerPosition')
},
min () {
return this.getProperty('min')
},
max () {
return this.getProperty('max')
},
step () {
return Math.abs(this.getProperty('step')) || 1
},
spinner () {
return this.getProperty('spinner')
},
value: {
get () {
return this.textValue
if (this.textValue === null || this.textValue === undefined || this.textValue === '') {
return this.textValue
} else {
return Number(this.textValue)
}
},
set (val) {
if (this.value === val) {
Expand All @@ -105,8 +124,43 @@ export default {
} else {
return []
}
},
rangeAndStep () {
return {
min: this.min,
max: this.max
}
}
},
watch: {
rangeAndStep: {
handler () {
if (this.value) {
if (this.value < this.min) {
this.value = this.min
} else if (this.value > this.max) {
this.value = this.max
}
this.send()
this.previousValue = this.value
}
}
},
props: {
handler () {
this.resize()
},
deep: true
}
},
mounted () {
// on resize handler for window resizing
window.addEventListener('resize', this.onResize)
this.onResize()
},
unmounted () {
window.removeEventListener('resize', this.onResize)
},
created () {
// can't do this in setup as we are using custom onInput function that needs access to 'this'
this.$dataTracker(this.id, this.onInput, this.onLoad, this.onDynamicProperties)
Expand Down Expand Up @@ -138,13 +192,13 @@ export default {
send () {
this.$socket.emit('widget-change', this.id, this.value)
},
onChange (e) {
onChange () {
// Since the Vuetify Input Number component doesn't currently support an onClick event,
// compare the previous value with the current value and check whether the value has been increased or decreased by one.
if (
this.previousValue === null ||
this.previousValue + 1 === this.value ||
this.previousValue - 1 === this.value
this.previousValue + (this.step || 1) === this.value ||
this.previousValue - (this.step || 1) === this.value
) {
this.send()
}
Expand All @@ -166,7 +220,7 @@ export default {
this.send()
},
makeMdiIcon (icon) {
return 'mdi-' + icon.replace(/^mdi-/, '')
return 'mdi-' + icon?.replace(/^mdi-/, '')
},
onDynamicProperties (msg) {
const updates = msg.ui_update
Expand All @@ -178,6 +232,19 @@ export default {
this.updateDynamicProperty('icon', updates.icon)
this.updateDynamicProperty('iconPosition', updates.iconPosition)
this.updateDynamicProperty('iconInnerPosition', updates.iconInnerPosition)
this.updateDynamicProperty('min', updates.min)
this.updateDynamicProperty('max', updates.max)
this.updateDynamicProperty('step', updates.step)
this.updateDynamicProperty('spinner', updates.spinner)
},
resize () {
// set isCompressed to true when clearable is true, icon is present and the container is less than 120px
this.isCompressed = this.$refs.container.clientWidth < 120 && this.clearable && Boolean(this.getProperty('icon'))
},
onResize () {
this.$nextTick(() => {
this.resize()
})
}
}
}
Expand All @@ -190,6 +257,7 @@ export default {
margin-left: 12px;
}
}
.v-field__append-inner {
> .v-icon {
margin-right: 12px;
Expand All @@ -199,5 +267,40 @@ export default {
button {
color: var(--red-ui-form-input-border-color);
}
.v-field__field input {
padding-inline: var(--v-field-padding-start) 0;
}
.v-btn--icon.v-btn--density-default {
width: calc(var(--v-btn-height) + 0px);
}
.stacked-spinner {
.v-btn--icon.v-btn--density-default {
width: auto;
min-height: 0;
}
}
.compressed {
.v-field__clearable,
.v-field__prepend-inner > .v-icon,
.v-field__append-inner > .v-icon,
.v-input__prepend > .v-icon,
.v-input__append > .v-icon{
display: none;
}
.v-field--active input {
padding-inline: 0.4rem 0.4rem;
}
}
.v-btn--disabled.v-btn--variant-elevated,
.v-btn--disabled.v-btn--variant-flat {
background-color: transparent;
color: var(--red-ui-form-input-border-color);
opacity: 0.25;
}
}
</style>

0 comments on commit 40d360a

Please sign in to comment.