Merge remote-tracking branch 'origin' into chuck/web-2435-move-comments-and-webhooks-without-attachments
This commit is contained in:
+9
-38
@@ -2,7 +2,7 @@ version: 2.1
|
||||
|
||||
orbs:
|
||||
snyk: snyk/snyk@2.0.3
|
||||
# codecov: codecov/codecov@4.1.0
|
||||
codecov: codecov/codecov@5.0.3
|
||||
|
||||
workflows:
|
||||
test-build:
|
||||
@@ -20,6 +20,7 @@ workflows:
|
||||
context:
|
||||
- speckle-server-licensing
|
||||
- stripe-integration
|
||||
- speckle-server-codecov
|
||||
filters: &filters-allow-all
|
||||
tags:
|
||||
# run tests for any commit on any branch, including any tags
|
||||
@@ -93,7 +94,6 @@ workflows:
|
||||
- get-version
|
||||
- deployment-testing-approval
|
||||
- docker-build-server
|
||||
- docker-build-frontend
|
||||
- docker-build-frontend-2
|
||||
- docker-build-previews
|
||||
- docker-build-webhooks
|
||||
@@ -116,12 +116,6 @@ workflows:
|
||||
requires:
|
||||
- get-version
|
||||
|
||||
- docker-build-frontend:
|
||||
context: *build-context
|
||||
filters: *filters-build
|
||||
requires:
|
||||
- get-version
|
||||
|
||||
- docker-build-frontend-2:
|
||||
context: *build-context
|
||||
filters: *filters-build
|
||||
@@ -195,22 +189,6 @@ workflows:
|
||||
- test-server-multiregion
|
||||
- test-preview-service
|
||||
|
||||
- docker-publish-frontend:
|
||||
context: *docker-hub-context
|
||||
filters: *filters-publish
|
||||
requires:
|
||||
- docker-build-frontend
|
||||
- get-version
|
||||
- pre-commit
|
||||
- publish-approval
|
||||
- test-frontend-2
|
||||
- test-viewer
|
||||
- test-objectsender
|
||||
- test-server
|
||||
- test-server-no-ff
|
||||
- test-server-multiregion
|
||||
- test-preview-service
|
||||
|
||||
- docker-publish-frontend-2:
|
||||
context: *docker-hub-context
|
||||
filters: *filters-publish
|
||||
@@ -340,7 +318,6 @@ workflows:
|
||||
- deployment-test-helm-chart
|
||||
- docker-publish-docker-compose-ingress
|
||||
- docker-publish-file-imports
|
||||
- docker-publish-frontend
|
||||
- docker-publish-frontend-2
|
||||
- docker-publish-monitor-container
|
||||
- docker-publish-previews
|
||||
@@ -494,8 +471,9 @@ jobs:
|
||||
S3_CREATE_BUCKET: 'true'
|
||||
REDIS_URL: 'redis://127.0.0.1:6379'
|
||||
S3_REGION: '' # optional, defaults to 'us-east-1'
|
||||
FRONTEND_ORIGIN: 'http://127.0.0.1:8081'
|
||||
ENCRYPTION_KEYS_PATH: 'test/assets/automate/encryptionKeys.json'
|
||||
FF_BILLING_INTEGRATION_ENABLED: 'true'
|
||||
ENABLE_ALL_FFS: 'true'
|
||||
RATELIMITER_ENABLED: 'false'
|
||||
steps:
|
||||
- checkout
|
||||
@@ -535,8 +513,8 @@ jobs:
|
||||
command: yarn test:report
|
||||
working_directory: 'packages/server'
|
||||
|
||||
# - codecov/upload:
|
||||
# file: packages/server/coverage/lcov.info
|
||||
- codecov/upload:
|
||||
files: packages/server/coverage/lcov.info
|
||||
|
||||
- run:
|
||||
name: Introspect GQL schema for subsequent checks
|
||||
@@ -566,6 +544,7 @@ jobs:
|
||||
NODE_ENV: test
|
||||
DATABASE_URL: 'postgres://speckle:speckle@127.0.0.1:5432/speckle2_test'
|
||||
PGDATABASE: speckle2_test
|
||||
POSTGRES_MAX_CONNECTIONS_SERVER: 20
|
||||
PGUSER: speckle
|
||||
SESSION_SECRET: 'keyboard cat'
|
||||
STRATEGY_LOCAL: 'true'
|
||||
@@ -577,6 +556,7 @@ jobs:
|
||||
S3_CREATE_BUCKET: 'true'
|
||||
REDIS_URL: 'redis://127.0.0.1:6379'
|
||||
S3_REGION: '' # optional, defaults to 'us-east-1'
|
||||
FRONTEND_ORIGIN: 'http://127.0.0.1:8081'
|
||||
ENCRYPTION_KEYS_PATH: 'test/assets/automate/encryptionKeys.json'
|
||||
DISABLE_ALL_FFS: 'true'
|
||||
RATELIMITER_ENABLED: 'false'
|
||||
@@ -627,6 +607,7 @@ jobs:
|
||||
S3_CREATE_BUCKET: 'true'
|
||||
REDIS_URL: 'redis://127.0.0.1:6379'
|
||||
S3_REGION: '' # optional, defaults to 'us-east-1'
|
||||
FRONTEND_ORIGIN: 'http://127.0.0.1:8081'
|
||||
ENCRYPTION_KEYS_PATH: 'test/assets/automate/encryptionKeys.json'
|
||||
FF_BILLING_INTEGRATION_ENABLED: 'true'
|
||||
# These are the only different env keys:
|
||||
@@ -1018,11 +999,6 @@ jobs:
|
||||
environment:
|
||||
SPECKLE_SERVER_PACKAGE: server
|
||||
|
||||
docker-build-frontend:
|
||||
<<: *build-job
|
||||
environment:
|
||||
SPECKLE_SERVER_PACKAGE: frontend
|
||||
|
||||
docker-build-frontend-2:
|
||||
<<: *build-job
|
||||
resource_class: xlarge
|
||||
@@ -1088,11 +1064,6 @@ jobs:
|
||||
environment:
|
||||
SPECKLE_SERVER_PACKAGE: server
|
||||
|
||||
docker-publish-frontend:
|
||||
<<: *publish-job
|
||||
environment:
|
||||
SPECKLE_SERVER_PACKAGE: frontend
|
||||
|
||||
docker-publish-frontend-2:
|
||||
<<: *publish-job
|
||||
environment:
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
*node_modules
|
||||
packages/server/.env
|
||||
packages/server/dist
|
||||
packages/frontend/dist
|
||||
packages/frontend/profiler
|
||||
packages/viewer/dist
|
||||
packages/objectloader/dist
|
||||
packages/*/dist
|
||||
@@ -22,7 +20,6 @@ coverage/
|
||||
|
||||
packages/viewer/example/*.js
|
||||
packages/viewer/example/*.js.map
|
||||
packages/frontend/schema.graphql
|
||||
.tool-versions
|
||||
|
||||
packages/server/reports*
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
This monorepo is the home of the Speckle v2 web packages:
|
||||
|
||||
- [`packages/server`](https://github.com/specklesystems/speckle-server/blob/main/packages/server): the Server, a nodejs app. Core external dependencies are a Redis and Postgresql db.
|
||||
- [`packages/frontend`](https://github.com/specklesystems/speckle-server/blob/main/packages/frontend): the Frontend, a static Vue app.
|
||||
- [`packages/frontend-2`](https://github.com/specklesystems/speckle-server/blob/main/packages/frontend-2): the Frontend, a Nuxt/Vue app.
|
||||
- [`packages/viewer`](https://github.com/specklesystems/speckle-server/blob/main/packages/viewer): a threejs extension that allows you to display 3D data [](https://www.npmjs.com/package/@speckle/viewer)
|
||||
- [`packages/objectloader`](https://github.com/specklesystems/speckle-server/blob/main/packages/objectloader): a small js utility class that helps you stream an object and all its sub-components from the Speckle Server API. [](https://www.npmjs.com/package/@speckle/objectloader)
|
||||
- [`packages/preview-service`](https://github.com/specklesystems/speckle-server/blob/main/packages/preview-service): generates object previews for Speckle Objects headlessly. This package is meant to be called on by the server.
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
codecov:
|
||||
notify:
|
||||
notify_error: true
|
||||
require_ci_to_pass: false
|
||||
@@ -72,7 +72,6 @@ services:
|
||||
FILE_SIZE_LIMIT_MB: 100
|
||||
EMAIL_FROM: 'no-reply@example.org'
|
||||
|
||||
USE_FRONTEND_2: true
|
||||
FRONTEND_ORIGIN: 'http://127.0.0.1'
|
||||
ONBOARDING_STREAM_URL: 'https://latest.speckle.systems/projects/843d07eb10'
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ services:
|
||||
environment:
|
||||
SPECKLE_SERVER: http://127.0.0.1 # this is the canonical url
|
||||
SERVER_VERSION: 2
|
||||
FRONTEND_VERSION: '2'
|
||||
VERIFY_CERTIFICATE: '0'
|
||||
restart: 'no'
|
||||
|
||||
|
||||
@@ -32,7 +32,6 @@
|
||||
"dev:minimal": "yarn workspaces foreach -pivW -j unlimited --include '{@speckle/server,@speckle/frontend-2}' run dev",
|
||||
"gqlgen": "yarn workspaces foreach -pivW -j unlimited --include '{@speckle/server,@speckle/frontend,@speckle/frontend-2,@speckle/dui3}' run gqlgen",
|
||||
"dev:server": "yarn workspace @speckle/server dev",
|
||||
"dev:frontend": "yarn workspace @speckle/frontend dev",
|
||||
"dev:frontend-2": "yarn workspace @speckle/frontend-2 dev",
|
||||
"dev:shared": "yarn workspace @speckle/shared dev",
|
||||
"prepare": "husky install",
|
||||
|
||||
@@ -9,7 +9,6 @@ const Observability = require('@speckle/shared/dist/commonjs/observability/index
|
||||
|
||||
const tables = (db) => ({
|
||||
objects: db('objects'),
|
||||
closures: db('object_children_closure'),
|
||||
branches: db('branches'),
|
||||
streams: db('streams'),
|
||||
apiTokens: db('api_tokens'),
|
||||
@@ -47,17 +46,9 @@ module.exports = class ServerAPI {
|
||||
async createObject({ streamId, object }) {
|
||||
const insertionObject = this.prepInsertionObject(streamId, object)
|
||||
|
||||
const closures = []
|
||||
const totalChildrenCountByDepth = {}
|
||||
if (object.__closure !== null) {
|
||||
for (const prop in object.__closure) {
|
||||
closures.push({
|
||||
streamId,
|
||||
parent: insertionObject.id,
|
||||
child: prop,
|
||||
minDepth: object.__closure[prop]
|
||||
})
|
||||
|
||||
if (totalChildrenCountByDepth[object.__closure[prop].toString()])
|
||||
totalChildrenCountByDepth[object.__closure[prop].toString()]++
|
||||
else totalChildrenCountByDepth[object.__closure[prop].toString()] = 1
|
||||
@@ -67,22 +58,17 @@ module.exports = class ServerAPI {
|
||||
delete insertionObject.__tree
|
||||
delete insertionObject.__closure
|
||||
|
||||
insertionObject.totalChildrenCount = closures.length
|
||||
insertionObject.totalChildrenCount = object.__closures.length
|
||||
insertionObject.totalChildrenCountByDepth = JSON.stringify(
|
||||
totalChildrenCountByDepth
|
||||
)
|
||||
|
||||
await this.tables.objects.insert(insertionObject).onConflict().ignore()
|
||||
|
||||
if (closures.length > 0) {
|
||||
await this.tables.closures.insert(closures).onConflict().ignore()
|
||||
}
|
||||
|
||||
return insertionObject.id
|
||||
}
|
||||
|
||||
async createObjectsBatched(streamId, objects) {
|
||||
const closures = []
|
||||
const objsToInsert = []
|
||||
const ids = []
|
||||
|
||||
@@ -94,12 +80,6 @@ module.exports = class ServerAPI {
|
||||
|
||||
if (obj.__closure !== null) {
|
||||
for (const prop in obj.__closure) {
|
||||
closures.push({
|
||||
streamId,
|
||||
parent: insertionObject.id,
|
||||
child: prop,
|
||||
minDepth: obj.__closure[prop]
|
||||
})
|
||||
totalChildrenCountGlobal++
|
||||
if (totalChildrenCountByDepth[obj.__closure[prop].toString()])
|
||||
totalChildrenCountByDepth[obj.__closure[prop].toString()]++
|
||||
@@ -119,7 +99,6 @@ module.exports = class ServerAPI {
|
||||
ids.push(insertionObject.id)
|
||||
})
|
||||
|
||||
const closureBatchSize = 1000
|
||||
const objectsBatchSize = 500
|
||||
|
||||
// step 1: insert objects
|
||||
@@ -139,23 +118,6 @@ module.exports = class ServerAPI {
|
||||
}
|
||||
}
|
||||
|
||||
// step 2: insert closures
|
||||
if (closures.length > 0) {
|
||||
const batches = chunk(closures, closureBatchSize)
|
||||
|
||||
for (const [index, batch] of batches.entries()) {
|
||||
this.prepInsertionClosureBatch(batch)
|
||||
await this.tables.closures.insert(batch).onConflict().ignore()
|
||||
this.logger.info(
|
||||
{
|
||||
currentBatchCount: batch.length,
|
||||
currentBatchId: index + 1,
|
||||
totalNumberOfBatches: batches.length
|
||||
},
|
||||
'Inserted {currentBatchCount} closures from batch {currentBatchId} of {totalNumberOfBatches}'
|
||||
)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
show-label
|
||||
:disabled="!!(loading || shouldForceInviteEmail)"
|
||||
auto-focus
|
||||
autocomplete="email"
|
||||
/>
|
||||
<FormTextInput
|
||||
type="password"
|
||||
@@ -23,6 +24,7 @@
|
||||
:rules="passwordRules"
|
||||
show-label
|
||||
:disabled="loading"
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
<FormButton
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<template>
|
||||
<FormCheckbox
|
||||
id="newsletter-consent-checkbox"
|
||||
v-model="newsletterConsent"
|
||||
name="newsletter"
|
||||
label="Opt in for exclusive Speckle news and tips"
|
||||
@@ -8,5 +9,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const newsletterConsent = ref<true | undefined>(undefined)
|
||||
const newsletterConsent = defineModel<boolean>('newsletterConsent', {
|
||||
required: true
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
:rules="emailRules"
|
||||
show-label
|
||||
:disabled="isEmailDisabled"
|
||||
autocomplete="email"
|
||||
/>
|
||||
<FormTextInput
|
||||
type="text"
|
||||
@@ -25,6 +26,7 @@
|
||||
show-label
|
||||
:disabled="loading"
|
||||
auto-focus
|
||||
autocomplete="name"
|
||||
/>
|
||||
<FormTextInput
|
||||
v-model="password"
|
||||
@@ -37,6 +39,7 @@
|
||||
:rules="passwordRules"
|
||||
show-label
|
||||
:disabled="loading"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<AuthPasswordChecks :password="password" class="mt-2 h-12 sm:h-8" />
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
<template>
|
||||
<div v-if="shouldShowBanner" class="flex flex-col px-3 pb-3">
|
||||
<div class="text-body-2xs text-foreground mb-3">{{ verifyBannerText }}</div>
|
||||
<FormButton
|
||||
size="sm"
|
||||
color="outline"
|
||||
:disabled="loading"
|
||||
@click="requestVerification"
|
||||
>
|
||||
{{ verifyBannerCtaText }}
|
||||
</FormButton>
|
||||
</div>
|
||||
<div v-else-if="noticeLoading">
|
||||
<CommonLoadingIcon size="sm" class="my-2 mx-auto" />
|
||||
</div>
|
||||
<div v-else />
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { graphql } from '~~/lib/common/generated/gql'
|
||||
import { useApolloClient, useQuery } from '@vue/apollo-composable'
|
||||
import {
|
||||
convertThrowIntoFetchResult,
|
||||
getFirstErrorMessage
|
||||
} from '~~/lib/common/helpers/graphql'
|
||||
import { ToastNotificationType, useGlobalToast } from '~~/lib/common/composables/toast'
|
||||
|
||||
const reminderStateQuery = graphql(`
|
||||
query EmailVerificationBannerState {
|
||||
activeUser {
|
||||
id
|
||||
email
|
||||
verified
|
||||
hasPendingVerification
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
const requestVerificationMutation = graphql(`
|
||||
mutation RequestVerification {
|
||||
requestVerification
|
||||
}
|
||||
`)
|
||||
|
||||
const apollo = useApolloClient().client
|
||||
const { triggerNotification } = useGlobalToast()
|
||||
|
||||
const { result, loading: noticeLoading } = useQuery(reminderStateQuery)
|
||||
const user = computed(() => result.value?.activeUser || null)
|
||||
|
||||
const dismissed = ref(false)
|
||||
const loading = ref(false)
|
||||
|
||||
const shouldShowBanner = computed(() => {
|
||||
if (!user.value) return false
|
||||
if (user.value.verified) return false
|
||||
if (dismissed.value) return false
|
||||
|
||||
return true
|
||||
})
|
||||
const hasPendingVerification = computed(() => !!user.value?.hasPendingVerification)
|
||||
|
||||
const verifyBannerText = computed(() => {
|
||||
if (!user.value?.email) return ''
|
||||
return hasPendingVerification.value
|
||||
? `Please check your inbox (${user.value.email}) for the verification e-mail`
|
||||
: `Please verify your e-mail address.`
|
||||
})
|
||||
|
||||
const verifyBannerCtaText = computed(() =>
|
||||
hasPendingVerification.value ? `Re-send verification` : `Send verification`
|
||||
)
|
||||
|
||||
const dismiss = () => (dismissed.value = true)
|
||||
const requestVerification = async () => {
|
||||
const userData = user.value
|
||||
if (!userData) return
|
||||
|
||||
loading.value = true
|
||||
const { data, errors } = await apollo
|
||||
.mutate({
|
||||
mutation: requestVerificationMutation,
|
||||
update: (cache, { data }) => {
|
||||
const isSuccess = !!data?.requestVerification
|
||||
if (!isSuccess) return
|
||||
|
||||
// Switch hasPendingVerification to true
|
||||
cache.modify({
|
||||
id: cache.identify(userData),
|
||||
fields: {
|
||||
hasPendingVerification: () => true
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch(convertThrowIntoFetchResult)
|
||||
.finally(() => (loading.value = false))
|
||||
|
||||
if (!data?.requestVerification) {
|
||||
const errMsg = getFirstErrorMessage(errors)
|
||||
triggerNotification({
|
||||
type: ToastNotificationType.Danger,
|
||||
title: 'Resend failed',
|
||||
description: errMsg
|
||||
})
|
||||
} else {
|
||||
triggerNotification({
|
||||
type: ToastNotificationType.Info,
|
||||
title: 'Verification e-mail sent, please check your inbox'
|
||||
})
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -39,7 +39,7 @@ import { useQuery } from '@vue/apollo-composable'
|
||||
|
||||
const route = useRoute()
|
||||
const loading = ref(false)
|
||||
const newsletterConsent = ref<true | undefined>(undefined)
|
||||
const newsletterConsent = ref<boolean>(false)
|
||||
|
||||
const { challenge } = useLoginOrRegisterUtils()
|
||||
const { signInOrSignUpWithSso } = useAuthManager()
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
<template>
|
||||
<div>
|
||||
<Portal to="navigation">
|
||||
<HeaderNavLink :to="homeRoute" name="Dashboard" hide-chevron :separator="false" />
|
||||
</Portal>
|
||||
<PromoBannersWrapper
|
||||
v-if="promoBanners && promoBanners.length"
|
||||
:banners="promoBanners"
|
||||
/>
|
||||
<ProjectsDashboardHeader
|
||||
:projects-invites="projectsResult?.activeUser || undefined"
|
||||
:workspaces-invites="workspacesResult?.activeUser || undefined"
|
||||
/>
|
||||
<div class="flex flex-col gap-y-12">
|
||||
<div class="flex flex-col-reverse lg:flex-col gap-y-12">
|
||||
<section>
|
||||
<h2 class="text-heading-sm text-foreground-2">Quickstart</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 pt-5">
|
||||
<CommonCard
|
||||
v-for="quickStartItem in quickStartItems"
|
||||
:key="quickStartItem.title"
|
||||
:title="quickStartItem.title"
|
||||
:description="quickStartItem.description"
|
||||
:buttons="quickStartItem.buttons"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-heading-sm text-foreground-2">Recently updated projects</h2>
|
||||
<FormButton
|
||||
color="outline"
|
||||
size="sm"
|
||||
@click.stop="router.push(projectsRoute)"
|
||||
>
|
||||
View all
|
||||
</FormButton>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-3 pt-5">
|
||||
<template v-if="hasProjects">
|
||||
<DashboardProjectCard
|
||||
v-for="project in projects"
|
||||
:key="project.id"
|
||||
:project="project"
|
||||
/>
|
||||
</template>
|
||||
<CommonCard
|
||||
v-else
|
||||
title="Create your first project"
|
||||
description="Projects are the place where your models and their versions live."
|
||||
:buttons="createProjectButton"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<section>
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-heading-sm text-foreground-2">Tutorials</h2>
|
||||
<FormButton
|
||||
color="outline"
|
||||
size="sm"
|
||||
to="https://www.speckle.systems/tutorials"
|
||||
external
|
||||
target="_blank"
|
||||
>
|
||||
View all
|
||||
</FormButton>
|
||||
</div>
|
||||
<div
|
||||
class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 pt-5"
|
||||
>
|
||||
<DashboardTutorialCard
|
||||
v-for="tutorialItem in tutorialItems"
|
||||
:key="tutorialItem.title"
|
||||
:tutorial-item="tutorialItem"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<ProjectsAddDialog v-model:open="openNewProject" />
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
dashboardProjectsPageQuery,
|
||||
dashboardProjectsPageWorkspacesQuery
|
||||
} from '~~/lib/dashboard/graphql/queries'
|
||||
import type { QuickStartItem } from '~~/lib/dashboard/helpers/types'
|
||||
import { useQuery } from '@vue/apollo-composable'
|
||||
import { useMixpanel } from '~~/lib/core/composables/mp'
|
||||
import {
|
||||
docsPageUrl,
|
||||
forumPageUrl,
|
||||
homeRoute,
|
||||
projectsRoute
|
||||
} from '~~/lib/common/helpers/route'
|
||||
import type { ManagerExtension } from '~~/lib/common/utils/downloadManager'
|
||||
import { downloadManager } from '~~/lib/common/utils/downloadManager'
|
||||
import { ToastNotificationType, useGlobalToast } from '~~/lib/common/composables/toast'
|
||||
import type { LayoutDialogButton } from '@speckle/ui-components'
|
||||
import type { PromoBanner } from '~/lib/promo-banners/types'
|
||||
import { tutorials } from '~/lib/dashboard/helpers/tutorials'
|
||||
import { useUserProjectsUpdatedTracking } from '~~/lib/user/composables/projectUpdates'
|
||||
|
||||
const mixpanel = useMixpanel()
|
||||
const isWorkspacesEnabled = useIsWorkspacesEnabled()
|
||||
const { result: projectsResult } = useQuery(dashboardProjectsPageQuery)
|
||||
const { result: workspacesResult } = useQuery(
|
||||
dashboardProjectsPageWorkspacesQuery,
|
||||
undefined,
|
||||
() => ({
|
||||
enabled: isWorkspacesEnabled.value
|
||||
})
|
||||
)
|
||||
const { triggerNotification } = useGlobalToast()
|
||||
const { isGuest } = useActiveUser()
|
||||
const router = useRouter()
|
||||
useUserProjectsUpdatedTracking()
|
||||
|
||||
const promoBanners = ref<PromoBanner[]>()
|
||||
const openNewProject = ref(false)
|
||||
const tutorialItems = shallowRef(tutorials)
|
||||
const quickStartItems = shallowRef<QuickStartItem[]>([
|
||||
{
|
||||
title: 'Install Speckle manager',
|
||||
description: 'Use our Manager to install and manage Connectors with ease.',
|
||||
buttons: [
|
||||
{
|
||||
text: 'Download for Windows',
|
||||
onClick: () => onDownloadManager('exe')
|
||||
},
|
||||
{
|
||||
text: 'Download for Mac',
|
||||
onClick: () => onDownloadManager('dmg')
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "Don't know where to start?",
|
||||
description: "We'll walk you through some of most common usage scenarios.",
|
||||
buttons: [
|
||||
{
|
||||
text: 'Open documentation',
|
||||
props: { to: docsPageUrl }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Have a question you need answered?',
|
||||
description: 'Submit your question on the forum and get help from the community.',
|
||||
buttons: [
|
||||
{
|
||||
text: 'Ask a question',
|
||||
props: { to: forumPageUrl }
|
||||
}
|
||||
]
|
||||
}
|
||||
])
|
||||
|
||||
const createProjectButton = shallowRef<LayoutDialogButton[]>([
|
||||
{
|
||||
text: 'Create a project',
|
||||
props: { disabled: isGuest.value },
|
||||
onClick: () => (openNewProject.value = true)
|
||||
}
|
||||
])
|
||||
|
||||
const projects = computed(() => projectsResult.value?.activeUser?.projects.items)
|
||||
const hasProjects = computed(() => (projects.value ? projects.value.length > 0 : false))
|
||||
|
||||
const onDownloadManager = (extension: ManagerExtension) => {
|
||||
try {
|
||||
downloadManager(extension)
|
||||
|
||||
mixpanel.track('Manager Download', {
|
||||
os: extension === 'exe' ? 'win' : 'mac'
|
||||
})
|
||||
} catch {
|
||||
triggerNotification({
|
||||
type: ToastNotificationType.Danger,
|
||||
title: 'Download failed'
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -98,8 +98,12 @@ const onSubmit = handleSubmit(async () => {
|
||||
})
|
||||
|
||||
await sendWebhook(defaultZapierWebhookUrl, {
|
||||
userId: user.value?.id ?? '',
|
||||
feedback: feedback.value
|
||||
feedback: [
|
||||
`**Action:** User Feedback`,
|
||||
`**Type:** ${props.type}`,
|
||||
`**User ID:** ${user.value?.id}`,
|
||||
`**Feedback:** ${feedback.value}`
|
||||
].join('\n')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -62,6 +62,7 @@ const emit = defineEmits<{
|
||||
const props = defineProps<{
|
||||
modelValue?: ValueType
|
||||
clearable?: boolean
|
||||
hiddenItems?: StreamRoles[]
|
||||
disabledItems?: StreamRoles[]
|
||||
disabledItemsTooltip?: string
|
||||
allowUnset?: boolean
|
||||
@@ -80,7 +81,9 @@ const { selectedValue, firstItem, isMultiItemArrayValue, hiddenSelectedItemCount
|
||||
dynamicVisibility: { elementToWatchForChanges, itemContainer }
|
||||
})
|
||||
|
||||
const roles = computed(() => Object.values(Roles.Stream))
|
||||
const roles = computed(() =>
|
||||
Object.values(Roles.Stream).filter((role) => !props.hiddenItems?.includes(role))
|
||||
)
|
||||
|
||||
const disabledItemPredicate = (item: StreamRoles) =>
|
||||
props.disabledItems && props.disabledItems.length > 0
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
:name="name || 'projects'"
|
||||
:label-id="labelId"
|
||||
:button-id="buttonId"
|
||||
:tooltip-text="tooltipText"
|
||||
by="id"
|
||||
>
|
||||
<template #nothing-selected>
|
||||
@@ -139,6 +140,9 @@ const props = defineProps({
|
||||
*/
|
||||
workspaceId: {
|
||||
type: String as PropType<Optional<string>>
|
||||
},
|
||||
tooltipText: {
|
||||
type: String as PropType<Optional<string>>
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<template>
|
||||
<nav class="fixed z-40 top-0 h-12 bg-foundation border-b border-outline-2">
|
||||
<div
|
||||
class="flex gap-8 items-center justify-between h-full w-screen py-4 px-3 sm:px-4"
|
||||
>
|
||||
<div>
|
||||
<slot name="header-left" />
|
||||
</div>
|
||||
<div>
|
||||
<slot name="header-right" />
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<Component
|
||||
:is="mainComponent"
|
||||
class="flex items-center shrink-0"
|
||||
class="flex items-center shrink-0 select-none"
|
||||
:to="to"
|
||||
:target="target"
|
||||
>
|
||||
|
||||
@@ -18,8 +18,6 @@
|
||||
<PortalTarget name="secondary-actions"></PortalTarget>
|
||||
<PortalTarget name="primary-actions"></PortalTarget>
|
||||
</ClientOnly>
|
||||
<!-- Notifications dropdown -->
|
||||
<HeaderNavNotifications v-if="hasNotifications" />
|
||||
<FormButton
|
||||
v-if="!activeUser"
|
||||
:to="loginUrl.fullPath"
|
||||
@@ -55,10 +53,4 @@ const loginUrl = computed(() =>
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const hasNotifications = computed(() => {
|
||||
if (!activeUser.value) return false
|
||||
if (!activeUser.value?.verified) return true
|
||||
return false
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<Menu as="div" class="flex items-center">
|
||||
<MenuButton :id="menuButtonId" v-slot="{ open: menuOpen }" as="div">
|
||||
<div
|
||||
class="relative cursor-pointer p-1 w-8 h-8 flex items-center justify-center rounded-md"
|
||||
:class="menuOpen ? 'border border-outline-2' : ''"
|
||||
>
|
||||
<span class="sr-only">Open notifications menu</span>
|
||||
<div class="relative">
|
||||
<div v-if="!menuOpen">
|
||||
<div
|
||||
class="absolute -top-1 right-0 w-1.5 h-1.5 rounded-full bg-primary animate-ping"
|
||||
></div>
|
||||
<div
|
||||
class="absolute -top-1 right-0 w-1.5 h-1.5 rounded-full bg-primary"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<BellIcon v-if="!menuOpen" class="w-5 h-5" />
|
||||
<XMarkIcon v-else class="w-5 h-5" />
|
||||
</div>
|
||||
</div>
|
||||
</MenuButton>
|
||||
<Transition
|
||||
enter-active-class="transition ease-out duration-200"
|
||||
enter-from-class="transform opacity-0 scale-95"
|
||||
enter-to-class="transform opacity-100 scale-100"
|
||||
leave-active-class="transition ease-in duration-75"
|
||||
leave-from-class="transform opacity-100 scale-100"
|
||||
leave-to-class="transform opacity-0 scale-95"
|
||||
>
|
||||
<MenuItems
|
||||
class="absolute z-50 right-0 md:right-20 top-10 mt-1.5 w-full sm:w-64 origin-top-right bg-foundation-page outline outline-2 outline-primary-muted rounded-md shadow-lg overflow-hidden"
|
||||
>
|
||||
<div class="px-3 py-2 text-body-xs font-medium">Notifications</div>
|
||||
<!-- <div class="p-2 text-sm">TODO: project invites</div> -->
|
||||
<MenuItem>
|
||||
<AuthVerificationReminderMenuNotice />
|
||||
</MenuItem>
|
||||
</MenuItems>
|
||||
</Transition>
|
||||
</Menu>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/vue'
|
||||
import { XMarkIcon, BellIcon } from '@heroicons/vue/24/outline'
|
||||
|
||||
const menuButtonId = useId()
|
||||
</script>
|
||||
@@ -0,0 +1,31 @@
|
||||
<template>
|
||||
<div>
|
||||
<HeaderEmpty v-if="emptyHeader">
|
||||
<template #header-left>
|
||||
<slot name="header-left" />
|
||||
</template>
|
||||
<template #header-right>
|
||||
<slot name="header-right" />
|
||||
</template>
|
||||
</HeaderEmpty>
|
||||
<HeaderNavBar v-else />
|
||||
<div class="h-dvh w-dvh overflow-hidden flex flex-col">
|
||||
<!-- Static Spacer to allow for absolutely positioned HeaderNavBar -->
|
||||
<div class="h-12 w-full shrink-0"></div>
|
||||
|
||||
<div class="relative flex h-[calc(100dvh-3rem)]">
|
||||
<main class="w-full h-full overflow-y-auto simple-scrollbar pt-4 lg:pt-6 pb-16">
|
||||
<div class="container mx-auto px-6 md:px-8">
|
||||
<slot />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
emptyHeader?: boolean
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,284 @@
|
||||
<template>
|
||||
<LayoutDialog v-model:open="isOpen" :buttons="dialogButtons" max-width="md">
|
||||
<template #header>Invite to Project</template>
|
||||
<template v-if="isInWorkspace && invitableWorkspaceMembers.length">
|
||||
<InviteDialogProjectWorkspaceMembers :project="props.project" />
|
||||
<hr v-if="isAdmin" class="border-outline-3 mb-3 mt-5" />
|
||||
</template>
|
||||
<template v-else-if="isInWorkspace && !isAdmin">
|
||||
<p class="text-body-xs text-foreground">
|
||||
All workspace members are already in this project.
|
||||
</p>
|
||||
</template>
|
||||
<template v-if="isAdmin || !isInWorkspace">
|
||||
<form @submit="onSubmit">
|
||||
<div class="flex flex-col gap-y-3 text-foreground">
|
||||
<div v-for="(item, index) in fields" :key="item.key" class="flex flex-col">
|
||||
<div class="flex flex-1 gap-x-3">
|
||||
<div class="flex flex-col gap-y-3 flex-1">
|
||||
<div class="flex items-start gap-x-3">
|
||||
<div class="flex-1">
|
||||
<FormTextInput
|
||||
v-model="item.value.email"
|
||||
:name="`email-${item.key}`"
|
||||
color="foundation"
|
||||
placeholder="Email address"
|
||||
show-clear
|
||||
full-width
|
||||
use-label-in-errors
|
||||
show-label
|
||||
label="Email"
|
||||
:rules="[isEmailOrEmpty]"
|
||||
/>
|
||||
</div>
|
||||
<FormSelectProjectRoles
|
||||
v-model="item.value.projectRole"
|
||||
label="Select role"
|
||||
:name="`fields.${index}.projectRole`"
|
||||
class="w-40"
|
||||
mount-menu-on-body
|
||||
show-label
|
||||
:allow-unset="false"
|
||||
:hidden-items="[Roles.Stream.Owner]"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="isInWorkspace">
|
||||
<FormSelectProjects
|
||||
v-model="item.value.project"
|
||||
label="Select project"
|
||||
class="w-full"
|
||||
owned-only
|
||||
show-optional
|
||||
mount-menu-on-body
|
||||
show-label
|
||||
:name="`project-${index}`"
|
||||
:disabled="!canBeMember(item.value.email)"
|
||||
/>
|
||||
<p
|
||||
v-if="!canBeMember(item.value.email)"
|
||||
class="text-body-3xs text-foreground-2 mt-2"
|
||||
>
|
||||
This email does not match the set domain policy, and can only be
|
||||
invited to individual projects
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<CommonTextLink class="mt-7">
|
||||
<TrashIcon
|
||||
v-if="fields.length > 1"
|
||||
class="h-4 w-4 text-foreground-2"
|
||||
@click="removeInvite(index)"
|
||||
/>
|
||||
<div v-else class="h-4 w-4"></div>
|
||||
</CommonTextLink>
|
||||
</div>
|
||||
<hr
|
||||
v-if="index !== fields.length - 1"
|
||||
class="flex-1 mt-3 border-outline-3"
|
||||
/>
|
||||
</div>
|
||||
<FormButton color="subtle" :icon-left="PlusIcon" @click="addInviteItem">
|
||||
Add another user
|
||||
</FormButton>
|
||||
</div>
|
||||
</form>
|
||||
<div
|
||||
v-if="showBillingInfo"
|
||||
class="text-body-2xs text-foreground-2 leading-5 mt-4"
|
||||
>
|
||||
<p>
|
||||
Inviting users may add seats to your current billing cycle. Your workspace is
|
||||
currently billed for
|
||||
{{ memberSeatText }}{{ hasGuestSeats ? ` and ${guestSeatText}` : '' }}.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
</LayoutDialog>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import type { LayoutDialogButton } from '@speckle/ui-components'
|
||||
import { graphql } from '~/lib/common/generated/gql'
|
||||
import { useForm, useFieldArray } from 'vee-validate'
|
||||
import { PlusIcon, TrashIcon } from '@heroicons/vue/24/outline'
|
||||
import type { InviteProjectForm, InviteProjectItem } from '~~/lib/invites/helpers/types'
|
||||
import { emptyInviteProjectItem } from '~~/lib/invites/helpers/constants'
|
||||
import { isEmailOrEmpty } from '~~/lib/common/helpers/validation'
|
||||
import { Roles } from '@speckle/shared'
|
||||
import { matchesDomainPolicy } from '~/lib/invites/helpers/validation'
|
||||
import {
|
||||
type InviteDialogProject_ProjectFragment,
|
||||
type WorkspacePlans,
|
||||
type ProjectInviteCreateInput,
|
||||
type WorkspaceProjectInviteCreateInput,
|
||||
WorkspacePlanStatuses
|
||||
} from '~/lib/common/generated/gql/graphql'
|
||||
import { useTeamInternals } from '~~/lib/projects/composables/team'
|
||||
import { isPaidPlan } from '~/lib/billing/helpers/types'
|
||||
import { useInviteUserToProject } from '~~/lib/projects/composables/projectManagement'
|
||||
import { useMixpanel } from '~~/lib/core/composables/mp'
|
||||
|
||||
graphql(`
|
||||
fragment InviteDialogProject_Project on Project {
|
||||
id
|
||||
name
|
||||
...InviteDialogProjectWorkspaceMembers_Project
|
||||
workspace {
|
||||
id
|
||||
name
|
||||
defaultProjectRole
|
||||
role
|
||||
domainBasedMembershipProtectionEnabled
|
||||
domains {
|
||||
domain
|
||||
id
|
||||
}
|
||||
plan {
|
||||
status
|
||||
name
|
||||
}
|
||||
subscription {
|
||||
seats {
|
||||
guest
|
||||
plan
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
const props = defineProps<{
|
||||
project: InviteDialogProject_ProjectFragment
|
||||
}>()
|
||||
const isOpen = defineModel<boolean>('open', { required: true })
|
||||
|
||||
const mixpanel = useMixpanel()
|
||||
const createInvite = useInviteUserToProject()
|
||||
const { collaboratorListItems } = useTeamInternals(computed(() => props.project))
|
||||
const { handleSubmit } = useForm<InviteProjectForm>({
|
||||
initialValues: {
|
||||
fields: [
|
||||
{
|
||||
...emptyInviteProjectItem,
|
||||
projectRole: Roles.Stream.Contributor
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
const {
|
||||
fields,
|
||||
replace: replaceFields,
|
||||
push: pushInvite,
|
||||
remove: removeInvite
|
||||
} = useFieldArray<InviteProjectItem>('fields')
|
||||
|
||||
const invitableWorkspaceMembers = computed(() => {
|
||||
const currentProjectMemberIds = new Set(
|
||||
collaboratorListItems.value.map((item) => item.user?.id)
|
||||
)
|
||||
|
||||
return (
|
||||
props.project?.workspace?.team?.items.filter(
|
||||
(member) => member.user.id && !currentProjectMemberIds.has(member.user.id)
|
||||
) || []
|
||||
)
|
||||
})
|
||||
const isInWorkspace = computed(() => !!props.project.workspace?.id)
|
||||
const allowedDomains = computed(() =>
|
||||
props.project.workspace?.domains?.map((d) => d.domain)
|
||||
)
|
||||
const memberSeatText = computed(() =>
|
||||
props.project.workspace?.subscription?.seats.plan
|
||||
? getSeatText(props.project.workspace.subscription.seats.plan, 'member')
|
||||
: ''
|
||||
)
|
||||
const guestSeatText = computed(() =>
|
||||
props.project.workspace?.subscription?.seats.guest
|
||||
? getSeatText(props.project.workspace.subscription.seats.guest, 'guest')
|
||||
: ''
|
||||
)
|
||||
const hasGuestSeats = computed(
|
||||
() => (props.project.workspace?.subscription?.seats.guest ?? 0) > 0
|
||||
)
|
||||
const showBillingInfo = computed(() => {
|
||||
if (!props.project.workspace?.plan) return false
|
||||
return (
|
||||
isPaidPlan(props.project.workspace.plan.name as unknown as WorkspacePlans) &&
|
||||
props.project.workspace.plan.status === WorkspacePlanStatuses.Valid
|
||||
)
|
||||
})
|
||||
const isAdmin = computed(() => props.project.workspace?.role === Roles.Workspace.Admin)
|
||||
const dialogButtons = computed((): LayoutDialogButton[] => [
|
||||
{
|
||||
text: 'Cancel',
|
||||
props: { color: 'outline' },
|
||||
onClick: () => (isOpen.value = false)
|
||||
},
|
||||
...(!isInWorkspace.value || isAdmin.value
|
||||
? [
|
||||
{
|
||||
text: 'Invite',
|
||||
props: {
|
||||
submit: true
|
||||
},
|
||||
onClick: onSubmit
|
||||
}
|
||||
]
|
||||
: [])
|
||||
])
|
||||
|
||||
const getSeatText = (count: number, type: 'member' | 'guest') =>
|
||||
`${count} ${type} ${count === 1 ? 'seat' : 'seats'}`
|
||||
|
||||
const canBeMember = (email: string) => matchesDomainPolicy(email, allowedDomains.value)
|
||||
|
||||
const addInviteItem = () => {
|
||||
pushInvite({
|
||||
...emptyInviteProjectItem,
|
||||
project: { id: props.project.id, name: props.project.name }
|
||||
})
|
||||
}
|
||||
|
||||
const onSubmit = handleSubmit(async () => {
|
||||
const invites = fields.value
|
||||
.filter((invite) => invite.value.email)
|
||||
.map((invite) => invite.value)
|
||||
|
||||
const inputs: ProjectInviteCreateInput[] | WorkspaceProjectInviteCreateInput[] =
|
||||
invites.map((u) => ({
|
||||
role: u.projectRole,
|
||||
email: u.email,
|
||||
serverRole: u.serverRole,
|
||||
...(props.project?.workspace?.id
|
||||
? {
|
||||
workspaceRole: u.project?.id
|
||||
? Roles.Workspace.Member
|
||||
: Roles.Workspace.Guest
|
||||
}
|
||||
: {})
|
||||
}))
|
||||
if (!inputs.length) return
|
||||
|
||||
await createInvite(props.project.id, inputs)
|
||||
|
||||
mixpanel.track('Invite Action', {
|
||||
type: 'project invite',
|
||||
name: 'send',
|
||||
multiple: inputs.length !== 1,
|
||||
count: inputs.length,
|
||||
hasProject: true
|
||||
})
|
||||
|
||||
isOpen.value = false
|
||||
})
|
||||
|
||||
watch(isOpen, (newVal, oldVal) => {
|
||||
if (newVal && !oldVal) {
|
||||
replaceFields([
|
||||
{
|
||||
...emptyInviteProjectItem,
|
||||
project: { id: props.project.id, name: props.project.name }
|
||||
}
|
||||
])
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,62 @@
|
||||
<template>
|
||||
<li
|
||||
class="border-outline-2 border-x border-b first:border-t first:rounded-t-lg last:rounded-b-lg p-3 pl-4 border-b-outline-3 last:border-b-outline-2 gap-x-2 flex items-center"
|
||||
>
|
||||
<p class="text-body-xs text-foreground flex-1">
|
||||
{{ user.user.name }}
|
||||
</p>
|
||||
<FormSelectProjectRoles
|
||||
v-model="selectedRole"
|
||||
label="Select role"
|
||||
:name="`projectRole-${user.user.id}`"
|
||||
class="w-40"
|
||||
mount-menu-on-body
|
||||
:allow-unset="false"
|
||||
:hidden-items="[Roles.Stream.Owner]"
|
||||
size="sm"
|
||||
/>
|
||||
<FormButton color="outline" size="sm" @click="onInvite">Add</FormButton>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { graphql } from '~/lib/common/generated/gql/gql'
|
||||
import type { InviteDialogProjectWorkspaceMembersRow_WorkspaceCollaboratorFragment } from '~/lib/common/generated/gql/graphql'
|
||||
import { type StreamRoles, Roles } from '@speckle/shared'
|
||||
import { useInviteUserToProject } from '~~/lib/projects/composables/projectManagement'
|
||||
|
||||
graphql(`
|
||||
fragment InviteDialogProjectWorkspaceMembersRow_WorkspaceCollaborator on WorkspaceCollaborator {
|
||||
role
|
||||
id
|
||||
user {
|
||||
id
|
||||
name
|
||||
bio
|
||||
company
|
||||
avatar
|
||||
verified
|
||||
role
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
const props = defineProps<{
|
||||
user: InviteDialogProjectWorkspaceMembersRow_WorkspaceCollaboratorFragment
|
||||
projectId: string
|
||||
}>()
|
||||
|
||||
const createInvite = useInviteUserToProject()
|
||||
|
||||
const selectedRole = ref<StreamRoles>(Roles.Stream.Contributor)
|
||||
|
||||
const onInvite = async () => {
|
||||
await createInvite(props.projectId, [
|
||||
{
|
||||
userId: props.user.id,
|
||||
role: selectedRole.value,
|
||||
workspaceRole: props.user.role
|
||||
}
|
||||
])
|
||||
}
|
||||
</script>
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<div>
|
||||
<h3 class="text-body-xs text-foreground font-medium mb-3">
|
||||
Add existing workspace members
|
||||
</h3>
|
||||
<ul class="flex flex-col">
|
||||
<InviteDialogProjectWorkspaceMembersRow
|
||||
v-for="member in invitableWorkspaceMembers"
|
||||
:key="member.user.id"
|
||||
:user="member"
|
||||
:project-id="props.project.id"
|
||||
/>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { graphql } from '~/lib/common/generated/gql/gql'
|
||||
import type { InviteDialogProjectWorkspaceMembers_ProjectFragment } from '~/lib/common/generated/gql/graphql'
|
||||
import { useTeamInternals } from '~~/lib/projects/composables/team'
|
||||
|
||||
graphql(`
|
||||
fragment InviteDialogProjectWorkspaceMembers_Project on Project {
|
||||
id
|
||||
...ProjectPageTeamInternals_Project
|
||||
workspace {
|
||||
team {
|
||||
items {
|
||||
...InviteDialogProjectWorkspaceMembersRow_WorkspaceCollaborator
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
const props = defineProps<{
|
||||
project: InviteDialogProjectWorkspaceMembers_ProjectFragment
|
||||
}>()
|
||||
|
||||
const { collaboratorListItems } = useTeamInternals(computed(() => props.project))
|
||||
|
||||
const invitableWorkspaceMembers = computed(() => {
|
||||
const currentProjectMemberIds = new Set(
|
||||
collaboratorListItems.value.map((item) => item.user?.id)
|
||||
)
|
||||
|
||||
return (
|
||||
props.project?.workspace?.team?.items.filter(
|
||||
(member) => member.user.id && !currentProjectMemberIds.has(member.user.id)
|
||||
) || []
|
||||
)
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,100 @@
|
||||
<template>
|
||||
<div class="flex flex-col items-center gap-2 w-full">
|
||||
<CommonCard
|
||||
v-for="workspace in workspaces"
|
||||
:key="workspace.id"
|
||||
class="w-full bg-foundation"
|
||||
>
|
||||
<div class="flex gap-4">
|
||||
<div>
|
||||
<WorkspaceAvatar :name="workspace.name" :logo="workspace.logo" size="xl" />
|
||||
</div>
|
||||
<div class="flex flex-col sm:flex-row gap-4 justify-between flex-1">
|
||||
<div class="flex flex-col flex-1">
|
||||
<h6 class="text-heading-sm">{{ workspace.name }}</h6>
|
||||
<p class="text-body-2xs text-foreground-2">{{ workspace.description }}</p>
|
||||
</div>
|
||||
<FormButton
|
||||
color="outline"
|
||||
size="sm"
|
||||
:loading="loadingStates[workspace.id]"
|
||||
:disabled="requestedWorkspaces.includes(workspace.id)"
|
||||
@click="() => processRequest(true, workspace.id)"
|
||||
>
|
||||
{{
|
||||
requestedWorkspaces.includes(workspace.id)
|
||||
? 'Requested'
|
||||
: 'Request to join'
|
||||
}}
|
||||
</FormButton>
|
||||
</div>
|
||||
</div>
|
||||
</CommonCard>
|
||||
<div class="mt-2 w-full">
|
||||
<FormButton size="lg" full-width @click="$emit('next')">Continue</FormButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { LimitedWorkspace } from '~/lib/common/generated/gql/graphql'
|
||||
import { dashboardRequestToJoinWorkspaceMutation } from '~~/lib/dashboard/graphql/mutations'
|
||||
import {
|
||||
convertThrowIntoFetchResult,
|
||||
getFirstErrorMessage
|
||||
} from '~~/lib/common/helpers/graphql'
|
||||
import { useMixpanel } from '~~/lib/core/composables/mp'
|
||||
import { useMutation } from '@vue/apollo-composable'
|
||||
|
||||
defineProps<{
|
||||
workspaces: LimitedWorkspace[]
|
||||
}>()
|
||||
|
||||
defineEmits(['next'])
|
||||
|
||||
const mixpanel = useMixpanel()
|
||||
const { triggerNotification } = useGlobalToast()
|
||||
|
||||
const loadingStates = ref<Record<string, boolean>>({})
|
||||
|
||||
const { mutate: requestToJoin } = useMutation(dashboardRequestToJoinWorkspaceMutation)
|
||||
|
||||
const requestedWorkspaces = ref<string[]>([])
|
||||
|
||||
const processRequest = async (accept: boolean, workspaceId: string) => {
|
||||
if (accept) {
|
||||
loadingStates.value[workspaceId] = true
|
||||
|
||||
try {
|
||||
const result = await requestToJoin({
|
||||
input: { workspaceId }
|
||||
}).catch(convertThrowIntoFetchResult)
|
||||
|
||||
if (result?.data) {
|
||||
requestedWorkspaces.value.push(workspaceId)
|
||||
mixpanel.track('Workspace Join Request Sent', {
|
||||
workspaceId,
|
||||
location: 'onboarding',
|
||||
// eslint-disable-next-line camelcase
|
||||
workspace_id: workspaceId
|
||||
})
|
||||
|
||||
triggerNotification({
|
||||
title: 'Request sent',
|
||||
description: 'Your request to join the workspace has been sent.',
|
||||
type: ToastNotificationType.Success
|
||||
})
|
||||
} else {
|
||||
const errorMessage = getFirstErrorMessage(result?.errors)
|
||||
triggerNotification({
|
||||
title: 'Failed to send request',
|
||||
description: errorMessage,
|
||||
type: ToastNotificationType.Danger
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
loadingStates.value[workspaceId] = false
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,445 +0,0 @@
|
||||
<!-- eslint-disable vuejs-accessibility/no-static-element-interactions -->
|
||||
<!-- eslint-disable vuejs-accessibility/click-events-have-key-events -->
|
||||
<template>
|
||||
<div class="relative">
|
||||
<div
|
||||
:class="`${
|
||||
background ? 'mx-2 sm:mx-auto px-2 bg-foundation rounded-md shadow-xl' : ''
|
||||
} ${allCompleted ? 'max-w-lg mx-auto' : ''}`"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
v-if="!allCompleted"
|
||||
:class="`hidden sm:grid gap-2 ${
|
||||
showIntro ? 'px-4 grid-cols-5' : 'grid-cols-4'
|
||||
}`"
|
||||
>
|
||||
<div
|
||||
v-if="showIntro"
|
||||
class="flex-col justify-around px-2 h-full py-2 md:col-span-1 hidden lg:flex"
|
||||
>
|
||||
<div class="text-heading-sm">Quickstart checklist</div>
|
||||
<div class="text-body-sm text-foreground-2">
|
||||
Become a Speckle pro in four steps!
|
||||
</div>
|
||||
<div class="space-x-1">
|
||||
<FormButton v-if="!allCompleted" size="sm" @click="dismissChecklist()">
|
||||
I'll do it later
|
||||
</FormButton>
|
||||
<FormButton
|
||||
v-if="!allCompleted"
|
||||
color="subtle"
|
||||
size="sm"
|
||||
@click="dismissChecklistForever()"
|
||||
>
|
||||
Don't show again
|
||||
</FormButton>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 grow col-span-5 lg:col-span-4">
|
||||
<div
|
||||
v-for="(step, idx) in steps"
|
||||
:key="idx"
|
||||
class="py-2 col-span-4 sm:col-span-2 lg:col-span-1"
|
||||
>
|
||||
<div
|
||||
:class="`
|
||||
${
|
||||
step.active
|
||||
? 'bg-primary text-foreground-on-primary shadow hover:shadow-md scale-100'
|
||||
: 'text-foreground-2 hover:bg-primary-muted scale-95'
|
||||
}
|
||||
transition rounded-md flex flex-col justify-between px-2 cursor-pointer h-full`"
|
||||
@click.stop="
|
||||
!step.active
|
||||
? activateStep(idx)
|
||||
: idx === 0 || steps[idx - 1].completed
|
||||
? step.action()
|
||||
: goToFirstUncompletedStep()
|
||||
"
|
||||
>
|
||||
<div
|
||||
:class="`text-lg sm:text-xl font-medium flex items-center justify-between ${
|
||||
step.active ? 'text-foreground-on-primary' : 'text-foreground-2'
|
||||
}`"
|
||||
>
|
||||
<span>{{ idx + 1 }}</span>
|
||||
<Component
|
||||
:is="step.icon"
|
||||
v-if="!step.completed"
|
||||
:class="`w-4 h-4 mt-1`"
|
||||
/>
|
||||
<CheckCircleIcon v-else class="w-4 h-4 mt-1 text-primary" />
|
||||
</div>
|
||||
<div
|
||||
:class="`${
|
||||
step.active
|
||||
? 'font-medium text-sm sm:text-base text-foreground-on-primary'
|
||||
: ''
|
||||
}`"
|
||||
>
|
||||
{{ step.title }}
|
||||
</div>
|
||||
<div class="text-xs mt-[2px]">{{ step.blurb }}</div>
|
||||
<div
|
||||
class="flex items-center justify-between"
|
||||
:class="step.active ? 'h-10' : 'h-4'"
|
||||
>
|
||||
<div
|
||||
v-if="idx === 0 || steps[idx - 1].completed"
|
||||
class="flex justify-between items-center py-2 w-full"
|
||||
>
|
||||
<FormButton
|
||||
v-if="!step.completed && step.active"
|
||||
:disabled="!step.active"
|
||||
color="outline"
|
||||
size="sm"
|
||||
@click.stop="step.action"
|
||||
>
|
||||
{{ step.cta }}
|
||||
</FormButton>
|
||||
|
||||
<FormButton
|
||||
v-if="step.active && !step.completed"
|
||||
v-tippy="'Mark completed'"
|
||||
text
|
||||
link
|
||||
size="sm"
|
||||
color="outline"
|
||||
@click.stop="markComplete(idx)"
|
||||
>
|
||||
<!-- Mark as complete -->
|
||||
<OutlineCheckCircleIcon class="w-4 h-4 text-foundation" />
|
||||
</FormButton>
|
||||
<span v-if="step.completed" class="text-xs font-medium">
|
||||
Completed!
|
||||
</span>
|
||||
<FormButton
|
||||
v-if="step.completed && step.active"
|
||||
size="sm"
|
||||
color="outline"
|
||||
@click.stop="step.action"
|
||||
>
|
||||
{{ step.postCompletionCta }}
|
||||
</FormButton>
|
||||
</div>
|
||||
<div v-else-if="step.active" class="text-sm">
|
||||
<FormButton
|
||||
size="sm"
|
||||
color="outline"
|
||||
@click.stop="goToFirstUncompletedStep()"
|
||||
>
|
||||
Complete the previous step!
|
||||
</FormButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="showIntro"
|
||||
class="lg:hidden col-span-5 pb-3 pt-2 text-center space-x-2"
|
||||
>
|
||||
<FormButton v-if="!allCompleted" size="sm" @click="dismissChecklist()">
|
||||
I'll do it later
|
||||
</FormButton>
|
||||
<FormButton
|
||||
v-if="!allCompleted"
|
||||
color="subtle"
|
||||
size="sm"
|
||||
@click="dismissChecklistForever()"
|
||||
>
|
||||
Don't show again
|
||||
</FormButton>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="relative hidden sm:flex flex-col sm:flex-row items-center justify-center flex-1 gap-x-2 py-4"
|
||||
>
|
||||
<div class="w-6 h-6">
|
||||
<!-- <CheckCircleIcon class="absolute w-6 h-6 text-primary" /> -->
|
||||
<CheckCircleIcon class="w-6 h-6 text-primary animate-ping animate-pulse" />
|
||||
</div>
|
||||
<div class="text-sm max-w-lg text-center sm:text-left">
|
||||
<b>All done!</b>
|
||||
PS: the
|
||||
<FormButton to="https://speckle.community" target="_blank" link>
|
||||
Community Forum
|
||||
</FormButton>
|
||||
is there to help!
|
||||
</div>
|
||||
<div class="absolute right-2 top-3">
|
||||
<FormButton
|
||||
color="outline"
|
||||
:icon-left="XMarkIcon"
|
||||
hide-text
|
||||
@click="closeChecklist()"
|
||||
>
|
||||
Close
|
||||
</FormButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
This is used as a dismissal prompt from when showing the checklist on top of the
|
||||
viewer. It does not directly dismiss the checklist as we still want to show it
|
||||
on the main dasboard page.
|
||||
-->
|
||||
<div v-if="showBottomEscape && !allCompleted" class="text-center mt-2">
|
||||
<FormButton @click="$emit('dismiss')">
|
||||
I'll do it later - let me explore first!
|
||||
</FormButton>
|
||||
</div>
|
||||
|
||||
<OnboardingDialogManager
|
||||
v-model:open="showManagerDownloadDialog"
|
||||
@done="markComplete(0)"
|
||||
@cancel="showManagerDownloadDialog = false"
|
||||
></OnboardingDialogManager>
|
||||
<OnboardingDialogAccountLink
|
||||
v-model:open="showAccountLinkDialog"
|
||||
@done="markComplete(1)"
|
||||
@cancel="showAccountLinkDialog = false"
|
||||
>
|
||||
<template #header>Desktop login</template>
|
||||
</OnboardingDialogAccountLink>
|
||||
<OnboardingDialogFirstSend
|
||||
v-model:open="showFirstSendDialog"
|
||||
@done="markComplete(2)"
|
||||
@cancel="showFirstSendDialog = false"
|
||||
>
|
||||
<template #header>Your first upload</template>
|
||||
</OnboardingDialogFirstSend>
|
||||
<InviteDialogServer
|
||||
v-model:open="showServerInviteDialog"
|
||||
@update:open="(v) => (!v ? markComplete(3) : '')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
ShareIcon,
|
||||
ComputerDesktopIcon,
|
||||
UserPlusIcon,
|
||||
CloudArrowUpIcon,
|
||||
XMarkIcon
|
||||
} from '@heroicons/vue/24/solid'
|
||||
import { CheckCircleIcon as OutlineCheckCircleIcon } from '@heroicons/vue/24/outline'
|
||||
import { useSynchronizedCookie } from '~~/lib/common/composables/reactiveCookie'
|
||||
import { useMixpanel } from '~~/lib/core/composables/mp'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
showIntro?: boolean
|
||||
showBottomEscape?: boolean
|
||||
background?: boolean
|
||||
}>(),
|
||||
{
|
||||
showIntro: false,
|
||||
showBottomEscape: false,
|
||||
background: false
|
||||
}
|
||||
)
|
||||
|
||||
const mp = useMixpanel()
|
||||
|
||||
const emit = defineEmits(['dismiss'])
|
||||
|
||||
const showManagerDownloadDialog = ref(false)
|
||||
const showAccountLinkDialog = ref(false)
|
||||
const showFirstSendDialog = ref(false)
|
||||
const showServerInviteDialog = ref(false)
|
||||
|
||||
const hasDownloadedManager = useSynchronizedCookie<boolean>(`hasDownloadedManager`, {
|
||||
default: () => false
|
||||
})
|
||||
const hasLinkedAccount = useSynchronizedCookie<boolean>(`hasLinkedAccount`, {
|
||||
default: () => false
|
||||
})
|
||||
const hasViewedFirstSend = useSynchronizedCookie<boolean>(`hasViewedFirstSend`, {
|
||||
default: () => false
|
||||
})
|
||||
const hasSharedProject = useSynchronizedCookie<boolean>(`hasSharedProject`, {
|
||||
default: () => false
|
||||
})
|
||||
const hasCompletedChecklistV1 = useSynchronizedCookie<boolean>(
|
||||
`hasCompletedChecklistV1`,
|
||||
{ default: () => false }
|
||||
)
|
||||
const hasDismissedChecklistTime = useSynchronizedCookie<string | undefined>(
|
||||
`hasDismissedChecklistTime`,
|
||||
{ default: () => undefined }
|
||||
)
|
||||
|
||||
const hasDismissedChecklistForever = useSynchronizedCookie<boolean | undefined>(
|
||||
`hasDismissedChecklistForever`,
|
||||
{ default: () => false }
|
||||
)
|
||||
|
||||
const getStatus = () => {
|
||||
return {
|
||||
hasDownloadedManager: hasDownloadedManager.value,
|
||||
hasLinkedAccount: hasLinkedAccount.value,
|
||||
hasViewedFirstSend: hasViewedFirstSend.value,
|
||||
hasSharedProject: hasSharedProject.value
|
||||
}
|
||||
}
|
||||
|
||||
const steps = ref([
|
||||
{
|
||||
title: 'Install Manager ⚙️',
|
||||
blurb: 'Use Manager to install the Speckle Connectors for your apps!',
|
||||
active: false,
|
||||
cta: "Let's go!",
|
||||
postCompletionCta: 'Download again',
|
||||
action: () => {
|
||||
showManagerDownloadDialog.value = true
|
||||
},
|
||||
completionAction: () => {
|
||||
showManagerDownloadDialog.value = false
|
||||
hasDownloadedManager.value = true
|
||||
mp.track('Onboarding Action', {
|
||||
type: 'action',
|
||||
name: 'checklist',
|
||||
action: 'step-completed',
|
||||
stepName: 'download manager'
|
||||
})
|
||||
},
|
||||
completed: hasDownloadedManager.value,
|
||||
icon: ComputerDesktopIcon
|
||||
},
|
||||
{
|
||||
title: 'Log in 🔑',
|
||||
blurb: 'Authorise our application connectors to send data to Speckle.',
|
||||
active: false,
|
||||
cta: "Let's go!",
|
||||
postCompletionCta: 'Login again',
|
||||
action: () => {
|
||||
showAccountLinkDialog.value = true
|
||||
},
|
||||
completionAction: () => {
|
||||
showAccountLinkDialog.value = false
|
||||
hasLinkedAccount.value = true
|
||||
mp.track('Onboarding Action', {
|
||||
type: 'action',
|
||||
name: 'checklist',
|
||||
action: 'step-completed',
|
||||
stepName: 'manager login'
|
||||
})
|
||||
},
|
||||
completed: hasLinkedAccount.value,
|
||||
icon: UserPlusIcon
|
||||
},
|
||||
{
|
||||
title: 'Your first model upload ⬆️',
|
||||
blurb: 'Use your favourite design app to send your first model to Speckle.',
|
||||
active: false,
|
||||
cta: "Let's go!",
|
||||
postCompletionCta: 'Show again',
|
||||
action: () => {
|
||||
showFirstSendDialog.value = true
|
||||
},
|
||||
completionAction: () => {
|
||||
showFirstSendDialog.value = false
|
||||
hasViewedFirstSend.value = true
|
||||
mp.track('Onboarding Action', {
|
||||
type: 'action',
|
||||
name: 'checklist',
|
||||
action: 'step-completed',
|
||||
stepName: 'first send'
|
||||
})
|
||||
},
|
||||
completed: hasViewedFirstSend.value,
|
||||
icon: CloudArrowUpIcon
|
||||
},
|
||||
{
|
||||
title: 'Enable multiplayer 📢',
|
||||
blurb: 'Share your project with your colleagues!',
|
||||
active: false,
|
||||
cta: "Let's go!",
|
||||
postCompletionCta: 'Invite again',
|
||||
action: () => {
|
||||
showServerInviteDialog.value = true
|
||||
//TODO: modify server invite dialog to include searchable project dropdown
|
||||
},
|
||||
completionAction: () => {
|
||||
showServerInviteDialog.value = false
|
||||
hasSharedProject.value = true
|
||||
mp.track('Onboarding Action', {
|
||||
type: 'action',
|
||||
name: 'checklist',
|
||||
action: 'step-completed',
|
||||
stepName: 'first share'
|
||||
})
|
||||
},
|
||||
completed: hasSharedProject.value,
|
||||
icon: ShareIcon
|
||||
}
|
||||
])
|
||||
|
||||
const activateStep = (idx: number) => {
|
||||
steps.value.forEach((s, index) => (s.active = idx === index))
|
||||
}
|
||||
|
||||
const markComplete = (idx: number) => {
|
||||
steps.value[idx].completed = true
|
||||
steps.value[idx].active = false
|
||||
steps.value[idx].completionAction()
|
||||
mp.track('Onboarding Action', {
|
||||
type: 'action',
|
||||
name: 'checklist',
|
||||
action: 'mark-complete',
|
||||
step: idx,
|
||||
status: getStatus()
|
||||
})
|
||||
activateStep(idx + 1)
|
||||
}
|
||||
|
||||
const goToFirstUncompletedStep = () => {
|
||||
const firstNonCompleteStepIndex = steps.value.findIndex((s) => s.completed === false)
|
||||
activateStep(firstNonCompleteStepIndex)
|
||||
|
||||
if (import.meta.client) {
|
||||
mp.track('Onboarding Action', {
|
||||
type: 'action',
|
||||
name: 'checklist',
|
||||
action: 'goto-uncompleted-step',
|
||||
status: getStatus()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const allCompleted = computed(() => steps.value.every((step) => step.completed))
|
||||
|
||||
const closeChecklist = () => {
|
||||
hasCompletedChecklistV1.value = true
|
||||
}
|
||||
|
||||
const dismissChecklist = () => {
|
||||
hasDismissedChecklistTime.value = Date.now().toString()
|
||||
emit('dismiss')
|
||||
mp.track('Onboarding Action', {
|
||||
type: 'action',
|
||||
name: 'checklist',
|
||||
action: 'dismiss',
|
||||
status: getStatus()
|
||||
})
|
||||
}
|
||||
|
||||
const dismissChecklistForever = () => {
|
||||
hasDismissedChecklistForever.value = true
|
||||
emit('dismiss')
|
||||
mp.track('Onboarding Action', {
|
||||
type: 'action',
|
||||
name: 'checklist',
|
||||
action: 'dismiss-forever',
|
||||
status: getStatus()
|
||||
})
|
||||
}
|
||||
|
||||
goToFirstUncompletedStep()
|
||||
</script>
|
||||
@@ -0,0 +1,56 @@
|
||||
<template>
|
||||
<form class="w-full flex flex-col gap-4" @submit="onSubmit">
|
||||
<OnboardingQuestionsRoleSelect v-model="values.role" name="role" required />
|
||||
<OnboardingQuestionsPlanSelect v-model="values.plan" name="plan" required />
|
||||
<OnboardingQuestionsSourceSelect v-model="values.source" name="source" required />
|
||||
<div class="mt-2 flex flex-col gap-4">
|
||||
<FormButton size="lg" :disabled="!meta.valid || isSubmitting" submit full-width>
|
||||
Continue
|
||||
</FormButton>
|
||||
<div class="opacity-70 hover:opacity-100 max-w-max mx-auto px-1">
|
||||
<FormButton
|
||||
v-if="!isOnboardingForced"
|
||||
size="sm"
|
||||
text
|
||||
link
|
||||
color="subtle"
|
||||
full-width
|
||||
@click="setUserOnboardingComplete()"
|
||||
>
|
||||
Skip
|
||||
</FormButton>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useForm } from 'vee-validate'
|
||||
import type {
|
||||
OnboardingRole,
|
||||
OnboardingPlan,
|
||||
OnboardingSource
|
||||
} from '~/lib/auth/helpers/onboarding'
|
||||
import { useProcessOnboarding } from '~~/lib/auth/composables/onboarding'
|
||||
import { homeRoute } from '~/lib/common/helpers/route'
|
||||
|
||||
const isOnboardingForced = useIsOnboardingForced()
|
||||
|
||||
const { setUserOnboardingComplete, setMixpanelSegments } = useProcessOnboarding()
|
||||
|
||||
const { handleSubmit, meta, isSubmitting, values } = useForm({
|
||||
initialValues: {
|
||||
role: undefined as OnboardingRole | undefined,
|
||||
plan: [] as OnboardingPlan[],
|
||||
source: undefined as OnboardingSource | undefined
|
||||
}
|
||||
})
|
||||
|
||||
const onSubmit = handleSubmit(async () => {
|
||||
if (values.role) {
|
||||
setMixpanelSegments({ role: values.role })
|
||||
}
|
||||
await setUserOnboardingComplete()
|
||||
navigateTo(homeRoute)
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,50 @@
|
||||
<template>
|
||||
<FormSelectMulti
|
||||
v-bind="props"
|
||||
id="plan-select"
|
||||
v-model="selectedValue"
|
||||
label="What are you planning to do with Speckle?"
|
||||
placeholder="Select all that apply"
|
||||
required
|
||||
:rules="isRequired"
|
||||
name="plan"
|
||||
show-label
|
||||
allow-unset
|
||||
clearable
|
||||
:items="plans"
|
||||
>
|
||||
<template #option="{ item }">
|
||||
<div class="label label--light">
|
||||
{{ PlanTitleMap[item] }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #something-selected="{ value }">
|
||||
<template v-if="value.length === 1">
|
||||
{{ PlanTitleMap[isArrayValue(value) ? value[0] : value] }}
|
||||
</template>
|
||||
<template v-else>{{ value.length }} items selected</template>
|
||||
</template>
|
||||
</FormSelectMulti>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useFormSelectChildInternals } from '@speckle/ui-components'
|
||||
import { OnboardingPlan, PlanTitleMap } from '~/lib/auth/helpers/onboarding'
|
||||
import { isRequired } from '~~/lib/common/helpers/validation'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue?: OnboardingPlan[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: OnboardingPlan | OnboardingPlan[] | undefined): void
|
||||
}>()
|
||||
|
||||
const plans = Object.values(OnboardingPlan)
|
||||
|
||||
const { selectedValue, isArrayValue } = useFormSelectChildInternals<OnboardingPlan>({
|
||||
props: toRefs(props),
|
||||
emit
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,46 @@
|
||||
<template>
|
||||
<FormSelectBase
|
||||
v-bind="props"
|
||||
id="role-select"
|
||||
v-model="selectedValue"
|
||||
label="What's your role?"
|
||||
placeholder="Select one"
|
||||
required
|
||||
:rules="isRequired"
|
||||
name="role"
|
||||
show-label
|
||||
allow-unset
|
||||
clearable
|
||||
:items="roles"
|
||||
>
|
||||
<template #option="{ item }">
|
||||
<div class="label label--light">
|
||||
{{ RoleTitleMap[item] }}
|
||||
</div>
|
||||
</template>
|
||||
<template #something-selected="{ value }">
|
||||
<span>{{ RoleTitleMap[isArrayValue(value) ? value[0] : value] }}</span>
|
||||
</template>
|
||||
</FormSelectBase>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useFormSelectChildInternals } from '@speckle/ui-components'
|
||||
import { OnboardingRole, RoleTitleMap } from '~/lib/auth/helpers/onboarding'
|
||||
import { isRequired } from '~~/lib/common/helpers/validation'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue?: OnboardingRole
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: OnboardingRole | OnboardingRole[] | undefined): void
|
||||
}>()
|
||||
|
||||
const roles = Object.values(OnboardingRole)
|
||||
|
||||
const { selectedValue, isArrayValue } = useFormSelectChildInternals<OnboardingRole>({
|
||||
props: toRefs(props),
|
||||
emit
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,49 @@
|
||||
<template>
|
||||
<FormSelectBase
|
||||
v-bind="props"
|
||||
id="source-select"
|
||||
v-model="selectedValue"
|
||||
label="How did you hear about Speckle?"
|
||||
placeholder="Select one"
|
||||
required
|
||||
:rules="isRequired"
|
||||
name="source"
|
||||
show-label
|
||||
allow-unset
|
||||
clearable
|
||||
:items="sources"
|
||||
>
|
||||
<template #option="{ item }">
|
||||
<div class="label label--light">
|
||||
{{ SourceTitleMap[item] }}
|
||||
</div>
|
||||
</template>
|
||||
<template #something-selected="{ value }">
|
||||
<span>{{ SourceTitleMap[isArrayValue(value) ? value[0] : value] }}</span>
|
||||
</template>
|
||||
</FormSelectBase>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useFormSelectChildInternals } from '@speckle/ui-components'
|
||||
import { OnboardingSource, SourceTitleMap } from '~/lib/auth/helpers/onboarding'
|
||||
import { isRequired } from '~~/lib/common/helpers/validation'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue?: OnboardingSource
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(
|
||||
e: 'update:modelValue',
|
||||
value: OnboardingSource | OnboardingSource[] | undefined
|
||||
): void
|
||||
}>()
|
||||
|
||||
const sources = Object.values(OnboardingSource)
|
||||
|
||||
const { selectedValue, isArrayValue } = useFormSelectChildInternals<OnboardingSource>({
|
||||
props: toRefs(props),
|
||||
emit
|
||||
})
|
||||
</script>
|
||||
@@ -42,6 +42,8 @@
|
||||
<p class="text-body-xs text-foreground-2 mt-2 mb-5 ml-0.5">
|
||||
Copy this code to embed your model in a webpage or document.
|
||||
</p>
|
||||
<h4 class="text-heading-sm text-foreground-2 mb-1 ml-0.5">Embed URL</h4>
|
||||
<FormClipboardInput class="mb-4" :value="updatedUrl" />
|
||||
<LayoutDialogSection border-b border-t title="Options">
|
||||
<div class="flex flex-col gap-1.5 sm:gap-2 text-body-xs cursor-default">
|
||||
<div v-for="option in embedDialogOptions" :key="option.id">
|
||||
|
||||
@@ -1,327 +0,0 @@
|
||||
<template>
|
||||
<LayoutDialog v-model:open="isOpen" max-width="md" :buttons="dialogButtons">
|
||||
<template #header>Invite to project</template>
|
||||
<div class="flex flex-col gap-4 mb-2">
|
||||
<div v-if="!isWorkspaceMemberAndProjectOwner" class="flex flex-col gap-4">
|
||||
<FormSelectWorkspaceRoles
|
||||
v-if="project?.workspaceId"
|
||||
v-model="workspaceRole"
|
||||
show-label
|
||||
label="Workspace role"
|
||||
size="lg"
|
||||
help="If target user does not have a role in the parent workspace, they will be assigned this role."
|
||||
:allow-unset="false"
|
||||
/>
|
||||
<FormTextInput
|
||||
v-model="search"
|
||||
name="search"
|
||||
size="lg"
|
||||
placeholder="Search by email or username..."
|
||||
:disabled="disabled"
|
||||
:help="disabled ? 'You must be the project owner to invite users' : ''"
|
||||
input-classes="pr-[85px] text-sm"
|
||||
color="foundation"
|
||||
label="Add people"
|
||||
show-label
|
||||
>
|
||||
<template #input-right>
|
||||
<div
|
||||
class="absolute inset-y-0 right-0 flex items-center pr-2"
|
||||
:class="disabled ? 'pointer-events-none' : ''"
|
||||
>
|
||||
<ProjectPageTeamPermissionSelect
|
||||
v-model="role"
|
||||
mount-menu-on-body
|
||||
:show-label="false"
|
||||
:disabled-roles="isTargettingWorkspaceGuest ? [Roles.Stream.Owner] : []"
|
||||
:disabled-item-tooltip="
|
||||
isTargettingWorkspaceGuest
|
||||
? 'Workspace guests cannot be project owners'
|
||||
: ''
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</FormTextInput>
|
||||
|
||||
<div
|
||||
v-if="hasTargets"
|
||||
class="flex flex-col border bg-foundation border-primary-muted rounded-md"
|
||||
>
|
||||
<template v-if="searchUsers.length">
|
||||
<ProjectPageTeamDialogInviteUserServerUserRow
|
||||
v-for="user in searchUsers"
|
||||
:key="user.id"
|
||||
:user="user"
|
||||
:stream-role="role"
|
||||
:disabled="loading"
|
||||
:target-workspace-role="workspaceRole"
|
||||
@invite-user="($event) => onInviteUser($event.user)"
|
||||
/>
|
||||
</template>
|
||||
<ProjectPageTeamDialogInviteUserEmailsRow
|
||||
v-else-if="selectedEmails?.length"
|
||||
:selected-emails="selectedEmails"
|
||||
:stream-role="role"
|
||||
:disabled="loading"
|
||||
:is-guest-mode="isGuestMode"
|
||||
:unmatching-domain-policy="unmatchingDomainPolicy"
|
||||
class="p-2"
|
||||
@invite-emails="($event) => onInviteUser($event.emails, $event.serverRole)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="flex flex-col gap-4">
|
||||
<FormSelectProjectRoles
|
||||
v-model="role"
|
||||
show-label
|
||||
label="Project role"
|
||||
size="lg"
|
||||
/>
|
||||
<div>
|
||||
<div class="text-body-xs font-medium mb-1">Add users from workspace</div>
|
||||
<div
|
||||
v-if="invitableWorkspaceMembers.length"
|
||||
class="flex flex-col border bg-foundation border-primary-muted rounded-md"
|
||||
>
|
||||
<ProjectPageTeamDialogInviteUserServerUserRow
|
||||
v-for="user in invitableWorkspaceMembers"
|
||||
:key="user.user.id"
|
||||
:user="user.user"
|
||||
:stream-role="role"
|
||||
:disabled="!!(loading || disabledWorkspaceMemberRowMessage(user))"
|
||||
:disabled-message="disabledWorkspaceMemberRowMessage(user)"
|
||||
:target-workspace-role="workspaceRole"
|
||||
@invite-user="($event) => onInviteUser($event.user)"
|
||||
/>
|
||||
</div>
|
||||
<p v-else class="text-sm text-gray-500 mt-4">No available users found.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</LayoutDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Roles } from '@speckle/shared'
|
||||
import type { ServerRoles, StreamRoles, WorkspaceRoles } from '@speckle/shared'
|
||||
import type { UserSearchItem } from '~~/lib/common/composables/users'
|
||||
import type {
|
||||
ProjectInviteCreateInput,
|
||||
ProjectPageInviteDialog_ProjectFragment,
|
||||
WorkspaceProjectInviteCreateInput
|
||||
} from '~~/lib/common/generated/gql/graphql'
|
||||
import type { SetFullyRequired } from '~~/lib/common/helpers/type'
|
||||
import { isString } from 'lodash-es'
|
||||
import { useInviteUserToProject } from '~~/lib/projects/composables/projectManagement'
|
||||
import { useTeamInternals } from '~~/lib/projects/composables/team'
|
||||
import { useMixpanel } from '~~/lib/core/composables/mp'
|
||||
import { useServerInfo } from '~~/lib/core/composables/server'
|
||||
import { graphql } from '~/lib/common/generated/gql/gql'
|
||||
import type { LayoutDialogButton } from '@speckle/ui-components'
|
||||
import { useResolveInviteTargets } from '~/lib/server/composables/invites'
|
||||
import { filterInvalidInviteTargets } from '~/lib/workspaces/helpers/invites'
|
||||
|
||||
graphql(`
|
||||
fragment ProjectPageInviteDialog_Project on Project {
|
||||
id
|
||||
workspaceId
|
||||
workspace {
|
||||
id
|
||||
defaultProjectRole
|
||||
team {
|
||||
items {
|
||||
role
|
||||
user {
|
||||
id
|
||||
name
|
||||
bio
|
||||
company
|
||||
avatar
|
||||
verified
|
||||
role
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
...ProjectPageTeamInternals_Project
|
||||
workspace {
|
||||
id
|
||||
domainBasedMembershipProtectionEnabled
|
||||
domains {
|
||||
domain
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
type InvitableUser = UserSearchItem | string
|
||||
|
||||
const props = defineProps<{
|
||||
projectId: string
|
||||
project?: ProjectPageInviteDialog_ProjectFragment
|
||||
disabled?: boolean
|
||||
}>()
|
||||
|
||||
const isOpen = defineModel<boolean>('open', { required: true })
|
||||
const mp = useMixpanel()
|
||||
|
||||
const projectId = computed(() => props.projectId as string)
|
||||
const projectData = computed(() => props.project)
|
||||
const { collaboratorListItems } = useTeamInternals(projectData)
|
||||
|
||||
const workspaceMembers = computed(() => {
|
||||
return props.project?.workspace?.team?.items || []
|
||||
})
|
||||
|
||||
const invitableWorkspaceMembers = computed(() => {
|
||||
const currentProjectMemberIds = new Set(
|
||||
collaboratorListItems.value.map((item) => item.user?.id)
|
||||
)
|
||||
|
||||
return workspaceMembers.value.filter((member) => {
|
||||
if (!member.user.id || currentProjectMemberIds.has(member.user.id)) return false
|
||||
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
const search = ref('')
|
||||
const role = ref<StreamRoles>(Roles.Stream.Contributor)
|
||||
const workspaceRole = ref<WorkspaceRoles>(Roles.Workspace.Guest)
|
||||
|
||||
const { isGuestMode } = useServerInfo()
|
||||
const createInvite = useInviteUserToProject()
|
||||
|
||||
const { activeUser } = useActiveUser()
|
||||
|
||||
const {
|
||||
users: searchUsers,
|
||||
emails: selectedEmails,
|
||||
hasTargets
|
||||
} = useResolveInviteTargets({
|
||||
search,
|
||||
excludeUserIds: computed(() =>
|
||||
collaboratorListItems.value
|
||||
.filter((i): i is SetFullyRequired<typeof i, 'user'> => !!i.user?.id)
|
||||
.map((t) => t.user.id)
|
||||
),
|
||||
workspaceId: props.project?.workspaceId
|
||||
})
|
||||
|
||||
const isWorkspaceMemberAndProjectOwner = computed(() => {
|
||||
const userIsWorkspaceMember =
|
||||
workspaceMembers.value.some(
|
||||
(item) =>
|
||||
item.user?.id === activeUser.value?.id && item.role === Roles.Workspace.Member
|
||||
) ?? false
|
||||
|
||||
const userIsProjectOwner = projectData.value?.role === Roles.Stream.Owner
|
||||
|
||||
return userIsWorkspaceMember && userIsProjectOwner
|
||||
})
|
||||
|
||||
const dialogButtons = computed<LayoutDialogButton[]>(() => [
|
||||
{
|
||||
text: 'Cancel',
|
||||
props: { color: 'outline' },
|
||||
onClick: () => {
|
||||
isOpen.value = false
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
const isOwnerSelected = computed(() => role.value === Roles.Stream.Owner)
|
||||
const allowedDomains = computed(() =>
|
||||
props.project?.workspace?.domains?.map((c) => c.domain)
|
||||
)
|
||||
const unmatchingDomainPolicy = computed(() => {
|
||||
if (props.project?.workspace?.domainBasedMembershipProtectionEnabled) {
|
||||
return workspaceRole.value === Roles.Workspace.Guest
|
||||
? false
|
||||
: !selectedEmails.value?.every((email) =>
|
||||
allowedDomains.value?.includes(email.split('@')[1])
|
||||
)
|
||||
}
|
||||
|
||||
return false
|
||||
})
|
||||
const isTargettingWorkspaceGuest = computed(
|
||||
() => workspaceRole.value === Roles.Workspace.Guest
|
||||
)
|
||||
|
||||
const onInviteUser = async (
|
||||
user: InvitableUser | InvitableUser[],
|
||||
serverRole?: ServerRoles
|
||||
) => {
|
||||
serverRole = serverRole || Roles.Server.User
|
||||
const users = filterInvalidInviteTargets(user, {
|
||||
isTargetResourceOwner: isOwnerSelected.value,
|
||||
emailTargetServerRole: serverRole
|
||||
})
|
||||
|
||||
const inputs: ProjectInviteCreateInput[] | WorkspaceProjectInviteCreateInput[] =
|
||||
users.map((u) => ({
|
||||
role: role.value,
|
||||
...(isString(u)
|
||||
? {
|
||||
email: u,
|
||||
serverRole
|
||||
}
|
||||
: {
|
||||
userId: u.id
|
||||
}),
|
||||
...(props.project?.workspaceId
|
||||
? {
|
||||
workspaceRole: workspaceRole.value
|
||||
}
|
||||
: {})
|
||||
}))
|
||||
if (!inputs.length) return
|
||||
|
||||
const isEmail = !!inputs.find((u) => !!u.email)
|
||||
|
||||
// Invite email
|
||||
loading.value = true
|
||||
await createInvite(projectId.value, inputs)
|
||||
|
||||
mp.track('Invite Action', {
|
||||
type: 'project invite',
|
||||
name: 'send',
|
||||
multiple: inputs.length !== 1,
|
||||
count: inputs.length,
|
||||
hasProject: true,
|
||||
to: isEmail ? 'email' : 'existing user'
|
||||
})
|
||||
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
const disabledWorkspaceMemberRowMessage = (
|
||||
item: (typeof invitableWorkspaceMembers.value)[0]
|
||||
) => {
|
||||
return item.role === Roles.Workspace.Guest && role.value === Roles.Stream.Owner
|
||||
? 'You cannot invite a workspace guest as a project owner.'
|
||||
: undefined
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.project?.workspace?.defaultProjectRole,
|
||||
(newRole, oldRole) => {
|
||||
if (newRole && newRole !== oldRole) {
|
||||
role.value = newRole as StreamRoles
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(workspaceRole, (newRole, oldRole) => {
|
||||
if (newRole === oldRole) return
|
||||
|
||||
if (newRole === Roles.Workspace.Guest && role.value === Roles.Stream.Owner) {
|
||||
role.value = Roles.Stream.Reviewer
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -22,7 +22,6 @@
|
||||
import { usePaginatedQuery } from '~/lib/common/composables/graphql'
|
||||
import { graphql } from '~/lib/common/generated/gql'
|
||||
import type { ProjectPageAutomationRuns_AutomationFragment } from '~/lib/common/generated/gql/graphql'
|
||||
import { useMixpanel } from '~/lib/core/composables/mp'
|
||||
import { useTriggerAutomation } from '~/lib/projects/composables/automationManagement'
|
||||
import { projectAutomationPagePaginatedRunsQuery } from '~/lib/projects/graphql/queries'
|
||||
|
||||
@@ -66,18 +65,8 @@ const { identifier, onInfiniteLoad } = usePaginatedQuery({
|
||||
resolveCursorFromVariables: (vars) => vars.cursor
|
||||
})
|
||||
const triggerAutomation = useTriggerAutomation()
|
||||
const mixpanel = useMixpanel()
|
||||
|
||||
const onTrigger = async () => {
|
||||
const res = await triggerAutomation(props.projectId, props.automation.id)
|
||||
if (res) {
|
||||
mixpanel.track('Automation Run Triggered', {
|
||||
automationId: props.automation.id,
|
||||
automationName: props.automation.name,
|
||||
automationRunId: res,
|
||||
projectId: props.projectId,
|
||||
source: 'manual'
|
||||
})
|
||||
}
|
||||
await triggerAutomation(props.projectId, props.automation.id)
|
||||
}
|
||||
</script>
|
||||
|
||||
+11
-8
@@ -6,7 +6,7 @@
|
||||
</p>
|
||||
</template>
|
||||
<template #top-buttons>
|
||||
<FormButton @click="toggleInviteDialog">Invite</FormButton>
|
||||
<FormButton :disabled="!canInvite" @click="toggleInviteDialog">Invite</FormButton>
|
||||
</template>
|
||||
|
||||
<div class="flex flex-col mt-6">
|
||||
@@ -21,19 +21,17 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ProjectPageInviteDialog
|
||||
<InviteDialogProject
|
||||
v-if="project"
|
||||
v-model:open="showInviteDialog"
|
||||
:project="project"
|
||||
:project-id="projectId"
|
||||
:disabled="!isOwner"
|
||||
/>
|
||||
</ProjectPageSettingsBlock>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import type { Project } from '~~/lib/common/generated/gql/graphql'
|
||||
import type { ProjectCollaboratorListItem } from '~~/lib/projects/helpers/components'
|
||||
import type { Nullable, StreamRoles } from '@speckle/shared'
|
||||
import { type Nullable, type StreamRoles, Roles } from '@speckle/shared'
|
||||
import { useQuery, useApolloClient } from '@vue/apollo-composable'
|
||||
import { useTeamInternals } from '~~/lib/projects/composables/team'
|
||||
import { graphql } from '~~/lib/common/generated/gql'
|
||||
@@ -53,7 +51,8 @@ const projectPageSettingsCollaboratorsQuery = graphql(`
|
||||
project(id: $projectId) {
|
||||
id
|
||||
...ProjectPageTeamInternals_Project
|
||||
...ProjectPageInviteDialog_Project
|
||||
...InviteDialogProject_Project
|
||||
workspaceId
|
||||
}
|
||||
}
|
||||
`)
|
||||
@@ -83,15 +82,16 @@ const { result: pageResult } = useQuery(projectPageSettingsCollaboratorsQuery, (
|
||||
const { result: workspaceResult } = useQuery(
|
||||
projectPageSettingsCollaboratorWorkspaceQuery,
|
||||
() => ({
|
||||
workspaceId: pageResult.value!.project.workspaceId!
|
||||
workspaceId: pageResult.value!.project.workspace!.id
|
||||
}),
|
||||
() => ({
|
||||
enabled: isWorkspacesEnabled.value && !!pageResult.value?.project.workspaceId
|
||||
enabled: isWorkspacesEnabled.value && !!pageResult.value?.project.workspace?.id
|
||||
})
|
||||
)
|
||||
|
||||
const project = computed(() => pageResult.value?.project)
|
||||
const workspace = computed(() => workspaceResult.value?.workspace)
|
||||
const projectRole = computed(() => project.value?.role)
|
||||
const updateRole = useUpdateUserRole(project)
|
||||
|
||||
const toggleInviteDialog = () => {
|
||||
@@ -104,6 +104,9 @@ const { collaboratorListItems, isOwner, isServerGuest } = useTeamInternals(
|
||||
)
|
||||
|
||||
const canEdit = computed(() => isOwner.value && !isServerGuest.value)
|
||||
const canInvite = computed(() =>
|
||||
workspace?.value?.id ? projectRole.value !== Roles.Stream.Reviewer : isOwner.value
|
||||
)
|
||||
|
||||
const onCollaboratorRoleChange = async (
|
||||
collaborator: ProjectCollaboratorListItem,
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
<div>
|
||||
<Portal to="primary-actions"></Portal>
|
||||
<ProjectsDashboardHeader
|
||||
:projects-invites="projectsPanelResult?.activeUser || undefined"
|
||||
:workspaces-invites="workspacesResult?.activeUser || undefined"
|
||||
:projects-invites="projectsPanelResult?.activeUser"
|
||||
:workspaces-invites="workspacesResult?.activeUser"
|
||||
/>
|
||||
|
||||
<div v-if="!showEmptyState" class="flex flex-col gap-4">
|
||||
@@ -24,7 +24,7 @@
|
||||
:show-clear="!!search"
|
||||
v-bind="bind"
|
||||
v-on="on"
|
||||
></FormTextInput>
|
||||
/>
|
||||
<FormSelectProjectRoles
|
||||
v-if="!showEmptyState"
|
||||
v-model="selectedRoles"
|
||||
@@ -65,25 +65,16 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
useApolloClient,
|
||||
useQuery,
|
||||
useQueryLoading,
|
||||
useSubscription
|
||||
} from '@vue/apollo-composable'
|
||||
import { useQuery, useQueryLoading } from '@vue/apollo-composable'
|
||||
import {
|
||||
projectsDashboardQuery,
|
||||
projectsDashboardWorkspaceQuery
|
||||
} from '~~/lib/projects/graphql/queries'
|
||||
import { graphql } from '~~/lib/common/generated/gql'
|
||||
import { getCacheId, modifyObjectField } from '~~/lib/common/helpers/graphql'
|
||||
import { UserProjectsUpdatedMessageType } from '~~/lib/common/generated/gql/graphql'
|
||||
import { ToastNotificationType, useGlobalToast } from '~~/lib/common/composables/toast'
|
||||
import { projectRoute } from '~~/lib/common/helpers/route'
|
||||
import { useActiveUser } from '~~/lib/auth/composables/activeUser'
|
||||
import type { Nullable, Optional, StreamRoles } from '@speckle/shared'
|
||||
import { useDebouncedTextInput, type InfiniteLoaderState } from '@speckle/ui-components'
|
||||
import { MagnifyingGlassIcon, Squares2X2Icon } from '@heroicons/vue/24/outline'
|
||||
import { useUserProjectsUpdatedTracking } from '~~/lib/user/composables/projectUpdates'
|
||||
|
||||
graphql(`
|
||||
fragment ProjectsDashboard_UserProjectCollection on UserProjectCollection {
|
||||
@@ -98,11 +89,10 @@ const cursor = ref(null as Nullable<string>)
|
||||
const selectedRoles = ref(undefined as Optional<StreamRoles[]>)
|
||||
const openNewProject = ref(false)
|
||||
const showLoadingBar = ref(false)
|
||||
const { activeUser, isGuest } = useActiveUser()
|
||||
const { triggerNotification } = useGlobalToast()
|
||||
const areQueriesLoading = useQueryLoading()
|
||||
const apollo = useApolloClient().client
|
||||
const isWorkspacesEnabled = useIsWorkspacesEnabled()
|
||||
const { isGuest } = useActiveUser()
|
||||
useUserProjectsUpdatedTracking()
|
||||
|
||||
const {
|
||||
on,
|
||||
@@ -138,20 +128,6 @@ onProjectsResult((res) => {
|
||||
infiniteLoaderId.value = JSON.stringify(projectsVariables.value?.filter || {})
|
||||
})
|
||||
|
||||
const { onResult: onUserProjectsUpdate } = useSubscription(
|
||||
graphql(`
|
||||
subscription OnUserProjectsUpdate {
|
||||
userProjectsUpdated {
|
||||
type
|
||||
id
|
||||
project {
|
||||
...ProjectDashboardItem
|
||||
}
|
||||
}
|
||||
}
|
||||
`)
|
||||
)
|
||||
|
||||
const projects = computed(() => projectsPanelResult.value?.activeUser?.projects)
|
||||
const showEmptyState = computed(() => {
|
||||
const isFiltering =
|
||||
@@ -168,56 +144,6 @@ const moreToLoad = computed(
|
||||
cursor.value
|
||||
)
|
||||
|
||||
onUserProjectsUpdate((res) => {
|
||||
const activeUserId = activeUser.value?.id
|
||||
const event = res.data?.userProjectsUpdated
|
||||
|
||||
if (!event) return
|
||||
if (!activeUserId) return
|
||||
|
||||
const isNewProject = event.type === UserProjectsUpdatedMessageType.Added
|
||||
const incomingProject = event.project
|
||||
const cache = apollo.cache
|
||||
|
||||
if (isNewProject && incomingProject) {
|
||||
// Add to User.projects where possible
|
||||
modifyObjectField(
|
||||
cache,
|
||||
getCacheId('User', activeUserId),
|
||||
'projects',
|
||||
({ helpers: { ref, createUpdatedValue } }) =>
|
||||
createUpdatedValue(({ update }) => {
|
||||
update('items', (items) => [
|
||||
ref('Project', incomingProject.id),
|
||||
...(items || [])
|
||||
])
|
||||
update('totalCount', (count) => count + 1)
|
||||
}),
|
||||
{ autoEvictFiltered: true }
|
||||
)
|
||||
}
|
||||
|
||||
if (!isNewProject) {
|
||||
// Evict old project from cache entirely to remove it from all searches
|
||||
cache.evict({
|
||||
id: getCacheId('Project', event.id)
|
||||
})
|
||||
}
|
||||
|
||||
// Emit toast notification
|
||||
triggerNotification({
|
||||
type: ToastNotificationType.Info,
|
||||
title: isNewProject ? 'New project added' : 'A project has been removed',
|
||||
cta:
|
||||
isNewProject && incomingProject
|
||||
? {
|
||||
url: projectRoute(incomingProject.id),
|
||||
title: 'View project'
|
||||
}
|
||||
: undefined
|
||||
})
|
||||
})
|
||||
|
||||
const infiniteLoad = async (state: InfiniteLoaderState) => {
|
||||
if (!moreToLoad.value) return state.complete()
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ import type {
|
||||
ProjectsDashboardHeaderWorkspaces_UserFragment
|
||||
} from '~/lib/common/generated/gql/graphql'
|
||||
import { CookieKeys } from '~/lib/common/helpers/constants'
|
||||
import type { MaybeNullOrUndefined } from '@speckle/shared'
|
||||
|
||||
graphql(`
|
||||
fragment ProjectsDashboardHeaderProjects_User on User {
|
||||
@@ -49,8 +50,8 @@ graphql(`
|
||||
`)
|
||||
|
||||
const props = defineProps<{
|
||||
projectsInvites?: ProjectsDashboardHeaderProjects_UserFragment
|
||||
workspacesInvites?: ProjectsDashboardHeaderWorkspaces_UserFragment
|
||||
projectsInvites: MaybeNullOrUndefined<ProjectsDashboardHeaderProjects_UserFragment>
|
||||
workspacesInvites: MaybeNullOrUndefined<ProjectsDashboardHeaderWorkspaces_UserFragment>
|
||||
}>()
|
||||
|
||||
const dismissedDiscoverableWorkspaces = useSynchronizedCookie<string[]>(
|
||||
|
||||
@@ -81,6 +81,7 @@
|
||||
? 'TRIAL'
|
||||
: undefined
|
||||
"
|
||||
nested
|
||||
>
|
||||
<template #title-icon>
|
||||
<WorkspaceAvatar
|
||||
|
||||
+2
-2
@@ -96,8 +96,8 @@ const onSubmit = handleSubmit(async (tokenFormValues) => {
|
||||
emit('token-created', result.data.apiTokenCreate)
|
||||
triggerNotification({
|
||||
type: ToastNotificationType.Success,
|
||||
title: 'Webhook created',
|
||||
description: 'The webhook has been successfully created'
|
||||
title: 'Token created',
|
||||
description: 'The token has been successfully created'
|
||||
})
|
||||
} else {
|
||||
const errorMessage = getFirstErrorMessage(result?.errors)
|
||||
|
||||
@@ -1,38 +1,33 @@
|
||||
<template>
|
||||
<LayoutDialog
|
||||
v-model:open="isOpen"
|
||||
title="Delete email address"
|
||||
:title="cancel ? 'Cancel adding email' : 'Delete email address'"
|
||||
max-width="xs"
|
||||
:buttons="dialogButtons"
|
||||
>
|
||||
<p class="text-body-xs text-foreground mb-2">
|
||||
Are you sure you want to delete
|
||||
<span class="font-medium">{{ email }}</span>
|
||||
from your account?
|
||||
{{
|
||||
cancel
|
||||
? `Are you sure you want to cancel adding ${email?.email} to your account?`
|
||||
: `Are you sure you want to delete ${email?.email} from your account?`
|
||||
}}
|
||||
</p>
|
||||
</LayoutDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { LayoutDialogButton } from '@speckle/ui-components'
|
||||
import { settingsDeleteUserEmailMutation } from '~/lib/settings/graphql/mutations'
|
||||
import { useMutation } from '@vue/apollo-composable'
|
||||
import { ToastNotificationType, useGlobalToast } from '~~/lib/common/composables/toast'
|
||||
import {
|
||||
getFirstErrorMessage,
|
||||
convertThrowIntoFetchResult
|
||||
} from '~~/lib/common/helpers/graphql'
|
||||
import { useMixpanel } from '~/lib/core/composables/mp'
|
||||
import type { UserEmail } from '~/lib/common/generated/gql/graphql'
|
||||
import { useUserEmails } from '~/lib/user/composables/emails'
|
||||
|
||||
const props = defineProps<{
|
||||
emailId: string
|
||||
email: string
|
||||
email?: UserEmail
|
||||
cancel?: boolean
|
||||
}>()
|
||||
|
||||
const isOpen = defineModel<boolean>('open', { required: true })
|
||||
|
||||
const { mutate: deleteMutation } = useMutation(settingsDeleteUserEmailMutation)
|
||||
const { triggerNotification } = useGlobalToast()
|
||||
const mixpanel = useMixpanel()
|
||||
const { deleteUserEmail } = useUserEmails()
|
||||
|
||||
const dialogButtons = computed((): LayoutDialogButton[] => [
|
||||
{
|
||||
@@ -43,7 +38,7 @@ const dialogButtons = computed((): LayoutDialogButton[] => [
|
||||
}
|
||||
},
|
||||
{
|
||||
text: 'Delete',
|
||||
text: props.cancel ? 'Confirm' : 'Delete',
|
||||
props: { color: 'primary' },
|
||||
onClick: () => {
|
||||
onDeleteEmail()
|
||||
@@ -52,24 +47,10 @@ const dialogButtons = computed((): LayoutDialogButton[] => [
|
||||
])
|
||||
|
||||
const onDeleteEmail = async () => {
|
||||
const result = await deleteMutation({ input: { id: props.emailId } }).catch(
|
||||
convertThrowIntoFetchResult
|
||||
)
|
||||
if (result?.data) {
|
||||
triggerNotification({
|
||||
type: ToastNotificationType.Success,
|
||||
title: `${props.email} deleted`
|
||||
})
|
||||
|
||||
mixpanel.track('Email Deleted')
|
||||
} else {
|
||||
const errorMessage = getFirstErrorMessage(result?.errors)
|
||||
triggerNotification({
|
||||
type: ToastNotificationType.Danger,
|
||||
title: errorMessage
|
||||
})
|
||||
if (!props.email) return
|
||||
const success = await deleteUserEmail(props.email)
|
||||
if (success) {
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
isOpen.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
<template>
|
||||
<ul class="flex flex-col">
|
||||
<SettingsUserEmailListItem
|
||||
v-for="email in emailData"
|
||||
:key="email.id"
|
||||
:email-data="email"
|
||||
/>
|
||||
<ul
|
||||
class="flex flex-col border border-outline-2 rounded-lg divide-y divide-outline-2 mt-4"
|
||||
>
|
||||
<li v-for="email in sortedEmails" :key="email.id">
|
||||
<SettingsUserEmailListItem :email-data="email" />
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { SettingsUserEmailCards_UserEmailFragment } from '~~/lib/common/generated/gql/graphql'
|
||||
import { useUserEmails } from '~/lib/user/composables/emails'
|
||||
import { sortBy } from 'lodash-es'
|
||||
|
||||
defineProps<{
|
||||
emailData: SettingsUserEmailCards_UserEmailFragment[]
|
||||
}>()
|
||||
const { emails } = useUserEmails()
|
||||
|
||||
const sortedEmails = computed(() =>
|
||||
sortBy(emails.value, [(email) => !email.primary, 'email'])
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
<template>
|
||||
<li
|
||||
class="border-outline-2 border-x border-b first:border-t first:rounded-t-lg last:rounded-b-lg p-6 border-b-outline-3 last:border-b-outline-2"
|
||||
>
|
||||
<div class="p-6">
|
||||
<div
|
||||
v-if="emailData.primary || !emailData.verified"
|
||||
class="flex w-full gap-x-2 pb-4 md:pb-3"
|
||||
@@ -18,9 +16,9 @@
|
||||
v-if="!emailData.verified"
|
||||
color="outline"
|
||||
size="sm"
|
||||
@click="resendVerificationEmail"
|
||||
@click="handleVerifyEmail"
|
||||
>
|
||||
Resend verification email
|
||||
Verify email
|
||||
</FormButton>
|
||||
</div>
|
||||
<div class="flex flex-col md:flex-row">
|
||||
@@ -66,38 +64,21 @@
|
||||
|
||||
<SettingsUserEmailDeleteDialog
|
||||
v-model:open="showDeleteDialog"
|
||||
:email-id="emailData.id"
|
||||
:email="emailData.email"
|
||||
:email="emailData"
|
||||
:is-verifying="!emailData.verified"
|
||||
/>
|
||||
</li>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { SettingsUserEmailCards_UserEmailFragment } from '~~/lib/common/generated/gql/graphql'
|
||||
import { useGlobalToast, ToastNotificationType } from '~~/lib/common/composables/toast'
|
||||
import { graphql } from '~~/lib/common/generated/gql'
|
||||
import { useMutation } from '@vue/apollo-composable'
|
||||
import { settingsNewEmailVerificationMutation } from '~~/lib/settings/graphql/mutations'
|
||||
import {
|
||||
getFirstErrorMessage,
|
||||
convertThrowIntoFetchResult
|
||||
} from '~~/lib/common/helpers/graphql'
|
||||
|
||||
graphql(`
|
||||
fragment SettingsUserEmailCards_UserEmail on UserEmail {
|
||||
email
|
||||
id
|
||||
primary
|
||||
verified
|
||||
}
|
||||
`)
|
||||
import type { UserEmail } from '~~/lib/common/generated/gql/graphql'
|
||||
import { useUserEmails } from '~/lib/user/composables/emails'
|
||||
|
||||
const props = defineProps<{
|
||||
emailData: SettingsUserEmailCards_UserEmailFragment
|
||||
emailData: UserEmail
|
||||
}>()
|
||||
|
||||
const { triggerNotification } = useGlobalToast()
|
||||
const { mutate: resendMutation } = useMutation(settingsNewEmailVerificationMutation)
|
||||
const { resendVerificationEmail } = useUserEmails()
|
||||
|
||||
const showDeleteDialog = ref(false)
|
||||
const showSetPrimaryDialog = ref(false)
|
||||
@@ -108,7 +89,6 @@ const primaryTooltip = computed(() => {
|
||||
} else if (!props.emailData.verified) {
|
||||
return 'Unverified emails cannot be set as primary'
|
||||
}
|
||||
|
||||
return undefined
|
||||
})
|
||||
|
||||
@@ -118,10 +98,14 @@ const description = computed(() => {
|
||||
} else if (!props.emailData.verified) {
|
||||
return 'Unverified emails cannot be set as primary'
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
const handleVerifyEmail = async () => {
|
||||
await resendVerificationEmail(props.emailData)
|
||||
navigateTo(`/verify-email?emailId=${props.emailData.id}`)
|
||||
}
|
||||
|
||||
const toggleSetPrimaryDialog = () => {
|
||||
showSetPrimaryDialog.value = true
|
||||
}
|
||||
@@ -129,22 +113,4 @@ const toggleSetPrimaryDialog = () => {
|
||||
const toggleDeleteDialog = () => {
|
||||
showDeleteDialog.value = true
|
||||
}
|
||||
|
||||
const resendVerificationEmail = async () => {
|
||||
const result = await resendMutation({ input: { id: props.emailData.id } }).catch(
|
||||
convertThrowIntoFetchResult
|
||||
)
|
||||
if (result?.data) {
|
||||
triggerNotification({
|
||||
type: ToastNotificationType.Success,
|
||||
title: `Verification mail sent to ${props.emailData.email}`
|
||||
})
|
||||
} else {
|
||||
const errorMessage = getFirstErrorMessage(result?.errors)
|
||||
triggerNotification({
|
||||
type: ToastNotificationType.Danger,
|
||||
title: errorMessage
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -89,6 +89,8 @@ const onDelete = async () => {
|
||||
if (!props.workspace) return
|
||||
if (workspaceNameInput.value !== props.workspace.name) return
|
||||
|
||||
// Create a copy of the workspace name and ID before deletion to avoid errors after deletion/cache update
|
||||
const { name: workspaceName, id: workspaceId } = props.workspace
|
||||
const cache = apollo.cache
|
||||
const result = await deleteWorkspace({
|
||||
workspaceId: props.workspace.id
|
||||
@@ -121,24 +123,26 @@ const onDelete = async () => {
|
||||
|
||||
mixpanel.track('Workspace Deleted', {
|
||||
// eslint-disable-next-line camelcase
|
||||
workspace_id: props.workspace.id,
|
||||
workspace_id: workspaceId,
|
||||
feedback: feedback.value
|
||||
})
|
||||
mixpanel.get_group('workspace_id', props.workspace.id).set_once({
|
||||
isDeleted: true
|
||||
})
|
||||
|
||||
await sendWebhook(defaultZapierWebhookUrl, {
|
||||
userId: activeUser.value?.id ?? '',
|
||||
feedback: feedback.value
|
||||
? `Action: Workspace Deleted(${props.workspace.name}) Feedback: ${feedback.value}`
|
||||
: `Action: Workspace Deleted(${props.workspace.name}) - No feedback provided`
|
||||
feedback: [
|
||||
`**Action:** Workspace Deleted`,
|
||||
`**Workspace:** ${workspaceName}`,
|
||||
`**User ID:** ${activeUser.value?.id}`,
|
||||
`**Workspace ID:** ${workspaceId}`,
|
||||
feedback.value
|
||||
? `**Feedback:** ${feedback.value}`
|
||||
: '**Feedback:** No feedback provided'
|
||||
].join('\n')
|
||||
})
|
||||
|
||||
triggerNotification({
|
||||
type: ToastNotificationType.Success,
|
||||
title: 'Workspace deleted',
|
||||
description: `The ${props.workspace.name} workspace has been deleted`
|
||||
description: `The ${workspaceName} workspace has been deleted`
|
||||
})
|
||||
|
||||
router.push(homeRoute)
|
||||
|
||||
+14
-13
@@ -17,11 +17,12 @@
|
||||
import type { LayoutDialogButton } from '@speckle/ui-components'
|
||||
import { useApolloClient } from '@vue/apollo-composable'
|
||||
import { graphql } from '~/lib/common/generated/gql'
|
||||
import { type SettingsWorkspacesSecurityDomainRemoveDialog_WorkspaceDomainFragment } from '~/lib/common/generated/gql/graphql'
|
||||
import {
|
||||
type Workspace,
|
||||
type SettingsWorkspacesSecurityDomainRemoveDialog_WorkspaceDomainFragment
|
||||
} from '~/lib/common/generated/gql/graphql'
|
||||
import { getCacheId, getFirstErrorMessage } from '~/lib/common/helpers/graphql'
|
||||
getCacheId,
|
||||
getFirstErrorMessage,
|
||||
modifyObjectField
|
||||
} from '~/lib/common/helpers/graphql'
|
||||
import { settingsDeleteWorkspaceDomainMutation } from '~/lib/settings/graphql/mutations'
|
||||
import { useMixpanel } from '~/lib/core/composables/mp'
|
||||
import type { MaybeNullOrUndefined } from '@speckle/shared'
|
||||
@@ -69,16 +70,16 @@ const handleRemove = async () => {
|
||||
const { data } = res
|
||||
if (!data?.workspaceMutations || !props.workspaceId) return
|
||||
|
||||
cache.modify<Workspace>({
|
||||
id: getCacheId('Workspace', props.workspaceId),
|
||||
fields: {
|
||||
domains(currentDomains, { isReference }) {
|
||||
return [...(currentDomains ?? [])].filter((domain) =>
|
||||
isReference(domain) ? false : domain.id !== props.domain.id
|
||||
)
|
||||
}
|
||||
modifyObjectField(
|
||||
cache,
|
||||
getCacheId('Workspace', props.workspaceId),
|
||||
'domains',
|
||||
({ value, helpers }) => {
|
||||
return value?.filter(
|
||||
(domain) => helpers.readField(domain, 'id') !== props.domain.id
|
||||
)
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
})
|
||||
.catch(convertThrowIntoFetchResult)
|
||||
|
||||
@@ -104,7 +104,9 @@ const onSubmit = handleSubmit(() => {
|
||||
url.searchParams.set('challenge', challenge.value)
|
||||
}
|
||||
|
||||
postAuthRedirect.set(`/workspaces/${props.workspaceSlug}?ssoValidationSuccess=true`)
|
||||
postAuthRedirect.set(
|
||||
`/settings/workspaces/${props.workspaceSlug}/security?ssoValidationSuccess=true`
|
||||
)
|
||||
|
||||
mixpanel.track('Workspace SSO Configuration Started', {
|
||||
// eslint-disable-next-line camelcase
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="relative">
|
||||
<button class="hidden sm:block pointer-events-auto group" @click="toggle(index)">
|
||||
<div
|
||||
v-show="!item.viewed"
|
||||
class="animate-ping absolute bg-primary rounded-full h-8 w-8"
|
||||
></div>
|
||||
<div
|
||||
class="sm:absolute bg-foundation group-hover:scale-125 scale transition rounded-full h-8 w-8 flex items-center justify-center text-primary cursor-pointer select-none text-sm font-medium"
|
||||
>
|
||||
<span>{{ index + 1 }}</span>
|
||||
<!-- <span v-if="!expanded">{{ index + 1 }}</span>
|
||||
<span v-else><XMarkIcon class="h-6 w-6" /></span> -->
|
||||
</div>
|
||||
</button>
|
||||
<Transition
|
||||
enter-from-class="opacity-0"
|
||||
leave-to-class="opacity-0"
|
||||
enter-active-class="transition duration-300"
|
||||
leave-active-class="transition duration-300"
|
||||
>
|
||||
<div
|
||||
v-show="item.expanded"
|
||||
class="transition bg-foundation-page border border-outline-3 rounded-lg shadow-md mb-8 mx-2 gap-2 sm:gap-4 sm:ml-12 sm:max-w-xs pointer-events-auto"
|
||||
>
|
||||
<div
|
||||
class="sm:hidden flex items-center justify-center w-full gap-3 mt-1 mb-3"
|
||||
>
|
||||
<div
|
||||
class="bg-primary rounded-full"
|
||||
:class="index === 0 ? 'h-3 w-3' : 'h-2 w-2 opacity-50'"
|
||||
></div>
|
||||
<div
|
||||
class="bg-primary rounded-full"
|
||||
:class="index === 1 ? 'h-3 w-3' : 'h-2 w-2 opacity-50'"
|
||||
></div>
|
||||
<div
|
||||
class="bg-primary rounded-full"
|
||||
:class="index === 2 ? 'h-3 w-3' : 'h-2 w-2 opacity-50'"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div class="px-6 py-4">
|
||||
<slot></slot>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-center justify-between pointer-events-auto px-6 py-2 border-t border-outline-3"
|
||||
>
|
||||
<slot name="actions">
|
||||
<FormButton text size="sm" color="outline" @click="$emit('skip')">
|
||||
Skip
|
||||
</FormButton>
|
||||
<div class="flex justify-center items-center space-x-2">
|
||||
<FormButton
|
||||
v-show="index !== 0"
|
||||
size="sm"
|
||||
color="outline"
|
||||
text
|
||||
@click="prev(index)"
|
||||
>
|
||||
<ArrowLeftIcon class="h-3 w-3 mr-1" />
|
||||
Previous
|
||||
</FormButton>
|
||||
<div v-if="index === 2">
|
||||
<div v-if="!disableNext" v-tippy="'First add another model'">
|
||||
<FormButton disabled>Finish</FormButton>
|
||||
</div>
|
||||
<FormButton v-else @click="$emit('skip')">Finish</FormButton>
|
||||
</div>
|
||||
<FormButton v-else :icon-right="ArrowRightIcon" @click="next(index)">
|
||||
Next
|
||||
</FormButton>
|
||||
</div>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { Vector3 } from 'three'
|
||||
import { ArrowRightIcon, ArrowLeftIcon } from '@heroicons/vue/24/solid'
|
||||
import { useInjectedViewerState } from '~~/lib/viewer/composables/setup'
|
||||
import type { SlideshowItem } from '~~/lib/tour/slideshowItems'
|
||||
|
||||
const { next, prev, toggle } = inject('slideshowActions') as {
|
||||
next: (currentIndex: number) => void
|
||||
prev: (currentIndex: number) => void
|
||||
toggle: (i: number) => void
|
||||
}
|
||||
|
||||
defineEmits(['skip', 'previous', 'next'])
|
||||
|
||||
const props = defineProps<{
|
||||
index: number
|
||||
item: SlideshowItem
|
||||
disableNext: boolean
|
||||
}>()
|
||||
|
||||
const {
|
||||
ui: {
|
||||
camera: { position, target }
|
||||
}
|
||||
} = useInjectedViewerState()
|
||||
|
||||
watchEffect(() => {
|
||||
if (props.item.expanded) setView()
|
||||
})
|
||||
|
||||
function setView() {
|
||||
const camPos = props.item.camPos
|
||||
position.value = new Vector3(camPos[0], camPos[1], camPos[2])
|
||||
target.value = new Vector3(camPos[3], camPos[4], camPos[5])
|
||||
}
|
||||
</script>
|
||||
@@ -1,25 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
class="relative max-w-4xl w-screen h-[100dvh] flex items-center justify-center z-50"
|
||||
>
|
||||
<TourSegmentation v-if="showSegmentation" />
|
||||
<TourSlideshow v-else @next="$emit('complete')" />
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { useViewerTour } from '~/lib/viewer/composables/tour'
|
||||
import { useMixpanel } from '~~/lib/core/composables/mp'
|
||||
|
||||
defineEmits(['complete'])
|
||||
|
||||
const { showSegmentation } = useViewerTour()
|
||||
|
||||
const mp = useMixpanel()
|
||||
watch(showSegmentation, (val) => {
|
||||
mp.track('Onboarding Action', {
|
||||
type: 'action',
|
||||
name: 'step-activation',
|
||||
stepName: val ? 'slideshow' : 'segmentation'
|
||||
})
|
||||
})
|
||||
</script>
|
||||
@@ -1,144 +0,0 @@
|
||||
<!-- eslint-disable vuejs-accessibility/no-static-element-interactions -->
|
||||
<template>
|
||||
<div class="max-w-xl w-screen h-[100dvh] flex items-center justify-center">
|
||||
<Transition
|
||||
enter-from-class="opacity-0"
|
||||
enter-active-class="transition duration-300"
|
||||
>
|
||||
<!-- eslint-disable-next-line vuejs-accessibility/mouse-events-have-key-events -->
|
||||
<div
|
||||
v-show="step === 0"
|
||||
class="border border-outline bg-foundation text-foreground backdrop-blur shadow-lg rounded-xl p-4 space-y-2 absolute pointer-events-auto mx-2"
|
||||
@mouseenter="rotateGently(Math.random() * 2)"
|
||||
@mouseleave="rotateGently(Math.random() * 2)"
|
||||
>
|
||||
<h2 class="text-center text-heading-lg font-medium">
|
||||
Welcome, {{ activeUser?.name?.split(' ')[0] }}!
|
||||
</h2>
|
||||
<p class="text-center text-body-2xs text-foreground2">
|
||||
Let's get to know each other. What industry do you work in?
|
||||
</p>
|
||||
<div class="grid grid-cols-2 gap-3 pt-2">
|
||||
<FormButton
|
||||
v-for="val in OnboardingIndustry"
|
||||
:key="val"
|
||||
class="text-xs hover:scale-[1.05] capitalize"
|
||||
size="sm"
|
||||
color="outline"
|
||||
full-width
|
||||
@click="setIndustry(val)"
|
||||
@mouseenter="rotateGently(Math.random() * 2)"
|
||||
@focus="rotateGently(Math.random() * 2)"
|
||||
>
|
||||
{{ val }}
|
||||
</FormButton>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
<Transition
|
||||
enter-from-class="opacity-0"
|
||||
enter-active-class="transition duration-300"
|
||||
>
|
||||
<!-- eslint-disable-next-line vuejs-accessibility/mouse-events-have-key-events -->
|
||||
<div
|
||||
v-show="step === 1"
|
||||
class="bg-foundation border dark:border-neutral-800 text-foreground backdrop-blur shadow-lg rounded-xl p-4 space-y-2 absolute pointer-events-auto mx-2"
|
||||
@mouseenter="rotateGently(Math.random() * 2)"
|
||||
@mouseleave="rotateGently(Math.random() * 2)"
|
||||
>
|
||||
<h2 class="text-center text-heading-lg font-medium">Thanks!</h2>
|
||||
<p class="text-center text-body-2xs text-foreground2">
|
||||
Last thing! Please select the role that best describes you:
|
||||
</p>
|
||||
<div class="grid grid-cols-2 gap-3 pt-2">
|
||||
<FormButton
|
||||
v-for="val in OnboardingRole"
|
||||
:key="val"
|
||||
class="text-xs hover:scale-[1.05]"
|
||||
size="sm"
|
||||
color="outline"
|
||||
full-width
|
||||
@click="setRole(val)"
|
||||
@mouseenter="rotateGently(Math.random() * 2)"
|
||||
@focus="rotateGently(Math.random() * 2)"
|
||||
>
|
||||
{{ RoleTitleMap[val] }}
|
||||
</FormButton>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { Vector3 } from 'three'
|
||||
import { useActiveUser } from '~~/lib/auth/composables/activeUser'
|
||||
import {
|
||||
OnboardingIndustry,
|
||||
OnboardingRole,
|
||||
RoleTitleMap
|
||||
} from '~~/lib/auth/helpers/onboarding'
|
||||
import type { OnboardingState } from '~~/lib/auth/helpers/onboarding'
|
||||
import { useProcessOnboarding } from '~~/lib/auth/composables/onboarding'
|
||||
import { useCameraUtilities } from '~~/lib/viewer/composables/ui'
|
||||
import { useViewerTour } from '~/lib/viewer/composables/tour'
|
||||
|
||||
const { setMixpanelSegments } = useProcessOnboarding()
|
||||
const {
|
||||
setView,
|
||||
camera: { position, target }
|
||||
} = useCameraUtilities()
|
||||
|
||||
const onboardingState = ref<OnboardingState>({ industry: undefined, role: undefined })
|
||||
|
||||
const { activeUser } = useActiveUser()
|
||||
const tourState = useViewerTour()
|
||||
|
||||
const emit = defineEmits(['next'])
|
||||
|
||||
const step = ref(0)
|
||||
|
||||
function setIndustry(val: OnboardingIndustry) {
|
||||
onboardingState.value.industry = val
|
||||
step.value++
|
||||
nextView()
|
||||
}
|
||||
|
||||
function setRole(val: OnboardingRole) {
|
||||
onboardingState.value.role = val
|
||||
step.value++
|
||||
nextView()
|
||||
// NOTE: workaround for being able to view this in storybook
|
||||
if (activeUser.value?.id) setMixpanelSegments(onboardingState.value)
|
||||
tourState.showSegmentation.value = false
|
||||
emit('next')
|
||||
}
|
||||
|
||||
/** Hardcoded vec3s in Z up space */
|
||||
const camPos = [
|
||||
[23.86779, 82.9541, 29.05586, -27.41942, 37.72358, 29.05586, 0, 1],
|
||||
[23.86779, 82.9541, 29.05586, -27.41942, 37.72358, 29.05586, 0, 1],
|
||||
[27.22726, 2.10995, 27.98292, -27.31762, 36.15982, 27.98292, 0, 1],
|
||||
[-42.39747, 72.34078, 29.54059, -25.71981, 35.86063, 29.54059, 0, 1],
|
||||
[27.22726, 2.10995, 27.98292, -27.31762, 36.15982, 27.98292, 0, 1],
|
||||
[-25.89795, 12.51216, 59.41238, -21.55546, 37.76445, 32.52495, 0, 1]
|
||||
]
|
||||
|
||||
let flip = 1
|
||||
const rotateGently = (factor = 1) => {
|
||||
setView({ azimuth: (Math.PI / 12) * flip * factor, polar: 0 }, true)
|
||||
flip *= -1
|
||||
}
|
||||
|
||||
function nextView() {
|
||||
position.value = new Vector3(
|
||||
camPos[step.value][0],
|
||||
camPos[step.value][1],
|
||||
camPos[step.value][2]
|
||||
)
|
||||
target.value = new Vector3(
|
||||
camPos[step.value][3],
|
||||
camPos[step.value][4],
|
||||
camPos[step.value][5]
|
||||
)
|
||||
}
|
||||
</script>
|
||||
@@ -1,162 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
ref="parentEl"
|
||||
class="fixed z-30 left-0 top-0 w-screen h-[100dvh] pointer-events-none overflow-hidden"
|
||||
>
|
||||
<!--
|
||||
Tour Slideshow
|
||||
-->
|
||||
<TourComment
|
||||
v-for="(item, index) in slideshowItems.slice(0, tourItems.length)"
|
||||
:key="index"
|
||||
:item="item"
|
||||
:index="index"
|
||||
class="absolute"
|
||||
:class="isSmallerOrEqualSm ? 'bottom-0 left-0 w-screen' : ''"
|
||||
:style="isSmallerOrEqualSm ? undefined : item.style"
|
||||
:show-controls="item.showControls"
|
||||
:disable-next="hasAddedOverlay"
|
||||
@skip="finishSlideshow()"
|
||||
>
|
||||
<Component :is="tourItems[index]" @has-added-overlay="hasAddedOverlay = true" />
|
||||
</TourComment>
|
||||
<!-- In case the bubble is closed by the user, we need to display something -->
|
||||
<Transition
|
||||
enter-from-class="opacity-0"
|
||||
leave-to-class="opacity-0"
|
||||
enter-active-class="transition duration-300"
|
||||
leave-active-class="transition duration-300"
|
||||
>
|
||||
<div
|
||||
v-show="!hasOpenComments"
|
||||
class="fixed bottom-0 left-0 w-full h-28 flex align-center p-10 items-center justify-center space-x-2 pointer-events-auto"
|
||||
>
|
||||
<FormButton size="sm" color="outline" rounded @click="finishSlideshow()">
|
||||
Skip
|
||||
</FormButton>
|
||||
<FormButton
|
||||
size="lg"
|
||||
:icon-right="ArrowRightIcon"
|
||||
rounded
|
||||
class="shadow-md"
|
||||
@click="resumeSlideshow()"
|
||||
>
|
||||
Resume tour
|
||||
</FormButton>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
// Disclaimer, not the cleanest code.
|
||||
import type { Nullable } from '@speckle/shared'
|
||||
import type { Vector3 } from 'three'
|
||||
import { items as slideshowItemsRaw } from '~~/lib/tour/slideshowItems'
|
||||
import { ArrowRightIcon } from '@heroicons/vue/24/solid'
|
||||
import { useViewerAnchoredPoints } from '~~/lib/viewer/composables/anchorPoints'
|
||||
|
||||
// Slideshow component imports
|
||||
import FirstTip from '~~/components/tour/content/FirstTip.vue'
|
||||
import BasicViewerNavigation from '~~/components/tour/content/BasicViewerNavigation.vue'
|
||||
import OverlayModel from '~~/components/tour/content/OverlayModel.vue'
|
||||
import { useCameraUtilities } from '~~/lib/viewer/composables/ui'
|
||||
import { useMixpanel } from '~~/lib/core/composables/mp'
|
||||
import { useViewerTour } from '~/lib/viewer/composables/tour'
|
||||
|
||||
const emit = defineEmits(['next'])
|
||||
|
||||
const tourStage = useViewerTour()
|
||||
const { zoom, setView } = useCameraUtilities()
|
||||
|
||||
// Drives the amount of slideshow items
|
||||
const tourItems = [FirstTip, BasicViewerNavigation, OverlayModel /* , LastTip */]
|
||||
|
||||
// Ensuring we don't have more 3d points than actual tips by slicing the array
|
||||
// TODO: should check the other way around, but since this part is so handcrafted
|
||||
// doesn't make much sense.
|
||||
const slideshowItems = ref(slideshowItemsRaw.slice(0, tourItems.length))
|
||||
provide('slideshowItems', slideshowItems)
|
||||
|
||||
const hasAddedOverlay = ref(false)
|
||||
const lastOpenIndex = ref(0)
|
||||
const mp = useMixpanel()
|
||||
|
||||
// const isSmallerOrEqualSm = computed(() => breakpoints.smallerOrEqual('sm').value)
|
||||
const { isSmallerOrEqualSm } = useIsSmallerOrEqualThanBreakpoint()
|
||||
|
||||
const next = (currentIndex: number) => {
|
||||
if (currentIndex + 1 >= slideshowItems.value.length) {
|
||||
finishSlideshow()
|
||||
return
|
||||
}
|
||||
slideshowItems.value[currentIndex].expanded = false
|
||||
slideshowItems.value[currentIndex].viewed = true
|
||||
slideshowItems.value[currentIndex + 1].expanded = true
|
||||
lastOpenIndex.value = currentIndex + 1
|
||||
mp.track('Onboarding Action', {
|
||||
type: 'action',
|
||||
name: 'slideshow',
|
||||
action: 'next',
|
||||
step: currentIndex
|
||||
})
|
||||
}
|
||||
|
||||
const prev = (currentIndex: number) => {
|
||||
if (currentIndex - 1 < 0) return
|
||||
slideshowItems.value[currentIndex].expanded = false
|
||||
slideshowItems.value[currentIndex - 1].expanded = true
|
||||
lastOpenIndex.value = currentIndex - 1
|
||||
mp.track('Onboarding Action', {
|
||||
type: 'action',
|
||||
name: 'slideshow',
|
||||
action: 'previous',
|
||||
step: currentIndex
|
||||
})
|
||||
}
|
||||
|
||||
const toggle = (index: number) => {
|
||||
if (!slideshowItems.value[index]) return
|
||||
slideshowItems.value[index].expanded = !slideshowItems.value[index].expanded
|
||||
if (slideshowItems.value[index].expanded) lastOpenIndex.value = index
|
||||
|
||||
mp.track('Onboarding Action', {
|
||||
type: 'action',
|
||||
name: 'slideshow',
|
||||
action: 'toggle',
|
||||
step: index
|
||||
})
|
||||
}
|
||||
|
||||
provide('slideshowActions', { next, prev, toggle })
|
||||
|
||||
const hasOpenComments = computed(() => {
|
||||
return slideshowItems.value.some((item) => item.expanded === true)
|
||||
})
|
||||
|
||||
const parentEl = ref(null as Nullable<HTMLElement>)
|
||||
useViewerAnchoredPoints({
|
||||
parentEl,
|
||||
points: slideshowItems,
|
||||
pointLocationGetter: (c) => c.location as Vector3,
|
||||
updatePositionCallback: (c, res) => {
|
||||
c.style = {
|
||||
...c.style,
|
||||
...res.style,
|
||||
display: 'inline-block',
|
||||
transition: 'all 0.1s ease'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const finishSlideshow = () => {
|
||||
zoom()
|
||||
setView('left')
|
||||
tourStage.showNavbar.value = true
|
||||
tourStage.showControls.value = true
|
||||
emit('next')
|
||||
}
|
||||
|
||||
const resumeSlideshow = () => {
|
||||
slideshowItems.value[lastOpenIndex.value].expanded = true
|
||||
}
|
||||
</script>
|
||||
@@ -1,78 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="isSmallerOrEqualSm">
|
||||
<p class="text-sm">
|
||||
<strong>Navigate</strong>
|
||||
easily with hand gestures:
|
||||
</p>
|
||||
<div class="flex items-center justify-between gap-4 py-3 text-xs">
|
||||
<div class="flex gap-1 items-center">
|
||||
<IconHandRotate class="h-5 w-5" />
|
||||
rotate
|
||||
</div>
|
||||
<div class="flex gap-1 items-center">
|
||||
<IconHandSelect class="h-5 w-5" />
|
||||
select
|
||||
</div>
|
||||
<div class="flex gap-1 items-center">
|
||||
<IconHandZoom class="h-5 w-5" />
|
||||
zoom
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<p class="text-sm">
|
||||
<strong>Navigate</strong>
|
||||
easily with your mouse:
|
||||
</p>
|
||||
<div class="flex items-center justify-between gap-4 py-3 text-xs">
|
||||
<div class="flex gap-1 items-center">
|
||||
<IconMouseRotate class="h-5 w-5" />
|
||||
rotate
|
||||
</div>
|
||||
<div class="flex gap-1 items-center">
|
||||
<IconMouseZoom class="h-5 w-5" />
|
||||
zoom
|
||||
</div>
|
||||
<div class="flex gap-1 items-center">
|
||||
<IconMousePan class="h-5 w-5" />
|
||||
pan
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-sm">
|
||||
<div v-if="hasMovedCamera" class="font-medium flex items-center">
|
||||
<CheckIcon class="w-4 h-4 text-success mr-2" />
|
||||
<p>{{ encouragements[controlEndCounts] }}</p>
|
||||
</div>
|
||||
<p v-else>Give it a try now!</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { CheckIcon } from '@heroicons/vue/24/solid'
|
||||
import { useIsSmallerOrEqualThanBreakpoint } from '~~/composables/browser'
|
||||
import { useViewerCameraControlEndTracker } from '~~/lib/viewer/composables/viewer'
|
||||
|
||||
const hasMovedCamera = ref(false)
|
||||
const controlEndCounts = ref(-1)
|
||||
const encouragements = [
|
||||
'Nicely done!',
|
||||
'Wow, you are a pro!',
|
||||
'3D is fun!',
|
||||
"Don't get dizzy!"
|
||||
]
|
||||
|
||||
const { isSmallerOrEqualSm } = useIsSmallerOrEqualThanBreakpoint()
|
||||
|
||||
useViewerCameraControlEndTracker(() => {
|
||||
hasMovedCamera.value = true
|
||||
if (controlEndCounts.value === encouragements.length - 1) {
|
||||
controlEndCounts.value = 0
|
||||
} else {
|
||||
controlEndCounts.value++
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -1,12 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<p class="text-sm">
|
||||
Let's run through a few fast tips! This is Speckle's 3D viewer, and what you're
|
||||
looking at is a
|
||||
<b>model.</b>
|
||||
<br />
|
||||
<br />
|
||||
Next, we're going to learn how to navigate it!
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,13 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<p class="text-sm">There's much more to Speckle.</p>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { useViewerTour } from '~/lib/viewer/composables/tour'
|
||||
|
||||
const state = useViewerTour()
|
||||
|
||||
state.showNavbar.value = true
|
||||
state.showControls.value = true
|
||||
</script>
|
||||
@@ -1,72 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<div v-show="!hasAddedOverlay">
|
||||
<p class="text-sm">
|
||||
Speckle allows you to load multiple models in the same viewer.
|
||||
</p>
|
||||
<p class="text-sm mt-3">
|
||||
<span v-show="!hasAddedOverlay">
|
||||
<FormButton
|
||||
color="outline"
|
||||
:icon-right="hasAddedOverlay ? CheckIcon : PlusIcon"
|
||||
:disabled="hasAddedOverlay"
|
||||
@click="addOverlay()"
|
||||
>
|
||||
Add another model
|
||||
</FormButton>
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div v-show="hasAddedOverlay">
|
||||
<p class="text-sm">
|
||||
Nice - you've just created a "federated" model. Ready for what's next?
|
||||
<!-- <br />
|
||||
<br />
|
||||
Let's go to the next step. -->
|
||||
</p>
|
||||
<!-- <p class="text-sm">You can overlay as many models as you want.</p> -->
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { CheckIcon, PlusIcon } from '@heroicons/vue/24/solid'
|
||||
import { SpeckleViewer } from '@speckle/shared'
|
||||
import { useQuery } from '@vue/apollo-composable'
|
||||
import { latestModelsQuery } from '~~/lib/projects/graphql/queries'
|
||||
import {
|
||||
useInjectedViewerRequestedResources,
|
||||
useInjectedViewerLoadedResources
|
||||
} from '~~/lib/viewer/composables/setup'
|
||||
|
||||
import { SECOND_MODEL_NAME } from '~~/lib/auth/composables/onboarding'
|
||||
|
||||
const emit = defineEmits(['hasAddedOverlay'])
|
||||
|
||||
const { items } = useInjectedViewerRequestedResources()
|
||||
const { project } = useInjectedViewerLoadedResources()
|
||||
const id = project.value?.id as string
|
||||
|
||||
const { result } = useQuery(latestModelsQuery, () => ({ projectId: id }))
|
||||
|
||||
const hasAddedOverlay = ref(false)
|
||||
async function addOverlay() {
|
||||
const models = result.value?.project?.models.items
|
||||
const otherModel = models?.find((m) => m.name === SECOND_MODEL_NAME)
|
||||
|
||||
if (otherModel)
|
||||
await items.update([
|
||||
...items.value,
|
||||
...SpeckleViewer.ViewerRoute.resourceBuilder()
|
||||
.addModel(otherModel?.id)
|
||||
.toResources()
|
||||
])
|
||||
|
||||
hasAddedOverlay.value = true
|
||||
emit('hasAddedOverlay')
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (hasAddedOverlay.value) return
|
||||
addOverlay()
|
||||
})
|
||||
</script>
|
||||
@@ -1,15 +1,9 @@
|
||||
<!-- eslint-disable vuejs-accessibility/no-static-element-interactions -->
|
||||
<template>
|
||||
<div v-if="showControls">
|
||||
<div>
|
||||
<div
|
||||
class="absolute z-20 flex max-h-screen simple-scrollbar flex-col space-y-1 md:space-y-2 px-2"
|
||||
:class="
|
||||
showNavbar && !isEmbedEnabled
|
||||
? 'pt-[3.8rem]'
|
||||
: isTransparent
|
||||
? 'pt-2'
|
||||
: 'pt-2 pb-16'
|
||||
"
|
||||
:class="!isEmbedEnabled ? 'pt-[3.8rem]' : isTransparent ? 'pt-2' : 'pt-2 pb-16'"
|
||||
>
|
||||
<!-- Models -->
|
||||
<ViewerControlsButtonToggle
|
||||
@@ -164,7 +158,7 @@
|
||||
<div
|
||||
v-if="activePanel !== 'none'"
|
||||
ref="resizeHandle"
|
||||
class="absolute z-10 max-h-[calc(100dvh-4rem)] w-7 mt-[3.9rem] hidden sm:flex group overflow-hidden items-center rounded-r cursor-ew-resize z-30"
|
||||
class="absolute max-h-[calc(100dvh-4rem)] w-7 mt-[3.9rem] hidden sm:flex group overflow-hidden items-center rounded-r cursor-ew-resize z-30"
|
||||
:style="`left:${width - 2}px; height:${height ? height - 10 : 0}px`"
|
||||
@mousedown="startResizing"
|
||||
>
|
||||
@@ -252,7 +246,6 @@
|
||||
<FormButton @click="resetSectionBox()">Reset section box</FormButton>
|
||||
</Portal>
|
||||
</div>
|
||||
<div v-else />
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
@@ -278,7 +271,6 @@ import {
|
||||
import { useMixpanel } from '~~/lib/core/composables/mp'
|
||||
import { useIsSmallerOrEqualThanBreakpoint } from '~~/composables/browser'
|
||||
import { useEmbed } from '~/lib/viewer/composables/setup/embed'
|
||||
import { useViewerTour } from '~/lib/viewer/composables/tour'
|
||||
import {
|
||||
onKeyStroke,
|
||||
useEventListener,
|
||||
@@ -361,7 +353,6 @@ const {
|
||||
} = useSectionBoxUtilities()
|
||||
const { getActiveMeasurement, removeMeasurement, enableMeasurements } =
|
||||
useMeasurementUtilities()
|
||||
const { showNavbar, showControls } = useViewerTour()
|
||||
const { isTransparent, isEnabled: isEmbedEnabled } = useEmbed()
|
||||
const {
|
||||
zoomExtentsOrSelection,
|
||||
|
||||
@@ -1,43 +1,29 @@
|
||||
<template>
|
||||
<div>
|
||||
<div :class="`${loadProgress < 1 && viewerBusy ? 'mt-0' : '-mt-5'} transition-all`">
|
||||
<div
|
||||
v-show="viewerBusy"
|
||||
:class="`absolute w-full max-w-screen h-1 bg-blue-500/20 overflow-hidden ${
|
||||
showNavbar && !isEmbedEnabled ? 'mt-14' : 'mt-0'
|
||||
} text-xs text-foreground-on-primary z-50`"
|
||||
:class="`absolute w-full max-w-screen flex justify-center ${
|
||||
!isEmbedEnabled ? 'mt-14' : 'mt-0'
|
||||
} z-50`"
|
||||
>
|
||||
<div class="swoosher absolute top-0 bg-blue-500/50 rounded-md"></div>
|
||||
<div
|
||||
class="relative bg-blue-500/50 mt-0 h-4 rounded-b-lg select-none px-2 py-1 w-2/3 lg:w-1/3 overflow-hidden"
|
||||
>
|
||||
<div
|
||||
class="absolute h-full inset-0 bg-primary transition-[width]"
|
||||
:style="`width: ${Math.floor(loadProgress * 100)}%`"
|
||||
></div>
|
||||
<div
|
||||
class="absolute h-full inset-0 text-center text-xs text-foreground-on-primary"
|
||||
>
|
||||
{{ Math.floor(loadProgress * 100) }}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { useEmbed } from '~/lib/viewer/composables/setup/embed'
|
||||
import { useViewerTour } from '~/lib/viewer/composables/tour'
|
||||
import { useInjectedViewerInterfaceState } from '~~/lib/viewer/composables/setup'
|
||||
const { isEnabled: isEmbedEnabled } = useEmbed()
|
||||
|
||||
const { viewerBusy } = useInjectedViewerInterfaceState()
|
||||
const { showNavbar } = useViewerTour()
|
||||
const { viewerBusy, loadProgress } = useInjectedViewerInterfaceState()
|
||||
</script>
|
||||
<style scoped>
|
||||
.swoosher {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
animation: swoosh 1s infinite linear;
|
||||
transform-origin: 0% 30%;
|
||||
}
|
||||
|
||||
@keyframes swoosh {
|
||||
0% {
|
||||
transform: translateX(0) scaleX(0);
|
||||
}
|
||||
|
||||
40% {
|
||||
transform: translateX(0) scaleX(0.4);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateX(100%) scaleX(0.5);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -27,13 +27,6 @@
|
||||
</Portal>
|
||||
|
||||
<ClientOnly>
|
||||
<!-- Tour host -->
|
||||
<div
|
||||
v-if="showTour"
|
||||
class="fixed w-full h-[100dvh] flex justify-center items-center pointer-events-none z-[100]"
|
||||
>
|
||||
<TourOnboarding @complete="showTour = false" />
|
||||
</div>
|
||||
<!-- Viewer host -->
|
||||
<div
|
||||
class="viewer special-gradient absolute z-10 overflow-hidden w-screen"
|
||||
@@ -50,20 +43,18 @@
|
||||
enter-from-class="opacity-0"
|
||||
enter-active-class="transition duration-1000"
|
||||
>
|
||||
<ViewerAnchoredPoints v-show="showControls" />
|
||||
<ViewerAnchoredPoints />
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<!-- Global loading bar -->
|
||||
<ViewerLoadingBar class="absolute -top-2 left-0 w-full z-40" />
|
||||
<ViewerLoadingBar
|
||||
class="absolute left-0 w-full z-40 h-30"
|
||||
:class="isEmbedEnabled ? 'top-0' : ' -top-2'"
|
||||
/>
|
||||
|
||||
<!-- Sidebar controls -->
|
||||
<Transition
|
||||
enter-from-class="opacity-0"
|
||||
enter-active-class="transition duration-1000"
|
||||
>
|
||||
<ViewerControls v-show="showControls" class="relative z-20" />
|
||||
</Transition>
|
||||
<ViewerControls v-if="showControls" class="relative z-20" />
|
||||
|
||||
<!-- Viewer Object Selection Info Display -->
|
||||
<Transition
|
||||
@@ -71,9 +62,7 @@
|
||||
enter-from-class="opacity-0"
|
||||
enter-active-class="transition duration-1000"
|
||||
>
|
||||
<div v-show="showControls">
|
||||
<ViewerSelectionSidebar class="z-20" />
|
||||
</div>
|
||||
<ViewerSelectionSidebar class="z-20" />
|
||||
</Transition>
|
||||
<div
|
||||
class="absolute z-10 w-screen px-8 grid grid-cols-1 sm:grid-cols-3 gap-2"
|
||||
@@ -119,7 +108,6 @@ import {
|
||||
import dayjs from 'dayjs'
|
||||
import { graphql } from '~~/lib/common/generated/gql'
|
||||
import { useEmbed } from '~/lib/viewer/composables/setup/embed'
|
||||
import { useViewerTour } from '~/lib/viewer/composables/tour'
|
||||
import { useFilterUtilities } from '~/lib/viewer/composables/ui'
|
||||
import { projectsRoute } from '~~/lib/common/helpers/route'
|
||||
import { workspaceRoute } from '~/lib/common/helpers/route'
|
||||
@@ -130,7 +118,6 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const route = useRoute()
|
||||
const { showTour, showControls } = useViewerTour()
|
||||
const isWorkspacesEnabled = useIsWorkspacesEnabled()
|
||||
|
||||
const modelId = computed(() => route.params.modelId as string)
|
||||
@@ -143,7 +130,12 @@ const state = useSetupViewer({
|
||||
const {
|
||||
filters: { hasAnyFiltersApplied }
|
||||
} = useFilterUtilities({ state })
|
||||
const { isEnabled: isEmbedEnabled, hideSelectionInfo, isTransparent } = useEmbed()
|
||||
const {
|
||||
isEnabled: isEmbedEnabled,
|
||||
hideSelectionInfo,
|
||||
isTransparent,
|
||||
showControls
|
||||
} = useEmbed()
|
||||
|
||||
emit('setup', state)
|
||||
|
||||
|
||||
@@ -1,30 +1,23 @@
|
||||
<template>
|
||||
<div class="bg-foundation-page">
|
||||
<nav class="fixed z-40 top-0 h-12 bg-foundation border-b border-outline-2">
|
||||
<div class="flex items-center justify-between h-full w-screen py-4 px-3 sm:px-4">
|
||||
<HeaderLogoBlock
|
||||
:active="false"
|
||||
class="min-w-40 cursor-pointer"
|
||||
no-link
|
||||
@click="onCancelClick"
|
||||
/>
|
||||
<FormButton size="sm" color="outline" @click="onCancelClick">Cancel</FormButton>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="h-dvh w-dvh overflow-hidden flex flex-col">
|
||||
<div class="h-12 w-full shrink-0" />
|
||||
<main class="w-full h-full overflow-y-auto simple-scrollbar pt-8 pb-16">
|
||||
<div class="container mx-auto px-6 md:px-12">
|
||||
<WorkspaceWizard :workspace-id="workspaceId" />
|
||||
<HeaderWithEmptyPage empty-header>
|
||||
<template #header-left>
|
||||
<HeaderLogoBlock
|
||||
:active="false"
|
||||
class="min-w-40 cursor-pointer"
|
||||
no-link
|
||||
@click="onCancelClick"
|
||||
/>
|
||||
</template>
|
||||
<template #header-right>
|
||||
<FormButton size="sm" color="outline" @click="onCancelClick">Cancel</FormButton>
|
||||
</template>
|
||||
|
||||
<WorkspaceWizardCancelDialog
|
||||
v-model:open="isCancelDialogOpen"
|
||||
:workspace-id="workspaceId"
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<WorkspaceWizard :workspace-id="workspaceId" />
|
||||
<WorkspaceWizardCancelDialog
|
||||
v-model:open="isCancelDialogOpen"
|
||||
:workspace-id="workspaceId"
|
||||
/>
|
||||
</HeaderWithEmptyPage>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
@@ -94,11 +94,9 @@ import { usePaginatedQuery } from '~/lib/common/composables/graphql'
|
||||
import { graphql } from '~~/lib/common/generated/gql'
|
||||
import type { WorkspaceProjectsQueryQueryVariables } from '~~/lib/common/generated/gql/graphql'
|
||||
import { workspaceRoute } from '~/lib/common/helpers/route'
|
||||
import { useWorkspacesMixpanel } from '~/lib/workspaces/composables/mixpanel'
|
||||
import { useBillingActions } from '~/lib/billing/composables/actions'
|
||||
import { useWorkspacesWizard } from '~/lib/workspaces/composables/wizard'
|
||||
import type { WorkspaceWizardState } from '~/lib/workspaces/helpers/types'
|
||||
import { useActiveUser } from '~~/lib/auth/composables/activeUser'
|
||||
|
||||
graphql(`
|
||||
fragment WorkspaceProjectList_Workspace on Workspace {
|
||||
@@ -107,7 +105,6 @@ graphql(`
|
||||
...WorkspaceTeam_Workspace
|
||||
...WorkspaceSecurity_Workspace
|
||||
...BillingAlert_Workspace
|
||||
...WorkspaceMixpanelUpdateGroup_Workspace
|
||||
...MoveProjectsDialog_Workspace
|
||||
...InviteDialogWorkspace_Workspace
|
||||
projects {
|
||||
@@ -131,9 +128,7 @@ graphql(`
|
||||
}
|
||||
`)
|
||||
|
||||
const { activeUser } = useActiveUser()
|
||||
const { validateCheckoutSession } = useBillingActions()
|
||||
const { workspaceMixpanelUpdateGroup } = useWorkspacesMixpanel()
|
||||
const areQueriesLoading = useQueryLoading()
|
||||
const route = useRoute()
|
||||
const {
|
||||
@@ -236,10 +231,6 @@ onResult((queryResult) => {
|
||||
}
|
||||
|
||||
if (queryResult.data?.workspaceBySlug) {
|
||||
workspaceMixpanelUpdateGroup(
|
||||
queryResult.data.workspaceBySlug,
|
||||
activeUser.value?.email
|
||||
)
|
||||
useHeadSafe({
|
||||
title: queryResult.data.workspaceBySlug.name
|
||||
})
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
<template>
|
||||
<div class="flex px-4 py-3 items-center space-x-2 justify-between">
|
||||
<div class="flex items-center space-x-2 flex-1 truncate">
|
||||
<div
|
||||
v-if="unmatchingDomainPolicy"
|
||||
v-tippy="
|
||||
'Users that do not comply with the domain policy can only be invited as guests'
|
||||
"
|
||||
>
|
||||
<ExclamationCircleIcon class="text-danger w-5 w-4" />
|
||||
</div>
|
||||
<span class="truncate text-body-sm flex-1">
|
||||
{{ selectedEmails.join(', ') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<FormSelectServerRoles
|
||||
v-if="showServerRoleSelect"
|
||||
v-model="serverRole"
|
||||
:allow-guest="isGuestMode"
|
||||
:allow-admin="isAdmin"
|
||||
fixed-height
|
||||
/>
|
||||
<span
|
||||
v-tippy="
|
||||
isTryingToSetGuestOwner ? `Server guests can't be project owners` : undefined
|
||||
"
|
||||
>
|
||||
<FormButton
|
||||
:disabled="isButtonDisabled"
|
||||
color="outline"
|
||||
@click="() => $emit('invite-emails', { serverRole })"
|
||||
>
|
||||
Invite
|
||||
</FormButton>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { useActiveUser } from '~~/lib/auth/composables/activeUser'
|
||||
import { ExclamationCircleIcon } from '@heroicons/vue/24/outline'
|
||||
import { Roles, type ServerRoles } from '@speckle/shared'
|
||||
|
||||
defineEmits<{
|
||||
(e: 'invite-emails', payload: { serverRole: ServerRoles }): void
|
||||
}>()
|
||||
|
||||
const props = defineProps<{
|
||||
selectedEmails: string[]
|
||||
disabled?: boolean
|
||||
isGuestMode: boolean
|
||||
isOwnerRole: boolean
|
||||
unmatchingDomainPolicy?: boolean
|
||||
}>()
|
||||
|
||||
const { isAdmin } = useActiveUser()
|
||||
|
||||
const serverRole = ref<ServerRoles>(Roles.Server.User)
|
||||
|
||||
const showServerRoleSelect = computed(() => props.isGuestMode || isAdmin.value)
|
||||
|
||||
const isTryingToSetGuestOwner = computed(() => {
|
||||
if (!showServerRoleSelect.value) return false
|
||||
if (serverRole.value === Roles.Server.Guest && props.isOwnerRole) return true
|
||||
return false
|
||||
})
|
||||
|
||||
const isButtonDisabled = computed(() => {
|
||||
if (props.disabled) return true
|
||||
if (isTryingToSetGuestOwner.value) return true
|
||||
if (!props.selectedEmails.length) return true
|
||||
if (props.unmatchingDomainPolicy) return true
|
||||
return false
|
||||
})
|
||||
</script>
|
||||
@@ -1,65 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex px-4 py-3 items-center space-x-2 justify-between border-b last:border-0 border-outline-3"
|
||||
>
|
||||
<div class="flex items-center space-x-2 flex-1 truncate">
|
||||
<UserAvatar hide-tooltip :user="user" />
|
||||
<div
|
||||
v-if="
|
||||
user.workspaceDomainPolicyCompliant === false &&
|
||||
targetRole !== Roles.Workspace.Guest
|
||||
"
|
||||
v-tippy="
|
||||
'Users that do not comply with the domain policy can only be invited as guests'
|
||||
"
|
||||
>
|
||||
<ExclamationCircleIcon class="text-danger w-5 w-4" />
|
||||
</div>
|
||||
<span class="grow truncate text-body-sm">{{ user.name }}</span>
|
||||
</div>
|
||||
<span v-tippy="isTryingToSetGuestOwner ? settingGuestOwnerErrorMessage : undefined">
|
||||
<FormButton
|
||||
size="sm"
|
||||
color="outline"
|
||||
:disabled="isButtonDisabled"
|
||||
@click="() => $emit('invite-user')"
|
||||
>
|
||||
Invite
|
||||
</FormButton>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { Roles, type WorkspaceRoles } from '@speckle/shared'
|
||||
import type { UserSearchItem } from '~~/lib/common/composables/users'
|
||||
import { ExclamationCircleIcon } from '@heroicons/vue/24/outline'
|
||||
|
||||
defineEmits<{
|
||||
(e: 'invite-user'): void
|
||||
}>()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
isOwnerRole: boolean
|
||||
user: UserSearchItem
|
||||
disabled?: boolean
|
||||
settingGuestOwnerErrorMessage?: string
|
||||
targetRole: WorkspaceRoles
|
||||
}>(),
|
||||
{
|
||||
settingGuestOwnerErrorMessage: "Server guests can't be workspace owners"
|
||||
}
|
||||
)
|
||||
|
||||
const isTryingToSetGuestOwner = computed(
|
||||
() => props.user.role === Roles.Server.Guest && props.isOwnerRole
|
||||
)
|
||||
const isButtonDisabled = computed(() => {
|
||||
if (props.disabled) return true
|
||||
if (isTryingToSetGuestOwner.value) return true
|
||||
if (props.user.workspaceDomainPolicyCompliant === false)
|
||||
return props.targetRole !== Roles.Workspace.Guest
|
||||
|
||||
return false
|
||||
})
|
||||
</script>
|
||||
@@ -1,17 +0,0 @@
|
||||
<template>
|
||||
<div class="flex flex-col border border-outline-3 rounded-lg pt-6 px-8 pb-10 h-full">
|
||||
<slot name="icon" />
|
||||
<div class="flex flex-col mt-4">
|
||||
<h5 class="text-foreground text-heading-sm">{{ title }}</h5>
|
||||
<p class="text-body-xs text-foreground-2">
|
||||
{{ description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
title: string
|
||||
description: string
|
||||
}>()
|
||||
</script>
|
||||
@@ -42,6 +42,14 @@ export const useIsMultipleEmailsEnabled = () => {
|
||||
return ref(FF_MULTIPLE_EMAILS_MODULE_ENABLED)
|
||||
}
|
||||
|
||||
export const useIsOnboardingForced = () => {
|
||||
const {
|
||||
public: { FF_FORCE_ONBOARDING }
|
||||
} = useRuntimeConfig()
|
||||
|
||||
return ref(FF_FORCE_ONBOARDING)
|
||||
}
|
||||
|
||||
export const useIsGendoModuleEnabled = () => {
|
||||
const {
|
||||
public: { FF_GENDOAI_MODULE_ENABLED }
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
<template>
|
||||
<div class="w-screen h-[100dvh] overflow-hidden">
|
||||
<ClientOnly>
|
||||
<slot />
|
||||
</ClientOnly>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,28 +1,7 @@
|
||||
<template>
|
||||
<div class="relative min-h-full">
|
||||
<div
|
||||
v-if="debug"
|
||||
class="pointer-events-none fixed bottom-0 z-40 flex w-full space-x-2 p-3 text-xs"
|
||||
>
|
||||
<FormButton class="pointer-events-auto" size="sm" @click="toggleNavbar">
|
||||
nav
|
||||
</FormButton>
|
||||
<FormButton class="pointer-events-auto" size="sm" @click="toggleViewerControls">
|
||||
viewer ctrls
|
||||
</FormButton>
|
||||
<FormButton class="pointer-events-auto" size="sm" @click="toggleTour">
|
||||
tour ctrls
|
||||
</FormButton>
|
||||
<!-- <span>{{ tourState }}</span> -->
|
||||
</div>
|
||||
|
||||
<ClientOnly>
|
||||
<Transition
|
||||
enter-from-class="opacity-0"
|
||||
enter-active-class="transition duration-1000"
|
||||
>
|
||||
<HeaderNavBar v-show="showNavbar" class="relative z-20" />
|
||||
</Transition>
|
||||
<HeaderNavBar v-if="!isEmbedEnabled" class="relative z-20" />
|
||||
</ClientOnly>
|
||||
<main class="absolute top-0 left-0 z-10 h-[100dvh] w-screen">
|
||||
<slot />
|
||||
@@ -30,19 +9,7 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { useViewerTour } from '~/lib/viewer/composables/tour'
|
||||
import { useEmbed } from '~/lib/viewer/composables/setup/embed'
|
||||
|
||||
const { showNavbar, showTour, showControls } = useViewerTour()
|
||||
|
||||
const debug = ref(false)
|
||||
|
||||
const toggleNavbar = () => {
|
||||
showNavbar.value = !showNavbar.value
|
||||
}
|
||||
const toggleTour = () => {
|
||||
showTour.value = !showTour.value
|
||||
}
|
||||
const toggleViewerControls = () => {
|
||||
showControls.value = !showControls.value
|
||||
}
|
||||
const { isEnabled: isEmbedEnabled } = useEmbed()
|
||||
</script>
|
||||
|
||||
@@ -11,6 +11,10 @@ export const activeUserQuery = graphql(`
|
||||
activeUser {
|
||||
id
|
||||
email
|
||||
emails {
|
||||
id
|
||||
verified
|
||||
}
|
||||
company
|
||||
bio
|
||||
name
|
||||
|
||||
@@ -303,12 +303,6 @@ export const useAuthManager = (
|
||||
skipRedirect: postAuthRedirect.hadPendingRedirect.value
|
||||
})
|
||||
|
||||
triggerNotification({
|
||||
type: ToastNotificationType.Success,
|
||||
title: 'Welcome!',
|
||||
description: "You've been successfully authenticated"
|
||||
})
|
||||
|
||||
postAuthRedirect.popAndFollowRedirect()
|
||||
} catch (e) {
|
||||
triggerNotification({
|
||||
@@ -378,6 +372,9 @@ export const useAuthManager = (
|
||||
newsletter
|
||||
})
|
||||
|
||||
const registeredThisSession = useRegisteredThisSession()
|
||||
registeredThisSession.value = true
|
||||
|
||||
// eslint-disable-next-line camelcase
|
||||
goHome({ query: { access_code: accessCode } })
|
||||
}
|
||||
@@ -480,6 +477,12 @@ const useAuthAppIdAndChallenge = () => {
|
||||
return { appId, challenge }
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates whether the user just completed registration
|
||||
*/
|
||||
export const useRegisteredThisSession = () =>
|
||||
useState<boolean>('registered-this-session', () => false)
|
||||
|
||||
export const useLoginOrRegisterUtils = () => {
|
||||
const appIdAndChallenge = useAuthAppIdAndChallenge()
|
||||
const route = useRoute()
|
||||
|
||||
@@ -15,26 +15,20 @@ import { ToastNotificationType, useGlobalToast } from '~~/lib/common/composables
|
||||
import { useNavigateToHome } from '~~/lib/common/helpers/route'
|
||||
import { projectsDashboardQuery } from '~~/lib/projects/graphql/queries'
|
||||
|
||||
const ONBOARDING_PROP_INDUSTRY = 'onboarding_v1_industry'
|
||||
const ONBOARDING_PROP_ROLE = 'onboarding_v1_role'
|
||||
|
||||
export const FIRST_MODEL_NAME = 'base design'
|
||||
export const SECOND_MODEL_NAME = 'building wrapper'
|
||||
|
||||
export function useProcessOnboarding() {
|
||||
export const useProcessOnboarding = () => {
|
||||
const mixpanel = useMixpanel()
|
||||
const { distinctId, activeUser } = useActiveUser()
|
||||
const apollo = useApolloClient().client
|
||||
const { triggerNotification } = useGlobalToast()
|
||||
const goHome = useNavigateToHome()
|
||||
|
||||
/**
|
||||
* Sends to mp the segmentation info (industry, role)
|
||||
* @param state
|
||||
*/
|
||||
const setMixpanelSegments = (state: OnboardingState) => {
|
||||
mixpanel.people.set_once(ONBOARDING_PROP_INDUSTRY, state.industry || null)
|
||||
mixpanel.people.set_once(ONBOARDING_PROP_ROLE, state.role || null)
|
||||
const setMixpanelSegments = (segments: Partial<OnboardingState>) => {
|
||||
mixpanel.people.set(segments)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -113,6 +107,7 @@ export function useProcessOnboarding() {
|
||||
}
|
||||
})
|
||||
.catch(convertThrowIntoFetchResult)
|
||||
goHome()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -130,7 +125,6 @@ export function useProcessOnboarding() {
|
||||
throw new OnboardingError('Attempting to onboard unidentified user')
|
||||
|
||||
// Send data to mixpanel
|
||||
mixpanel.people.set_once(ONBOARDING_PROP_INDUSTRY, state.industry || null)
|
||||
mixpanel.people.set_once(ONBOARDING_PROP_ROLE, state.role || null)
|
||||
|
||||
// Mark onboarding as finished
|
||||
|
||||
@@ -1,31 +1,66 @@
|
||||
export enum OnboardingIndustry {
|
||||
Architecture = 'architecture & planning',
|
||||
Engineering = 'engineering',
|
||||
Construction = 'construction',
|
||||
Software = 'software development',
|
||||
Edu = 'higher education',
|
||||
export enum OnboardingRole {
|
||||
ComputationalDesign = 'computational-design',
|
||||
BIM = 'bim',
|
||||
ArchitecturePlanning = 'architecture-planning',
|
||||
EngineeringAEC = 'engineering-aec',
|
||||
EngineeringSoftware = 'engineering-software',
|
||||
Education = 'education',
|
||||
Management = 'management',
|
||||
Other = 'other'
|
||||
}
|
||||
|
||||
export enum OnboardingRole {
|
||||
ComputationalDesigner = 'computational-designer',
|
||||
SoftwareDeveloper = 'software-developer',
|
||||
DesignerOrEngineer = 'designer-or-engineer',
|
||||
Manager = 'manager',
|
||||
Student = 'student',
|
||||
export enum OnboardingPlan {
|
||||
Exploring = 'exploring',
|
||||
DataExchange = 'data-exchange',
|
||||
Analytics = 'analytics',
|
||||
Collaboration = 'collaboration',
|
||||
DataWarehouse = 'data-warehouse',
|
||||
Development = 'development',
|
||||
Other = 'other'
|
||||
}
|
||||
|
||||
export enum OnboardingSource {
|
||||
SocialMedia = 'social-media',
|
||||
Search = 'internet-search',
|
||||
Referral = 'friend-or-colleague',
|
||||
Event = 'event-conference',
|
||||
Education = 'university-course',
|
||||
Other = 'other'
|
||||
}
|
||||
|
||||
export const RoleTitleMap: Record<OnboardingRole, string> = {
|
||||
[OnboardingRole.ComputationalDesigner]: 'Computational Designer',
|
||||
[OnboardingRole.SoftwareDeveloper]: 'Software Developer',
|
||||
[OnboardingRole.DesignerOrEngineer]: 'Designer Or Engineer',
|
||||
[OnboardingRole.Manager]: 'Manager',
|
||||
[OnboardingRole.Student]: 'Student',
|
||||
[OnboardingRole.ComputationalDesign]: 'Computational Design',
|
||||
[OnboardingRole.BIM]: 'Building Information Modelling (BIM)',
|
||||
[OnboardingRole.ArchitecturePlanning]: 'Architecture & Planning',
|
||||
[OnboardingRole.EngineeringAEC]: 'Engineering (Structural, MEP, Civil, etc)',
|
||||
[OnboardingRole.EngineeringSoftware]: 'Engineering (Software)',
|
||||
[OnboardingRole.Education]: 'Education',
|
||||
[OnboardingRole.Management]: 'Management & Leadership',
|
||||
[OnboardingRole.Other]: 'Other'
|
||||
}
|
||||
|
||||
export type OnboardingState = {
|
||||
industry?: OnboardingIndustry
|
||||
role?: OnboardingRole
|
||||
export const PlanTitleMap: Record<OnboardingPlan, string> = {
|
||||
[OnboardingPlan.Exploring]: 'Just checking things out',
|
||||
[OnboardingPlan.DataExchange]: 'Exchange data between applications',
|
||||
[OnboardingPlan.Analytics]:
|
||||
'Data analytics, visualisation and reporting (eg PowerBI)',
|
||||
[OnboardingPlan.Collaboration]: 'Collaborate with my team and share 3D models online',
|
||||
[OnboardingPlan.DataWarehouse]: 'Data warehouse and common data environment (CDE)',
|
||||
[OnboardingPlan.Development]: 'Develop custom functionalities and apps',
|
||||
[OnboardingPlan.Other]: 'Other'
|
||||
}
|
||||
|
||||
export const SourceTitleMap: Record<OnboardingSource, string> = {
|
||||
[OnboardingSource.SocialMedia]: 'Social Media',
|
||||
[OnboardingSource.Search]: 'Internet search',
|
||||
[OnboardingSource.Referral]: 'Friend or colleague',
|
||||
[OnboardingSource.Event]: 'Event or conference',
|
||||
[OnboardingSource.Education]: 'University or course',
|
||||
[OnboardingSource.Other]: 'Other'
|
||||
}
|
||||
|
||||
export type OnboardingState = {
|
||||
role?: OnboardingRole
|
||||
plans?: OnboardingPlan[]
|
||||
source?: OnboardingSource
|
||||
}
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
/**
|
||||
* TODO: Does this need to change for new frontend?
|
||||
*/
|
||||
export const speckleWebAppId = 'spklwebapp'
|
||||
|
||||
export enum AuthStrategy {
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
} from '~~/lib/auth/errors/errors'
|
||||
import { speckleWebAppId } from '~~/lib/auth/helpers/strategies'
|
||||
|
||||
// TODO: Should these differ from the old frontend values?
|
||||
const appId = speckleWebAppId
|
||||
const appSecret = speckleWebAppId
|
||||
|
||||
|
||||
@@ -14,8 +14,6 @@ import { settingsBillingCancelCheckoutSessionMutation } from '~/lib/settings/gra
|
||||
import { ToastNotificationType, useGlobalToast } from '~~/lib/common/composables/toast'
|
||||
import { useMixpanel } from '~/lib/core/composables/mp'
|
||||
import { graphql } from '~~/lib/common/generated/gql'
|
||||
import { useZapier } from '~/lib/core/composables/zapier'
|
||||
import { defaultZapierWebhookUrl } from '~/lib/common/helpers/route'
|
||||
|
||||
graphql(`
|
||||
fragment BillingActions_Workspace on Workspace {
|
||||
@@ -49,7 +47,6 @@ export const useBillingActions = () => {
|
||||
const { mutate: cancelCheckoutSessionMutation } = useMutation(
|
||||
settingsBillingCancelCheckoutSessionMutation
|
||||
)
|
||||
const { sendWebhook } = useZapier()
|
||||
|
||||
const billingPortalRedirect = async (workspaceId?: string) => {
|
||||
if (!workspaceId) return
|
||||
@@ -188,9 +185,7 @@ export const useBillingActions = () => {
|
||||
})
|
||||
}
|
||||
|
||||
const validateCheckoutSession = async (
|
||||
workspace: BillingActions_WorkspaceFragment
|
||||
) => {
|
||||
const validateCheckoutSession = (workspace: BillingActions_WorkspaceFragment) => {
|
||||
const sessionIdQuery = route.query?.session_id
|
||||
const paymentStatusQuery = route.query?.payment_status
|
||||
|
||||
@@ -218,19 +213,6 @@ export const useBillingActions = () => {
|
||||
// eslint-disable-next-line camelcase
|
||||
workspace_id: workspace.id
|
||||
})
|
||||
|
||||
if (import.meta.server) {
|
||||
await sendWebhook(defaultZapierWebhookUrl, {
|
||||
workspaceId: workspace.id,
|
||||
workspaceName: workspace.name,
|
||||
plan: workspace.plan?.name ?? '',
|
||||
cycle: workspace.subscription?.billingInterval ?? '',
|
||||
status: WorkspacePlanStatuses.Valid,
|
||||
invitedTeamCount: workspace.invitedTeam?.length ?? 0,
|
||||
teamCount: workspace.team?.totalCount ?? 0,
|
||||
defaultRegion: workspace.defaultRegion?.name ?? ''
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const currentQueryParams = { ...route.query }
|
||||
|
||||
@@ -17,8 +17,6 @@ const documents = {
|
||||
"\n fragment AuthLoginWithEmailBlock_PendingWorkspaceCollaborator on PendingWorkspaceCollaborator {\n id\n email\n user {\n id\n }\n }\n": types.AuthLoginWithEmailBlock_PendingWorkspaceCollaboratorFragmentDoc,
|
||||
"\n query AuthRegisterPanelWorkspaceInvite($token: String) {\n workspaceInvite(token: $token) {\n id\n ...AuthWorkspaceInviteHeader_PendingWorkspaceCollaborator\n }\n }\n": types.AuthRegisterPanelWorkspaceInviteDocument,
|
||||
"\n fragment ServerTermsOfServicePrivacyPolicyFragment on ServerInfo {\n termsOfService\n }\n": types.ServerTermsOfServicePrivacyPolicyFragmentFragmentDoc,
|
||||
"\n query EmailVerificationBannerState {\n activeUser {\n id\n email\n verified\n hasPendingVerification\n }\n }\n": types.EmailVerificationBannerStateDocument,
|
||||
"\n mutation RequestVerification {\n requestVerification\n }\n": types.RequestVerificationDocument,
|
||||
"\n fragment AuthWorkspaceInviteHeader_PendingWorkspaceCollaborator on PendingWorkspaceCollaborator {\n id\n workspaceName\n email\n user {\n id\n ...LimitedUserAvatar\n }\n }\n": types.AuthWorkspaceInviteHeader_PendingWorkspaceCollaboratorFragmentDoc,
|
||||
"\n fragment AuthSsoLogin_Workspace on LimitedWorkspace {\n id\n slug\n name\n logo\n }\n": types.AuthSsoLogin_WorkspaceFragmentDoc,
|
||||
"\n fragment AuthStategiesServerInfoFragment on ServerInfo {\n authStrategies {\n id\n name\n url\n }\n ...AuthThirdPartyLoginButtonOIDC_ServerInfo\n }\n": types.AuthStategiesServerInfoFragmentFragmentDoc,
|
||||
@@ -51,6 +49,9 @@ const documents = {
|
||||
"\n fragment FormUsersSelectItem on LimitedUser {\n id\n name\n avatar\n }\n": types.FormUsersSelectItemFragmentDoc,
|
||||
"\n fragment HeaderNavShare_Project on Project {\n id\n visibility\n ...ProjectsModelPageEmbed_Project\n }\n": types.HeaderNavShare_ProjectFragmentDoc,
|
||||
"\n fragment InviteDialogWorkspace_Workspace on Workspace {\n id\n domainBasedMembershipProtectionEnabled\n domains {\n domain\n id\n }\n plan {\n status\n name\n }\n subscription {\n seats {\n guest\n plan\n }\n }\n }\n": types.InviteDialogWorkspace_WorkspaceFragmentDoc,
|
||||
"\n fragment InviteDialogProject_Project on Project {\n id\n name\n ...InviteDialogProjectWorkspaceMembers_Project\n workspace {\n id\n name\n defaultProjectRole\n role\n domainBasedMembershipProtectionEnabled\n domains {\n domain\n id\n }\n plan {\n status\n name\n }\n subscription {\n seats {\n guest\n plan\n }\n }\n }\n }\n": types.InviteDialogProject_ProjectFragmentDoc,
|
||||
"\n fragment InviteDialogProjectWorkspaceMembersRow_WorkspaceCollaborator on WorkspaceCollaborator {\n role\n id\n user {\n id\n name\n bio\n company\n avatar\n verified\n role\n }\n }\n": types.InviteDialogProjectWorkspaceMembersRow_WorkspaceCollaboratorFragmentDoc,
|
||||
"\n fragment InviteDialogProjectWorkspaceMembers_Project on Project {\n id\n ...ProjectPageTeamInternals_Project\n workspace {\n team {\n items {\n ...InviteDialogProjectWorkspaceMembersRow_WorkspaceCollaborator\n }\n }\n }\n }\n": types.InviteDialogProjectWorkspaceMembers_ProjectFragmentDoc,
|
||||
"\n fragment ProjectModelPageHeaderProject on Project {\n id\n name\n model(id: $modelId) {\n id\n name\n description\n }\n workspace {\n id\n slug\n name\n }\n }\n": types.ProjectModelPageHeaderProjectFragmentDoc,
|
||||
"\n fragment ProjectModelPageVersionsPagination on Project {\n id\n visibility\n model(id: $modelId) {\n id\n versions(limit: 16, cursor: $versionsCursor) {\n cursor\n totalCount\n items {\n ...ProjectModelPageVersionsCardVersion\n }\n }\n }\n ...ProjectsModelPageEmbed_Project\n }\n": types.ProjectModelPageVersionsPaginationFragmentDoc,
|
||||
"\n fragment ProjectModelPageVersionsProject on Project {\n ...ProjectPageProjectHeader\n model(id: $modelId) {\n id\n name\n pendingImportedVersions {\n ...PendingFileUpload\n }\n }\n ...ProjectModelPageVersionsPagination\n ...ProjectsModelPageEmbed_Project\n workspace {\n id\n readOnly\n }\n }\n": types.ProjectModelPageVersionsProjectFragmentDoc,
|
||||
@@ -60,7 +61,6 @@ const documents = {
|
||||
"\n fragment ProjectsModelPageEmbed_Project on Project {\n id\n ...ProjectsPageTeamDialogManagePermissions_Project\n }\n": types.ProjectsModelPageEmbed_ProjectFragmentDoc,
|
||||
"\n fragment ProjectModelPageVersionsCardVersion on Version {\n id\n message\n authorUser {\n ...LimitedUserAvatar\n }\n createdAt\n previewUrl\n sourceApplication\n commentThreadCount: commentThreads(limit: 0) {\n totalCount\n }\n ...ProjectModelPageDialogDeleteVersion\n ...ProjectModelPageDialogMoveToVersion\n automationsStatus {\n ...AutomateRunsTriggerStatus_TriggeredAutomationsStatus\n }\n }\n": types.ProjectModelPageVersionsCardVersionFragmentDoc,
|
||||
"\n fragment ProjectPageProjectHeader on Project {\n id\n role\n name\n description\n visibility\n allowPublicComments\n workspace {\n id\n slug\n name\n logo\n }\n }\n": types.ProjectPageProjectHeaderFragmentDoc,
|
||||
"\n fragment ProjectPageInviteDialog_Project on Project {\n id\n workspaceId\n workspace {\n id\n defaultProjectRole\n team {\n items {\n role\n user {\n id\n name\n bio\n company\n avatar\n verified\n role\n }\n }\n }\n }\n ...ProjectPageTeamInternals_Project\n workspace {\n id\n domainBasedMembershipProtectionEnabled\n domains {\n domain\n id\n }\n }\n }\n": types.ProjectPageInviteDialog_ProjectFragmentDoc,
|
||||
"\n fragment ProjectPageAutomationFunctionSettingsDialog_AutomationRevisionFunction on AutomationRevisionFunction {\n parameters\n release {\n id\n versionTag\n createdAt\n inputSchema\n function {\n id\n }\n }\n }\n": types.ProjectPageAutomationFunctionSettingsDialog_AutomationRevisionFunctionFragmentDoc,
|
||||
"\n fragment ProjectPageAutomationFunctionSettingsDialog_AutomationRevision on AutomationRevision {\n id\n triggerDefinitions {\n ... on VersionCreatedTriggerDefinition {\n type\n model {\n id\n ...CommonModelSelectorModel\n }\n }\n }\n }\n": types.ProjectPageAutomationFunctionSettingsDialog_AutomationRevisionFragmentDoc,
|
||||
"\n fragment ProjectPageAutomationFunctions_Automation on Automation {\n id\n currentRevision {\n id\n ...ProjectPageAutomationFunctionSettingsDialog_AutomationRevision\n functions {\n release {\n id\n inputSchema\n function {\n id\n ...AutomationsFunctionsCard_AutomateFunction\n releases(limit: 1) {\n items {\n id\n }\n }\n }\n }\n ...ProjectPageAutomationFunctionSettingsDialog_AutomationRevisionFunction\n }\n }\n }\n": types.ProjectPageAutomationFunctions_AutomationFragmentDoc,
|
||||
@@ -80,7 +80,7 @@ const documents = {
|
||||
"\n fragment SingleLevelModelTreeItem on ModelsTreeItem {\n id\n name\n fullName\n model {\n ...ProjectPageLatestItemsModelItem\n }\n hasChildren\n updatedAt\n }\n": types.SingleLevelModelTreeItemFragmentDoc,
|
||||
"\n fragment ProjectPageModelsCardDeleteDialog on Model {\n id\n name\n }\n": types.ProjectPageModelsCardDeleteDialogFragmentDoc,
|
||||
"\n fragment ProjectPageModelsCardRenameDialog on Model {\n id\n name\n description\n }\n": types.ProjectPageModelsCardRenameDialogFragmentDoc,
|
||||
"\n query ProjectPageSettingsCollaborators($projectId: String!) {\n project(id: $projectId) {\n id\n ...ProjectPageTeamInternals_Project\n ...ProjectPageInviteDialog_Project\n }\n }\n": types.ProjectPageSettingsCollaboratorsDocument,
|
||||
"\n query ProjectPageSettingsCollaborators($projectId: String!) {\n project(id: $projectId) {\n id\n ...ProjectPageTeamInternals_Project\n ...InviteDialogProject_Project\n workspaceId\n }\n }\n": types.ProjectPageSettingsCollaboratorsDocument,
|
||||
"\n query ProjectPageSettingsCollaboratorsWorkspace($workspaceId: String!) {\n workspace(id: $workspaceId) {\n ...ProjectPageTeamInternals_Workspace\n }\n }\n": types.ProjectPageSettingsCollaboratorsWorkspaceDocument,
|
||||
"\n query ProjectPageSettingsGeneral($projectId: String!) {\n project(id: $projectId) {\n id\n role\n ...ProjectPageSettingsGeneralBlockProjectInfo_Project\n ...ProjectPageSettingsGeneralBlockAccess_Project\n ...ProjectPageSettingsGeneralBlockDiscussions_Project\n ...ProjectPageSettingsGeneralBlockLeave_Project\n ...ProjectPageSettingsGeneralBlockDelete_Project\n ...ProjectPageTeamInternals_Project\n }\n }\n": types.ProjectPageSettingsGeneralDocument,
|
||||
"\n fragment ProjectPageSettingsGeneralBlockAccess_Project on Project {\n id\n visibility\n }\n": types.ProjectPageSettingsGeneralBlockAccess_ProjectFragmentDoc,
|
||||
@@ -93,7 +93,6 @@ const documents = {
|
||||
"\n fragment ProjectsAddDialog_Workspace on Workspace {\n id\n ...ProjectsWorkspaceSelect_Workspace\n }\n": types.ProjectsAddDialog_WorkspaceFragmentDoc,
|
||||
"\n fragment ProjectsAddDialog_User on User {\n workspaces {\n items {\n ...ProjectsAddDialog_Workspace\n }\n }\n }\n": types.ProjectsAddDialog_UserFragmentDoc,
|
||||
"\n fragment ProjectsDashboard_UserProjectCollection on UserProjectCollection {\n numberOfHidden\n }\n": types.ProjectsDashboard_UserProjectCollectionFragmentDoc,
|
||||
"\n subscription OnUserProjectsUpdate {\n userProjectsUpdated {\n type\n id\n project {\n ...ProjectDashboardItem\n }\n }\n }\n ": types.OnUserProjectsUpdateDocument,
|
||||
"\n fragment ProjectsDashboardFilledProject on ProjectCollection {\n items {\n ...ProjectDashboardItem\n }\n }\n": types.ProjectsDashboardFilledProjectFragmentDoc,
|
||||
"\n fragment ProjectsDashboardFilledUser on UserProjectCollection {\n items {\n ...ProjectDashboardItem\n }\n }\n": types.ProjectsDashboardFilledUserFragmentDoc,
|
||||
"\n fragment ProjectsDashboardHeaderProjects_User on User {\n projectInvites {\n ...ProjectsInviteBanner\n }\n }\n": types.ProjectsDashboardHeaderProjects_UserFragmentDoc,
|
||||
@@ -112,7 +111,6 @@ const documents = {
|
||||
"\n fragment SettingsServerRegionsTable_ServerRegionItem on ServerRegionItem {\n id\n name\n key\n description\n }\n": types.SettingsServerRegionsTable_ServerRegionItemFragmentDoc,
|
||||
"\n fragment SettingsSharedDeleteUserDialog_Workspace on Workspace {\n id\n plan {\n status\n name\n }\n subscription {\n currentBillingCycleEnd\n seats {\n guest\n plan\n }\n }\n }\n": types.SettingsSharedDeleteUserDialog_WorkspaceFragmentDoc,
|
||||
"\n fragment SettingsSharedProjects_Project on Project {\n ...ProjectsDeleteDialog_Project\n id\n name\n visibility\n createdAt\n updatedAt\n models(limit: 0) {\n totalCount\n }\n versions(limit: 0) {\n totalCount\n }\n team {\n id\n user {\n name\n id\n avatar\n }\n }\n }\n": types.SettingsSharedProjects_ProjectFragmentDoc,
|
||||
"\n fragment SettingsUserEmailCards_UserEmail on UserEmail {\n email\n id\n primary\n verified\n }\n": types.SettingsUserEmailCards_UserEmailFragmentDoc,
|
||||
"\n fragment SettingsUserProfileChangePassword_User on User {\n id\n email\n }\n": types.SettingsUserProfileChangePassword_UserFragmentDoc,
|
||||
"\n fragment SettingsUserProfileDeleteAccount_User on User {\n id\n email\n }\n": types.SettingsUserProfileDeleteAccount_UserFragmentDoc,
|
||||
"\n fragment SettingsUserProfileDetails_User on User {\n id\n name\n company\n ...UserProfileEditDialogAvatar_User\n }\n": types.SettingsUserProfileDetails_UserFragmentDoc,
|
||||
@@ -139,7 +137,7 @@ const documents = {
|
||||
"\n fragment ViewerModelVersionCardItem on Version {\n id\n message\n referencedObject\n sourceApplication\n createdAt\n previewUrl\n authorUser {\n ...LimitedUserAvatar\n }\n }\n": types.ViewerModelVersionCardItemFragmentDoc,
|
||||
"\n fragment MoveProjectsDialog_Workspace on Workspace {\n id\n ...ProjectsMoveToWorkspaceDialog_Workspace\n projects {\n items {\n id\n modelCount: models(limit: 0) {\n totalCount\n }\n versions(limit: 0) {\n totalCount\n }\n }\n }\n }\n": types.MoveProjectsDialog_WorkspaceFragmentDoc,
|
||||
"\n fragment MoveProjectsDialog_User on User {\n projects {\n items {\n ...ProjectsMoveToWorkspaceDialog_Project\n role\n workspace {\n id\n }\n }\n }\n }\n": types.MoveProjectsDialog_UserFragmentDoc,
|
||||
"\n fragment WorkspaceProjectList_Workspace on Workspace {\n id\n ...WorkspaceBase_Workspace\n ...WorkspaceTeam_Workspace\n ...WorkspaceSecurity_Workspace\n ...BillingAlert_Workspace\n ...WorkspaceMixpanelUpdateGroup_Workspace\n ...MoveProjectsDialog_Workspace\n ...InviteDialogWorkspace_Workspace\n projects {\n ...WorkspaceProjectList_ProjectCollection\n }\n creationState {\n completed\n state\n }\n readOnly\n }\n": types.WorkspaceProjectList_WorkspaceFragmentDoc,
|
||||
"\n fragment WorkspaceProjectList_Workspace on Workspace {\n id\n ...WorkspaceBase_Workspace\n ...WorkspaceTeam_Workspace\n ...WorkspaceSecurity_Workspace\n ...BillingAlert_Workspace\n ...MoveProjectsDialog_Workspace\n ...InviteDialogWorkspace_Workspace\n projects {\n ...WorkspaceProjectList_ProjectCollection\n }\n creationState {\n completed\n state\n }\n readOnly\n }\n": types.WorkspaceProjectList_WorkspaceFragmentDoc,
|
||||
"\n fragment WorkspaceProjectList_ProjectCollection on ProjectCollection {\n totalCount\n items {\n ...ProjectDashboardItem\n }\n cursor\n }\n": types.WorkspaceProjectList_ProjectCollectionFragmentDoc,
|
||||
"\n fragment WorkspaceHeader_Workspace on Workspace {\n ...WorkspaceBase_Workspace\n ...WorkspaceTeam_Workspace\n ...BillingAlert_Workspace\n slug\n readOnly\n }\n": types.WorkspaceHeader_WorkspaceFragmentDoc,
|
||||
"\n fragment WorkspaceInviteBanner_PendingWorkspaceCollaborator on PendingWorkspaceCollaborator {\n id\n invitedBy {\n id\n ...LimitedUserAvatar\n }\n workspaceId\n workspaceName\n token\n user {\n id\n }\n ...UseWorkspaceInviteManager_PendingWorkspaceCollaborator\n }\n": types.WorkspaceInviteBanner_PendingWorkspaceCollaboratorFragmentDoc,
|
||||
@@ -152,7 +150,7 @@ const documents = {
|
||||
"\n fragment WorkspaceSidebar_Workspace on Workspace {\n ...WorkspaceDashboardAbout_Workspace\n ...WorkspaceTeam_Workspace\n ...WorkspaceSecurity_Workspace\n slug\n plan {\n status\n }\n }\n": types.WorkspaceSidebar_WorkspaceFragmentDoc,
|
||||
"\n fragment WorkspaceWizard_Workspace on Workspace {\n creationState {\n completed\n state\n }\n name\n slug\n }\n": types.WorkspaceWizard_WorkspaceFragmentDoc,
|
||||
"\n fragment WorkspaceWizardStepRegion_ServerInfo on ServerInfo {\n multiRegion {\n regions {\n id\n ...SettingsWorkspacesRegionsSelect_ServerRegionItem\n }\n }\n }\n": types.WorkspaceWizardStepRegion_ServerInfoFragmentDoc,
|
||||
"\n query ActiveUserMainMetadata {\n activeUser {\n id\n email\n company\n bio\n name\n role\n avatar\n isOnboardingFinished\n createdAt\n verified\n notificationPreferences\n versions(limit: 0) {\n totalCount\n }\n }\n }\n": types.ActiveUserMainMetadataDocument,
|
||||
"\n query ActiveUserMainMetadata {\n activeUser {\n id\n email\n emails {\n id\n verified\n }\n company\n bio\n name\n role\n avatar\n isOnboardingFinished\n createdAt\n verified\n notificationPreferences\n versions(limit: 0) {\n totalCount\n }\n }\n }\n": types.ActiveUserMainMetadataDocument,
|
||||
"\n mutation CreateOnboardingProject {\n projectMutations {\n createForOnboarding {\n ...ProjectPageProject\n ...ProjectDashboardItem\n }\n }\n }\n ": types.CreateOnboardingProjectDocument,
|
||||
"\n mutation FinishOnboarding {\n activeUserMutations {\n finishOnboarding\n }\n }\n": types.FinishOnboardingDocument,
|
||||
"\n mutation RequestVerificationByEmail($email: String!) {\n requestVerificationByEmail(email: $email)\n }\n": types.RequestVerificationByEmailDocument,
|
||||
@@ -204,6 +202,7 @@ const documents = {
|
||||
"\n query InviteUserSearch($input: UsersRetrievalInput!) {\n users(input: $input) {\n items {\n id\n name\n avatar\n }\n }\n }\n": types.InviteUserSearchDocument,
|
||||
"\n mutation CreateNewRegion($input: CreateServerRegionInput!) {\n serverInfoMutations {\n multiRegion {\n create(input: $input) {\n id\n ...SettingsServerRegionsAddEditDialog_ServerRegionItem\n ...SettingsServerRegionsTable_ServerRegionItem\n }\n }\n }\n }\n": types.CreateNewRegionDocument,
|
||||
"\n mutation UpdateRegion($input: UpdateServerRegionInput!) {\n serverInfoMutations {\n multiRegion {\n update(input: $input) {\n id\n ...SettingsServerRegionsAddEditDialog_ServerRegionItem\n ...SettingsServerRegionsTable_ServerRegionItem\n }\n }\n }\n }\n": types.UpdateRegionDocument,
|
||||
"\n query PagesOnboardingDiscoverableWorkspaces_ActiveUser {\n activeUser {\n id\n ...PagesOnboarding_DiscoverableWorkspaces\n }\n }\n": types.PagesOnboardingDiscoverableWorkspaces_ActiveUserDocument,
|
||||
"\n fragment ProjectPageTeamInternals_Project on Project {\n id\n role\n invitedTeam {\n id\n title\n role\n inviteId\n user {\n role\n ...LimitedUserAvatar\n }\n }\n team {\n role\n user {\n id\n role\n ...LimitedUserAvatar\n }\n }\n }\n": types.ProjectPageTeamInternals_ProjectFragmentDoc,
|
||||
"\n fragment ProjectPageTeamInternals_Workspace on Workspace {\n id\n team {\n items {\n id\n role\n user {\n id\n }\n }\n }\n }\n": types.ProjectPageTeamInternals_WorkspaceFragmentDoc,
|
||||
"\n fragment ProjectDashboardItemNoModels on Project {\n id\n name\n createdAt\n updatedAt\n role\n team {\n id\n user {\n id\n name\n avatar\n }\n }\n ...ProjectPageModelsCardProject\n }\n": types.ProjectDashboardItemNoModelsFragmentDoc,
|
||||
@@ -290,9 +289,9 @@ const documents = {
|
||||
"\n fragment AddDomainWorkspace on Workspace {\n slug\n }\n ": types.AddDomainWorkspaceFragmentDoc,
|
||||
"\n fragment SettingsMenu_Workspace on Workspace {\n id\n sso {\n provider {\n id\n }\n session {\n validUntil\n }\n }\n }\n": types.SettingsMenu_WorkspaceFragmentDoc,
|
||||
"\n mutation SettingsUpdateWorkspace($input: WorkspaceUpdateInput!) {\n workspaceMutations {\n update(input: $input) {\n ...SettingsWorkspacesGeneral_Workspace\n }\n }\n }\n": types.SettingsUpdateWorkspaceDocument,
|
||||
"\n mutation SettingsCreateUserEmail($input: CreateUserEmailInput!) {\n activeUserMutations {\n emailMutations {\n create(input: $input) {\n ...SettingsUserEmails_User\n }\n }\n }\n }\n": types.SettingsCreateUserEmailDocument,
|
||||
"\n mutation SettingsDeleteUserEmail($input: DeleteUserEmailInput!) {\n activeUserMutations {\n emailMutations {\n delete(input: $input) {\n ...SettingsUserEmails_User\n }\n }\n }\n }\n": types.SettingsDeleteUserEmailDocument,
|
||||
"\n mutation SettingsSetPrimaryUserEmail($input: SetPrimaryUserEmailInput!) {\n activeUserMutations {\n emailMutations {\n setPrimary(input: $input) {\n ...SettingsUserEmails_User\n }\n }\n }\n }\n": types.SettingsSetPrimaryUserEmailDocument,
|
||||
"\n mutation SettingsCreateUserEmail($input: CreateUserEmailInput!) {\n activeUserMutations {\n emailMutations {\n create(input: $input) {\n id\n emails {\n ...EmailFields\n }\n }\n }\n }\n }\n": types.SettingsCreateUserEmailDocument,
|
||||
"\n mutation SettingsDeleteUserEmail($input: DeleteUserEmailInput!) {\n activeUserMutations {\n emailMutations {\n delete(input: $input) {\n id\n emails {\n ...EmailFields\n }\n }\n }\n }\n }\n": types.SettingsDeleteUserEmailDocument,
|
||||
"\n mutation SettingsSetPrimaryUserEmail($input: SetPrimaryUserEmailInput!) {\n activeUserMutations {\n emailMutations {\n setPrimary(input: $input) {\n id\n emails {\n ...EmailFields\n }\n }\n }\n }\n }\n": types.SettingsSetPrimaryUserEmailDocument,
|
||||
"\n mutation SettingsNewEmailVerification($input: EmailVerificationRequestInput!) {\n activeUserMutations {\n emailMutations {\n requestNewEmailVerification(input: $input)\n }\n }\n }\n": types.SettingsNewEmailVerificationDocument,
|
||||
"\n mutation SettingsUpdateWorkspaceSecurity($input: WorkspaceUpdateInput!) {\n workspaceMutations {\n update(input: $input) {\n id\n domainBasedMembershipProtectionEnabled\n discoverabilityEnabled\n }\n }\n }\n": types.SettingsUpdateWorkspaceSecurityDocument,
|
||||
"\n mutation SettingsDeleteWorkspace($workspaceId: String!) {\n workspaceMutations {\n delete(workspaceId: $workspaceId)\n }\n }\n": types.SettingsDeleteWorkspaceDocument,
|
||||
@@ -312,15 +311,18 @@ const documents = {
|
||||
"\n query SettingsWorkspacesMembersSearch($slug: String!, $filter: WorkspaceTeamFilter) {\n workspaceBySlug(slug: $slug) {\n id\n team(filter: $filter) {\n items {\n id\n ...SettingsWorkspacesMembersMembersTable_WorkspaceCollaborator\n }\n }\n }\n }\n": types.SettingsWorkspacesMembersSearchDocument,
|
||||
"\n query SettingsWorkspacesJoinRequestsSearch(\n $slug: String!\n $joinRequestsFilter: AdminWorkspaceJoinRequestFilter\n ) {\n workspaceBySlug(slug: $slug) {\n id\n ...SettingsWorkspacesMembersRequestsTable_Workspace\n }\n }\n": types.SettingsWorkspacesJoinRequestsSearchDocument,
|
||||
"\n query SettingsWorkspacesInvitesSearch(\n $slug: String!\n $invitesFilter: PendingWorkspaceCollaboratorsFilter\n ) {\n workspaceBySlug(slug: $slug) {\n ...SettingsWorkspacesMembersInvitesTable_Workspace\n }\n }\n": types.SettingsWorkspacesInvitesSearchDocument,
|
||||
"\n query SettingsUserEmailsQuery {\n activeUser {\n ...SettingsUserEmails_User\n }\n }\n": types.SettingsUserEmailsQueryDocument,
|
||||
"\n query SettingsWorkspacesProjects(\n $slug: String!\n $limit: Int!\n $cursor: String\n $filter: WorkspaceProjectsFilter\n ) {\n workspaceBySlug(slug: $slug) {\n id\n slug\n readOnly\n projects(limit: $limit, cursor: $cursor, filter: $filter) {\n cursor\n ...SettingsWorkspacesProjects_ProjectCollection\n }\n }\n }\n": types.SettingsWorkspacesProjectsDocument,
|
||||
"\n query SettingsWorkspaceSecurity($slug: String!) {\n workspaceBySlug(slug: $slug) {\n ...SettingsWorkspacesSecurity_Workspace\n }\n activeUser {\n ...SettingsWorkspacesSecurity_User\n }\n }\n": types.SettingsWorkspaceSecurityDocument,
|
||||
"\n fragment AppAuthorAvatar on AppAuthor {\n id\n name\n avatar\n }\n": types.AppAuthorAvatarFragmentDoc,
|
||||
"\n fragment LimitedUserAvatar on LimitedUser {\n id\n name\n avatar\n }\n": types.LimitedUserAvatarFragmentDoc,
|
||||
"\n fragment ActiveUserAvatar on User {\n id\n name\n avatar\n }\n": types.ActiveUserAvatarFragmentDoc,
|
||||
"\n subscription OnUserProjectsUpdate {\n userProjectsUpdated {\n type\n id\n project {\n ...ProjectDashboardItem\n }\n }\n }\n ": types.OnUserProjectsUpdateDocument,
|
||||
"\n mutation UpdateUser($input: UserUpdateInput!) {\n activeUserMutations {\n update(user: $input) {\n id\n name\n bio\n company\n avatar\n }\n }\n }\n": types.UpdateUserDocument,
|
||||
"\n mutation UpdateNotificationPreferences($input: JSONObject!) {\n userNotificationPreferencesUpdate(preferences: $input)\n }\n": types.UpdateNotificationPreferencesDocument,
|
||||
"\n mutation DeleteAccount($input: UserDeleteInput!) {\n userDelete(userConfirmation: $input)\n }\n": types.DeleteAccountDocument,
|
||||
"\n mutation verifyEmail($input: VerifyUserEmailInput!) {\n activeUserMutations {\n emailMutations {\n verify(input: $input)\n }\n }\n }\n": types.VerifyEmailDocument,
|
||||
"\n fragment EmailFields on UserEmail {\n id\n email\n verified\n primary\n userId\n }\n": types.EmailFieldsFragmentDoc,
|
||||
"\n query UserEmails {\n activeUser {\n id\n emails {\n ...EmailFields\n }\n hasPendingVerification\n }\n }\n": types.UserEmailsDocument,
|
||||
"\n fragment ViewerCommentBubblesData on Comment {\n id\n viewedAt\n viewerState\n }\n": types.ViewerCommentBubblesDataFragmentDoc,
|
||||
"\n fragment ViewerCommentThread on Comment {\n ...ViewerCommentsListItem\n ...ViewerCommentBubblesData\n ...ViewerCommentsReplyItem\n }\n": types.ViewerCommentThreadFragmentDoc,
|
||||
"\n fragment ViewerCommentsReplyItem on Comment {\n id\n archived\n rawText\n text {\n doc\n }\n author {\n ...LimitedUserAvatar\n }\n createdAt\n ...ThreadCommentAttachment\n }\n": types.ViewerCommentsReplyItemFragmentDoc,
|
||||
@@ -339,8 +341,6 @@ const documents = {
|
||||
"\n subscription OnViewerCommentsUpdated($target: ViewerUpdateTrackingTarget!) {\n projectCommentsUpdated(target: $target) {\n id\n type\n comment {\n id\n parent {\n id\n }\n ...ViewerCommentThread\n }\n }\n }\n": types.OnViewerCommentsUpdatedDocument,
|
||||
"\n fragment LinkableComment on Comment {\n id\n viewerResources {\n modelId\n versionId\n objectId\n }\n }\n": types.LinkableCommentFragmentDoc,
|
||||
"\n fragment UseWorkspaceInviteManager_PendingWorkspaceCollaborator on PendingWorkspaceCollaborator {\n id\n token\n workspaceId\n workspaceSlug\n user {\n id\n }\n }\n": types.UseWorkspaceInviteManager_PendingWorkspaceCollaboratorFragmentDoc,
|
||||
"\n fragment WorkspaceMixpanelUpdateGroup_WorkspaceCollaborator on WorkspaceCollaborator {\n id\n role\n }\n": types.WorkspaceMixpanelUpdateGroup_WorkspaceCollaboratorFragmentDoc,
|
||||
"\n fragment WorkspaceMixpanelUpdateGroup_Workspace on Workspace {\n id\n name\n description\n domainBasedMembershipProtectionEnabled\n discoverabilityEnabled\n plan {\n status\n name\n createdAt\n }\n subscription {\n billingInterval\n currentBillingCycleEnd\n seats {\n guest\n plan\n }\n }\n team {\n totalCount\n items {\n ...WorkspaceMixpanelUpdateGroup_WorkspaceCollaborator\n }\n }\n defaultRegion {\n key\n }\n }\n": types.WorkspaceMixpanelUpdateGroup_WorkspaceFragmentDoc,
|
||||
"\n subscription OnWorkspaceProjectsUpdate($slug: String!) {\n workspaceProjectsUpdated(workspaceId: null, workspaceSlug: $slug) {\n projectId\n workspaceId\n type\n project {\n ...ProjectDashboardItem\n }\n }\n }\n ": types.OnWorkspaceProjectsUpdateDocument,
|
||||
"\n fragment WorkspaceHasCustomDataResidency_Workspace on Workspace {\n id\n defaultRegion {\n id\n name\n }\n }\n": types.WorkspaceHasCustomDataResidency_WorkspaceFragmentDoc,
|
||||
"\n query CheckProjectWorkspaceDataResidency($projectId: String!) {\n project(id: $projectId) {\n id\n workspace {\n ...WorkspaceHasCustomDataResidency_Workspace\n }\n }\n }\n": types.CheckProjectWorkspaceDataResidencyDocument,
|
||||
@@ -382,14 +382,13 @@ const documents = {
|
||||
"\n fragment AutomateFunctionPage_AutomateFunction on AutomateFunction {\n id\n name\n description\n logo\n supportedSourceApps\n tags\n ...AutomateFunctionPageHeader_Function\n ...AutomateFunctionPageInfo_AutomateFunction\n ...AutomateAutomationCreateDialog_AutomateFunction\n creator {\n id\n }\n }\n": types.AutomateFunctionPage_AutomateFunctionFragmentDoc,
|
||||
"\n query AutomateFunctionPage($functionId: ID!) {\n automateFunction(id: $functionId) {\n ...AutomateFunctionPage_AutomateFunction\n }\n activeUser {\n workspaces {\n items {\n ...AutomateFunctionCreateDialog_Workspace\n ...AutomateFunctionEditDialog_Workspace\n }\n }\n }\n }\n": types.AutomateFunctionPageDocument,
|
||||
"\n query AutomateFunctionPageWorkspace($workspaceId: String!) {\n workspace(id: $workspaceId) {\n id\n ...AutomateFunctionPageHeader_Workspace\n }\n }\n": types.AutomateFunctionPageWorkspaceDocument,
|
||||
"\n query AutomateFunctionsPage($search: String, $cursor: String = null) {\n ...AutomateFunctionsPageItems_Query\n ...AutomateFunctionsPageHeader_Query\n }\n": types.AutomateFunctionsPageDocument,
|
||||
"\n fragment PagesOnboarding_DiscoverableWorkspaces on User {\n discoverableWorkspaces {\n id\n name\n logo\n description\n slug\n }\n }\n": types.PagesOnboarding_DiscoverableWorkspacesFragmentDoc,
|
||||
"\n fragment ProjectPageProject on Project {\n id\n createdAt\n modelCount: models(limit: 0) {\n totalCount\n }\n commentThreadCount: commentThreads(limit: 0) {\n totalCount\n }\n workspace {\n id\n }\n ...ProjectPageTeamInternals_Project\n ...ProjectPageProjectHeader\n ...ProjectPageTeamDialog\n ...ProjectsMoveToWorkspaceDialog_Project\n }\n": types.ProjectPageProjectFragmentDoc,
|
||||
"\n fragment ProjectPageAutomationPage_Automation on Automation {\n id\n ...ProjectPageAutomationHeader_Automation\n ...ProjectPageAutomationFunctions_Automation\n ...ProjectPageAutomationRuns_Automation\n }\n": types.ProjectPageAutomationPage_AutomationFragmentDoc,
|
||||
"\n fragment ProjectPageAutomationPage_Project on Project {\n id\n workspaceId\n ...ProjectPageAutomationHeader_Project\n }\n": types.ProjectPageAutomationPage_ProjectFragmentDoc,
|
||||
"\n fragment ProjectPageSettingsTab_Project on Project {\n id\n role\n }\n": types.ProjectPageSettingsTab_ProjectFragmentDoc,
|
||||
"\n fragment SettingsServerProjects_ProjectCollection on ProjectCollection {\n totalCount\n items {\n ...SettingsSharedProjects_Project\n }\n }\n": types.SettingsServerProjects_ProjectCollectionFragmentDoc,
|
||||
"\n query SettingsServerRegions {\n serverInfo {\n multiRegion {\n regions {\n id\n ...SettingsServerRegionsTable_ServerRegionItem\n }\n availableKeys\n }\n }\n }\n": types.SettingsServerRegionsDocument,
|
||||
"\n fragment SettingsUserEmails_User on User {\n id\n emails {\n ...SettingsUserEmailCards_UserEmail\n }\n }\n": types.SettingsUserEmails_UserFragmentDoc,
|
||||
"\n fragment SettingsWorkspacesBilling_Workspace on Workspace {\n ...BillingAlert_Workspace\n id\n role\n plan {\n name\n status\n createdAt\n paymentMethod\n }\n subscription {\n billingInterval\n currentBillingCycleEnd\n seats {\n guest\n plan\n }\n }\n team {\n items {\n id\n role\n }\n }\n }\n": types.SettingsWorkspacesBilling_WorkspaceFragmentDoc,
|
||||
"\n fragment SettingsWorkspacesGeneral_Workspace on Workspace {\n ...SettingsWorkspacesGeneralEditAvatar_Workspace\n ...SettingsWorkspaceGeneralDeleteDialog_Workspace\n ...SettingsWorkspacesGeneralEditSlugDialog_Workspace\n id\n name\n slug\n description\n logo\n role\n defaultProjectRole\n plan {\n status\n name\n }\n }\n": types.SettingsWorkspacesGeneral_WorkspaceFragmentDoc,
|
||||
"\n fragment SettingsWorkspacesMembers_Workspace on Workspace {\n id\n role\n team {\n items {\n id\n }\n }\n invitedTeam(filter: $invitesFilter) {\n user {\n id\n }\n }\n adminWorkspacesJoinRequests(filter: $joinRequestsFilter) {\n totalCount\n }\n }\n": types.SettingsWorkspacesMembers_WorkspaceFragmentDoc,
|
||||
@@ -425,14 +424,6 @@ export function graphql(source: "\n query AuthRegisterPanelWorkspaceInvite($tok
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n fragment ServerTermsOfServicePrivacyPolicyFragment on ServerInfo {\n termsOfService\n }\n"): (typeof documents)["\n fragment ServerTermsOfServicePrivacyPolicyFragment on ServerInfo {\n termsOfService\n }\n"];
|
||||
/**
|
||||
* 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 EmailVerificationBannerState {\n activeUser {\n id\n email\n verified\n hasPendingVerification\n }\n }\n"): (typeof documents)["\n query EmailVerificationBannerState {\n activeUser {\n id\n email\n verified\n hasPendingVerification\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n mutation RequestVerification {\n requestVerification\n }\n"): (typeof documents)["\n mutation RequestVerification {\n requestVerification\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
@@ -561,6 +552,18 @@ export function graphql(source: "\n fragment HeaderNavShare_Project on Project
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n fragment InviteDialogWorkspace_Workspace on Workspace {\n id\n domainBasedMembershipProtectionEnabled\n domains {\n domain\n id\n }\n plan {\n status\n name\n }\n subscription {\n seats {\n guest\n plan\n }\n }\n }\n"): (typeof documents)["\n fragment InviteDialogWorkspace_Workspace on Workspace {\n id\n domainBasedMembershipProtectionEnabled\n domains {\n domain\n id\n }\n plan {\n status\n name\n }\n subscription {\n seats {\n guest\n plan\n }\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n fragment InviteDialogProject_Project on Project {\n id\n name\n ...InviteDialogProjectWorkspaceMembers_Project\n workspace {\n id\n name\n defaultProjectRole\n role\n domainBasedMembershipProtectionEnabled\n domains {\n domain\n id\n }\n plan {\n status\n name\n }\n subscription {\n seats {\n guest\n plan\n }\n }\n }\n }\n"): (typeof documents)["\n fragment InviteDialogProject_Project on Project {\n id\n name\n ...InviteDialogProjectWorkspaceMembers_Project\n workspace {\n id\n name\n defaultProjectRole\n role\n domainBasedMembershipProtectionEnabled\n domains {\n domain\n id\n }\n plan {\n status\n name\n }\n subscription {\n seats {\n guest\n plan\n }\n }\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n fragment InviteDialogProjectWorkspaceMembersRow_WorkspaceCollaborator on WorkspaceCollaborator {\n role\n id\n user {\n id\n name\n bio\n company\n avatar\n verified\n role\n }\n }\n"): (typeof documents)["\n fragment InviteDialogProjectWorkspaceMembersRow_WorkspaceCollaborator on WorkspaceCollaborator {\n role\n id\n user {\n id\n name\n bio\n company\n avatar\n verified\n role\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n fragment InviteDialogProjectWorkspaceMembers_Project on Project {\n id\n ...ProjectPageTeamInternals_Project\n workspace {\n team {\n items {\n ...InviteDialogProjectWorkspaceMembersRow_WorkspaceCollaborator\n }\n }\n }\n }\n"): (typeof documents)["\n fragment InviteDialogProjectWorkspaceMembers_Project on Project {\n id\n ...ProjectPageTeamInternals_Project\n workspace {\n team {\n items {\n ...InviteDialogProjectWorkspaceMembersRow_WorkspaceCollaborator\n }\n }\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
@@ -597,10 +600,6 @@ export function graphql(source: "\n fragment ProjectModelPageVersionsCardVersio
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n fragment ProjectPageProjectHeader on Project {\n id\n role\n name\n description\n visibility\n allowPublicComments\n workspace {\n id\n slug\n name\n logo\n }\n }\n"): (typeof documents)["\n fragment ProjectPageProjectHeader on Project {\n id\n role\n name\n description\n visibility\n allowPublicComments\n workspace {\n id\n slug\n name\n logo\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n fragment ProjectPageInviteDialog_Project on Project {\n id\n workspaceId\n workspace {\n id\n defaultProjectRole\n team {\n items {\n role\n user {\n id\n name\n bio\n company\n avatar\n verified\n role\n }\n }\n }\n }\n ...ProjectPageTeamInternals_Project\n workspace {\n id\n domainBasedMembershipProtectionEnabled\n domains {\n domain\n id\n }\n }\n }\n"): (typeof documents)["\n fragment ProjectPageInviteDialog_Project on Project {\n id\n workspaceId\n workspace {\n id\n defaultProjectRole\n team {\n items {\n role\n user {\n id\n name\n bio\n company\n avatar\n verified\n role\n }\n }\n }\n }\n ...ProjectPageTeamInternals_Project\n workspace {\n id\n domainBasedMembershipProtectionEnabled\n domains {\n domain\n id\n }\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
@@ -680,7 +679,7 @@ export function graphql(source: "\n fragment ProjectPageModelsCardRenameDialog
|
||||
/**
|
||||
* 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 ProjectPageSettingsCollaborators($projectId: String!) {\n project(id: $projectId) {\n id\n ...ProjectPageTeamInternals_Project\n ...ProjectPageInviteDialog_Project\n }\n }\n"): (typeof documents)["\n query ProjectPageSettingsCollaborators($projectId: String!) {\n project(id: $projectId) {\n id\n ...ProjectPageTeamInternals_Project\n ...ProjectPageInviteDialog_Project\n }\n }\n"];
|
||||
export function graphql(source: "\n query ProjectPageSettingsCollaborators($projectId: String!) {\n project(id: $projectId) {\n id\n ...ProjectPageTeamInternals_Project\n ...InviteDialogProject_Project\n workspaceId\n }\n }\n"): (typeof documents)["\n query ProjectPageSettingsCollaborators($projectId: String!) {\n project(id: $projectId) {\n id\n ...ProjectPageTeamInternals_Project\n ...InviteDialogProject_Project\n workspaceId\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
@@ -729,10 +728,6 @@ export function graphql(source: "\n fragment ProjectsAddDialog_User on User {\n
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n fragment ProjectsDashboard_UserProjectCollection on UserProjectCollection {\n numberOfHidden\n }\n"): (typeof documents)["\n fragment ProjectsDashboard_UserProjectCollection on UserProjectCollection {\n numberOfHidden\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n subscription OnUserProjectsUpdate {\n userProjectsUpdated {\n type\n id\n project {\n ...ProjectDashboardItem\n }\n }\n }\n "): (typeof documents)["\n subscription OnUserProjectsUpdate {\n userProjectsUpdated {\n type\n id\n project {\n ...ProjectDashboardItem\n }\n }\n }\n "];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
@@ -805,10 +800,6 @@ export function graphql(source: "\n fragment SettingsSharedDeleteUserDialog_Wor
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n fragment SettingsSharedProjects_Project on Project {\n ...ProjectsDeleteDialog_Project\n id\n name\n visibility\n createdAt\n updatedAt\n models(limit: 0) {\n totalCount\n }\n versions(limit: 0) {\n totalCount\n }\n team {\n id\n user {\n name\n id\n avatar\n }\n }\n }\n"): (typeof documents)["\n fragment SettingsSharedProjects_Project on Project {\n ...ProjectsDeleteDialog_Project\n id\n name\n visibility\n createdAt\n updatedAt\n models(limit: 0) {\n totalCount\n }\n versions(limit: 0) {\n totalCount\n }\n team {\n id\n user {\n name\n id\n avatar\n }\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n fragment SettingsUserEmailCards_UserEmail on UserEmail {\n email\n id\n primary\n verified\n }\n"): (typeof documents)["\n fragment SettingsUserEmailCards_UserEmail on UserEmail {\n email\n id\n primary\n verified\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
@@ -916,7 +907,7 @@ export function graphql(source: "\n fragment MoveProjectsDialog_User on User {\
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n fragment WorkspaceProjectList_Workspace on Workspace {\n id\n ...WorkspaceBase_Workspace\n ...WorkspaceTeam_Workspace\n ...WorkspaceSecurity_Workspace\n ...BillingAlert_Workspace\n ...WorkspaceMixpanelUpdateGroup_Workspace\n ...MoveProjectsDialog_Workspace\n ...InviteDialogWorkspace_Workspace\n projects {\n ...WorkspaceProjectList_ProjectCollection\n }\n creationState {\n completed\n state\n }\n readOnly\n }\n"): (typeof documents)["\n fragment WorkspaceProjectList_Workspace on Workspace {\n id\n ...WorkspaceBase_Workspace\n ...WorkspaceTeam_Workspace\n ...WorkspaceSecurity_Workspace\n ...BillingAlert_Workspace\n ...WorkspaceMixpanelUpdateGroup_Workspace\n ...MoveProjectsDialog_Workspace\n ...InviteDialogWorkspace_Workspace\n projects {\n ...WorkspaceProjectList_ProjectCollection\n }\n creationState {\n completed\n state\n }\n readOnly\n }\n"];
|
||||
export function graphql(source: "\n fragment WorkspaceProjectList_Workspace on Workspace {\n id\n ...WorkspaceBase_Workspace\n ...WorkspaceTeam_Workspace\n ...WorkspaceSecurity_Workspace\n ...BillingAlert_Workspace\n ...MoveProjectsDialog_Workspace\n ...InviteDialogWorkspace_Workspace\n projects {\n ...WorkspaceProjectList_ProjectCollection\n }\n creationState {\n completed\n state\n }\n readOnly\n }\n"): (typeof documents)["\n fragment WorkspaceProjectList_Workspace on Workspace {\n id\n ...WorkspaceBase_Workspace\n ...WorkspaceTeam_Workspace\n ...WorkspaceSecurity_Workspace\n ...BillingAlert_Workspace\n ...MoveProjectsDialog_Workspace\n ...InviteDialogWorkspace_Workspace\n projects {\n ...WorkspaceProjectList_ProjectCollection\n }\n creationState {\n completed\n state\n }\n readOnly\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
@@ -968,7 +959,7 @@ export function graphql(source: "\n fragment WorkspaceWizardStepRegion_ServerIn
|
||||
/**
|
||||
* 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 ActiveUserMainMetadata {\n activeUser {\n id\n email\n company\n bio\n name\n role\n avatar\n isOnboardingFinished\n createdAt\n verified\n notificationPreferences\n versions(limit: 0) {\n totalCount\n }\n }\n }\n"): (typeof documents)["\n query ActiveUserMainMetadata {\n activeUser {\n id\n email\n company\n bio\n name\n role\n avatar\n isOnboardingFinished\n createdAt\n verified\n notificationPreferences\n versions(limit: 0) {\n totalCount\n }\n }\n }\n"];
|
||||
export function graphql(source: "\n query ActiveUserMainMetadata {\n activeUser {\n id\n email\n emails {\n id\n verified\n }\n company\n bio\n name\n role\n avatar\n isOnboardingFinished\n createdAt\n verified\n notificationPreferences\n versions(limit: 0) {\n totalCount\n }\n }\n }\n"): (typeof documents)["\n query ActiveUserMainMetadata {\n activeUser {\n id\n email\n emails {\n id\n verified\n }\n company\n bio\n name\n role\n avatar\n isOnboardingFinished\n createdAt\n verified\n notificationPreferences\n versions(limit: 0) {\n totalCount\n }\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
@@ -1173,6 +1164,10 @@ export function graphql(source: "\n mutation CreateNewRegion($input: CreateServ
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n mutation UpdateRegion($input: UpdateServerRegionInput!) {\n serverInfoMutations {\n multiRegion {\n update(input: $input) {\n id\n ...SettingsServerRegionsAddEditDialog_ServerRegionItem\n ...SettingsServerRegionsTable_ServerRegionItem\n }\n }\n }\n }\n"): (typeof documents)["\n mutation UpdateRegion($input: UpdateServerRegionInput!) {\n serverInfoMutations {\n multiRegion {\n update(input: $input) {\n id\n ...SettingsServerRegionsAddEditDialog_ServerRegionItem\n ...SettingsServerRegionsTable_ServerRegionItem\n }\n }\n }\n }\n"];
|
||||
/**
|
||||
* 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 PagesOnboardingDiscoverableWorkspaces_ActiveUser {\n activeUser {\n id\n ...PagesOnboarding_DiscoverableWorkspaces\n }\n }\n"): (typeof documents)["\n query PagesOnboardingDiscoverableWorkspaces_ActiveUser {\n activeUser {\n id\n ...PagesOnboarding_DiscoverableWorkspaces\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
@@ -1520,15 +1515,15 @@ export function graphql(source: "\n mutation SettingsUpdateWorkspace($input: Wo
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n mutation SettingsCreateUserEmail($input: CreateUserEmailInput!) {\n activeUserMutations {\n emailMutations {\n create(input: $input) {\n ...SettingsUserEmails_User\n }\n }\n }\n }\n"): (typeof documents)["\n mutation SettingsCreateUserEmail($input: CreateUserEmailInput!) {\n activeUserMutations {\n emailMutations {\n create(input: $input) {\n ...SettingsUserEmails_User\n }\n }\n }\n }\n"];
|
||||
export function graphql(source: "\n mutation SettingsCreateUserEmail($input: CreateUserEmailInput!) {\n activeUserMutations {\n emailMutations {\n create(input: $input) {\n id\n emails {\n ...EmailFields\n }\n }\n }\n }\n }\n"): (typeof documents)["\n mutation SettingsCreateUserEmail($input: CreateUserEmailInput!) {\n activeUserMutations {\n emailMutations {\n create(input: $input) {\n id\n emails {\n ...EmailFields\n }\n }\n }\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n mutation SettingsDeleteUserEmail($input: DeleteUserEmailInput!) {\n activeUserMutations {\n emailMutations {\n delete(input: $input) {\n ...SettingsUserEmails_User\n }\n }\n }\n }\n"): (typeof documents)["\n mutation SettingsDeleteUserEmail($input: DeleteUserEmailInput!) {\n activeUserMutations {\n emailMutations {\n delete(input: $input) {\n ...SettingsUserEmails_User\n }\n }\n }\n }\n"];
|
||||
export function graphql(source: "\n mutation SettingsDeleteUserEmail($input: DeleteUserEmailInput!) {\n activeUserMutations {\n emailMutations {\n delete(input: $input) {\n id\n emails {\n ...EmailFields\n }\n }\n }\n }\n }\n"): (typeof documents)["\n mutation SettingsDeleteUserEmail($input: DeleteUserEmailInput!) {\n activeUserMutations {\n emailMutations {\n delete(input: $input) {\n id\n emails {\n ...EmailFields\n }\n }\n }\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n mutation SettingsSetPrimaryUserEmail($input: SetPrimaryUserEmailInput!) {\n activeUserMutations {\n emailMutations {\n setPrimary(input: $input) {\n ...SettingsUserEmails_User\n }\n }\n }\n }\n"): (typeof documents)["\n mutation SettingsSetPrimaryUserEmail($input: SetPrimaryUserEmailInput!) {\n activeUserMutations {\n emailMutations {\n setPrimary(input: $input) {\n ...SettingsUserEmails_User\n }\n }\n }\n }\n"];
|
||||
export function graphql(source: "\n mutation SettingsSetPrimaryUserEmail($input: SetPrimaryUserEmailInput!) {\n activeUserMutations {\n emailMutations {\n setPrimary(input: $input) {\n id\n emails {\n ...EmailFields\n }\n }\n }\n }\n }\n"): (typeof documents)["\n mutation SettingsSetPrimaryUserEmail($input: SetPrimaryUserEmailInput!) {\n activeUserMutations {\n emailMutations {\n setPrimary(input: $input) {\n id\n emails {\n ...EmailFields\n }\n }\n }\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
@@ -1605,10 +1600,6 @@ export function graphql(source: "\n query SettingsWorkspacesJoinRequestsSearch(
|
||||
* 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 SettingsWorkspacesInvitesSearch(\n $slug: String!\n $invitesFilter: PendingWorkspaceCollaboratorsFilter\n ) {\n workspaceBySlug(slug: $slug) {\n ...SettingsWorkspacesMembersInvitesTable_Workspace\n }\n }\n"): (typeof documents)["\n query SettingsWorkspacesInvitesSearch(\n $slug: String!\n $invitesFilter: PendingWorkspaceCollaboratorsFilter\n ) {\n workspaceBySlug(slug: $slug) {\n ...SettingsWorkspacesMembersInvitesTable_Workspace\n }\n }\n"];
|
||||
/**
|
||||
* 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 SettingsUserEmailsQuery {\n activeUser {\n ...SettingsUserEmails_User\n }\n }\n"): (typeof documents)["\n query SettingsUserEmailsQuery {\n activeUser {\n ...SettingsUserEmails_User\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
@@ -1629,6 +1620,10 @@ export function graphql(source: "\n fragment LimitedUserAvatar on LimitedUser {
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n fragment ActiveUserAvatar on User {\n id\n name\n avatar\n }\n"): (typeof documents)["\n fragment ActiveUserAvatar on User {\n id\n name\n avatar\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n subscription OnUserProjectsUpdate {\n userProjectsUpdated {\n type\n id\n project {\n ...ProjectDashboardItem\n }\n }\n }\n "): (typeof documents)["\n subscription OnUserProjectsUpdate {\n userProjectsUpdated {\n type\n id\n project {\n ...ProjectDashboardItem\n }\n }\n }\n "];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
@@ -1641,6 +1636,18 @@ export function graphql(source: "\n mutation UpdateNotificationPreferences($inp
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n mutation DeleteAccount($input: UserDeleteInput!) {\n userDelete(userConfirmation: $input)\n }\n"): (typeof documents)["\n mutation DeleteAccount($input: UserDeleteInput!) {\n userDelete(userConfirmation: $input)\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n mutation verifyEmail($input: VerifyUserEmailInput!) {\n activeUserMutations {\n emailMutations {\n verify(input: $input)\n }\n }\n }\n"): (typeof documents)["\n mutation verifyEmail($input: VerifyUserEmailInput!) {\n activeUserMutations {\n emailMutations {\n verify(input: $input)\n }\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n fragment EmailFields on UserEmail {\n id\n email\n verified\n primary\n userId\n }\n"): (typeof documents)["\n fragment EmailFields on UserEmail {\n id\n email\n verified\n primary\n userId\n }\n"];
|
||||
/**
|
||||
* 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 UserEmails {\n activeUser {\n id\n emails {\n ...EmailFields\n }\n hasPendingVerification\n }\n }\n"): (typeof documents)["\n query UserEmails {\n activeUser {\n id\n emails {\n ...EmailFields\n }\n hasPendingVerification\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
@@ -1713,14 +1720,6 @@ export function graphql(source: "\n fragment LinkableComment on Comment {\n
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n fragment UseWorkspaceInviteManager_PendingWorkspaceCollaborator on PendingWorkspaceCollaborator {\n id\n token\n workspaceId\n workspaceSlug\n user {\n id\n }\n }\n"): (typeof documents)["\n fragment UseWorkspaceInviteManager_PendingWorkspaceCollaborator on PendingWorkspaceCollaborator {\n id\n token\n workspaceId\n workspaceSlug\n user {\n id\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n fragment WorkspaceMixpanelUpdateGroup_WorkspaceCollaborator on WorkspaceCollaborator {\n id\n role\n }\n"): (typeof documents)["\n fragment WorkspaceMixpanelUpdateGroup_WorkspaceCollaborator on WorkspaceCollaborator {\n id\n role\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n fragment WorkspaceMixpanelUpdateGroup_Workspace on Workspace {\n id\n name\n description\n domainBasedMembershipProtectionEnabled\n discoverabilityEnabled\n plan {\n status\n name\n createdAt\n }\n subscription {\n billingInterval\n currentBillingCycleEnd\n seats {\n guest\n plan\n }\n }\n team {\n totalCount\n items {\n ...WorkspaceMixpanelUpdateGroup_WorkspaceCollaborator\n }\n }\n defaultRegion {\n key\n }\n }\n"): (typeof documents)["\n fragment WorkspaceMixpanelUpdateGroup_Workspace on Workspace {\n id\n name\n description\n domainBasedMembershipProtectionEnabled\n discoverabilityEnabled\n plan {\n status\n name\n createdAt\n }\n subscription {\n billingInterval\n currentBillingCycleEnd\n seats {\n guest\n plan\n }\n }\n team {\n totalCount\n items {\n ...WorkspaceMixpanelUpdateGroup_WorkspaceCollaborator\n }\n }\n defaultRegion {\n key\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
@@ -1888,7 +1887,7 @@ export function graphql(source: "\n query AutomateFunctionPageWorkspace($worksp
|
||||
/**
|
||||
* 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 AutomateFunctionsPage($search: String, $cursor: String = null) {\n ...AutomateFunctionsPageItems_Query\n ...AutomateFunctionsPageHeader_Query\n }\n"): (typeof documents)["\n query AutomateFunctionsPage($search: String, $cursor: String = null) {\n ...AutomateFunctionsPageItems_Query\n ...AutomateFunctionsPageHeader_Query\n }\n"];
|
||||
export function graphql(source: "\n fragment PagesOnboarding_DiscoverableWorkspaces on User {\n discoverableWorkspaces {\n id\n name\n logo\n description\n slug\n }\n }\n"): (typeof documents)["\n fragment PagesOnboarding_DiscoverableWorkspaces on User {\n discoverableWorkspaces {\n id\n name\n logo\n description\n slug\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
@@ -1913,10 +1912,6 @@ export function graphql(source: "\n fragment SettingsServerProjects_ProjectColl
|
||||
* 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 SettingsServerRegions {\n serverInfo {\n multiRegion {\n regions {\n id\n ...SettingsServerRegionsTable_ServerRegionItem\n }\n availableKeys\n }\n }\n }\n"): (typeof documents)["\n query SettingsServerRegions {\n serverInfo {\n multiRegion {\n regions {\n id\n ...SettingsServerRegionsTable_ServerRegionItem\n }\n availableKeys\n }\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n fragment SettingsUserEmails_User on User {\n id\n emails {\n ...SettingsUserEmailCards_UserEmail\n }\n }\n"): (typeof documents)["\n fragment SettingsUserEmails_User on User {\n id\n emails {\n ...SettingsUserEmailCards_UserEmail\n }\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
@@ -13,6 +13,8 @@ export const registerRoute = '/authn/register'
|
||||
export const ssoLoginRoute = '/authn/sso'
|
||||
export const forgottenPasswordRoute = '/authn/forgotten-password'
|
||||
export const onboardingRoute = '/onboarding'
|
||||
export const verifyEmailRoute = '/verify-email'
|
||||
export const verifyEmailCountdownRoute = '/verify-email?source=registration'
|
||||
export const serverManagementRoute = '/server-management'
|
||||
export const downloadManagerUrl = 'https://speckle.systems/download'
|
||||
export const docsPageUrl = 'https://speckle.guide/'
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { InviteServerItem, InviteGenericItem } from '~~/lib/invites/helpers/types'
|
||||
import type {
|
||||
InviteServerItem,
|
||||
InviteGenericItem,
|
||||
InviteProjectItem
|
||||
} from '~~/lib/invites/helpers/types'
|
||||
import { Roles } from '@speckle/shared'
|
||||
|
||||
export const emptyInviteServerItem: InviteServerItem = {
|
||||
@@ -7,6 +11,13 @@ export const emptyInviteServerItem: InviteServerItem = {
|
||||
project: undefined
|
||||
}
|
||||
|
||||
export const emptyInviteProjectItem: InviteProjectItem = {
|
||||
email: '',
|
||||
serverRole: Roles.Server.User,
|
||||
projectRole: Roles.Stream.Contributor,
|
||||
project: undefined
|
||||
}
|
||||
|
||||
export const emptyInviteGenericItem: InviteGenericItem = {
|
||||
email: '',
|
||||
workspaceRole: undefined,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ServerRoles, WorkspaceRoles, StreamRoles } from '@speckle/shared'
|
||||
import type { FormSelectProjects_ProjectFragment } from '~~/lib/common/generated/gql/graphql'
|
||||
|
||||
// Server
|
||||
export type InviteServerItem = {
|
||||
email: string
|
||||
serverRole: ServerRoles
|
||||
@@ -11,6 +12,19 @@ export interface InviteServerForm {
|
||||
fields: InviteServerItem[]
|
||||
}
|
||||
|
||||
// Project
|
||||
export type InviteProjectItem = {
|
||||
email: string
|
||||
serverRole: ServerRoles
|
||||
projectRole?: StreamRoles
|
||||
project?: FormSelectProjects_ProjectFragment
|
||||
}
|
||||
|
||||
export interface InviteProjectForm {
|
||||
fields: InviteProjectItem[]
|
||||
}
|
||||
|
||||
// Workspace
|
||||
export type InviteGenericItem = {
|
||||
email: string
|
||||
workspaceRole?: WorkspaceRoles
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { isEmail } from '~/lib/common/helpers/validation'
|
||||
import type { GenericValidateFunction } from 'vee-validate'
|
||||
import { Roles, type WorkspaceRoles, type MaybeNullOrUndefined } from '@speckle/shared'
|
||||
import {
|
||||
Roles,
|
||||
type StreamRoles,
|
||||
type WorkspaceRoles,
|
||||
type ServerRoles,
|
||||
type MaybeNullOrUndefined
|
||||
} from '@speckle/shared'
|
||||
|
||||
export const isValidEmail = (val: string) =>
|
||||
isEmail(val || '', {
|
||||
@@ -23,16 +29,47 @@ export const canHaveRole =
|
||||
(params: {
|
||||
allowedDomains: MaybeNullOrUndefined<string[]>
|
||||
workspaceRole?: WorkspaceRoles
|
||||
projectRole?: StreamRoles
|
||||
}): GenericValidateFunction<string> =>
|
||||
(val) => {
|
||||
const { allowedDomains, workspaceRole } = params
|
||||
const { allowedDomains, workspaceRole, projectRole } = params
|
||||
if (!allowedDomains || !val) return true
|
||||
|
||||
if (
|
||||
!matchesDomainPolicy(val, allowedDomains) &&
|
||||
workspaceRole !== Roles.Workspace.Guest
|
||||
) {
|
||||
return 'This email does not match the set domain policy, and can only be invited as a guest'
|
||||
if (!matchesDomainPolicy(val, allowedDomains)) {
|
||||
if (workspaceRole && workspaceRole !== Roles.Workspace.Guest) {
|
||||
return 'This email does not match the set domain policy, and can only be invited as a guest'
|
||||
}
|
||||
if (projectRole && projectRole !== Roles.Stream.Reviewer) {
|
||||
return 'This email does not match the set domain policy, and can only be invited as a reviewer'
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export const isRequiredIfDependencyExists =
|
||||
(dependency: () => string) => (val?: string) =>
|
||||
!dependency() || !!val || 'This field is required'
|
||||
|
||||
export const canBeServerGuest =
|
||||
({
|
||||
workspaceRole,
|
||||
projectRole
|
||||
}: {
|
||||
workspaceRole?: WorkspaceRoles
|
||||
projectRole?: StreamRoles
|
||||
}) =>
|
||||
(val?: ServerRoles) => {
|
||||
if (val === Roles.Server.Guest) {
|
||||
if (projectRole === Roles.Stream.Owner) {
|
||||
return 'A guest user cannot be a stream owner'
|
||||
}
|
||||
if (workspaceRole === Roles.Workspace.Admin) {
|
||||
return 'A guest user cannot be a workspace admin'
|
||||
}
|
||||
if (workspaceRole === Roles.Workspace.Member) {
|
||||
return 'A guest user cannot be a workspace member'
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { graphql } from '~/lib/common/generated/gql'
|
||||
|
||||
export const PagesOnboardingDiscoverableWorkspaces = graphql(`
|
||||
query PagesOnboardingDiscoverableWorkspaces_ActiveUser {
|
||||
activeUser {
|
||||
id
|
||||
...PagesOnboarding_DiscoverableWorkspaces
|
||||
}
|
||||
}
|
||||
`)
|
||||
@@ -0,0 +1,4 @@
|
||||
export type OnboardingSelectOption = {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
@@ -304,10 +304,7 @@ export function useInviteUserToProject() {
|
||||
if (err && !hideToasts) {
|
||||
triggerNotification({
|
||||
type: ToastNotificationType.Danger,
|
||||
title:
|
||||
input.length > 1
|
||||
? "Couldn't send invites"
|
||||
: `Coudldn't send invite to ${input[0].email}`,
|
||||
title: input.length > 1 ? "Couldn't send invites" : "Couldn't send invite",
|
||||
description: err
|
||||
})
|
||||
} else {
|
||||
@@ -315,9 +312,7 @@ export function useInviteUserToProject() {
|
||||
triggerNotification({
|
||||
type: ToastNotificationType.Success,
|
||||
title:
|
||||
input.length > 1
|
||||
? 'Invites successfully send'
|
||||
: `Invite successfully sent to ${input[0].email}`
|
||||
input.length > 1 ? 'Invites successfully send' : 'Invite successfully sent'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,5 +22,6 @@ export function sanitizeModelName(name: string): string {
|
||||
return name
|
||||
.split('/')
|
||||
.map((part) => part.trim())
|
||||
.filter((part) => part.length > 0)
|
||||
.join('/')
|
||||
}
|
||||
|
||||
@@ -15,7 +15,10 @@ export const settingsCreateUserEmailMutation = graphql(`
|
||||
activeUserMutations {
|
||||
emailMutations {
|
||||
create(input: $input) {
|
||||
...SettingsUserEmails_User
|
||||
id
|
||||
emails {
|
||||
...EmailFields
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,7 +30,10 @@ export const settingsDeleteUserEmailMutation = graphql(`
|
||||
activeUserMutations {
|
||||
emailMutations {
|
||||
delete(input: $input) {
|
||||
...SettingsUserEmails_User
|
||||
id
|
||||
emails {
|
||||
...EmailFields
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -39,7 +45,10 @@ export const settingsSetPrimaryUserEmailMutation = graphql(`
|
||||
activeUserMutations {
|
||||
emailMutations {
|
||||
setPrimary(input: $input) {
|
||||
...SettingsUserEmails_User
|
||||
id
|
||||
emails {
|
||||
...EmailFields
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,14 +106,6 @@ export const settingsWorkspacesInvitesSearchQuery = graphql(`
|
||||
}
|
||||
`)
|
||||
|
||||
export const settingsUserEmailsQuery = graphql(`
|
||||
query SettingsUserEmailsQuery {
|
||||
activeUser {
|
||||
...SettingsUserEmails_User
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
export const settingsWorkspacesProjectsQuery = graphql(`
|
||||
query SettingsWorkspacesProjects(
|
||||
$slug: String!
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import { useApolloClient, useMutation, useQuery } from '@vue/apollo-composable'
|
||||
import {
|
||||
settingsNewEmailVerificationMutation,
|
||||
settingsDeleteUserEmailMutation,
|
||||
settingsCreateUserEmailMutation
|
||||
} from '~/lib/settings/graphql/mutations'
|
||||
import { userEmailsQuery } from '~/lib/user/graphql/queries'
|
||||
import {
|
||||
convertThrowIntoFetchResult,
|
||||
getFirstErrorMessage,
|
||||
getCacheId,
|
||||
modifyObjectField
|
||||
} from '~/lib/common/helpers/graphql'
|
||||
import type { UserEmail } from '~/lib/common/generated/gql/graphql'
|
||||
import { useGlobalToast } from '~/lib/common/composables/toast'
|
||||
import { useMixpanel } from '~/lib/core/composables/mp'
|
||||
import {
|
||||
verifyEmailRoute,
|
||||
homeRoute,
|
||||
settingsUserRoutes
|
||||
} from '~/lib/common/helpers/route'
|
||||
import { verifyEmailMutation } from '~/lib/user/graphql/mutations'
|
||||
|
||||
export function useUserEmails() {
|
||||
const { triggerNotification } = useGlobalToast()
|
||||
const mixpanel = useMixpanel()
|
||||
const { result } = useQuery(userEmailsQuery)
|
||||
const route = useRoute()
|
||||
const apollo = useApolloClient().client
|
||||
const { activeUser } = useActiveUser()
|
||||
|
||||
const { mutate: resendMutation } = useMutation(settingsNewEmailVerificationMutation)
|
||||
const { mutate: deleteMutation } = useMutation(settingsDeleteUserEmailMutation)
|
||||
const { mutate: createMutation } = useMutation(settingsCreateUserEmailMutation)
|
||||
const { mutate: verifyMutation } = useMutation(verifyEmailMutation)
|
||||
|
||||
// Simple array of all emails
|
||||
const emails = computed(() => result.value?.activeUser?.emails ?? ([] as UserEmail[]))
|
||||
|
||||
// Helper computed properties for common queries
|
||||
const unverifiedPrimaryEmail = computed(
|
||||
() => emails.value.find((e) => e.primary && !e.verified) || null
|
||||
)
|
||||
|
||||
const unverifiedEmails = computed(() => emails.value.filter((e) => !e.verified))
|
||||
|
||||
const addUserEmail = async (email: string) => {
|
||||
const result = await createMutation({
|
||||
input: { email }
|
||||
}).catch(convertThrowIntoFetchResult)
|
||||
|
||||
if (result?.data) {
|
||||
mixpanel.track('Email Added')
|
||||
navigateTo(verifyEmailRoute)
|
||||
return true
|
||||
}
|
||||
|
||||
const errorMessage = getFirstErrorMessage(result?.errors)
|
||||
triggerNotification({
|
||||
type: ToastNotificationType.Danger,
|
||||
title: 'Error adding email',
|
||||
description: errorMessage
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
const resendVerificationEmail = async (email: UserEmail) => {
|
||||
const result = await resendMutation({
|
||||
input: { id: email.id }
|
||||
}).catch(convertThrowIntoFetchResult)
|
||||
|
||||
if (result?.data) {
|
||||
triggerNotification({
|
||||
type: ToastNotificationType.Success,
|
||||
title: `Verification email sent to ${email.email}`
|
||||
})
|
||||
navigateTo(verifyEmailRoute)
|
||||
return true
|
||||
}
|
||||
|
||||
const errorMessage = getFirstErrorMessage(result?.errors)
|
||||
triggerNotification({
|
||||
type: ToastNotificationType.Danger,
|
||||
title: 'Error sending verification email',
|
||||
description: errorMessage
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
const deleteUserEmail = async (email: UserEmail, cancel = false) => {
|
||||
const result = await deleteMutation({
|
||||
input: { id: email.id }
|
||||
}).catch(convertThrowIntoFetchResult)
|
||||
|
||||
if (result?.data) {
|
||||
triggerNotification({
|
||||
type: ToastNotificationType.Success,
|
||||
title: `${cancel ? 'Cancelled adding email' : 'Deleted email'}`,
|
||||
description: email.email
|
||||
})
|
||||
mixpanel.track('Email Deleted')
|
||||
|
||||
// If we're on the verify email page and there are no more unverified emails, redirect home
|
||||
if (route.path === verifyEmailRoute && unverifiedEmails.value.length === 0) {
|
||||
navigateTo(homeRoute)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const errorMessage = getFirstErrorMessage(result?.errors)
|
||||
triggerNotification({
|
||||
type: ToastNotificationType.Danger,
|
||||
title: 'Error deleting email',
|
||||
description: errorMessage
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
const verifyUserEmail = async (email: UserEmail, code: string) => {
|
||||
mixpanel.track('Email Verification Started', {
|
||||
email: email.email,
|
||||
isPrimary: email.primary
|
||||
})
|
||||
|
||||
const result = await verifyMutation({
|
||||
input: { email: email.email, code }
|
||||
}).catch(convertThrowIntoFetchResult)
|
||||
|
||||
const activeUserId = computed(() => activeUser.value?.id)
|
||||
|
||||
if (result?.data?.activeUserMutations?.emailMutations?.verify) {
|
||||
if (!activeUserId.value) return
|
||||
|
||||
mixpanel.track('Email Verified', {
|
||||
email: email.email,
|
||||
isPrimary: email.primary
|
||||
})
|
||||
|
||||
// Update UserEmail verified status in cache
|
||||
modifyObjectField(
|
||||
apollo.cache,
|
||||
getCacheId('UserEmail', email.id),
|
||||
'verified',
|
||||
() => true
|
||||
)
|
||||
|
||||
// Only update User verified status if this is the primary email
|
||||
if (email.primary) {
|
||||
modifyObjectField(
|
||||
apollo.cache,
|
||||
getCacheId('User', activeUserId.value),
|
||||
'verified',
|
||||
() => true
|
||||
)
|
||||
navigateTo(homeRoute)
|
||||
} else {
|
||||
navigateTo(settingsUserRoutes.emails)
|
||||
}
|
||||
|
||||
triggerNotification({
|
||||
type: ToastNotificationType.Success,
|
||||
title: 'Email verified',
|
||||
description: 'Your email has been successfully verified'
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
mixpanel.track('Email Verification Failed', {
|
||||
email: email.email,
|
||||
isPrimary: email.primary
|
||||
})
|
||||
|
||||
triggerNotification({
|
||||
type: ToastNotificationType.Danger,
|
||||
title: 'Verification failed',
|
||||
description: 'The verification code you entered is incorrect or expired'
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
return {
|
||||
emails,
|
||||
unverifiedPrimaryEmail,
|
||||
unverifiedEmails,
|
||||
addUserEmail,
|
||||
resendVerificationEmail,
|
||||
deleteUserEmail,
|
||||
verifyUserEmail
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useApolloClient, useSubscription } from '@vue/apollo-composable'
|
||||
import { graphql } from '~/lib/common/generated/gql'
|
||||
import { UserProjectsUpdatedMessageType } from '~/lib/common/generated/gql/graphql'
|
||||
import { getCacheId, modifyObjectField } from '~/lib/common/helpers/graphql'
|
||||
import { ToastNotificationType, useGlobalToast } from '~/lib/common/composables/toast'
|
||||
import { projectRoute } from '~/lib/common/helpers/route'
|
||||
|
||||
export function useUserProjectsUpdatedTracking() {
|
||||
const apollo = useApolloClient().client
|
||||
const { triggerNotification } = useGlobalToast()
|
||||
const { activeUser } = useActiveUser()
|
||||
|
||||
const { onResult: onUserProjectsUpdate } = useSubscription(
|
||||
graphql(`
|
||||
subscription OnUserProjectsUpdate {
|
||||
userProjectsUpdated {
|
||||
type
|
||||
id
|
||||
project {
|
||||
...ProjectDashboardItem
|
||||
}
|
||||
}
|
||||
}
|
||||
`)
|
||||
)
|
||||
|
||||
onUserProjectsUpdate((res) => {
|
||||
const activeUserId = activeUser.value?.id
|
||||
const event = res.data?.userProjectsUpdated
|
||||
|
||||
if (!event) return
|
||||
if (!activeUserId) return
|
||||
|
||||
const isNewProject = event.type === UserProjectsUpdatedMessageType.Added
|
||||
const incomingProject = event.project
|
||||
const cache = apollo.cache
|
||||
|
||||
if (isNewProject && incomingProject) {
|
||||
// Add to User.projects where possible
|
||||
modifyObjectField(
|
||||
cache,
|
||||
getCacheId('User', activeUserId),
|
||||
'projects',
|
||||
({ helpers: { ref, createUpdatedValue } }) =>
|
||||
createUpdatedValue(({ update }) => {
|
||||
update('items', (items) => [
|
||||
ref('Project', incomingProject.id),
|
||||
...(items || [])
|
||||
])
|
||||
update('totalCount', (count) => count + 1)
|
||||
}),
|
||||
{ autoEvictFiltered: true }
|
||||
)
|
||||
}
|
||||
|
||||
if (!isNewProject) {
|
||||
// Evict old project from cache entirely to remove it from all searches
|
||||
cache.evict({
|
||||
id: getCacheId('Project', event.id)
|
||||
})
|
||||
}
|
||||
|
||||
// Emit toast notification
|
||||
triggerNotification({
|
||||
type: ToastNotificationType.Info,
|
||||
title: isNewProject ? 'New project added' : 'A project has been removed',
|
||||
cta:
|
||||
isNewProject && incomingProject
|
||||
? {
|
||||
url: projectRoute(incomingProject.id),
|
||||
title: 'View project'
|
||||
}
|
||||
: undefined
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -25,3 +25,13 @@ export const deleteAccountMutation = graphql(`
|
||||
userDelete(userConfirmation: $input)
|
||||
}
|
||||
`)
|
||||
|
||||
export const verifyEmailMutation = graphql(`
|
||||
mutation verifyEmail($input: VerifyUserEmailInput!) {
|
||||
activeUserMutations {
|
||||
emailMutations {
|
||||
verify(input: $input)
|
||||
}
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { graphql } from '~/lib/common/generated/gql'
|
||||
|
||||
export const emailFieldsFragment = graphql(`
|
||||
fragment EmailFields on UserEmail {
|
||||
id
|
||||
email
|
||||
verified
|
||||
primary
|
||||
userId
|
||||
}
|
||||
`)
|
||||
|
||||
export const userEmailsQuery = graphql(`
|
||||
query UserEmails {
|
||||
activeUser {
|
||||
id
|
||||
emails {
|
||||
...EmailFields
|
||||
}
|
||||
hasPendingVerification
|
||||
}
|
||||
}
|
||||
`)
|
||||
@@ -277,6 +277,7 @@ export type InjectableViewerState = Readonly<{
|
||||
lightConfig: Ref<SunLightConfiguration>
|
||||
explodeFactor: Ref<number>
|
||||
viewerBusy: WritableComputedRef<boolean>
|
||||
loadProgress: Ref<number>
|
||||
selection: Ref<Nullable<Vector3>>
|
||||
measurement: {
|
||||
enabled: Ref<boolean>
|
||||
@@ -919,6 +920,8 @@ function setupInterfaceState(
|
||||
set: (newVal) => (isViewerBusy.value = !!newVal)
|
||||
})
|
||||
|
||||
const loadProgress = ref(0)
|
||||
|
||||
const isolatedObjectIds = ref([] as string[])
|
||||
const hiddenObjectIds = ref([] as string[])
|
||||
const selectedObjects = shallowRef<Raw<SpeckleObject>[]>([])
|
||||
@@ -978,6 +981,7 @@ function setupInterfaceState(
|
||||
explodeFactor,
|
||||
spotlightUserSessionId,
|
||||
viewerBusy,
|
||||
loadProgress,
|
||||
threads: {
|
||||
items: commentThreads,
|
||||
openThread: {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { difference, flatten, isEqual, uniq } from 'lodash-es'
|
||||
import { useThrottleFn, onKeyStroke, watchTriggerable } from '@vueuse/core'
|
||||
import {
|
||||
LoaderEvent,
|
||||
ViewMode,
|
||||
type PropertyInfo,
|
||||
type StringPropertyInfo,
|
||||
@@ -14,7 +16,8 @@ import {
|
||||
SectionToolEvent,
|
||||
SectionTool,
|
||||
ViewModes,
|
||||
ViewModeEvent
|
||||
ViewModeEvent,
|
||||
SpeckleLoader
|
||||
} from '@speckle/viewer'
|
||||
import { useAuthCookie } from '~~/lib/auth/composables/auth'
|
||||
import type {
|
||||
@@ -55,7 +58,6 @@ import {
|
||||
useCameraUtilities,
|
||||
useMeasurementUtilities
|
||||
} from '~~/lib/viewer/composables/ui'
|
||||
import { onKeyStroke, watchTriggerable } from '@vueuse/core'
|
||||
import { setupDebugMode } from '~~/lib/viewer/composables/setup/dev'
|
||||
import { useEmbed } from '~/lib/viewer/composables/setup/embed'
|
||||
import { useMixpanel } from '~~/lib/core/composables/mp'
|
||||
@@ -95,24 +97,55 @@ function useViewerObjectAutoLoading() {
|
||||
resources: {
|
||||
response: { resourceItems }
|
||||
},
|
||||
ui: { loadProgress },
|
||||
urlHashState: { focusedThreadId }
|
||||
} = useInjectedViewerState()
|
||||
|
||||
const loadingProgressMap: { [id: string]: number } = {}
|
||||
|
||||
viewer.on(ViewerEvent.LoadComplete, (id) => {
|
||||
delete loadingProgressMap[id]
|
||||
consolidateProgressInternal({ id, progress: 1 })
|
||||
})
|
||||
|
||||
const consolidateProgressInternal = (args: { progress: number; id: string }) => {
|
||||
loadingProgressMap[args.id] = args.progress
|
||||
let min = 42
|
||||
const values = Object.values(loadingProgressMap) as number[]
|
||||
for (const num of values) {
|
||||
min = Math.min(min, num)
|
||||
}
|
||||
|
||||
loadProgress.value = min
|
||||
}
|
||||
|
||||
const consolidateProgressThorttled = useThrottleFn(consolidateProgressInternal, 250)
|
||||
|
||||
const loadObject = (
|
||||
objectId: string,
|
||||
unload?: boolean,
|
||||
options?: Partial<{ zoomToObject: boolean }>
|
||||
) => {
|
||||
const objectUrl = getObjectUrl(projectId.value, objectId)
|
||||
|
||||
if (unload) {
|
||||
viewer.unloadObject(objectUrl)
|
||||
} else {
|
||||
viewer.loadObjectAsync(
|
||||
const loader = new SpeckleLoader(
|
||||
viewer.getWorldTree(),
|
||||
objectUrl,
|
||||
authToken.value || undefined,
|
||||
disableViewerCache ? false : undefined,
|
||||
options?.zoomToObject
|
||||
undefined
|
||||
)
|
||||
|
||||
loader.on(LoaderEvent.LoadProgress, (args) => consolidateProgressThorttled(args))
|
||||
loader.on(LoaderEvent.LoadCancelled, (id) => {
|
||||
delete loadingProgressMap[id]
|
||||
consolidateProgressInternal({ id, progress: 1 })
|
||||
})
|
||||
|
||||
viewer.loadObject(loader, options?.zoomToObject)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import { useConditionalViewerRendering } from '~/lib/viewer/composables/ui'
|
||||
|
||||
export const useTourStageState = () =>
|
||||
useState('viewer-tour-state', () => ({
|
||||
showNavbar: true,
|
||||
showViewerControls: true,
|
||||
showTour: false,
|
||||
showSegmentation: true
|
||||
}))
|
||||
|
||||
export function useViewerTour() {
|
||||
const state = useTourStageState()
|
||||
const conditionalRendering = useConditionalViewerRendering()
|
||||
|
||||
const showNavbar = computed({
|
||||
get: () => conditionalRendering.showNavbar.value,
|
||||
set: (newVal) => (state.value.showNavbar = newVal)
|
||||
})
|
||||
|
||||
const showControls = computed({
|
||||
get: () => conditionalRendering.showControls.value,
|
||||
set: (newVal) => (state.value.showViewerControls = newVal)
|
||||
})
|
||||
|
||||
const showTour = computed({
|
||||
get: () => state.value.showTour,
|
||||
set: (newVal) => (state.value.showTour = newVal)
|
||||
})
|
||||
|
||||
const showSegmentation = computed({
|
||||
get: () => state.value.showSegmentation,
|
||||
set: (newVal) => (state.value.showSegmentation = newVal)
|
||||
})
|
||||
|
||||
return {
|
||||
showNavbar,
|
||||
showControls,
|
||||
showTour,
|
||||
showSegmentation
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
type InjectableViewerState
|
||||
} from '~~/lib/viewer/composables/setup'
|
||||
import { useDiffBuilderUtilities } from '~~/lib/viewer/composables/setup/diff'
|
||||
import { useTourStageState } from '~~/lib/viewer/composables/tour'
|
||||
import { Vector3, Box3 } from 'three'
|
||||
import { getKeyboardShortcutTitle, onKeyboardShortcut } from '@speckle/ui-components'
|
||||
import { ViewerShortcuts } from '~/lib/viewer/helpers/shortcuts/shortcuts'
|
||||
@@ -435,11 +434,9 @@ export function useMeasurementUtilities() {
|
||||
* Some conditional rendering values depend on multiple & overlapping states. This utility reconciles that.
|
||||
*/
|
||||
export function useConditionalViewerRendering() {
|
||||
const tourState = useTourStageState()
|
||||
const embedMode = useEmbedState()
|
||||
|
||||
const showControls = computed(() => {
|
||||
if (tourState.value.showTour && !tourState.value.showViewerControls) return false
|
||||
if (
|
||||
embedMode.embedOptions.value?.isEnabled &&
|
||||
embedMode.embedOptions.value.hideControls
|
||||
@@ -452,7 +449,6 @@ export function useConditionalViewerRendering() {
|
||||
|
||||
const showNavbar = computed(() => {
|
||||
if (!showControls.value) return false
|
||||
if (tourState.value.showTour && !tourState.value.showNavbar) return false
|
||||
if (embedMode.embedOptions.value?.isEnabled) return false
|
||||
return true
|
||||
})
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
import { useMixpanel } from '~/lib/core/composables/mp'
|
||||
import { graphql } from '~~/lib/common/generated/gql'
|
||||
import {
|
||||
type WorkspaceMixpanelUpdateGroup_WorkspaceFragment,
|
||||
type WorkspaceMixpanelUpdateGroup_WorkspaceCollaboratorFragment,
|
||||
type PaidWorkspacePlans,
|
||||
BillingInterval
|
||||
} from '~/lib/common/generated/gql/graphql'
|
||||
import { type MaybeNullOrUndefined, Roles, type WorkspaceRoles } from '@speckle/shared'
|
||||
import { resolveMixpanelServerId } from '@speckle/shared'
|
||||
import { WorkspacePlanStatuses } from '~/lib/common/generated/gql/graphql'
|
||||
import { isPaidPlan } from '@/lib/billing/helpers/types'
|
||||
import { pricingPlansConfig } from '~/lib/billing/helpers/constants'
|
||||
|
||||
graphql(`
|
||||
fragment WorkspaceMixpanelUpdateGroup_WorkspaceCollaborator on WorkspaceCollaborator {
|
||||
id
|
||||
role
|
||||
}
|
||||
`)
|
||||
|
||||
graphql(`
|
||||
fragment WorkspaceMixpanelUpdateGroup_Workspace on Workspace {
|
||||
id
|
||||
name
|
||||
description
|
||||
domainBasedMembershipProtectionEnabled
|
||||
discoverabilityEnabled
|
||||
plan {
|
||||
status
|
||||
name
|
||||
createdAt
|
||||
}
|
||||
subscription {
|
||||
billingInterval
|
||||
currentBillingCycleEnd
|
||||
seats {
|
||||
guest
|
||||
plan
|
||||
}
|
||||
}
|
||||
team {
|
||||
totalCount
|
||||
items {
|
||||
...WorkspaceMixpanelUpdateGroup_WorkspaceCollaborator
|
||||
}
|
||||
}
|
||||
defaultRegion {
|
||||
key
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
export const useWorkspacesMixpanel = () => {
|
||||
const mixpanel = useMixpanel()
|
||||
const isBillingIntegrationEnabled = useIsBillingIntegrationEnabled()
|
||||
|
||||
const workspaceMixpanelUpdateGroup = (
|
||||
workspace: WorkspaceMixpanelUpdateGroup_WorkspaceFragment,
|
||||
userEmail: MaybeNullOrUndefined<string>
|
||||
) => {
|
||||
if (!workspace.id || !import.meta.client) return
|
||||
|
||||
const getEstimatedBill = () => {
|
||||
if (
|
||||
!isBillingIntegrationEnabled.value ||
|
||||
!isPaidPlan(workspace.plan?.name) ||
|
||||
workspace.plan?.status !== WorkspacePlanStatuses.Valid ||
|
||||
!workspace.subscription?.billingInterval
|
||||
)
|
||||
return null
|
||||
|
||||
const planConfig =
|
||||
pricingPlansConfig.plans[workspace.plan.name as unknown as PaidWorkspacePlans]
|
||||
const cost = planConfig.cost[workspace.subscription.billingInterval]
|
||||
const memberPrice = cost[Roles.Workspace.Member]
|
||||
const guestPrice = cost[Roles.Workspace.Guest]
|
||||
const memberCount = workspace.subscription?.seats?.plan || 0
|
||||
const guestCount = workspace.subscription?.seats?.guest || 0
|
||||
const totalPrice = memberPrice * memberCount + guestPrice * guestCount
|
||||
return workspace.subscription.billingInterval === BillingInterval.Yearly
|
||||
? totalPrice * 12
|
||||
: totalPrice
|
||||
}
|
||||
|
||||
const roleCount = {
|
||||
[Roles.Workspace.Admin]: 0,
|
||||
[Roles.Workspace.Member]: 0,
|
||||
[Roles.Workspace.Guest]: 0
|
||||
}
|
||||
|
||||
workspace.team.items.forEach(
|
||||
(item: WorkspaceMixpanelUpdateGroup_WorkspaceCollaboratorFragment) => {
|
||||
roleCount[item.role as WorkspaceRoles] =
|
||||
(roleCount[item.role as WorkspaceRoles] ?? 0) + 1
|
||||
}
|
||||
)
|
||||
|
||||
const input = {
|
||||
name: workspace.name,
|
||||
description: workspace.description,
|
||||
domainBasedMembershipProtectionEnabled:
|
||||
workspace.domainBasedMembershipProtectionEnabled,
|
||||
discoverabilityEnabled: workspace.discoverabilityEnabled,
|
||||
teamTotalCount: workspace.team.totalCount,
|
||||
teamAdminCount: roleCount[Roles.Workspace.Admin],
|
||||
teamMemberCount: roleCount[Roles.Workspace.Member],
|
||||
teamGuestCount: roleCount[Roles.Workspace.Guest],
|
||||
defaultRegionKey: workspace.defaultRegion?.key,
|
||||
planName: workspace.plan?.name || '',
|
||||
planStatus: workspace.plan?.status || '',
|
||||
planCreatedAt: workspace.plan?.createdAt,
|
||||
subscriptionBillingInterval: workspace.subscription?.billingInterval,
|
||||
subscriptionCurrentBillingCycleEnd:
|
||||
workspace.subscription?.currentBillingCycleEnd,
|
||||
seats: workspace.subscription?.seats?.plan || 0,
|
||||
seatsGuest: workspace.subscription?.seats?.guest || 0,
|
||||
estimatedBill: getEstimatedBill(),
|
||||
// eslint-disable-next-line camelcase
|
||||
server_id: resolveMixpanelServerId(window.location.hostname)
|
||||
}
|
||||
|
||||
mixpanel.get_group('workspace_id', workspace.id).set(input)
|
||||
|
||||
if (userEmail?.includes('speckle.systems')) {
|
||||
mixpanel.get_group('workspace_id', workspace.id).set_once({
|
||||
hasSpeckleMembers: true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
workspaceMixpanelUpdateGroup
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { homeRoute, verifyEmailRoute } from '~/lib/common/helpers/route'
|
||||
import { activeUserQuery } from '~~/lib/auth/composables/activeUser'
|
||||
import { useApolloClientFromNuxt } from '~~/lib/common/composables/graphql'
|
||||
import { convertThrowIntoFetchResult } from '~~/lib/common/helpers/graphql'
|
||||
|
||||
/**
|
||||
* Redirect user to /verify-email, if they haven't done it yet
|
||||
*/
|
||||
export default defineNuxtRouteMiddleware(async (to) => {
|
||||
const client = useApolloClientFromNuxt()
|
||||
const { data } = await client
|
||||
.query({
|
||||
query: activeUserQuery
|
||||
})
|
||||
.catch(convertThrowIntoFetchResult)
|
||||
|
||||
if (!data?.activeUser?.id) return
|
||||
|
||||
const isAuthPage = to.path.startsWith('/authn/')
|
||||
const isVerifyEmailPage = to.path === verifyEmailRoute
|
||||
|
||||
if (isAuthPage) return
|
||||
|
||||
const hasUnverifiedEmails = data.activeUser.emails.some((email) => !email.verified)
|
||||
|
||||
if (hasUnverifiedEmails) {
|
||||
// Redirect to verify email if not already there
|
||||
if (!isVerifyEmailPage) {
|
||||
return navigateTo(verifyEmailRoute)
|
||||
}
|
||||
} else {
|
||||
if (isVerifyEmailPage) {
|
||||
return navigateTo(homeRoute)
|
||||
}
|
||||
}
|
||||
})
|
||||
+3
@@ -17,6 +17,9 @@ export default defineNuxtRouteMiddleware(async (to) => {
|
||||
// Ignore if not logged in
|
||||
if (!data?.activeUser?.id) return
|
||||
|
||||
// Ignore if user has not verified their email yet
|
||||
if (!data?.activeUser?.verified) return
|
||||
|
||||
const isOnboardingFinished = data?.activeUser?.isOnboardingFinished
|
||||
const isGoingToOnboarding = to.path === onboardingRoute
|
||||
const shouldRedirectToOnboarding =
|
||||
@@ -158,6 +158,12 @@ export default defineNuxtConfig({
|
||||
'Access-Control-Expose-Headers': '*'
|
||||
}
|
||||
},
|
||||
'/functions': {
|
||||
redirect: {
|
||||
to: '/',
|
||||
statusCode: 307
|
||||
}
|
||||
},
|
||||
// Redirect old settings pages
|
||||
'/server-management/projects': {
|
||||
redirect: {
|
||||
@@ -207,7 +213,7 @@ export default defineNuxtConfig({
|
||||
'/settings/server/*': {
|
||||
appMiddleware: ['auth', 'settings', 'admin']
|
||||
},
|
||||
'/settings/workspaces/*': {
|
||||
'/settings/workspaces/:slug/*': {
|
||||
appMiddleware: [
|
||||
'auth',
|
||||
'settings',
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<template>
|
||||
<NuxtPage />
|
||||
<div>
|
||||
<NuxtPage />
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { loginRoute } from '~~/lib/common/helpers/route'
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
</DisclosureButton>
|
||||
|
||||
<DisclosurePanel
|
||||
class="flex flex-col px-2 py-5 space-y-5 label-light border-b border-outline-3 label-light"
|
||||
class="flex flex-col px-2 py-5 space-y-5 label-light border-b border-outline-3"
|
||||
>
|
||||
<table v-if="app.author || app.description?.length" class="table-fixed">
|
||||
<tbody>
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
:disabled="resendVerificationEmailLoading"
|
||||
@click="onResend"
|
||||
>
|
||||
Resend verification
|
||||
Verify email
|
||||
</FormButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<Portal to="navigation">
|
||||
<HeaderNavLink
|
||||
:to="publicAutomateFunctionsRoute"
|
||||
name="Functions"
|
||||
:separator="false"
|
||||
/>
|
||||
</Portal>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<IconBolt class="h-5 w-5" />
|
||||
<h1 class="text-heading-lg">Functions</h1>
|
||||
</div>
|
||||
<AutomateFunctionsPageHeader
|
||||
v-model:search="search"
|
||||
:active-user="result?.activeUser"
|
||||
:server-info="result?.serverInfo"
|
||||
class="mb-6"
|
||||
/>
|
||||
</div>
|
||||
<AutomateFunctionsPageItems
|
||||
:functions="finalResult"
|
||||
:search="!!search"
|
||||
:loading="paginationLoading"
|
||||
@create-automation-from="openCreateNewAutomation"
|
||||
@clear-search="search = ''"
|
||||
/>
|
||||
<CommonLoadingBar :loading="pageQueryLoading" client-only class="mb-2" />
|
||||
<InfiniteLoading :settings="{ identifier }" @infinite="onInfiniteLoad" />
|
||||
<AutomateAutomationCreateDialog
|
||||
v-model:open="showNewAutomationDialog"
|
||||
:preselected-function="newAutomationTargetFn"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { CommonLoadingBar } from '@speckle/ui-components'
|
||||
import { useQuery } from '@vue/apollo-composable'
|
||||
import { automateFunctionsPagePaginationQuery } from '~/lib/automate/graphql/queries'
|
||||
import type { CreateAutomationSelectableFunction } from '~/lib/automate/helpers/automations'
|
||||
import {
|
||||
usePageQueryStandardFetchPolicy,
|
||||
usePaginatedQuery
|
||||
} from '~/lib/common/composables/graphql'
|
||||
import { graphql } from '~/lib/common/generated/gql'
|
||||
import { publicAutomateFunctionsRoute } from '~/lib/common/helpers/route'
|
||||
|
||||
definePageMeta({
|
||||
middleware: ['auth', 'requires-automate-enabled']
|
||||
})
|
||||
|
||||
const pageQuery = graphql(`
|
||||
query AutomateFunctionsPage($search: String, $cursor: String = null) {
|
||||
...AutomateFunctionsPageItems_Query
|
||||
...AutomateFunctionsPageHeader_Query
|
||||
}
|
||||
`)
|
||||
|
||||
const search = ref('')
|
||||
const pageFetchPolicy = usePageQueryStandardFetchPolicy()
|
||||
const { result, loading: pageQueryLoading } = useQuery(
|
||||
pageQuery,
|
||||
() => ({
|
||||
search: search.value?.length ? search.value : null
|
||||
}),
|
||||
() => ({
|
||||
fetchPolicy: pageFetchPolicy.value
|
||||
})
|
||||
)
|
||||
|
||||
const {
|
||||
identifier,
|
||||
onInfiniteLoad,
|
||||
query: { result: paginatedResult, loading: paginationLoading }
|
||||
} = usePaginatedQuery({
|
||||
query: automateFunctionsPagePaginationQuery,
|
||||
baseVariables: computed(() => ({
|
||||
search: search.value?.length ? search.value : null
|
||||
})),
|
||||
resolveKey: (vars) => [vars.search || ''],
|
||||
resolveCurrentResult: (res) => res?.automateFunctions,
|
||||
resolveInitialResult: () => result.value?.automateFunctions,
|
||||
resolveNextPageVariables: (baseVars, cursor) => ({
|
||||
...baseVars,
|
||||
cursor
|
||||
}),
|
||||
resolveCursorFromVariables: (vars) => vars.cursor,
|
||||
options: () => ({
|
||||
fetchPolicy: pageFetchPolicy.value
|
||||
})
|
||||
})
|
||||
|
||||
const showNewAutomationDialog = ref(false)
|
||||
const newAutomationTargetFn = ref<CreateAutomationSelectableFunction>()
|
||||
|
||||
const finalResult = computed(
|
||||
() =>
|
||||
paginatedResult.value?.automateFunctions.items ||
|
||||
result.value?.automateFunctions.items
|
||||
)
|
||||
|
||||
const openCreateNewAutomation = (fn: CreateAutomationSelectableFunction) => {
|
||||
newAutomationTargetFn.value = fn
|
||||
showNewAutomationDialog.value = true
|
||||
}
|
||||
|
||||
useSeoMeta({
|
||||
title: 'All functions',
|
||||
description: 'Select a function get started with Speckle Automate'
|
||||
})
|
||||
</script>
|
||||
@@ -1,192 +1,12 @@
|
||||
<template>
|
||||
<div>
|
||||
<Portal to="navigation">
|
||||
<HeaderNavLink :to="homeRoute" name="Dashboard" hide-chevron :separator="false" />
|
||||
</Portal>
|
||||
<PromoBannersWrapper
|
||||
v-if="promoBanners && promoBanners.length"
|
||||
:banners="promoBanners"
|
||||
/>
|
||||
<ProjectsDashboardHeader
|
||||
:projects-invites="projectsResult?.activeUser || undefined"
|
||||
:workspaces-invites="workspacesResult?.activeUser || undefined"
|
||||
/>
|
||||
<div class="flex flex-col gap-y-12">
|
||||
<div class="flex flex-col-reverse lg:flex-col gap-y-12">
|
||||
<section>
|
||||
<h2 class="text-heading-sm text-foreground-2">Quickstart</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 pt-5">
|
||||
<CommonCard
|
||||
v-for="quickStartItem in quickStartItems"
|
||||
:key="quickStartItem.title"
|
||||
:title="quickStartItem.title"
|
||||
:description="quickStartItem.description"
|
||||
:buttons="quickStartItem.buttons"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-heading-sm text-foreground-2">Recently updated projects</h2>
|
||||
<FormButton
|
||||
color="outline"
|
||||
size="sm"
|
||||
@click.stop="router.push(projectsRoute)"
|
||||
>
|
||||
View all
|
||||
</FormButton>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-3 pt-5">
|
||||
<template v-if="hasProjects">
|
||||
<DashboardProjectCard
|
||||
v-for="project in projects"
|
||||
:key="project.id"
|
||||
:project="project"
|
||||
/>
|
||||
</template>
|
||||
<CommonCard
|
||||
v-else
|
||||
title="Create your first project"
|
||||
description="Projects are the place where your models and their versions live."
|
||||
:buttons="createProjectButton"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<section>
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-heading-sm text-foreground-2">Tutorials</h2>
|
||||
<FormButton
|
||||
color="outline"
|
||||
size="sm"
|
||||
to="https://www.speckle.systems/tutorials"
|
||||
external
|
||||
target="_blank"
|
||||
>
|
||||
View all
|
||||
</FormButton>
|
||||
</div>
|
||||
<div
|
||||
class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 pt-5"
|
||||
>
|
||||
<DashboardTutorialCard
|
||||
v-for="tutorialItem in tutorialItems"
|
||||
:key="tutorialItem.title"
|
||||
:tutorial-item="tutorialItem"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<ProjectsAddDialog v-model:open="openNewProject" />
|
||||
</div>
|
||||
<DashboardPage />
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
dashboardProjectsPageQuery,
|
||||
dashboardProjectsPageWorkspacesQuery
|
||||
} from '~~/lib/dashboard/graphql/queries'
|
||||
import type { QuickStartItem } from '~~/lib/dashboard/helpers/types'
|
||||
import { useQuery } from '@vue/apollo-composable'
|
||||
import { useMixpanel } from '~~/lib/core/composables/mp'
|
||||
import {
|
||||
docsPageUrl,
|
||||
forumPageUrl,
|
||||
homeRoute,
|
||||
projectsRoute
|
||||
} from '~~/lib/common/helpers/route'
|
||||
import type { ManagerExtension } from '~~/lib/common/utils/downloadManager'
|
||||
import { downloadManager } from '~~/lib/common/utils/downloadManager'
|
||||
import { ToastNotificationType, useGlobalToast } from '~~/lib/common/composables/toast'
|
||||
import type { LayoutDialogButton } from '@speckle/ui-components'
|
||||
import type { PromoBanner } from '~/lib/promo-banners/types'
|
||||
import { tutorials } from '~/lib/dashboard/helpers/tutorials'
|
||||
|
||||
<script setup lang="ts">
|
||||
useHead({ title: 'Dashboard' })
|
||||
|
||||
definePageMeta({
|
||||
middleware: ['auth'],
|
||||
alias: ['/profile', '/dashboard']
|
||||
})
|
||||
|
||||
const mixpanel = useMixpanel()
|
||||
const isWorkspacesEnabled = useIsWorkspacesEnabled()
|
||||
const { result: projectsResult } = useQuery(dashboardProjectsPageQuery)
|
||||
const { result: workspacesResult } = useQuery(
|
||||
dashboardProjectsPageWorkspacesQuery,
|
||||
undefined,
|
||||
() => ({
|
||||
enabled: isWorkspacesEnabled.value
|
||||
})
|
||||
)
|
||||
const { triggerNotification } = useGlobalToast()
|
||||
const { isGuest } = useActiveUser()
|
||||
const router = useRouter()
|
||||
|
||||
const openNewProject = ref(false)
|
||||
const tutorialItems = shallowRef(tutorials)
|
||||
const quickStartItems = shallowRef<QuickStartItem[]>([
|
||||
{
|
||||
title: 'Install Speckle manager',
|
||||
description: 'Use our Manager to install and manage Connectors with ease.',
|
||||
buttons: [
|
||||
{
|
||||
text: 'Download for Windows',
|
||||
onClick: () => onDownloadManager('exe')
|
||||
},
|
||||
{
|
||||
text: 'Download for Mac',
|
||||
onClick: () => onDownloadManager('dmg')
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "Don't know where to start?",
|
||||
description: "We'll walk you through some of most common usage scenarios.",
|
||||
buttons: [
|
||||
{
|
||||
text: 'Open documentation',
|
||||
props: { to: docsPageUrl }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Have a question you need answered?',
|
||||
description: 'Submit your question on the forum and get help from the community.',
|
||||
buttons: [
|
||||
{
|
||||
text: 'Ask a question',
|
||||
props: { to: forumPageUrl }
|
||||
}
|
||||
]
|
||||
}
|
||||
])
|
||||
|
||||
const createProjectButton = shallowRef<LayoutDialogButton[]>([
|
||||
{
|
||||
text: 'Create a project',
|
||||
props: { disabled: isGuest.value },
|
||||
onClick: () => (openNewProject.value = true)
|
||||
}
|
||||
])
|
||||
|
||||
const projects = computed(() => projectsResult.value?.activeUser?.projects.items)
|
||||
const hasProjects = computed(() => (projects.value ? projects.value.length > 0 : false))
|
||||
|
||||
const onDownloadManager = (extension: ManagerExtension) => {
|
||||
try {
|
||||
downloadManager(extension)
|
||||
|
||||
mixpanel.track('Manager Download', {
|
||||
os: extension === 'exe' ? 'win' : 'mac'
|
||||
})
|
||||
} catch {
|
||||
triggerNotification({
|
||||
type: ToastNotificationType.Danger,
|
||||
title: 'Download failed'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const promoBanners = ref<PromoBanner[]>()
|
||||
</script>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user