Skip to content

Commit

Permalink
Merge pull request #2896 from FlowFuse/2895-device-assignment-ux
Browse files Browse the repository at this point in the history
New "Assign Device" dialog, exposed at Device page & new "Assignment" Settings
  • Loading branch information
knolleary authored Oct 5, 2023
2 parents 6d1cd32 + a5d4641 commit d39204d
Show file tree
Hide file tree
Showing 5 changed files with 384 additions and 15 deletions.
114 changes: 105 additions & 9 deletions frontend/src/pages/device/Settings/General.vue
Original file line number Diff line number Diff line change
@@ -1,32 +1,84 @@
<template>
<form class="space-y-6">
<FormHeading>
<template #default>
General
</template>
<template #tools>
<div v-if="hasPermission('device:edit')" class="mb-2">
<ff-button v-if="!editing.deviceName" size="small" kind="primary" @click="editDevice">Edit Device</ff-button>
<ff-button v-else kind="primary" size="small" @click="updateDevice">Save Changes</ff-button>
</div>
</template>
</FormHeading>
<FormRow v-model="input.deviceId" type="uneditable" id="deviceId" inputClass="font-mono">
Device ID
</FormRow>

<FormRow v-model="input.deviceName" :type="editing.deviceName ? 'text' : 'uneditable'" ref="deviceName">
Name
</FormRow>

<div v-if="hasPermission('device:edit')">
<ff-button v-if="!editing.deviceName" kind="primary" @click="editDevice">Edit Device</ff-button>
<ff-button v-else kind="primary" @click="updateDevice">Save Changes</ff-button>
</div>
</form>
<form class="mt-12 space-y-6">
<FormHeading>
<template #default>
Assignment
</template>
<template #tools>
<div v-if="hasPermission('device:edit')" class="mb-2">
<ff-button v-if="!notAssigned" size="small" kind="primary" data-action="unassign-device" @click="unassign">Unassign</ff-button>
</div>
<div v-if="hasPermission('device:edit')" class="mb-2">
<ff-button v-if="notAssigned" size="small" kind="primary" data-action="assign-device" @click="assign">Assign</ff-button>
</div>
</template>
</FormHeading>
<template v-if="notAssigned">
<p>To use Devices they must be assigned to an Application or Instance.</p>
<ul class="list-disc ml-6 space-y-2 max-w-xl">
<li><label class="font-medium mr-2">Application:</label>Flows on this Device can only be edited and deployed via the 'Remote Editor' feature, available in 'Developer Mode'. You can create Snapshots for version control of the flows on your Device</li>
<li><label class="font-medium mr-2">Instance:</label>Auto-deploy flows from the bound Instance directly to this Device. You can still remotely edit and create Snapshots on the Device when the Device is in 'Developer Mode'.</li>
</ul>
</template>
<template v-else-if="hasApplication">
<div>
<label class="font-medium mr-2">Application:</label>
<router-link :to="{name: 'Application', params: {id: device.application.id}}" class="ff-link">{{ device.application.name }}</router-link>
</div>
<h3>Features:</h3>
<ul class="list-disc ml-6 space-y-2 max-w-xl">
<li><label class="font-medium mr-2">Editing Remotely:</label>You can read our documentation <a class="ff-link" href="https://flowfuse.com/docs/device-agent/deploy/#editing-the-node-red-flows-on-a-device-that-is-assigned-to-an-application">here</a> on how to remotely edit the flows on your Device. Make sure you create a Snapshot of your changes when in Developer Mode if you wish to keep them, any changes made inside "Developer Mode" will be undone when leaving "Developer Mode".</li>
</ul>
</template>
<template v-else-if="hasInstance">
<div>
<label class="font-medium mr-2">Instance:</label>
<router-link :to="{name: 'Instance', params: {id: device.instance.id}}" class="ff-link">{{ device.instance.name }}</router-link>
</div>
<h3>Features:</h3>
<ul class="list-disc ml-6 space-y-2 max-w-xl">
<li><label class="font-medium mr-2">Deploying Remotely:</label>You can read our documentation <a class="ff-link" target="_blank" rel="noreferrer" href="https://flowfuse.com/docs/device-agent/deploy/#deploying-a-node-red-instance-to-the-device">here</a> on how to remotely deploy flows to your Device.</li>
<li><label class="font-medium mr-2">Editing Remotely:</label>You can read our documentation <a class="ff-link" target="_blank" rel="noreferrer" href="https://flowfuse.com/docs/device-agent/deploy/#editing-the-node-red-flows-on-a-device-that-is-assigned-to-an-instance">here</a> on how to remotely edit the flows on your Device. Make sure you create a Snapshot of your changes when in Developer Mode if you wish to keep them, any changes made inside "Developer Mode" will be undone when leaving "Developer Mode".</li>
</ul>
</template>
</form>
</template>

<script>
import { mapState } from 'vuex'
import deviceApi from '../../../api/devices.js'
import FormHeading from '../../../components/FormHeading.vue'
import FormRow from '../../../components/FormRow.vue'
import permissionsMixin from '../../../mixins/Permissions.js'
import Alerts from '../../../services/alerts.js'
import Dialog from '../../../services/dialog.js'
export default {
name: 'DeviceSettings',
props: ['device'],
emits: ['device-updated'],
emits: ['device-updated', 'assign-device'],
mixins: [permissionsMixin],
data () {
return {
Expand All @@ -43,10 +95,24 @@ export default {
}
},
watch: {
device: 'fetchData'
device: {
handler () {
this.fetchData()
},
deep: true
}
},
computed: {
...mapState('account', ['teamMembership'])
...mapState('account', ['teamMembership']),
hasApplication () {
return this.device?.ownerType === 'application' && this.device.application
},
hasInstance () {
return this.device?.ownerType === 'instance' && this.device.instance
},
notAssigned () {
return !this.hasApplication && !this.hasInstance
}
},
mounted () {
this.fetchData()
Expand All @@ -70,10 +136,40 @@ export default {
this.input.deviceId = this.device.id
this.input.deviceName = this.device.name
}
},
unassign () {
const device = this.device
if (this.hasInstance) {
Dialog.show({
header: 'Remove Device from Instance',
kind: 'danger',
text: 'Are you sure you want to remove this device from the instance? This will stop the flows running on the device.',
confirmLabel: 'Remove'
}, async () => {
await deviceApi.updateDevice(device.id, { instance: null })
this.$emit('device-updated')
Alerts.emit('Successfully removed the device from the instance.', 'confirmation')
})
} else if (this.hasApplication) {
Dialog.show({
header: 'Remove Device from Application',
kind: 'danger',
text: 'Are you sure you want to remove this device from the application? This will stop the flows running on the device.',
confirmLabel: 'Remove'
}, async () => {
await deviceApi.updateDevice(device.id, { application: null })
this.$emit('device-updated')
Alerts.emit('Successfully removed the device from the application.', 'confirmation')
})
}
},
assign () {
this.$emit('assign-device')
}
},
components: {
FormRow
FormRow,
FormHeading
}
}
</script>
4 changes: 2 additions & 2 deletions frontend/src/pages/device/Settings/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<div class="flex flex-col sm:flex-row">
<SectionSideMenu :options="sideNavigation" />
<div class="flex-grow">
<router-view :device="device" @device-updated="$emit('device-updated')" />
<router-view :device="device" @device-updated="$emit('device-updated')" @assign-device="$emit('assign-device')" />
</div>
</div>
</template>
Expand All @@ -18,7 +18,7 @@ import permissionsMixin from '../../../mixins/Permissions.js'
export default {
name: 'DeviceSettins',
props: ['device'],
emits: ['device-updated'],
emits: ['device-updated', 'assign-device'],
mixins: [permissionsMixin],
data: function () {
return {
Expand Down
71 changes: 71 additions & 0 deletions frontend/src/pages/device/components/AssignDeviceDialog.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
<template>
<ff-dialog
id="assign-device-dialog"
ref="dialog"
header="Assign Device"
class="ff-dialog-fixed-height"
data-el="assign-device-dialog"
:disable-primary="!assignOption"
@confirm="select"
>
<template #default>
<p class="text-sm text-gray-500">
Please select whether you want to assign this Device to an Instance or an Application.
</p>
<ff-tile-selection v-model="assignOption">
<ff-tile-selection-option
value="instance" label="Instance" data-form="assign-to-instance"
description="<p>Auto-deploy flows from the bound Instance directly to this Device.</p></br><p>You can still remotely edit and create Snapshots on the Device when the Device is in 'Developer Mode'.</p>"
/>
<ff-tile-selection-option
value="application" label="Application" data-form="assign-to-application"
description="<p>Flows on this Device can only be edited and deployed via the 'Remote Editor' feature, available in 'Developer Mode'.</p></br><p>You can create Snapshots here for version control of the flows on your Device</p>"
/>
</ff-tile-selection>
</template>
</ff-dialog>
</template>

<script>
export default {
name: 'AssignDeviceDialog',
emits: ['assignOptionSelected'],
setup () {
return {
async show () {
this.$refs.dialog.show()
}
}
},
data () {
return {
assignOption: null
}
},
methods: {
select () {
this.$emit('assignOptionSelected', this.assignOption)
this.assignOption = null
},
close () {
this.$refs.dialog.close()
}
}
}
</script>

<style lang="scss" scoped>
#assign-device-dialog {
.ff-tile-selection {
margin-top: 1rem;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
}
.ff-tile-selection-option {
width: auto;
margin: 0;
}
}
</style>
66 changes: 62 additions & 4 deletions frontend/src/pages/device/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,17 @@
</div>
</template>
<template #context>
<div v-if="device?.ownerType === 'application' && device.application">
<div v-if="device?.ownerType === 'application' && device.application" data-el="device-assigned-application">
Application:
<router-link :to="{name: 'Application', params: {id: device.application.id}}" class="text-blue-600 cursor-pointer hover:text-blue-700 hover:underline">{{ device.application.name }}</router-link>
</div>
<div v-else-if="device?.ownerType === 'instance' && device.instance">
<div v-else-if="device?.ownerType === 'instance' && device.instance" data-el="device-assigned-instance">
Instance:
<router-link :to="{name: 'Instance', params: {id: device.instance.id}}" class="text-blue-600 cursor-pointer hover:text-blue-700 hover:underline">{{ device.instance.name }}</router-link>
</div>
<div v-else data-el="device-assigned-none">
<span class="italic">No Application or Instance Assigned</span> - <a class="ff-link" data-action="assign-device" @click="openAssignmentDialog">Assign</a>
</div>
</template>
<template v-if="isDevModeAvailable" #tools>
<div class="space-x-2 flex align-center">
Expand All @@ -54,9 +57,28 @@
<div class="ff-banner" data-el="banner-device-as-admin">You are viewing this device as an Administrator</div>
</Teleport>
<div class="px-3 pb-3 md:px-6 md:pb-6">
<router-view :instance="device.instance" :device="device" @device-updated="loadDevice()" @device-refresh="loadDevice()" />
<router-view :instance="device.instance" :device="device" @device-updated="loadDevice()" @device-refresh="loadDevice()" @assign-device="openAssignmentDialog" />
</div>
</div>
<!-- Dialogs -->
<AssignDeviceDialog
v-if="notAssigned"
ref="assignment-dialog"
data-el="assignment-dialog"
@assign-option-selected="assignOptionSelected"
/>
<DeviceAssignInstanceDialog
v-if="notAssigned"
ref="deviceAssignInstanceDialog"
data-el="assignment-dialog-instance"
@assign-device="assignDeviceToInstance"
/>
<DeviceAssignApplicationDialog
v-if="notAssigned"
ref="deviceAssignApplicationDialog"
data-el="assignment-dialog-application"
@assign-device="assignDeviceToApplication"
/>
</main>
</template>

Expand All @@ -75,6 +97,12 @@ import StatusBadge from '../../components/StatusBadge.vue'
import SubscriptionExpiredBanner from '../../components/banners/SubscriptionExpired.vue'
import TeamTrialBanner from '../../components/banners/TeamTrial.vue'
import permissionsMixin from '../../mixins/Permissions.js'
import Alerts from '../../services/alerts.js'
import DeviceAssignApplicationDialog from '../team/Devices/dialogs/DeviceAssignApplicationDialog.vue'
import DeviceAssignInstanceDialog from '../team/Devices/dialogs/DeviceAssignInstanceDialog.vue'
import AssignDeviceDialog from './components/AssignDeviceDialog.vue'
import DeveloperModeBadge from './components/DeveloperModeBadge.vue'
import DeveloperModeToggle from './components/DeveloperModeToggle.vue'
Expand All @@ -91,7 +119,10 @@ export default {
SideNavigationTeamOptions,
StatusBadge,
SubscriptionExpiredBanner,
TeamTrialBanner
TeamTrialBanner,
AssignDeviceDialog,
DeviceAssignApplicationDialog,
DeviceAssignInstanceDialog
},
mixins: [permissionsMixin],
data: function () {
Expand Down Expand Up @@ -132,6 +163,12 @@ export default {
},
deviceEditorURL: function () {
return this.device.editor?.url || ''
},
notAssigned () {
const device = this.device
const hasApplication = device?.ownerType === 'application' && device.application
const hasInstance = device?.ownerType === 'instance' && device.instance
return !hasApplication && !hasInstance
}
},
watch: {
Expand Down Expand Up @@ -206,6 +243,27 @@ export default {
throw new Error('Unknown mode')
}
}
},
openAssignmentDialog () {
this.$refs['assignment-dialog'].show()
},
assignOptionSelected (option) {
if (option === 'instance') {
this.$refs.deviceAssignInstanceDialog.show(this.device)
} else if (option === 'application') {
this.$refs.deviceAssignApplicationDialog.show(this.device)
}
},
async assignDeviceToInstance (device, instanceId) {
this.device = await deviceApi.updateDevice(device.id, { instance: instanceId })
Alerts.emit('Device successfully assigned to instance.', 'confirmation')
},
async assignDeviceToApplication (device, applicationId) {
this.device = await deviceApi.updateDevice(device.id, { application: applicationId, instance: null })
Alerts.emit('Device successfully assigned to application.', 'confirmation')
}
}
}
Expand Down
Loading

0 comments on commit d39204d

Please sign in to comment.