Skip to content

Commit

Permalink
Merge pull request #4956 from FlowFuse/4955-no-instances-feedback
Browse files Browse the repository at this point in the history
Improve feedback when Hosted Instances are not available to a team
  • Loading branch information
joepavitt authored Dec 23, 2024
2 parents d29e97d + 0c84d0a commit aa77197
Show file tree
Hide file tree
Showing 12 changed files with 145 additions and 24 deletions.
2 changes: 1 addition & 1 deletion frontend/src/pages/application/Devices.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<template>
<div>
<SectionTopMenu hero="Edge Devices" help-header="Node-RED Edge Devices - Registered to FlowFuse" info="Edge Devices belonging to this application.">
<SectionTopMenu hero="Edge Devices" help-header="Node-RED Edge Devices - Registered to FlowFuse" info="Manage remote instances of Node-RED running on your own hardware.">
<template #pictogram>
<img src="../../images/pictograms/devices_red.png">
</template>
Expand Down
28 changes: 23 additions & 5 deletions frontend/src/pages/application/Overview.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<template>
<div>
<SectionTopMenu hero="Node-RED Instances" help-header="Node-RED Instances - Running in FlowFuse" info="Instances of Node-RED belonging to this application.">
<SectionTopMenu hero="Node-RED Instances" help-header="Node-RED Instances - Running in FlowFuse" info="Hosted instances of Node-RED, owned by this application.">
<template #pictogram>
<img src="../../images/pictograms/instance_red.png">
</template>
Expand All @@ -9,7 +9,7 @@
<p>It will always run the latest flow deployed in Node-RED and use the latest credentials and runtime settings defined in the Projects settings.</p>
<p>To edit an Application's flow, open the editor of the Instance.</p>
</template>
<template #tools>
<template v-if="instancesAvailable" #tools>
<ff-button
v-if="hasPermission('project:create')"
data-action="create-instance"
Expand All @@ -21,6 +21,7 @@
</ff-button>
</template>
</SectionTopMenu>
<FeatureUnavailableToTeam v-if="!instancesAvailable" />
<!-- set mb-14 (~56px) on the form to permit access to kebab actions where hubspot chat covers it -->
<div class="space-y-6 mb-14">
<ff-data-table
Expand Down Expand Up @@ -66,7 +67,7 @@
/>
</template>
</ff-data-table>
<EmptyState v-else>
<EmptyState v-else-if="instancesAvailable">
<template #img>
<img src="../../images/empty-states/application-instances.png">
</template>
Expand Down Expand Up @@ -94,6 +95,17 @@
</p>
</template>
</EmptyState>
<EmptyState v-else>
<template #img>
<img src="../../images/empty-states/application-instances.png">
</template>
<template #header>Hosted Instances Not Available</template>
<template #message>
<p>
Hosted Instances are not available for this team tier. Please consider upgrading if you would like to enable this feature.
</p>
</template>
</EmptyState>
</div>
</div>
</template>
Expand All @@ -102,10 +114,11 @@
import { PlusSmIcon } from '@heroicons/vue/outline'
import { markRaw } from 'vue'
import { mapState } from 'vuex'
import { mapGetters, mapState } from 'vuex'
import EmptyState from '../../components/EmptyState.vue'
import SectionTopMenu from '../../components/SectionTopMenu.vue'
import FeatureUnavailableToTeam from '../../components/banners/FeatureUnavailableToTeam.vue'
import { useNavigationHelper } from '../../composables/NavigationHelper.js'
import permissionsMixin from '../../mixins/Permissions.js'
Expand All @@ -122,7 +135,8 @@ export default {
components: {
PlusSmIcon,
SectionTopMenu,
EmptyState
EmptyState,
FeatureUnavailableToTeam
},
mixins: [permissionsMixin],
inheritAttrs: false,
Expand Down Expand Up @@ -151,6 +165,7 @@ export default {
},
computed: {
...mapState('account', ['team', 'teamMembership']),
...mapGetters('account', ['featuresCheck']),
cloudColumns () {
return [
{ label: 'Name', class: ['w-1/2'], component: { is: markRaw(DeploymentName) } },
Expand Down Expand Up @@ -182,6 +197,9 @@ export default {
},
isVisitingAdmin () {
return this.teamMembership.role === Roles.Admin
},
instancesAvailable () {
return this.featuresCheck?.isHostedInstancesEnabledForTeam
}
},
mounted () {
Expand Down
19 changes: 12 additions & 7 deletions frontend/src/pages/application/routes.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,5 @@
/**
* WARNING: There is ongoing work to move Application functionality up into applications
* or down into instances.
*
* No new functionality should be added here.
*/
import store from '../../store/index.js'

import ApplicationActivity from './Activity.vue'
import Dependencies from './Dependencies/Dependencies.vue'
import ApplicationDeviceGroupSettingsEnvironment from './DeviceGroup/Settings/Environment.vue'
Expand All @@ -25,10 +21,19 @@ import ApplicationSnapshots from './Snapshots.vue'
import ApplicationCreateInstance from './createInstance.vue'
import ApplicationIndex from './index.vue'

// import account vuex store

export default [
{
path: ':id',
redirect: { name: 'ApplicationInstances' },
redirect: function () {
const features = store.getters['account/featuresCheck']
if (features.isHostedInstancesEnabledForTeam) {
return { name: 'ApplicationInstances' }
} else {
return { name: 'ApplicationDevices' }
}
},
name: 'Application',
component: ApplicationIndex,
meta: {
Expand Down
13 changes: 8 additions & 5 deletions frontend/src/pages/instance/components/InstanceForm.vue
Original file line number Diff line number Diff line change
Expand Up @@ -57,14 +57,14 @@
</FormRow>
</div>

<FormRow v-if="creatingApplication" v-model="input.createInstance" type="checkbox" data-form="create-instance">
<FormRow v-if="creatingApplication && instancesAvailable" v-model="input.createInstance" type="checkbox" data-form="create-instance">
Create Node-RED Instance
<template #description>
This will create an instance of Node-RED that will be managed in your new Application.
</template>
</FormRow>

<div v-if="!creatingApplication || input.createInstance" :class="creatingApplication ? 'ml-6' : ''" class="space-y-6">
<div v-if="instancesAvailable && (!creatingApplication || input.createInstance)" :class="creatingApplication ? 'ml-6' : ''" class="space-y-6">
<FeatureUnavailableToTeam v-if="teamRuntimeLimitReached" fullMessage="You have reached the runtime limit for this team." />
<FeatureUnavailableToTeam v-else-if="teamInstanceLimitReached" fullMessage="You have reached the instance limit for this team." />
<!-- Instance Name -->
Expand Down Expand Up @@ -212,7 +212,7 @@
type="submit"
>
<template v-if="creatingNew">
<span v-if="applicationFieldsVisible">Create Application<span v-if="input.createInstance"> &amp; Instance</span></span>
<span v-if="applicationFieldsVisible">Create Application<span v-if="input.createInstance && instancesAvailable"> &amp; Instance</span></span>
<span v-else>Create Instance</span>
</template>
<template v-else>
Expand Down Expand Up @@ -384,7 +384,7 @@ export default {
},
computed: {
...mapState('account', ['settings']),
...mapGetters('account', ['blueprints', 'defaultBlueprint']),
...mapGetters('account', ['blueprints', 'defaultBlueprint', 'featuresCheck']),
creatingApplication () {
return (this.applicationSelection && !this.applications.length) || (this.creatingNew && this.applicationFieldsVisible)
},
Expand Down Expand Up @@ -458,6 +458,9 @@ export default {
// Hence, if activeProjectTypeCount === 0, then they are at their limit of usage
return this.projectTypes.length > 0 && this.activeProjectTypeCount === 0
},
instancesAvailable () {
return this.featuresCheck?.isHostedInstancesEnabledForTeam
},
atLeastOneFlowBlueprint () {
return this.blueprints.length > 0
},
Expand Down Expand Up @@ -652,7 +655,7 @@ export default {
...this.preDefinedInputs
}
}
if (this.teamInstanceLimitReached || this.teamRuntimeLimitReached) {
if (this.teamInstanceLimitReached || this.teamRuntimeLimitReached || !this.instancesAvailable) {
this.input.createInstance = false
}
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
</label>
<span v-if="!isSearching" class="message">
This Application currently has no
<router-link :to="`/application/${application.id}/devices`" class="ff-link">attached devices</router-link>
<router-link :to="{name: 'ApplicationDevices', params: {team_slug: team.slug, id: application.id}}" class="ff-link">attached devices</router-link>
.
</span>
<span v-else class="message">
Expand Down Expand Up @@ -68,6 +68,8 @@
</template>

<script>
import { mapState } from 'vuex'
import IconDeviceSolid from '../../../../../components/icons/DeviceSolid.js'
import deviceActionsMixin from '../../../../../mixins/DeviceActions.js'
import DeviceCredentialsDialog from '../../../Devices/dialogs/DeviceCredentialsDialog.vue'
Expand Down Expand Up @@ -99,6 +101,7 @@ export default {
}
},
computed: {
...mapState('account', ['team']),
hasMoreDevices () {
return this.application.deviceCount > this.visibleDevices.length
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
</label>
<span v-if="!isSearching" class="message">
This Application currently has no
<router-link :to="`/application/${application.id}/instances`" class="ff-link">
<router-link :to="{name: 'ApplicationInstances', params: {team_slug: team.slug, id: application.id}}" class="ff-link">
attached Node-RED Instances
</router-link>
.
Expand Down Expand Up @@ -39,6 +39,8 @@
</template>

<script>
import { mapState } from 'vuex'
import IconNodeRedSolid from '../../../../../components/icons/NodeRedSolid.js'
import HasMoreTile from './HasMoreTile.vue'
Expand All @@ -62,6 +64,7 @@ export default {
},
emits: ['delete-instance'],
computed: {
...mapState('account', ['team']),
instances () {
return this.application.instances.slice(0, 3)
},
Expand Down
33 changes: 29 additions & 4 deletions frontend/src/pages/team/Instances.vue
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,11 @@
</ff-page-header>
</template>
<div class="space-y-6">
<div class="banner-wrapper">
<FeatureUnavailableToTeam v-if="!instancesAvailable" />
</div>
<ff-loading v-if="loading" message="Loading Instances..." />
<template v-else>
<template v-else-if="instancesAvailable">
<ff-data-table
v-if="instances.length > 0"
data-el="instances-table" :columns="columns" :rows="instances" :show-search="true" search-placeholder="Search Instances..."
Expand Down Expand Up @@ -98,16 +101,31 @@
<template #header>There are no dashboards in this team.</template>
</EmptyState>
</template>
<template v-else>
<EmptyState>
<template #img>
<img src="../../images/empty-states/team-instances.png">
</template>
<template #header>Hosted Instances Not Available</template>
<template #message>
<p>
Hosted Node-RED Instances are not available on your Team Tier. Please explore upgrade options to enable it.
</p>
</template>
</EmptyState>
</template>
</div>
</ff-page>
</template>

<script>
import { PlusSmIcon } from '@heroicons/vue/outline'
import { markRaw } from 'vue'
import { mapGetters } from 'vuex'
import teamApi from '../../api/team.js'
import EmptyState from '../../components/EmptyState.vue'
import FeatureUnavailableToTeam from '../../components/banners/FeatureUnavailableToTeam.vue'
import permissionsMixin from '../../mixins/Permissions.js'
import DeploymentName from '../application/components/cells/DeploymentName.vue'
import SimpleTextCell from '../application/components/cells/SimpleTextCell.vue'
Expand All @@ -121,7 +139,8 @@ export default {
InstanceEditorLink,
DashboardLink,
PlusSmIcon,
EmptyState
EmptyState,
FeatureUnavailableToTeam
},
mixins: [permissionsMixin],
props: {
Expand Down Expand Up @@ -152,16 +171,22 @@ export default {
]
}
},
computed: {
...mapGetters('account', ['featuresCheck']),
instancesAvailable () {
return this.featuresCheck?.isHostedInstancesEnabledForTeam
}
},
watch: {
team: 'fetchData'
},
mounted () {
this.fetchData()
},
methods: {
fetchData: async function (newVal) {
fetchData: async function () {
this.loading = true
if (this.team.id) {
if (this.team.id && this.instancesAvailable) {
if (this.hasPermission('team:projects:list')) {
this.instances = (await teamApi.getTeamInstances(this.team.id)).projects
} else if (this.hasPermission('team:read')) {
Expand Down
13 changes: 13 additions & 0 deletions frontend/src/store/account.js
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,19 @@ const getters = {

featuresCheck: (state) => {
const preCheck = {
// Instances
isHostedInstancesEnabledForTeam: ((state) => {
let available = false
// loop over the different instance types
for (const instanceType of Object.keys(state.team.type.properties?.instances) || []) {
if (state.team.type.properties?.instances[instanceType].active) {
available = true
break
}
}
return available
})(state),

// Shared Library
isSharedLibraryFeatureEnabledForTeam: ((state) => {
const flag = state.team?.type?.properties?.features?.['shared-library']
Expand Down
1 change: 1 addition & 0 deletions frontend/src/store/ux.js
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ const getters = {
to: { name: 'Instances', params: { team_slug: team.slug } },
tag: 'team-instances',
icon: ProjectsIcon,
featureUnavailable: !features.isHostedInstancesEnabledForTeam,
disabled: noBilling
},
{
Expand Down
7 changes: 7 additions & 0 deletions frontend/src/ui-components/components/tabs/Tabs.vue
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,21 @@
@click="selectTab($index)"
>
{{ tab.label }}
<span v-if="tab.featureUnavailable" v-ff-tooltip="'Not available in this Team Tier'" data-el="premium-feature">
<SparklesIcon class="ff-icon transition-fade--color hollow" style="stroke-width: 1;" />
</span>
</router-link>
</ul>
</div>
</template>

<script>
import { SparklesIcon } from '@heroicons/vue/outline'
export default {
name: 'ff-tabs',
components: {
SparklesIcon
},
props: {
orientation: {
default: 'horizontal',
Expand Down
22 changes: 22 additions & 0 deletions test/e2e/frontend/cypress/tests-ee/free-tier/free-tier.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
describe('FlowFuse EE - Free Tier', () => {
beforeEach(() => {
cy.login('freddie', 'ffPassword')
cy.home()
})

it('shows that Hosted Instances are a premium feature in the side navigation', () => {
cy.get('[data-nav="team-instances"]').get('[data-el="premium-feature"]').should('exist')
})

it('shows that Hosted Instances are a premium feature when on the /team/<teamId>/instances page', () => {
cy.get('[data-nav="team-instances"]').click()
cy.get('[data-el="page-banner-feature-unavailable-to-team"]').should('exist')
})

it('redirects to /application/devices after creating an Application when Hosted Instances are not enabled', () => {
cy.get('[data-el="empty-state"] [data-action="create-application"]').click()
cy.get('[data-form="application-name"] input[type="text"]').type('My Application')
cy.get('[data-action="create-project"]').click()
cy.url().should('include', '/devices')
})
})
Loading

0 comments on commit aa77197

Please sign in to comment.