fix(automate): update empty state (#3642)

* fix(automate): wip onboarding copy

* fix(automate): conditional onboarding buttons
This commit is contained in:
Chuck Driesler
2024-12-05 13:30:17 +00:00
committed by GitHub
parent 8927490797
commit dab1bc758c
7 changed files with 204 additions and 61 deletions
+16 -11
View File
@@ -21,20 +21,25 @@
v-if="buttons"
class="flex flex-col flex-wrap md:flex-row gap-y-2 md:gap-x-2 gap-y-0 mt-3"
>
<FormButton
<div
v-for="(button, index) in buttons"
:key="button.id || index"
v-bind="button.props || {}"
:disabled="button.props?.disabled || button.disabled"
:submit="button.props?.submit || button.submit"
target="_blank"
external
size="sm"
color="outline"
@click="($event) => button.onClick?.($event)"
v-tippy="button.disabledMessage"
class="shrink-0"
>
{{ button.text }}
</FormButton>
<FormButton
v-bind="button.props || {}"
:disabled="button.props?.disabled || button.disabled"
:submit="button.props?.submit || button.submit"
target="_blank"
external
size="sm"
color="outline"
@click="($event) => button.onClick?.($event)"
>
{{ button.text }}
</FormButton>
</div>
</div>
</div>
</template>
@@ -1,27 +1,18 @@
<template>
<div class="flex flex-col gap-y-6 md:gap-y-8">
<div class="p-4 flex flex-col gap-y-4 rounded-lg max-w-2xl mx-auto items-center">
<div class="gap-y-4 flex flex-col items-center">
<div class="text-heading-lg text-foreground">Scale your digital impact</div>
<div class="text-body-xs text-foreground-2">
Speckle Automate empowers you to continuously monitor your published models,
automatically ensuring project data standards, identifying potential design
faults, and effortlessly creating delivery artifacts.
</div>
<div class="flex gap-x-4">
<FormButton
:disabled="!!creationDisabledMessage"
@click="$emit('new-automation')"
>
New automation
</FormButton>
<FormButton color="outline" :to="functionsGalleryRoute">
View functions
</FormButton>
</div>
</div>
<section class="flex flex-col items-center justify-center py-8 md:py-16">
<h3 class="text-heading-lg text-foreground">
Scale your digital impact with Automate. Let's get you started...
</h3>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-3 pt-5 mt-4 max-w-5xl">
<CommonCard
v-for="emptyStateItem in emptyStateItems"
:key="emptyStateItem.title"
:title="emptyStateItem.title"
:description="emptyStateItem.description"
:buttons="emptyStateItem.buttons"
/>
</div>
</div>
</section>
</template>
<script setup lang="ts">
import {
@@ -29,20 +20,111 @@ import {
workspaceFunctionsRoute
} from '~/lib/common/helpers/route'
import type { CreateAutomationSelectableFunction } from '~/lib/automate/helpers/automations'
import type { LayoutDialogButton } from '@speckle/ui-components'
defineEmits<{
const emit = defineEmits<{
'new-automation': [fn?: CreateAutomationSelectableFunction]
'new-function': []
}>()
const props = defineProps<{
workspaceSlug?: string
isAutomateEnabled: boolean
creationDisabledMessage?: string
}>()
export type AutomateOnboardingAction =
| 'create-function'
| 'view-functions'
| 'create-automation'
const props = withDefaults(
defineProps<{
workspaceSlug?: string
hiddenActions?: AutomateOnboardingAction[]
disabledActions?: {
action: AutomateOnboardingAction
reason: string
}[]
}>(),
{
hiddenActions: () => [],
disabledActions: () => []
}
)
const router = useRouter()
const functionsGalleryRoute = computed(() =>
props.workspaceSlug
? workspaceFunctionsRoute(props.workspaceSlug)
: automationFunctionsRoute
)
const isVisibleAction = (action: LayoutDialogButton): boolean => {
return !props.hiddenActions.includes(action.id as AutomateOnboardingAction)
}
const isDisabledAction = (action: AutomateOnboardingAction): boolean => {
return props.disabledActions.some((entry) => entry.action === action)
}
const getDisabledMessage = (action: AutomateOnboardingAction): string | undefined => {
return props.disabledActions.find((entry) => entry.action === action)?.reason
}
const emptyStateItems = computed(() => {
const items: {
title: string
description: string
buttons: LayoutDialogButton[]
}[] = [
{
title: "Capture your team's knowledge",
description:
'Turn tacit knowledge and monotonous process into code. Use private functions across projects in your workspace.',
buttons: [
{
id: 'create-function',
text: 'Create function',
onClick: () => {
emit('new-function')
},
disabled: isDisabledAction('create-function')
},
{
id: 'view-functions',
text: 'View functions',
onClick: () => {
router.push(functionsGalleryRoute.value)
}
}
].filter(isVisibleAction)
},
{
title: 'Automate your workflows',
description:
'Continuously ensure project data standards, generate delivery artifacts, and more!',
buttons: [
{
text: 'Create automation',
onClick: () => {
emit('new-automation')
},
disabled: isDisabledAction('create-automation'),
disabledMessage: getDisabledMessage('create-automation')
}
]
},
{
title: 'Learn more',
description:
'Find out how Automate can be customised to support virtually any of your custom workflows.',
buttons: [
{
text: 'View docs',
props: {
to: 'https://speckle.guide/automate/',
external: true
}
}
]
}
]
return items
})
</script>
@@ -4,7 +4,7 @@
v-model:search="search"
:workspace-slug="workspaceSlug"
:show-empty-state="shouldShowEmptyState"
:creation-disabled-message="disableCreateMessage"
:creation-disabled-message="disableCreateAutomationMessage"
@new-automation="onNewAutomation"
/>
<template v-if="loading">
@@ -14,10 +14,10 @@
<ProjectPageAutomationsEmptyState
v-if="shouldShowEmptyState"
:workspace-slug="workspaceSlug"
:functions="result"
:is-automate-enabled="isAutomateEnabled"
:creation-disabled-message="disableCreateMessage"
:hidden-actions="hiddenActions"
:disabled-actions="disabledActions"
@new-automation="onNewAutomation"
@new-function="onNewFunction"
/>
<template v-else>
<template v-if="automations.length">
@@ -43,6 +43,13 @@
:preselected-project="project"
:preselected-function="newAutomationTargetFn"
/>
<AutomateFunctionCreateDialog
v-model:open="showNewFunctionDialog"
:workspace-id="workspaceId"
:is-authorized="isGithubAppConfigured"
:github-orgs="githubOrgs"
:templates="availableFunctionTemplates"
/>
</div>
</template>
<script setup lang="ts">
@@ -54,6 +61,7 @@ import {
import type { CreateAutomationSelectableFunction } from '~/lib/automate/helpers/automations'
import { usePaginatedQuery } from '~/lib/common/composables/graphql'
import { Roles } from '@speckle/shared'
import type { AutomateOnboardingAction } from '~/components/project/page/automations/EmptyState.vue'
const route = useRoute()
const projectId = computed(() => route.params.id as string)
@@ -78,6 +86,58 @@ const { result, loading } = useQuery(
const workspaceId = computed(() => result.value?.project?.workspace?.id)
const workspaceSlug = computed(() => result.value?.project?.workspace?.slug)
const workspaceFunctionCount = computed(
() => result.value?.project.workspace?.automateFunctions.totalCount ?? 0
)
const hiddenActions = computed<AutomateOnboardingAction[]>(() => {
return workspaceFunctionCount.value > 0 ? [] : ['view-functions']
})
const disabledActions = computed<
{ action: AutomateOnboardingAction; reason: string }[]
>(() => {
if (workspaceFunctionCount.value === 0) {
return [
{
action: 'create-automation',
reason:
'You must create at least one function before you can create an automation.'
}
]
}
if (result.value?.project?.role !== Roles.Stream.Owner) {
return [
{
action: 'create-automation',
reason: 'Only project owners can create new automations.'
}
]
}
if ((result.value?.project?.models?.items.length || 0) === 0) {
return [
{
action: 'create-automation',
reason:
'Your project should have at least 1 model before you can create an automation.'
}
]
}
return []
})
const disableCreateAutomationMessage = computed(
() =>
disabledActions.value?.find((entry) => entry.action === 'create-automation')?.reason
)
const isGithubAppConfigured = computed(
() => !!result.value?.activeUser?.automateInfo.hasAutomateGithubApp
)
const githubOrgs = computed(
() => result.value?.activeUser?.automateInfo.availableGithubOrgs || []
)
const availableFunctionTemplates = computed(
() => result.value?.serverInfo.automate.availableFunctionTemplates || []
)
// Pagination query
const {
identifier,
@@ -99,6 +159,7 @@ const {
resolveCursorFromVariables: (vars) => vars.cursor
})
const showNewFunctionDialog = ref(false)
const showNewAutomationDialog = ref(false)
const newAutomationTargetFn = ref<CreateAutomationSelectableFunction>()
@@ -118,22 +179,12 @@ const shouldShowEmptyState = computed(() => {
return false
})
const disableCreateMessage = computed(() => {
const allowedRoles: string[] = [Roles.Stream.Owner]
if (!allowedRoles.includes(result.value?.project?.role ?? '')) {
return 'You must be a project owner to create automations.'
}
if ((result.value?.project?.models?.items.length || 0) === 0) {
return 'Your project should have at least 1 model before you can create an automation.'
}
return undefined
})
const onNewAutomation = (fn?: CreateAutomationSelectableFunction) => {
newAutomationTargetFn.value = fn
showNewAutomationDialog.value = true
}
const onNewFunction = () => {
showNewFunctionDialog.value = true
}
</script>
@@ -258,7 +258,7 @@ const documents = {
"\n query ProjectModelVersions(\n $projectId: String!\n $modelId: String!\n $versionsCursor: String\n ) {\n project(id: $projectId) {\n id\n ...ProjectModelPageVersionsPagination\n }\n }\n": types.ProjectModelVersionsDocument,
"\n query ProjectModelsPage($projectId: String!) {\n project(id: $projectId) {\n id\n ...ProjectModelsPageHeader_Project\n ...ProjectModelsPageResults_Project\n }\n }\n": types.ProjectModelsPageDocument,
"\n query ProjectDiscussionsPage($projectId: String!) {\n project(id: $projectId) {\n id\n ...ProjectDiscussionsPageHeader_Project\n ...ProjectDiscussionsPageResults_Project\n }\n }\n": types.ProjectDiscussionsPageDocument,
"\n query ProjectAutomationsTab($projectId: String!) {\n project(id: $projectId) {\n id\n role\n models(limit: 1) {\n items {\n id\n }\n }\n automations(filter: null, cursor: null, limit: 5) {\n totalCount\n items {\n id\n ...ProjectPageAutomationsRow_Automation\n }\n cursor\n }\n workspace {\n id\n slug\n }\n ...FormSelectProjects_Project\n }\n }\n": types.ProjectAutomationsTabDocument,
"\n query ProjectAutomationsTab($projectId: String!) {\n project(id: $projectId) {\n id\n role\n models(limit: 1) {\n items {\n id\n }\n }\n automations(filter: null, cursor: null, limit: 5) {\n totalCount\n items {\n id\n ...ProjectPageAutomationsRow_Automation\n }\n cursor\n }\n workspace {\n id\n slug\n automateFunctions(limit: 0) {\n totalCount\n }\n }\n ...FormSelectProjects_Project\n }\n ...AutomateFunctionsPageHeader_Query\n }\n": types.ProjectAutomationsTabDocument,
"\n query ProjectAutomationsTabAutomationsPagination(\n $projectId: String!\n $search: String = null\n $cursor: String = null\n ) {\n project(id: $projectId) {\n id\n automations(filter: $search, cursor: $cursor, limit: 5) {\n totalCount\n cursor\n items {\n id\n ...ProjectPageAutomationsRow_Automation\n }\n }\n }\n }\n": types.ProjectAutomationsTabAutomationsPaginationDocument,
"\n query ProjectAutomationPage($projectId: String!, $automationId: String!) {\n project(id: $projectId) {\n id\n ...ProjectPageAutomationPage_Project\n automation(id: $automationId) {\n id\n ...ProjectPageAutomationPage_Automation\n }\n }\n }\n": types.ProjectAutomationPageDocument,
"\n query ProjectAutomationPagePaginatedRuns(\n $projectId: String!\n $automationId: String!\n $cursor: String = null\n ) {\n project(id: $projectId) {\n id\n automation(id: $automationId) {\n id\n runs(cursor: $cursor, limit: 10) {\n totalCount\n cursor\n items {\n id\n ...AutomationRunDetails\n }\n }\n }\n }\n }\n": types.ProjectAutomationPagePaginatedRunsDocument,
@@ -1373,7 +1373,7 @@ export function graphql(source: "\n query ProjectDiscussionsPage($projectId: St
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n query ProjectAutomationsTab($projectId: String!) {\n project(id: $projectId) {\n id\n role\n models(limit: 1) {\n items {\n id\n }\n }\n automations(filter: null, cursor: null, limit: 5) {\n totalCount\n items {\n id\n ...ProjectPageAutomationsRow_Automation\n }\n cursor\n }\n workspace {\n id\n slug\n }\n ...FormSelectProjects_Project\n }\n }\n"): (typeof documents)["\n query ProjectAutomationsTab($projectId: String!) {\n project(id: $projectId) {\n id\n role\n models(limit: 1) {\n items {\n id\n }\n }\n automations(filter: null, cursor: null, limit: 5) {\n totalCount\n items {\n id\n ...ProjectPageAutomationsRow_Automation\n }\n cursor\n }\n workspace {\n id\n slug\n }\n ...FormSelectProjects_Project\n }\n }\n"];
export function graphql(source: "\n query ProjectAutomationsTab($projectId: String!) {\n project(id: $projectId) {\n id\n role\n models(limit: 1) {\n items {\n id\n }\n }\n automations(filter: null, cursor: null, limit: 5) {\n totalCount\n items {\n id\n ...ProjectPageAutomationsRow_Automation\n }\n cursor\n }\n workspace {\n id\n slug\n automateFunctions(limit: 0) {\n totalCount\n }\n }\n ...FormSelectProjects_Project\n }\n ...AutomateFunctionsPageHeader_Query\n }\n"): (typeof documents)["\n query ProjectAutomationsTab($projectId: String!) {\n project(id: $projectId) {\n id\n role\n models(limit: 1) {\n items {\n id\n }\n }\n automations(filter: null, cursor: null, limit: 5) {\n totalCount\n items {\n id\n ...ProjectPageAutomationsRow_Automation\n }\n cursor\n }\n workspace {\n id\n slug\n automateFunctions(limit: 0) {\n totalCount\n }\n }\n ...FormSelectProjects_Project\n }\n ...AutomateFunctionsPageHeader_Query\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
File diff suppressed because one or more lines are too long
@@ -254,9 +254,13 @@ export const projectAutomationsTabQuery = graphql(`
workspace {
id
slug
automateFunctions(limit: 0) {
totalCount
}
}
...FormSelectProjects_Project
}
...AutomateFunctionsPageHeader_Query
}
`)
@@ -38,6 +38,7 @@ export type LayoutDialogButton = {
props?: Record<string, unknown> & FormButtonProps
onClick?: (e: MouseEvent) => void
disabled?: boolean
disabledMessage?: string
submit?: boolean
/**
* This should uniquely identify the button within the form. Even if you have different sets