Compare commits

...

54 Commits

Author SHA1 Message Date
oguzhankoral 50e58a4091 fix: upgrade cta 2026-03-06 11:13:05 +03:00
Oğuzhan Koral 05bfc40ad6 Merge branch 'main' into bjorn/cnx-2294-upsell-message-display-only-when-user-is-trying-to-create-a 2026-03-06 10:53:00 +03:00
Oğuzhan Koral 009cc77bab fix: correct url for create workspace action (#93) 2026-03-06 10:52:46 +03:00
Björn Steinhagen a8b802b7e3 fix(dui): prevents empty publish selection state
fix(dui): prevents empty publish selection state
2026-03-03 08:29:40 +02:00
Björn Steinhagen 6fc3df4a0d refactor: centralized filter validation and generalized 'empty selection' checks 2026-03-02 15:34:36 +02:00
Björn Steinhagen f47f19c02d Merge branch 'main' into bjorn/cnx-3125-prevent-publishing-without-a-valid-selection 2026-02-25 14:42:35 +02:00
Oğuzhan Koral 85f806368a feat: handle model card state according to given ingestion id (#89)
* feat: handle model card state according to given ingestion id

* chore: linting
2026-02-25 14:00:59 +03:00
Björn Steinhagen 35ddce1f90 fix(dui): prevents invalidate selection filter across not just selection 2026-02-25 11:01:30 +02:00
Björn Steinhagen a37b3389d6 fix(dui): prevents empty publish selection state 2026-02-25 10:23:19 +02:00
Björn Steinhagen ed4aa92ce1 fix: disable deletion of model card while ops are happening (#87)
* chore: battling git

* fix: logic to card base for sender and receiver fix
2026-02-16 11:50:28 +03:00
Björn Steinhagen 69c87e8ed3 chore: upsell message 2026-02-12 21:28:37 +02:00
Björn Steinhagen 60f3bed254 feat: loading state on publish wizard
feat: loading state on publish button
2026-02-03 14:16:20 +02:00
Björn 2f412df64a feat: loading state on publish button 2026-02-03 14:11:37 +02:00
Oğuzhan Koral c7e0929eca feat: new business model changes (#85)
* feat: initial can create version implementation on model card

* feat: disable model card CTAs for send

* feat: initial model ingestion tests

* fix: apply ingestion send to all CTAs

* feat: sketchup bridge

* feat: centeralize the start ingestion logic in host app store

* fix: sketchup is handling via model ingestion

* chore: cosmetics

* feat(ingestion): add failWithError and failWithCancel GraphQL mutations

* feat(ingestion): add failIngestion and cancelIngestion methods to useModelIngestion composable

* feat(ingestion): handle ingestion failure and cancellation in hostAppStore

* fix: reviewers comments

* fix: don't know where the f that came from

* refactor(ingestion): remove unused statusData and fix lint errors

* feat(wizard): add canCreateVersion permission check to publish wizard

* TODOs

* feat(permissions): add 1s polling for canCreateVersion to reflect workspace limit changes

* fix(tooltip): undefined doesnt refresh v-tippy

* fix(wizard): too much ctrl z lol

* refactor(permissions): check canCreateVersion on action instead of polling

* feat(hostApp): adds fallback for model ingestion on older servers

* fix: ingestion available check and rock'n roll

* feat: workspace plan updated subscription boilerplate

* fix: bump the timeout to 2h

* feat: handle version limits in publish flows via subscription

* feat: align Archicad and Vectorworks with new ingestion flow

* chore: onMounted at end of file

* fix: logic and ui adjustments

* fix: refactoring and permissions

* refactor: ingestionStatus renamed to activeIngestions

* fix: error handling and notifications

* fix: global error handling

* chore: general alignment and clean up

* fix(vectorworks): now uses capital V

* chore: revert codegen

---------

Co-authored-by: Björn Steinhagen <88777268+bjoernsteinhagen@users.noreply.github.com>
Co-authored-by: Björn Steinhagen <steinhagen.bjoern@gmail.com>
2026-02-03 14:43:16 +03:00
Oğuzhan Koral eef0a59719 feat: disable intercom for non speckle distributions + partner badge (#84)
* feat: disable intercom for non speckle distributions + partner badge

* no logging
2026-01-16 18:00:49 +03:00
Dogukan Karatas 19f306756c fix: handle network connectivity in DUI (#80)
* error handler

* top-level handling

* internet check

* pass other network errors

---------

Co-authored-by: Oğuzhan Koral <45078678+oguzhankoral@users.noreply.github.com>
2026-01-12 17:51:47 +03:00
Oğuzhan Koral 305b100d34 feat: remove personal projects (#82) 2026-01-08 12:56:31 +03:00
Oğuzhan Koral f2cc0d55e3 fix: workspace avatars (#81)
* fix: workspace avatars

* get rid of from old logo prop
2026-01-06 16:21:53 +03:00
Oğuzhan Koral fdfef1d496 feat: issues (#77)
* WIP

* feat: readonly issues in connectors

* fix created at on replies

* filter out by resourceStringId

* show label name if just one

* generate gql

* linting

* linting
2025-12-10 18:01:13 +03:00
Oğuzhan Koral 5174af78cc fix: remove completed state for workspaces (#78)
* fix: remove completed state for workspaces

* remove experimental create automation dialog
2025-12-03 18:45:04 +03:00
Oğuzhan Koral ede6e99440 feat: new auth is default, desktop service is legacy and fallback (#76)
* feat: new auth is default, desktop service is legacy and fallback

* cleanup

* css

* rename login to signin

* better buttons

* default value instead placeholder
2025-11-25 23:04:40 +03:00
Oğuzhan Koral 9c708c64a0 fix: account by url should default to active one first (#75) 2025-11-11 18:31:21 +03:00
Oğuzhan Koral 41e635c8ef store url in cache (#74) 2025-10-27 16:53:35 +03:00
Oğuzhan Koral 095ccf114d feat: auth in dui (#71)
* feat: auth in dui

* feat: enable auth with registered app

* feat: handle exceptions
2025-10-27 15:31:56 +03:00
Dogukan Karatas a95fd9bdfe adds the server_domain (#62) 2025-10-16 16:23:01 +03:00
Dogukan Karatas bc665a008c userId is added to properties (#61) 2025-10-16 16:12:03 +03:00
Oğuzhan Koral 00a6a66ee0 fix: confusion on CTA and dry messaging (#70)
* fix: confusion on CTA and dry messaging

* make bjorn happy again
2025-10-15 10:39:04 +03:00
Oğuzhan Koral b0157af3c8 fix: find workspace from active limited workspace (#69) 2025-10-15 10:17:13 +03:00
Oğuzhan Koral 9b065bf921 fix(sketchup): disable progress update for now till replacing with objectloader2 (#68) 2025-10-15 10:16:45 +03:00
Dogukan Karatas 99ebd403c7 feat: track settings change on mixpanel events (#65)
* adds properties to update settings

* adds settings track on publish

* added track on publish/receive

* renaming

* fix some types

* introduced a helper function

* created a separate composable

* updated the comparing
2025-10-09 23:51:15 +03:00
Björn Steinhagen a166b86657 fix: hide update alert for non-distributed connectors (#63)
* fix: hide update alert for non-distributed connectors

* chore: formatting
2025-10-09 09:36:11 +03:00
Björn Steinhagen 185ba0f50a fix: replace Manager references with Desktop Service download link (#64)
* fix: replace Manager references with Desktop Service download link

* chore: restores old commented-out code

* fix: redirect to releases

---------

Co-authored-by: Oğuzhan Koral <45078678+oguzhankoral@users.noreply.github.com>
2025-10-09 09:27:47 +03:00
Oğuzhan Koral 49cabaa1bc Feat: archicad layers (#66)
* ugly workaround

* ugly workaround for archicadLayers

* comment
2025-10-08 17:09:19 +03:00
Björn Steinhagen 11b6d5254e feat(etabs): improve MultiEnumControlRenderer UX for analysis result export (#60)
* fix(ui): add scrolling support to MultiEnumControlRenderer dropdown

* feat(ui): add select all/deselect all functionality to MultiEnumControlRenderer

* fix(ui): prevent jumpiness and dropdown misalignment

* fix: not generous enough on the width

* fix: heigh alignment and comments
2025-09-19 15:07:04 +03:00
Björn Steinhagen aa5d59ba5b chore: mapper terminology (#59) 2025-09-05 16:22:44 +01:00
Oğuzhan Koral 1cdd5c89a4 check against null which was failing on production only (#58) 2025-08-27 10:37:16 +03:00
Oğuzhan Koral 047dbff259 Fix unnecessary is in check (#57) 2025-08-26 19:51:42 +03:00
Oğuzhan Koral ffff7366c3 Feat: disable update prompt in connectors (#56)
* Do not check for updates if it explicitly disabled by someone

* fix order of ops

* remove unused function

* check function in binding is implemented

* remove console logging

* sort logic finally

* fix mocked binding
2025-08-26 19:15:31 +03:00
Björn Steinhagen 4ecd6fbee9 fix(dui): align active workspace with recent GraphQL API changes (#55)
* feat: add `isProjectsActive` parameter to `setActiveWorkspace` mutation

* feat: pass `isProjectsActive` parameter to `setActiveWorkspace` mutation

* fix: align GraphQL queries with `LimitedWorkspace` schema

* fix: handle LimitedWorkspace type in activeWorkspace logic

* chore: regenerate GraphQL types after schema alignment
2025-08-25 14:16:45 +03:00
Oğuzhan Koral 54039daa32 fix: mixpanel email (#54) 2025-08-19 16:15:50 +01:00
Oğuzhan Koral b7e347f3f0 Chore: disable intercom for externals (#53)
* switches mapper terminology to category assignment

* linting

* chore: disable intercom for external developers

---------

Co-authored-by: Claire Kuang <kuang.claire@gmail.com>
2025-08-18 22:20:27 +01:00
Claire Kuang c8f85c3874 fix(mapper): switches mapper terminology to category assignment (#52)
* switches mapper terminology to category assignment

* linting

---------

Co-authored-by: oguzhankoral <oguzhankoral@gmail.com>
2025-08-18 14:14:39 +01:00
Björn Steinhagen 85405d10dd chore: separate header for mapper (#51) 2025-08-18 11:08:01 +01:00
Björn Steinhagen d8fdc2c3c5 fix(mapper): preserve mapping mode state on navigation (#49)
* feat: poc

- needs cleaning
- just me, hacking

* refactor: cleaning

* chore: update available categories

* fix: remember previous mode

* fix: clear search string after mapping

* feat: add Mixpanel tracking to revit mapper interactions (#50)

* feat: add Mixpanel tracking to revit mapper interactions

* fix: pr comments

* fix: just mode

* chore(interop-lite): rename event name prop

---------

Co-authored-by: oguzhankoral <oguzhankoral@gmail.com>

* revit mapper store

* WIP

* Fix form select base placeholder on select

* refactor: convention, not composable

* fix: deselecting objects through mapped mode

* fix: eslinting ?

* chore: remove console log

---------

Co-authored-by: oguzhankoral <oguzhankoral@gmail.com>
2025-08-15 18:05:18 +03:00
Björn Steinhagen 034d8645c6 feat(ui): show existing category mappings in revit mapper dropdown (#48)
* feat: poc

- needs cleaning
- just me, hacking

* refactor: cleaning

* chore: update available categories

* feat: add Mixpanel tracking to revit mapper interactions (#50)

* feat: add Mixpanel tracking to revit mapper interactions

* fix: pr comments

* fix: just mode

* chore(interop-lite): rename event name prop

---------

Co-authored-by: oguzhankoral <oguzhankoral@gmail.com>

* revit mapper store

* WIP

* Fix form select base placeholder on select

* refactor: convention, not composable

* fix: deselecting objects through mapped mode

* fix: eslinting ?

* chore: remove console log

---------

Co-authored-by: oguzhankoral <oguzhankoral@gmail.com>
2025-08-15 17:53:07 +03:00
Björn Steinhagen d797a65fab fix: pre-selected objects (#47) 2025-08-14 15:28:45 +03:00
Björn Steinhagen 028c9d2ac1 feat(dui): layer mapping for revit integration in interop lite (#44)
* feat: update mapper binding interface for layer support and renamed methods

* fix: add missing layer mock methods to `IRevitMapperBinding`

* feat: adds mode toggle

* feat: layer dropdown

* feat: hierarchical layer object highlighting and simple mappings mgmt

* fix: multi instead of base

* fix: refresh layer list on doc switch

* fix: formatting

* feat: added `Select All` button and updated event handling

* fix: event handling

* refactor: components to make mapper more maintainable

* chore: rename button to Assign Revit Categories

* refactor: hardcoded list now in dui

* fix: pr comments

* fix: redundant div

* refactor: remove redundant Props interfaces in mapper components

* refactor: group conditional buttons in mapper

* fix: auto import not working?

* fix: jokes i was being dumb

* chore(revit-mapper): css

---------

Co-authored-by: oguzhankoral <oguzhankoral@gmail.com>
2025-08-14 15:01:29 +03:00
Oğuzhan Koral b2695e77f5 fix(settings): warn user and force push them to refresh (#43) 2025-08-07 11:30:18 +03:00
Björn Steinhagen 669afe81cf feat(rhino): add revit mapper UI for category assignment (#41)
* feat: basic structure

* feat: categories

* feat: selection filter

* chore: mock categories

* feat: second iteration

* docs: comments

* feat: create mapper binding interface

* feat: register bindings

* feat: add Revit Integration button

conditionally based on the presence or absence of binding

* fix: tooltip

* fix: missing method and interface for `getAvailableCategories`

* fix: remove hardcoded categories

* chore: categories from connector

* chore: remaining methods

* chore: remove unused method

* fix: removing duplicate interfaces

* chore: cleanups

* fix: add DocumentModelStore dependency for event handling

* fix: linting

* fix: dropdown

* fix: again, linting

* chore: don't need the double label

* fix: missing label

* chore: small tweaks

* chore: name

* chore(revit-mapper): css

* chore(revit-mapper): correct routing

* fix(revit-mapper): revit integration buttons

---------

Co-authored-by: oguzhankoral <oguzhankoral@gmail.com>
2025-08-06 14:55:44 +03:00
Oğuzhan Koral 48bb180899 feat: remove button for deleted models (#40) 2025-08-01 20:06:58 +03:00
Oğuzhan Koral 3b4aa93858 Feat: mocked bindings and logging to seq (#39)
* mocked bindings and logging to seq

* test deploy

* test deploy

* test deploy

* connectorless state

* remove logs

* remove more logs

* add flags to globalThus

* log with /api/events/raw

* log error link on prod over local account

* handle test query to distinguish self hosters

* throw again

* log again...

* sa and ra

* error policy non none

* attach server url to logs

* Add host app version

* rename name to slug

* remove useless re throw

* fix confusion on versions
2025-07-23 15:51:09 +01:00
Oğuzhan Koral 4ebf702ab2 Feat: receive settings (#35)
* Receive settings for POC for now

* Patch the model after settings change
2025-06-27 12:38:47 +03:00
Oğuzhan Koral 6e6bd423a0 multi selectable card setting (#38) 2025-06-25 19:30:30 +03:00
Oğuzhan Koral 57ef9685b6 Pass URL origin to auth flow (#37) 2025-06-24 18:19:18 +03:00
91 changed files with 8888 additions and 1227 deletions
+3
View File
@@ -15,6 +15,7 @@ import { useConfigStore } from '~/store/config'
import { useAccountStore } from '~/store/accounts'
import { useHostAppStore } from '~/store/hostApp'
import { storeToRefs } from 'pinia'
import { logToSeq } from '~/lib/logger/composables/useLogger'
const uiConfigStore = useConfigStore()
const { isDarkTheme } = storeToRefs(uiConfigStore)
@@ -57,5 +58,7 @@ onMounted(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { $intercom } = useNuxtApp() // needed her for initialisation
logToSeq('Information', 'DUI3 initialized')
})
</script>
+160
View File
@@ -0,0 +1,160 @@
<template>
<div class="flex flex-col space-y-2">
<div v-if="isDesktopServiceAvailable">
<div v-show="!isAddingAccount" class="text-foreground-2 space-y-2">
<FormButton
text
size="sm"
full-width
@click="showCustomServerInput = !showCustomServerInput"
>
{{ showCustomServerInput ? 'Use default server' : 'Set custom server url' }}
</FormButton>
<div v-if="showCustomServerInput">
<FormTextInput
v-model="customServerUrl"
name="name"
:show-label="false"
color="foundation"
autocomplete="off"
show-clear
@clear="showCustomServerInput = false"
/>
</div>
<div class="flex space-x-2">
<FormButton
color="outline"
class="px-1"
:icon-left="ArrowLeftIcon"
hide-text
@click="emit('backToSignIn')"
/>
<FormButton full-width @click="startAccountAddFlow()">
Sign in (Legacy)
</FormButton>
</div>
</div>
<div v-show="isAddingAccount" class="text-foreground-2 mt-2 mb-4 space-y-2">
<div class="text-sm text-center">
Please check your browser: waiting for authorization to complete.
</div>
<div class="py-2"><CommonLoadingBar :loading="isAddingAccount" /></div>
<div v-if="showHelp" class="bg-blue-500/10 p-2 rounded-md space-y-2">
<div class="text-sm text-center">Having trouble?</div>
<FormButton size="sm" full-width @click="restartFlow()">Retry</FormButton>
<FormButton
text
size="sm"
full-width
@click="$openUrl('https://speckle.community')"
>
Get in touch with us
</FormButton>
</div>
</div>
</div>
<div v-else class="space-y-3">
<div class="text-foreground-2 text-sm">
The Speckle Desktop Service is required to add accounts as legacy way. This
background service handles authentication securely.
</div>
<div class="flex space-x-2">
<FormButton
color="outline"
class="px-1"
:icon-left="ArrowLeftIcon"
hide-text
@click="emit('backToSignIn')"
/>
<FormButton full-width @click="$openUrl('https://releases.speckle.systems')">
Download Desktop Service
</FormButton>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useIntervalFn } from '@vueuse/core'
import { useHostAppStore } from '~/store/hostApp'
import { ToastNotificationType } from '@speckle/ui-components'
import { useMixpanel } from '~/lib/core/composables/mixpanel'
import { useAccountStore } from '~~/store/accounts'
import { useDesktopService } from '~/lib/core/composables/desktopService'
import { ArrowLeftIcon } from '@heroicons/vue/24/solid'
const accountStore = useAccountStore()
const { pingDesktopService } = useDesktopService()
const hostApp = useHostAppStore()
const app = useNuxtApp()
const { trackEvent } = useMixpanel()
const emit = defineEmits<{
(e: 'backToSignIn'): void
}>()
const showCustomServerInput = ref(false)
const isAddingAccount = ref(false)
const isDesktopServiceAvailable = ref(false) // this should be false default because there is a delay if /ping is not successful.
const customServerUrl = ref<string | undefined>('https://app.speckle.systems')
const showHelp = ref(false)
const accountCheckerIntervalFn = useIntervalFn(
async () => {
const previousAccountCount = accountStore.accounts.length
await accountStore.refreshAccounts()
const currentAccountCount = accountStore.accounts.length
if (previousAccountCount !== currentAccountCount) {
isAddingAccount.value = false
showCustomServerInput.value = false
accountCheckerIntervalFn.pause()
trackEvent('DUI Account Added')
}
},
1000,
{ immediate: false }
)
const startAccountAddFlow = () => {
isAddingAccount.value = true
accountCheckerIntervalFn.resume()
setTimeout(() => {
showHelp.value = true
}, 10_000)
const url = customServerUrl.value
? `http://localhost:29364/auth/add-account?serverUrl=${
new URL(customServerUrl.value).origin
}`
: `http://localhost:29364/auth/add-account`
app.$openUrl(url)
// this is a annoying timeout that we cannot detect if user added same account or not.
setTimeout(() => {
if (isAddingAccount.value) {
isAddingAccount.value = false
showCustomServerInput.value = false
accountCheckerIntervalFn.pause()
// Note to Dim: not sure about toast
hostApp.setNotification({
title: 'Sign In',
type: ToastNotificationType.Info,
description:
'Sign in timed out. This may have happened because you tried adding an existing account.'
})
// TODO: we could log it to sentry/seq later to see how likely it happens?
}
}, 30_000)
}
const restartFlow = () => {
isAddingAccount.value = false
showHelp.value = false
}
onMounted(async () => {
isDesktopServiceAvailable.value = await pingDesktopService()
})
</script>
+24 -16
View File
@@ -39,18 +39,20 @@
title="Add a new account"
fullscreen="none"
>
<div>
<div v-if="isDesktopServiceAvailable">
<AccountsSignInFlow />
</div>
<div v-else class="flex flex-wrap justify-center space-x-4 max-width">
<FormButton text @click="$openUrl(`speckle://accounts`)">
Add account via Manager
</FormButton>
<FormButton text @click="accountStore.refreshAccounts()">
Refresh accounts
</FormButton>
</div>
<div class="flex flex-col space-y-2">
<AccountsSignInFlow v-if="!showLegacy" />
<AccountsLegacySignInFlow v-else @back-to-sign-in="showLegacy = false" />
<FormButton
v-if="!showLegacy"
text
full-width
size="sm"
class="text-xs"
@click="showLegacy = true"
>
Legacy Sign in
</FormButton>
</div>
</CommonDialog>
</div>
@@ -58,6 +60,7 @@
</CommonDialog>
</div>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { XMarkIcon } from '@heroicons/vue/20/solid'
@@ -68,7 +71,6 @@ import { useDesktopService } from '~/lib/core/composables/desktopService'
const { trackEvent } = useMixpanel()
const app = useNuxtApp()
const { $openUrl } = useNuxtApp()
const { pingDesktopService } = useDesktopService()
const props = withDefaults(
@@ -86,7 +88,7 @@ defineEmits<{
}>()
const showAddNewAccount = ref(false)
// const showAccountsDialog = ref(false)
const showLegacy = ref(false)
const showAccountsDialog = defineModel<boolean>('open', {
required: false,
@@ -95,7 +97,7 @@ const showAccountsDialog = defineModel<boolean>('open', {
const isDesktopServiceAvailable = ref(false) // this should be false default because there is a delay if /ping is not successful.
app.$baseBinding.on('documentChanged', () => {
app.$baseBinding?.on('documentChanged', () => {
showAccountsDialog.value = false
})
@@ -106,6 +108,13 @@ watch(showAccountsDialog, (newVal) => {
}
})
watch(showAddNewAccount, (newVal) => {
if (newVal) {
// reset the current/legacy state on every add account sub-dialog
showLegacy.value = false
}
})
const accountStore = useAccountStore()
const { accounts, activeAccount, userSelectedAccount, isLoading } =
storeToRefs(accountStore)
@@ -144,7 +153,6 @@ const user = computed(() => {
// acc = currentSelectedAccount
// }
// }
return {
name: activeAccount.value.accountInfo.userInfo.name,
avatar: activeAccount.value.accountInfo.userInfo.avatar
+35 -100
View File
@@ -1,116 +1,51 @@
<template>
<div>
<div v-show="!isAddingAccount" class="text-foreground-2 my-2 space-y-2">
<div v-if="showCustomServerInput">
<FormTextInput
v-model="customServerUrl"
name="name"
:show-label="false"
placeholder="https://app.speckle.systems"
color="foundation"
autocomplete="off"
show-clear
@clear="showCustomServerInput = false"
/>
</div>
<FormButton full-width @click="startAccountAddFlow()">Sign In</FormButton>
<FormButton
text
size="sm"
full-width
@click="showCustomServerInput = !showCustomServerInput"
>
{{ showCustomServerInput ? 'Use default server' : 'Set custom server url' }}
</FormButton>
<div class="flex flex-col space-y-2">
<FormButton
text
size="sm"
full-width
@click="showCustomServerInput = !showCustomServerInput"
>
{{ showCustomServerInput ? 'Use default server' : 'Set custom server url' }}
</FormButton>
<div v-if="showCustomServerInput">
<FormTextInput
v-model="customServerUrl"
name="name"
:show-label="false"
placeholder="https://app.speckle.systems"
color="foundation"
autocomplete="off"
show-clear
@clear="showCustomServerInput = false"
/>
</div>
<div v-show="isAddingAccount" class="text-foreground-2 mt-2 mb-4 space-y-2">
<div class="text-sm text-center">
Please check your browser: waiting for authorization to complete.
</div>
<div class="py-2"><CommonLoadingBar :loading="isAddingAccount" /></div>
<div v-if="showHelp" class="bg-blue-500/10 p-2 rounded-md space-y-2">
<div class="text-sm text-center">Having trouble?</div>
<FormButton size="sm" full-width @click="restartFlow()">Retry</FormButton>
<FormButton
text
size="sm"
full-width
@click="$openUrl('https://speckle.community')"
>
Get in touch with us
</FormButton>
</div>
</div>
<FormButton v-if="canAddAccount" full-width @click="logIn()">Sign in</FormButton>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useIntervalFn } from '@vueuse/core'
import { useAccountStore } from '~~/store/accounts'
import { useHostAppStore } from '~/store/hostApp'
import { ToastNotificationType } from '@speckle/ui-components'
import { useMixpanel } from '~/lib/core/composables/mixpanel'
import { useAuthManager } from '~/lib/authn/useAuthManager'
import type { BaseBridge } from '~/lib/bridge/base'
const accountStore = useAccountStore()
const hostApp = useHostAppStore()
const app = useNuxtApp()
const { trackEvent } = useMixpanel()
const customServerUrl = ref<string | undefined>(undefined)
const isAddingAccount = ref(false)
const showHelp = ref(false)
const customServerUrl = ref<string | undefined>('https://app.speckle.systems')
const showCustomServerInput = ref(false)
const accountCheckerIntervalFn = useIntervalFn(
async () => {
const previousAccountCount = accountStore.accounts.length
await accountStore.refreshAccounts()
const currentAccountCount = accountStore.accounts.length
if (previousAccountCount !== currentAccountCount) {
isAddingAccount.value = false
showCustomServerInput.value = false
accountCheckerIntervalFn.pause()
trackEvent('DUI Account Added')
}
},
1000,
{ immediate: false }
const { $accountBinding } = useNuxtApp()
const canAddAccount = ['AddAccount', 'addAccount'].some((name) =>
($accountBinding as unknown as BaseBridge).availableMethodNames.includes(name)
)
const startAccountAddFlow = () => {
isAddingAccount.value = true
accountCheckerIntervalFn.resume()
setTimeout(() => {
showHelp.value = true
}, 10_000)
const url = customServerUrl.value
? `http://localhost:29364/auth/add-account?serverUrl=${customServerUrl.value}`
: `http://localhost:29364/auth/add-account`
const { generateChallenge } = useAuthManager()
app.$openUrl(url)
// this is a annoying timeout that we cannot detect if user added same account or not.
setTimeout(() => {
if (isAddingAccount.value) {
isAddingAccount.value = false
showCustomServerInput.value = false
accountCheckerIntervalFn.pause()
// Note to Dim: not sure about toast
hostApp.setNotification({
title: 'Sign In',
type: ToastNotificationType.Info,
description:
'Sign in timed out. This may have happened because you tried adding an existing account.'
})
// TODO: we could log it to sentry/seq later to see how likely it happens?
}
}, 30_000)
}
const restartFlow = () => {
isAddingAccount.value = false
showHelp.value = false
const logIn = () => {
const serverUrl = customServerUrl.value
? new URL(customServerUrl.value).origin
: 'https://app.speckle.systems'
const challenge = generateChallenge(serverUrl)
const authUrl = `${serverUrl}/authn/verify/sdui/${challenge}`
window.location.href = authUrl
}
</script>
-159
View File
@@ -1,159 +0,0 @@
<!-- NOT WILL BE USED SINCE WE ENABLE AUTOMATION CREATION FROM DUI3 -->
<template>
<div class="p-0">
<slot name="activator" :toggle="toggleDialog"></slot>
<CommonDialog
v-model:open="showAutomateDialog"
:title="`Settings`"
fullscreen="none"
>
<div v-if="hasFunctions">
<FormSelectBase
key="name"
v-model="selectedFunction"
clearable
label="Automate functions"
placeholder="Nothing selected"
name="Functions"
show-label
:items="functions"
mount-menu-on-body
>
<template #something-selected="{ value }">
<span>{{ isArray(value) ? value[0].name : value.name }}</span>
</template>
<template #option="{ item }">
<div class="flex items-center">
<span class="truncate">{{ item.name }}</span>
</div>
</template>
</FormSelectBase>
</div>
<div v-if="selectedFunction && finalParams && step === 0">
<FormJsonForm
ref="jsonForm"
:data="data"
:schema="finalParams"
class="space-y-4"
:validate-on-mount="false"
@change="handler"
/>
</div>
<div v-if="step === 1">
<FormTextInput
v-model="automationName"
name="automationName"
label="Automation name"
color="foundation"
show-label
help="Give your automation a name"
placeholder="Name"
show-required
validate-on-value-update
/>
</div>
<FormButton
v-if="selectedFunction && step === 0"
size="sm"
class="mt-4"
@click="step++"
>
Next
</FormButton>
<FormButton
v-if="selectedFunction && step === 1"
size="sm"
class="mt-4"
@click="createAutomationHandler"
>
Create
</FormButton>
</CommonDialog>
</div>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import type { AutomateFunctionItemFragment } from '~/lib/common/generated/gql/graphql'
import {
automateFunctionsQuery,
createAutomationMutation
} from '~/lib/graphql/mutationsAndQueries'
import { provideApolloClient, useMutation, useQuery } from '@vue/apollo-composable'
import { useAccountStore, type DUIAccount } from '~/store/accounts'
import type { ApolloError } from '@apollo/client/errors'
import { formatVersionParams } from '~/lib/common/helpers/jsonSchema'
import { useJsonFormsChangeHandler } from '~/lib/core/composables/jsonSchema'
import { isArray } from 'lodash-es'
const props = defineProps<{
projectId: string
modelId: string
}>()
const step = ref<number>(0)
const automationName = ref<string>('')
const accountStore = useAccountStore()
const { activeAccount } = storeToRefs(accountStore)
const accountId = computed(() => activeAccount.value?.accountInfo.id) // NOTE: none of the tokens here has read, write access to automate, only frontend tokens have. Keep in mind after first pass!
const selectedFunction = ref<AutomateFunctionItemFragment>()
const showAutomateDialog = ref(false)
const toggleDialog = () => {
showAutomateDialog.value = !showAutomateDialog.value
}
const { mutate } = provideApolloClient((activeAccount.value as DUIAccount).client)(() =>
useMutation(createAutomationMutation)
)
const createAutomationHandler = async () => {
const _res = await mutate({
projectId: props.projectId,
input: { name: automationName.value, enabled: false }
})
showAutomateDialog.value = false
}
const { result: functionsResult, onError } = useQuery(
automateFunctionsQuery,
() => ({}),
() => ({ clientId: accountId.value, debounce: 500, fetchPolicy: 'network-only' })
)
onError((err: ApolloError) => {
console.warn(err.message)
})
const functions = computed(() => functionsResult.value?.automateFunctions.items)
const hasFunctions = computed(() => functions.value?.length !== 0)
const release = computed(() =>
selectedFunction.value?.releases.items.length
? selectedFunction.value?.releases.items[0]
: undefined
)
const finalParams = computed(() => formatVersionParams(release.value?.inputSchema))
const { handler } = useJsonFormsChangeHandler({
schema: finalParams
})
console.log(finalParams)
type DataType = Record<string, unknown>
const data = computed(() => {
const kvp = {} as DataType
if (finalParams.value) {
Object.entries(finalParams.value).forEach((k, _) => {
kvp[k as unknown as string] = undefined
})
}
return kvp
})
</script>
+11 -10
View File
@@ -27,16 +27,17 @@
>
{{ notification.secondaryCta.name }}
</FormButton>
<FormButton
v-if="notification.cta"
v-tippy="notification.cta.tooltipText"
size="sm"
color="primary"
full-width
@click.stop="notification.cta?.action"
>
{{ notification.cta.name }}
</FormButton>
<div v-if="notification.cta" v-tippy="notification.cta.tooltipText">
<FormButton
:disabled="notification.cta.disabled"
size="sm"
color="primary"
full-width
@click.stop="notification.cta?.action"
>
{{ notification.cta.name }}
</FormButton>
</div>
</div>
</div>
<div
+7 -1
View File
@@ -1,6 +1,12 @@
<template>
<CommonAlert
v-if="!store.isConnectorUpToDate && !hasDismissedAlert"
v-if="
store.isDistributedBySpeckle &&
store.latestAvailableVersion &&
!store.isConnectorUpToDate &&
!hasDismissedAlert &&
!store.isUpdateNotificationDisabled
"
v-tippy="
'Version: ' + store.latestAvailableVersion?.Number + ', released ' + createdAgo
"
+6 -2
View File
@@ -1,7 +1,6 @@
<template>
<!-- ONLY FOR TEST FOR NOW-->
<form class="flex flex-col space-y-4 form-json-form">
<span>Settings</span>
<FormJsonForm :schema="jsonSchema" @change="onParamsFormChange"></FormJsonForm>
</form>
</template>
@@ -21,6 +20,11 @@ const jsonSchema = {
type: 'string',
title: 'Favorite Color',
enum: ['red', 'green', 'blue']
},
multiSelect: {
type: 'array',
title: 'Multi Favorite Chars',
enum: ['a', 'b', 'c', 'd']
}
}
}
@@ -28,6 +32,6 @@ const jsonSchema = {
const paramsFormState = ref<JsonFormsChangeEvent>()
const onParamsFormChange = (e: JsonFormsChangeEvent) => {
paramsFormState.value = e
console.log(JSON.stringify(e))
console.log(e)
}
</script>
+7
View File
@@ -66,6 +66,13 @@
@update:filter="(filter : ISendFilter) => (selectedFilter = filter)"
/>
</div>
<!-- I dont like the way we use revit categories filter for archicad layers, this component need to be generalized if we have one more -->
<div v-else-if="selectedFilter.id === 'archicadLayers'">
<FilterRevitCategories
:filter="(selectedFilter as RevitCategoriesSendFilter)"
@update:filter="(filter : ISendFilter) => (selectedFilter = filter)"
/>
</div>
<!-- Below should have been implemented as sendFilterSelect as above, we can delete it later -->
<div v-else-if="selectedFilter.id === 'navisworksSavedSets'">
<FilterFormSelect
@@ -0,0 +1,146 @@
<template>
<div>
<div class="text-foreground-2 text-body-2xs mb-1 pl-1">
{{ control.label }}
</div>
<!-- button next to component (like revit send categories) -->
<!-- min width to keep components "in-sync" at narrow sizes -->
<!-- size "sm" matches height of select all toggle -->
<div class="flex items-center space-x-2 min-w-72">
<FormSelectMulti
:model-value="modelValue"
:name="fieldName"
:rules="multiValidator"
:label="control.label"
:items="control.options"
class="flex-1 min-w-0"
clearable
:search="true"
:search-placeholder="'Search'"
:filter-predicate="searchFilterPredicate"
:help="control.description"
:allow-unset="false"
by="value"
button-style="tinted"
:validate-on-value-update="validateOnValueUpdate"
mount-menu-on-body
fixed-height
@update:model-value="handleChange"
>
<template #nothing-selected>
{{
appliedOptions['placeholder']
? appliedOptions['placeholder']
: 'Select values'
}}
</template>
<template #something-selected="{ value }">
<div ref="elementToWatchForChanges" class="flex items-center space-x-0.5">
<div ref="itemContainer" class="flex flex-wrap overflow-hidden space-x-0.5">
<div v-for="(item, i) in value" :key="item.value" class="text-foreground">
{{ item.label + (i < value.length - 1 ? ', ' : '') }}
</div>
</div>
<div v-if="hiddenSelectedItemCount > 0" class="text-foreground-2 normal">
+{{ hiddenSelectedItemCount }}
</div>
</div>
</template>
<template #option="{ item }">
<div class="flex items-center text-foreground-2 text-body-2xs">
<span class="truncate">{{ item.label }}</span>
</div>
</template>
</FormSelectMulti>
<!-- Select All / Deselect All button - positioned next to dropdown like Revit -->
<FormButton color="outline" class="min-w-28" size="base" @click="toggleSelectAll">
{{ allSelected ? 'Deselect all' : 'Select all' }}
</FormButton>
</div>
</div>
</template>
<script setup lang="ts">
import type { ControlElement } from '@jsonforms/core'
import { rendererProps, useJsonFormsEnumControl } from '@jsonforms/vue'
import type { Nullable } from '@speckle/shared'
import { useFormSelectChildInternals } from '@speckle/ui-components'
import type { GenericValidateFunction } from 'vee-validate'
import { useJsonRendererBaseSetup } from '~/lib/form/composables/jsonRenderers'
type OptionType = { value: string; label: string }
type ValueType = OptionType | OptionType[] | undefined
const emit = defineEmits<(e: 'update:modelValue', v: ValueType) => void>()
const props = defineProps({
...rendererProps<ControlElement>(),
// TODO: Doesn't appear that jsonforms properly supports multiple selection
multiple: {
type: Boolean,
default: true
},
controlOverrides: {
type: Object as PropType<Nullable<ReturnType<typeof useJsonFormsEnumControl>>>,
default: null
}
})
const searchFilterPredicate = (item: OptionType, search: string) =>
item.label.toLocaleLowerCase().includes(search.toLocaleLowerCase())
const elementToWatchForChanges = ref(null as Nullable<HTMLElement>)
const itemContainer = ref(null as Nullable<HTMLElement>)
const { hiddenSelectedItemCount, isArrayValue } =
useFormSelectChildInternals<OptionType>({
props: toRefs(props),
emit,
dynamicVisibility: { elementToWatchForChanges, itemContainer }
})
/* eslint-disable @typescript-eslint/no-explicit-any */
const multiValidator: GenericValidateFunction<any> = () => true // ignoring validation for multi enum since it is custom and jsonforms does not support it properly
const { handleChange, control, appliedOptions, fieldName, validateOnValueUpdate } =
useJsonRendererBaseSetup(props.controlOverrides || useJsonFormsEnumControl(props), {
onChangeValueConverter: (newVal: ValueType) => {
if (props.multiple && isArrayValue(newVal)) {
return newVal.map((v) => v.value)
} else if (newVal && !props.multiple && !isArrayValue(newVal)) {
return newVal.value
} else {
return undefined
}
}
})
const modelValue = computed(() => {
const val = control.value.data as OptionType[]
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
return control.value.options.filter((o) => val?.includes(o.value))
})
/**
* Computed property to check if all available options are selected.
*/
const allSelected = computed(() => {
const currentSelection = modelValue.value || []
const allOptions = control.value.options || []
return currentSelection.length === allOptions.length && allOptions.length > 0
})
/**
* Toggle between selecting all categories and clearing all selections.
*/
const toggleSelectAll = () => {
if (allSelected.value) {
// deselect all -> pass empty array
handleChange([])
} else {
// select all available options
const allOptions = control.value.options || []
handleChange(allOptions)
}
}
</script>
@@ -7,8 +7,8 @@
:label="control.label"
:placeholder="appliedOptions['placeholder']"
:help="control.description"
color="foundation"
show-label
size="lg"
:validate-on-value-update="validateOnValueUpdate"
@update:model-value="handleChange"
/>
+17
View File
@@ -0,0 +1,17 @@
<template>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6.73336 1.45469C7.57004 1.29277 8.43001 1.29277 9.26669 1.45469M9.26669 14.5454C8.43001 14.7073 7.57004 14.7073 6.73336 14.5454M11.7394 2.48069C12.447 2.96017 13.0558 3.57127 13.5327 4.28069M1.45469 9.26669C1.29277 8.43001 1.29277 7.57004 1.45469 6.73336M13.5194 11.7394C13.0399 12.447 12.4288 13.0558 11.7194 13.5327M14.5454 6.73336C14.7073 7.57004 14.7073 8.43001 14.5454 9.26669M2.48069 4.26069C2.96017 3.55304 3.57127 2.94421 4.28069 2.46736M4.26069 13.5194C3.55304 13.0399 2.94421 12.4288 2.46736 11.7194"
stroke="#707070"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</template>
+18
View File
@@ -0,0 +1,18 @@
<template>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8 1.33301C11.6819 1.33301 14.667 4.3181 14.667 8C14.6669 11.6819 11.6819 14.667 8 14.667C4.3182 14.6669 1.33305 11.6818 1.33301 8C1.33301 4.31816 4.31818 1.3331 8 1.33301ZM10.5303 6.13672C10.2374 5.84383 9.76262 5.84383 9.46973 6.13672L7.33301 8.27246L6.53027 7.46973C6.23742 7.17705 5.76258 7.17705 5.46973 7.46973C5.17713 7.76259 5.17708 8.23745 5.46973 8.53027L6.80273 9.86426C6.94329 10.0047 7.13433 10.0839 7.33301 10.084C7.53165 10.084 7.72268 10.0046 7.86328 9.86426L10.5303 7.19727C10.8231 6.90445 10.8229 6.42963 10.5303 6.13672Z"
fill="#15803D"
/>
<path
d="M8 1.33301C11.6819 1.33301 14.667 4.3181 14.667 8C14.6669 11.6819 11.6819 14.667 8 14.667C4.3182 14.6669 1.33305 11.6818 1.33301 8C1.33301 4.31816 4.31818 1.3331 8 1.33301ZM10.5303 6.13672C10.2374 5.84383 9.76262 5.84383 9.46973 6.13672L7.33301 8.27246L6.53027 7.46973C6.23742 7.17705 5.76258 7.17705 5.46973 7.46973C5.17713 7.76259 5.17708 8.23745 5.46973 8.53027L6.80273 9.86426C6.94329 10.0047 7.13433 10.0839 7.33301 10.084C7.53165 10.084 7.72268 10.0046 7.86328 9.86426L10.5303 7.19727C10.8231 6.90445 10.8229 6.42963 10.5303 6.13672Z"
fill="#16A34A"
/>
</svg>
</template>
+35
View File
@@ -0,0 +1,35 @@
<template>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8.00024 2.08337C11.2678 2.08355 13.9163 4.73279 13.9163 8.00037C13.9161 11.2678 11.2677 13.9162 8.00024 13.9164C4.73267 13.9164 2.08343 11.2679 2.08325 8.00037C2.08325 4.73268 4.73256 2.08337 8.00024 2.08337Z"
stroke="#EAB308"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M8.75 4.83789C10.1832 5.17655 11.25 6.46328 11.25 8C11.25 9.53664 10.1831 10.8224 8.75 11.1611V4.83789Z"
fill="#EAB308"
/>
<path
d="M8.75 4.83789C10.1832 5.17655 11.25 6.46328 11.25 8C11.25 9.53664 10.1831 10.8224 8.75 11.1611V4.83789Z"
stroke="#7C7C7D"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M8.75 4.83789C10.1832 5.17655 11.25 6.46328 11.25 8C11.25 9.53664 10.1831 10.8224 8.75 11.1611V4.83789Z"
stroke="#EAB308"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</template>
+27 -5
View File
@@ -49,9 +49,26 @@
>
<span class="">Update</span>
</FormButton> -->
<div class="text-[8px] text-foreground-disabled max-[150px]:hidden">
<div
class="text-[8px] text-foreground-disabled max-[150px]:hidden"
:class="{ 'mr-2': !hostAppStore.isDistributedBySpeckle }"
>
{{ hostAppStore.connectorVersion }}
</div>
<div
v-if="!hostAppStore.isDistributedBySpeckle && hostAppStore.hostAppName"
v-tippy="
`${hostAppStore.hostAppName
.charAt(0)
.toUpperCase()}${hostAppStore.hostAppName.slice(
1
)} connector is not distributed by Speckle.`
"
class="text-xs text-foreground-disabled max-[150px]:hidden mr-1"
>
<CommonBadge color="secondary">Partner</CommonBadge>
</div>
<HeaderButton
v-if="hostAppStore.isDistributedBySpeckle"
v-tippy="'Documentation and help'"
@@ -65,7 +82,11 @@
class="w-4 text-foreground-disabled group-hover:text-foreground-2"
/>
</HeaderButton>
<HeaderButton v-tippy="'Send us feedback'" @click="openFeedbackDialog()">
<HeaderButton
v-if="hostAppStore.isDistributedBySpeckle"
v-tippy="'Send us feedback'"
@click="openFeedbackDialog()"
>
<ChatBubbleLeftIcon
class="w-4 text-foreground-disabled group-hover:text-foreground-2"
/>
@@ -97,7 +118,7 @@ const showFeedbackDialog = ref<boolean>(false)
const showSendDialog = ref<boolean>(false)
const showReceiveDialog = ref<boolean>(false)
app.$baseBinding.on('documentChanged', () => {
app.$baseBinding?.on('documentChanged', () => {
showSendDialog.value = false
showReceiveDialog.value = false
})
@@ -106,8 +127,9 @@ const { $intercom } = useNuxtApp()
const openFeedbackDialog = () => {
if (
hostAppStore.hostAppName?.toLowerCase() === 'revit' &&
hostAppStore.hostAppVersion?.includes('2022')
(hostAppStore.hostAppName?.toLowerCase() === 'revit' &&
hostAppStore.hostAppVersion?.includes('2022')) ||
!hostAppStore.isDistributedBySpeckle
) {
showFeedbackDialog.value = true
} else {
+14
View File
@@ -37,6 +37,18 @@
</div>
</MenuItem>
<div class="border-t border-outline-3 mt-1">
<MenuItem v-if="app.$revitMapperBinding" v-slot="{ active }">
<button
type="button"
:class="[
active ? 'bg-highlight-1' : '',
'my-1 text-body-2xs flex px-2 py-1 text-foreground cursor-pointer transition mx-1 rounded'
]"
@click="$router.push('/revit-mapper')"
>
Assign Revit Categories
</button>
</MenuItem>
<MenuItem
v-slot="{ active }"
@click="
@@ -109,6 +121,8 @@ import { XMarkIcon, Bars3Icon } from '@heroicons/vue/20/solid'
import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/vue'
import { useConfigStore } from '~/store/config'
const app = useNuxtApp()
const uiConfigStore = useConfigStore()
const { isDarkTheme, hasConfigBindings, isDevMode } = storeToRefs(uiConfigStore)
const { toggleTheme } = uiConfigStore
+30 -11
View File
@@ -5,21 +5,31 @@
>
Welcome to Speckle
</h1>
<div v-if="isDesktopServiceAvailable">
<AccountsSignInFlow />
<div v-if="isDesktopServiceAvailable || canAddAccount">
<AccountsSignInFlow v-if="!showLegacy" />
<AccountsLegacySignInFlow v-else @back-to-sign-in="showLegacy = false" />
<FormButton
v-if="!showLegacy"
text
full-width
size="sm"
class="text-xs"
@click="showLegacy = true"
>
Legacy Sign in
</FormButton>
</div>
<div v-else>
<div class="text-foreground-2 mt-2 mb-4">
Click the button below to sign into Speckle via Manager. This will allow you to
publish or load data.
To sign in and start using Speckle, you'll need the Desktop Service running.
This lightweight background service handles secure authentication.
</div>
<div class="text-foreground-2 text-sm mt-2 mb-4"></div>
<div class="flex flex-wrap justify-center space-y-2 max-width">
<FormButton full-width @click="$openUrl(`speckle://accounts`)">
Sign In
<div class="space-y-3">
<FormButton full-width @click="$openUrl('https://releases.speckle.systems')">
Download Desktop Service
</FormButton>
<div>
<div class="text-xs">Already done?</div>
<div class="text-center">
<div class="text-foreground-2 text-xs mb-2">Already installed?</div>
<FormButton
size="sm"
full-width
@@ -27,20 +37,29 @@
link
@click="accountStore.refreshAccounts()"
>
Click to refresh
Refresh to check again
</FormButton>
</div>
</div>
</div>
</LayoutPanel>
</template>
<script setup lang="ts">
import { useAccountStore } from '~~/store/accounts'
import { useDesktopService } from '~/lib/core/composables/desktopService'
import type { BaseBridge } from '~/lib/bridge/base'
const accountStore = useAccountStore()
const { pingDesktopService } = useDesktopService()
const { $accountBinding } = useNuxtApp()
const canAddAccount = ['AddAccount', 'addAccount'].some((name) =>
($accountBinding as unknown as BaseBridge).availableMethodNames.includes(name)
)
const showLegacy = ref(false)
const isDesktopServiceAvailable = ref(false) // this should be false default because there is a delay if /ping is not successful.
onMounted(async () => {
+51
View File
@@ -0,0 +1,51 @@
<!-- CommonTiptapViewer.vue -->
<template>
<!-- read-only output -->
<div
v-if="html"
class="p-1 pl-3 group w-full whitespace-pre-wrap break-words"
v-html="html"
/>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { JSONContent } from '@tiptap/core'
const props = defineProps<{
doc: JSONContent | null | undefined
}>()
const escapeHtml = (str: string): string =>
str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
function renderNode(node?: JSONContent): string {
if (!node) return ''
const children = (node.content ?? []).map(renderNode).join('')
switch (node.type) {
case 'doc':
return children
case 'paragraph':
// empty paragraph → visual empty line
return children ? `<p>${children}</p>` : '<p><br /></p>'
case 'text': {
const text = escapeHtml(node.text ?? '')
// if you need marks later (bold, italic, etc.), handle here
return text
}
case 'hardBreak':
return '<br />'
default:
// unknown node → just render its children
return children
}
}
const html = computed(() => (props.doc ? renderNode(props.doc) : ''))
</script>
+77
View File
@@ -0,0 +1,77 @@
<template>
<div class="p-0">
<slot name="activator" :toggle="toggleDialog"></slot>
<CommonDialog v-model:open="showIssuesDialog" :title="`Issues`" fullscreen="none">
<div class="flex flex-col space-y-2">
<div v-if="selectedIssue" class="flex flex-col space-y-1.5">
<div class="relative flex items-center h-8">
<div class="absolute left-0">
<FormButton
color="outline"
hide-text
:icon-left="ArrowLeft"
@click="selectedIssue = undefined"
/>
</div>
<div class="mx-auto text-foreground-2 font-medium font-mono text-body-xs">
{{ selectedIssue.identifier }}
</div>
<div class="absolute right-0">
<FormButton
v-tippy="'Open issue in browser'"
color="outline"
hide-text
:icon-left="ArrowTopRightOnSquareIcon"
@click="openIssueOnWeb(selectedIssue.id)"
/>
</div>
</div>
<hr />
<IssuesSelectedItem :issue="selectedIssue" />
</div>
<div v-if="!selectedIssue" class="flex flex-col space-y-2">
<IssuesItem
v-for="issue in issues"
:key="issue.id"
:issue="issue"
:model-card="modelCard"
@select="selectedIssue = issue"
@open-on-web="(issueId) => openIssueOnWeb(issueId)"
/>
</div>
</div>
</CommonDialog>
</div>
</template>
<script setup lang="ts">
import type { IssuesItemFragment } from '~/lib/common/generated/gql/graphql'
import type { IModelCard } from '~/lib/models/card'
import { ArrowLeft } from 'lucide-vue-next'
import { ArrowTopRightOnSquareIcon } from '@heroicons/vue/24/solid'
const props = defineProps<{
issues: IssuesItemFragment[]
modelCard: IModelCard
}>()
const app = useNuxtApp()
const showIssuesDialog = ref(false)
const selectedIssue = ref<IssuesItemFragment | undefined>(undefined)
const toggleDialog = () => {
showIssuesDialog.value = !showIssuesDialog.value
}
const openIssueOnWeb = (issueId: string) => {
app.$baseBinding.openUrl(
`${props.modelCard.serverUrl}/projects/${props.modelCard?.projectId}/models/${props.modelCard.modelId}#threadId=${issueId}`
)
}
watch(showIssuesDialog, (open) => {
if (!open) selectedIssue.value = undefined
})
</script>
+142
View File
@@ -0,0 +1,142 @@
<template>
<button
class="gap-1 border rounded-xl border-outline-3 p-1.5 pt-1 pl-3 group hover:shadow-md hover:cursor-pointer space-y-2"
@click="emit('select'), highlightModel()"
>
<!-- Item Header -->
<div class="flex justify-between items-center">
<div class="text-foreground-2 font-medium font-mono text-body-xs">
{{ issue.identifier }}
</div>
<div class="flex items-center">
<FormButton
v-if="store.hostAppName !== 'navisworks' && store.hostAppName !== 'etabs'"
v-tippy="'Highlight'"
color="subtle"
:icon-left="CursorArrowRaysIcon"
hide-text
size="sm"
@click.stop="highlightModel"
/>
<FormButton
v-tippy="'Open issue in browser'"
color="subtle"
:icon-left="ArrowTopRightOnSquareIcon"
hide-text
size="sm"
class="mr-1"
@click.stop="emit('open-on-web', issue.id)"
/>
<UserAvatar :user="issue.assignee?.user" size="xs" class="rounded-full" />
</div>
</div>
<!-- Item Title & status -->
<div class="flex items-center gap-1">
<IssuesStatusIcon :status="issue.status" />
<div class="line-clamp-2 font-medium text-body-2xs text-foreground">
{{ issue.title ? issue.title : 'No title' }}
</div>
</div>
<!-- Remaining secondary fields -->
<div class="flex items-center gap-4 ml-0.5">
<IssuesPriorityIcon :priority="issue.priority" />
<IssuesLabels :labels="issue.labels" />
<div v-if="formattedDate" class="flex items-center gap-1 h-6">
<Calendar class="text-foreground-2 shrink-0" :stroke-width="1.5" :size="12" />
<span class="text-body-3xs text-foreground-2 font-medium">
{{ formattedDate }}
</span>
</div>
<div v-else class="flex items-center gap-1 h-6">
<Calendar class="text-foreground-2 shrink-0" :stroke-width="1.5" :size="12" />
<span class="text-body-3xs text-foreground-2 font-medium">No due date</span>
</div>
</div>
</button>
</template>
<script lang="ts" setup>
import type { IssuesItemFragment } from '~/lib/common/generated/gql/graphql'
import { CursorArrowRaysIcon } from '@heroicons/vue/24/outline'
import { Calendar } from 'lucide-vue-next'
import dayjs from 'dayjs'
import { useHostAppStore } from '~~/store/hostApp'
import { ToastNotificationType } from '@speckle/ui-components'
import type { IModelCard } from '~/lib/models/card'
import type { SenderModelCard } from '~/lib/models/card/send'
import { ArrowTopRightOnSquareIcon } from '@heroicons/vue/24/solid'
const store = useHostAppStore()
const props = defineProps<{
modelCard: IModelCard
issue: IssuesItemFragment
}>()
const emit = defineEmits<{
(e: 'select'): void
(e: 'open-on-web', issueId: string): void
}>()
const app = useNuxtApp()
type IssueViewerState = {
ui: {
filters: {
selectedObjectApplicationIds?: Record<string, string>
}
}
}
const highlightModel = async () => {
if (!props.issue.viewerState) {
store.setNotification({
title: 'Objects not found to highlight',
type: ToastNotificationType.Info
})
return
}
if (props.modelCard.typeDiscriminator !== 'SenderModelCard') return
const sender = props.modelCard as SenderModelCard
type SelectedObjectMap = Record<string, string>
const selectedObjectApplicationIds = Object.values(
((props.issue.viewerState as IssueViewerState).ui.filters
.selectedObjectApplicationIds ?? {}) as SelectedObjectMap
)
const appIdsToHighlight = (sender.sendFilter?.selectedObjectIds ?? []).filter((id) =>
selectedObjectApplicationIds.includes(id)
)
if (appIdsToHighlight.length > 0) {
await app.$baseBinding.highlightObjects(appIdsToHighlight)
} else {
store.setNotification({
title: 'Objects not found to highlight on this model.',
type: ToastNotificationType.Info
})
}
}
const formattedDate = computed((): string | null => {
try {
const date = props.issue.dueDate ? dayjs(props.issue.dueDate).toDate() : null
if (!(date instanceof Date)) return null
const time = date.getTime()
if (isNaN(time)) return null
return new Intl.DateTimeFormat('en-GB', {
month: 'short',
day: 'numeric'
}).format(date)
} catch {
return null
}
})
</script>
+39
View File
@@ -0,0 +1,39 @@
<template>
<div class="flex items-center gap-1.5">
<div class="flex items-center -space-x-1">
<template
v-for="labelItem in maxVisible ? labels.slice(0, maxVisible) : labels"
:key="labelItem.id"
>
<div
v-if="labelItem.hexColor"
class="w-2 h-2 rounded-full shrink-0"
:style="{ backgroundColor: labelItem.hexColor }"
/>
</template>
</div>
<!-- Single label -->
<span
v-if="labels.length === 1"
class="text-body-3xs font-medium flex items-center gap-1"
:style="{ color: labels[0].hexColor || undefined }"
>
{{ labels[0].name }}
</span>
<!-- Multiple labels -->
<span v-else class="text-body-3xs text-foreground-2 font-medium">
{{ labels.length }} label{{ labels.length !== 1 ? 's' : '' }}
</span>
</div>
</template>
<script setup lang="ts">
import type { Label } from '~/lib/issues/types'
defineProps<{
labels: Label[]
maxVisible?: number
}>()
</script>
+34
View File
@@ -0,0 +1,34 @@
<template>
<Tippy interactive placement="bottom" :offset="[0, 6]">
<!-- Trigger -->
<template #default>
<IssuesLabelGroup :labels="labels" />
</template>
<!-- Tooltip content -->
<template v-if="labels.length > 0" #content>
<div class="rounded-md shadow-lg p-0.5 text-xs space-y-1">
<div
v-for="label in labels"
:key="label.id"
class="flex items-center space-x-2"
>
<span
class="w-2 h-2 rounded-full"
:style="{ backgroundColor: label.hexColor }"
/>
<span>{{ label.name }}</span>
</div>
</div>
</template>
</Tippy>
</template>
<script setup lang="ts">
import { Tippy } from 'vue-tippy'
import type { Label } from '~/lib/issues/types'
defineProps<{
labels: Label[]
}>()
</script>
+60
View File
@@ -0,0 +1,60 @@
<template>
<div class="flex items-center space-x-2">
<div
v-if="priority !== null && priority !== 'none'"
v-tippy="showLabel ? undefined : priorityText"
class="flex flex-col gap-0.5 items-start justify-center w-3 h-3"
>
<!-- Top line -->
<div
class="h-0.5 rounded-full bg-foreground-2 w-3"
:class="priority !== 'high' && 'opacity-25'"
/>
<!-- Middle line -->
<div
class="h-0.5 rounded-full bg-foreground-2 w-2"
:class="priority === 'low' && 'opacity-25'"
/>
<!-- Bottom line -->
<div class="h-0.5 rounded-full bg-foreground-2 w-1" />
</div>
<!-- No priority: Two dashes -->
<div v-else class="flex gap-0.5 items-center justify-center h-3 w-3">
<div class="h-px rounded-full bg-foreground-3 w-1" />
<div class="h-px rounded-full bg-foreground-3 w-1" />
</div>
<span v-if="showLabel" class="text-body-3xs text-foreground-2 font-medium">
{{ priorityText }}
</span>
</div>
</template>
<script setup lang="ts">
const props = withDefaults(
defineProps<{
priority: 'none' | 'low' | 'medium' | 'high' | null
showLabel?: boolean
}>(),
{
showLabel: false
}
)
const priorityText = computed(() => {
switch (props.priority) {
case 'high':
return 'High'
case 'medium':
return 'Medium'
case 'low':
return 'Low'
case 'none':
return 'No priority'
case null:
return 'No priority'
default:
return ''
}
})
</script>
+112
View File
@@ -0,0 +1,112 @@
<template>
<div class="flex flex-col space-y-1.5">
<div class="flex flex-col items-start space-y-2 p-2">
<div class="line-clamp-2 font-medium text-body text-foreground">
{{ issue.title ? issue.title : 'No title' }}
</div>
<IssuesBasicTiptap
v-if="issue.description?.doc"
class="border rounded-xl border-outline-3"
:doc="issue.description?.doc"
></IssuesBasicTiptap>
<div class="flex flex-wrap items-center gap-x-3 gap-y-1">
<IssuesStatusIcon :status="issue.status" show-label />
<IssuesPriorityIcon :priority="issue.priority" show-label />
<div class="flex items-center justify-between space-x-1">
<UserAvatar :user="issue.assignee?.user" size="xs" />
<span class="text-body-3xs text-foreground-2 font-medium">
{{ issue.assignee ? issue.assignee?.user.name : 'No assignee' }}
</span>
</div>
<IssuesLabels :labels="issue.labels" />
<div v-if="formattedDate" class="flex items-center gap-1 h-6">
<Calendar class="text-foreground-2 shrink-0" :stroke-width="1.5" :size="12" />
<span class="text-body-3xs text-foreground-2 font-medium">
{{ formattedDate }}
</span>
</div>
<div v-else class="flex items-center gap-1 h-6">
<Calendar class="text-foreground-2 shrink-0" :stroke-width="1.5" :size="12" />
<span class="text-body-3xs text-foreground-2 font-medium">No due date</span>
</div>
</div>
<div
v-if="issue.activities && issue.activities.totalCount > 0"
class="flex items-center gap-2 p-1 min-w-0"
>
<UserAvatar
:user="issue.activities?.items?.[0]?.actor?.user"
size="xs"
class="shrink-0"
/>
<div class="text-body-2xs text-foreground-2 leading-tight min-w-0">
<span class="font-medium">
{{ issue.activities?.items?.[0]?.actor?.user.name }}
</span>
<span>
&nbsp;created this issue &middot;
{{ dayjs(issue.activities?.items?.[0].createdAt).from(dayjs()) }}
</span>
</div>
</div>
<div
v-if="issue.replies && issue.replies.totalCount > 0"
class="flex flex-col justify-between space-y-2 w-full"
>
<div
v-for="reply in issue.replies.items"
:key="reply.id"
class="flex flex-col items-start border rounded-xl border-outline-3 p-1 w-full"
>
<div class="flex items-center gap-2 w-full">
<UserAvatar :user="reply.author?.user" size="xs" class="shrink-0" />
<div class="text-body-2xs text-foreground-2 leading-tight min-w-0">
<span class="font-medium">
{{ reply.author?.user.name }}
</span>
<span>
&nbsp;replied &middot;
{{ dayjs(reply.createdAt).from(dayjs()) }}
</span>
</div>
</div>
<IssuesBasicTiptap
v-if="reply.description?.doc"
class="ml-4"
:doc="reply.description?.doc"
/>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { IssuesItemFragment } from '~/lib/common/generated/gql/graphql'
import dayjs from 'dayjs'
import { Calendar } from 'lucide-vue-next'
const props = defineProps<{
issue: IssuesItemFragment
}>()
const formattedDate = computed((): string | null => {
try {
const date = props.issue.dueDate ? dayjs(props.issue.dueDate).toDate() : null
if (!(date instanceof Date)) return null
const time = date.getTime()
if (isNaN(time)) return null
return new Intl.DateTimeFormat('en-GB', {
month: 'short',
day: 'numeric'
}).format(date)
} catch {
return null
}
})
</script>
+49
View File
@@ -0,0 +1,49 @@
<template>
<div
v-tippy="showLabel ? undefined : statusText"
class="flex items-center gap-1 rounded-md hover:bg-foreground-1"
>
<GlobalIconStatusOpen v-if="status === 'open'" class="w-3 h-3 shrink-0" />
<GlobalIconStatusReview
v-else-if="status === 'readyForReview'"
class="w-3 h-3 shrink-0"
/>
<GlobalIconStatusResolved
v-else-if="status === 'resolved'"
class="w-3 h-3 shrink-0"
/>
<span v-if="showLabel" class="text-body-3xs text-foreground-2 font-medium">
{{ statusText }}
</span>
</div>
</template>
<script setup lang="ts">
import GlobalIconStatusOpen from '~/components/global/icon/StatusOpen.vue'
import GlobalIconStatusReview from '~/components/global/icon/StatusReview.vue'
import GlobalIconStatusResolved from '~/components/global/icon/StatusResolved.vue'
const props = withDefaults(
defineProps<{
status: 'open' | 'readyForReview' | 'resolved'
showLabel?: boolean
}>(),
{
showLabel: false
}
)
const statusText = computed(() => {
switch (props.status) {
case 'open':
return 'Open'
case 'readyForReview':
return 'Ready for review'
case 'resolved':
return 'Resolved'
default:
return ''
}
})
</script>
+82
View File
@@ -0,0 +1,82 @@
<template>
<div class="px-2">
<p class="h5">Layer Selection</p>
<div class="space-y-2 my-2">
<!-- Multi-select layer dropdown -->
<FormSelectMulti
:key="selectedLayers.length === 0 ? 'empty' : 'hasSelection'"
:model-value="selectedLayers"
name="layerSelection"
label="Select layers"
class="w-full"
fixed-height
size="sm"
:items="layerOptions"
:allow-unset="false"
by="id"
clearable
:search="true"
:search-placeholder="''"
:filter-predicate="layerSearchFilterPredicate"
mount-menu-on-body
@update:model-value="(value) => $emit('update:selectedLayers', value as LayerOption[])"
>
<template #something-selected="{ value }">
<span class="text-primary text-xs">
{{ `${value.length} layer${value.length !== 1 ? 's' : ''} selected` }}
</span>
</template>
<template #option="{ item }">
<span class="text-xs">{{ item.name }}</span>
</template>
</FormSelectMulti>
<!-- Layer selection summary -->
<div
v-if="selectedLayers.length === 0"
class="space-y-2 p-2 bg-highlight-1 rounded-md text-body-xs"
>
<div class="text-foreground-2">
No layers selected, choose layers from the dropdown above!
</div>
</div>
<div v-else class="space-y-2 p-2 bg-highlight-1 rounded-md text-body-xs">
<div>
Selected {{ selectedLayers.length }} layer{{
selectedLayers.length !== 1 ? 's' : ''
}}:
{{ selectedLayers.map((l) => l.name).join(', ') }}
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
interface LayerOption {
id: string
name: string
[key: string]: unknown
}
defineProps<{
selectedLayers: LayerOption[]
layerOptions: LayerOption[]
}>()
defineEmits<{
'update:selectedLayers': [layers: LayerOption[]]
}>()
// Search predicate for layer dropdown
const layerSearchFilterPredicate = (
item: LayerOption | string | number | Record<string, unknown>,
query: string
): boolean => {
if (typeof item === 'object' && item !== null && 'name' in item) {
const layerItem = item as LayerOption
return layerItem.name.toLowerCase().includes(query.toLowerCase())
}
return false
}
</script>
+48
View File
@@ -0,0 +1,48 @@
<template>
<div class="py-1 px-2 bg-foundation border rounded-lg">
<div class="flex justify-between items-center">
<div class="text-xs font-medium grow">{{ categoryLabel }}</div>
<div class="flex space-x-1">
<div class="flex justify-center items-center text-xs text-foreground-2 mr-1">
{{ countText }}
</div>
<FormButton
v-if="tooltipText"
v-tippy="tooltipText"
size="sm"
color="outline"
:icon-left="CursorArrowRaysIcon"
hide-text
@click="$emit('select')"
/>
<FormButton
v-else
size="sm"
color="outline"
:icon-left="CursorArrowRaysIcon"
hide-text
@click="$emit('select')"
/>
<FormButton class="!px-1.5" size="sm" color="outline" @click="$emit('clear')">
<TrashIcon class="w-3 h-3" />
</FormButton>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { CursorArrowRaysIcon, TrashIcon } from '@heroicons/vue/24/outline'
defineProps<{
categoryLabel: string
countText: string
tooltipText?: string
}>()
defineEmits<{
select: []
clear: []
}>()
</script>
+15
View File
@@ -0,0 +1,15 @@
<template>
<div class="space-y-2 p-2 bg-highlight-1 rounded-md text-body-xs">
<div v-if="!hasSelection">
No objects selected, go ahead and select some from your model!
</div>
<div v-else>{{ selectionSummary }}</div>
</div>
</template>
<script setup lang="ts">
defineProps<{
hasSelection: boolean
selectionSummary: string
}>()
</script>
+34 -1
View File
@@ -5,6 +5,7 @@
:icon-left="Bars3Icon"
hide-text
size="sm"
:disabled="!!props.modelCard.progress"
@click.stop="openModelCardActionsDialog = true"
/>
<CommonDialog
@@ -32,6 +33,18 @@
</button>
</template>
</ReportBase>
<IssuesDialog
v-if="issues && issues.length > 0"
:model-card="modelCard"
:issues="issues"
>
<template #activator="{ toggle }">
<button class="action action-normal" @click="toggle()">
<div class="truncate max-[275px]:text-xs">Issues</div>
<div><Cog6ToothIcon class="w-5 h-5" /></div>
</button>
</template>
</IssuesDialog>
<button
v-for="item in items"
:key="item.name"
@@ -57,6 +70,10 @@ import {
} from '@heroicons/vue/24/outline'
import type { IModelCard } from '~/lib/models/card'
import { useMixpanel } from '~/lib/core/composables/mixpanel'
import { issuesListQuery } from '~/lib/issues/graphql/queries'
import { useAccountStore } from '~/store/accounts'
import { storeToRefs } from 'pinia'
import { useQuery } from '@vue/apollo-composable'
const { trackEvent } = useMixpanel()
@@ -73,7 +90,7 @@ const hasSettings = computed(() => {
})
const app = useNuxtApp()
app.$baseBinding.on('documentChanged', () => {
app.$baseBinding?.on('documentChanged', () => {
openModelCardActionsDialog.value = false
})
@@ -113,6 +130,22 @@ const items = [
}
}
]
const accountStore = useAccountStore()
const { activeAccount } = storeToRefs(accountStore)
const accountId = computed(() => activeAccount.value.accountInfo.id)
const { result: issuesResult } = useQuery(
issuesListQuery,
() => ({ projectId: props.modelCard.projectId }),
() => ({
clientId: accountId.value,
debounce: 500,
fetchPolicy: 'network-only'
})
)
const issues = computed(() => issuesResult?.value?.project.issues.items)
</script>
<style scoped lang="postcss">
.action {
+117 -18
View File
@@ -5,10 +5,12 @@
>
<div v-if="modelData" class="relative px-1 py-1">
<div class="relative flex items-center space-x-2 min-w-0">
<div class="text-foreground-2 mt-[2px] flex items-center -space-x-2 relative">
<div
v-tippy="buttonTooltip"
class="text-foreground-2 mt-[2px] flex items-center -space-x-2 relative"
>
<!-- CTA button -->
<FormButton
v-tippy="buttonTooltip"
color="outline"
:icon-left="
modelCard.progress
@@ -19,7 +21,9 @@
"
hide-text
class=""
:disabled="!canEdit"
:disabled="
(!canEdit || isSettingsMissing || ctaDisabled) && !modelCard.progress
"
@click.stop="$emit('manual-publish-or-load')"
></FormButton>
</div>
@@ -57,6 +61,32 @@
</button>
</template>
</AutomateResultDialog>
<!-- To test missing settings -->
<!-- <FormButton
v-if="!isSettingsMissing"
v-tippy="'Refresh settings are needed'"
color="subtle"
:icon-left="TrashIcon"
hide-text
size="sm"
@click="deleteSettings"
/> -->
<IssuesDialog
v-if="issues && issues.length > 0"
:model-card="modelCard"
:issues="issues"
>
<template #activator="{ toggle }">
<FormButton
v-tippy="'Issues'"
color="subtle"
:icon-left="MessageCircleMore"
hide-text
size="sm"
@click="toggle()"
/>
</template>
</IssuesDialog>
<FormButton
v-if="store.hostAppName !== 'navisworks' && store.hostAppName !== 'etabs'"
v-tippy="'Highlight'"
@@ -92,7 +122,24 @@
Fetching model data...
<CommonLoadingBar loading />
</div>
<div v-else class="px-1 py-1">Error loading data.</div>
<div
v-else
class="flex flex-row items-center px-2 pt-2 text-body-2xs text-foreground-2 truncate text-red-500"
>
<span class="ml-1.5">Error on loading model data.</span>
<div class="flex items-center justify-end grow">
<FormButton
v-tippy="'Remove model card'"
color="subtle"
:icon-left="TrashIcon"
hide-text
size="sm"
class="text-red-500"
@click.stop="removeModel"
/>
</div>
</div>
<!-- Slot to allow senders or receivers to hoist their own buttons/ui -->
<!-- class="px-2 h-0 group-hover:h-auto transition-all overflow-hidden" -->
@@ -152,7 +199,7 @@
>
<div
v-tippy="
`${latestCommentNotification.comment?.author.name} just left a
`${latestCommentNotification.comment?.author?.name} just left a
comment.`
"
class="flex items-center space-x-1"
@@ -162,8 +209,8 @@
:users="[latestCommentNotification.comment?.author as AvatarUserWithId]"
/>
<span class="line-clamp-1">
{{ latestCommentNotification.comment?.author.name }} just left a
comment.
{{ latestCommentNotification.comment?.author?.name }} just left a
comment on the issue.
</span>
</div>
<div>
@@ -207,19 +254,28 @@ import { useMixpanel } from '~/lib/core/composables/mixpanel'
import { useIntervalFn, useTimeoutFn } from '@vueuse/core'
import type { ProjectCommentsUpdatedMessage } from '~/lib/common/generated/gql/graphql'
import { useFunctionRunsStatusSummary } from '~/lib/automate/runStatus'
import { CursorArrowRaysIcon, XCircleIcon } from '@heroicons/vue/24/outline'
import { CursorArrowRaysIcon, XCircleIcon, TrashIcon } from '@heroicons/vue/24/outline'
import type { AvatarUserWithId } from '@speckle/ui-components'
import { issuesListQuery } from '~/lib/issues/graphql/queries'
import { MessageCircleMore } from 'lucide-vue-next'
const app = useNuxtApp()
const store = useHostAppStore()
const accStore = useAccountStore()
const { trackEvent } = useMixpanel()
const props = defineProps<{
modelCard: IModelCard
project: ProjectModelGroup
canEdit: boolean
}>()
const props = withDefaults(
defineProps<{
modelCard: IModelCard
project: ProjectModelGroup
canEdit: boolean
ctaDisabled?: boolean
ctaDisabledMessage?: string
}>(),
{
ctaDisabled: false
}
)
defineEmits<{
(e: 'manual-publish-or-load'): void
@@ -230,11 +286,9 @@ const isSender = computed(() => {
})
const buttonTooltip = computed(() => {
return props.modelCard.progress
? 'Cancel'
: isSender.value
? 'Publish model'
: 'Load selected version'
if (props.modelCard.progress) return 'Cancel'
if (props.ctaDisabled) return props.ctaDisabledMessage
return isSender.value ? 'Publish model' : 'Load selected version'
})
const projectAccount = computed(() =>
@@ -300,6 +354,25 @@ const summary = computed(() => {
})
})
const { result: issuesResult, refetch: refetchIssues } = useQuery(
issuesListQuery,
() => ({ projectId: props.modelCard.projectId }),
() => ({
clientId,
debounce: 500,
fetchPolicy: 'network-only'
})
)
const issues = computed(() =>
issuesResult?.value?.project.issues.items.filter(
(issue) =>
issue.status !== 'resolved' &&
issue.resourceIdString &&
(issue.resourceIdString as string).includes(props.modelCard.modelId)
)
)
provide<IModelCard>('cardBase', props.modelCard)
const highlightModel = () => {
@@ -321,6 +394,31 @@ const highlightModel = () => {
trackEvent('DUI3 Action', { name: 'Highlight Model' }, props.modelCard.accountId)
}
const isSettingsMissing = computed(() =>
isSender.value ? isSendSettingsMissing.value : isReceiveSettingsMissing.value
)
const isSendSettingsMissing = computed(
() =>
isSender.value &&
store.sendSettings &&
store.sendSettings.length > 0 &&
!props.modelCard.settings
)
const isReceiveSettingsMissing = computed(
() =>
!isSender.value &&
store.receiveSettings &&
store.receiveSettings.length > 0 &&
!props.modelCard.settings
)
// To test missing settings
// const deleteSettings = async () => {
// await store.patchModel(props.modelCard.modelCardId, { settings: undefined })
// }
const viewModel = () => {
// previously with DUI2, it was Stream View but actually it is "Version View" now. Also having conflict with old/new terminology.
trackEvent('DUI3 Action', { name: 'Version View' }, props.modelCard.accountId)
@@ -434,6 +532,7 @@ onCommentResult((res) => {
latestCommentNotification.value = res.data
?.projectCommentsUpdated as ProjectCommentsUpdatedMessage
startCommentClearTimeout()
refetchIssues()
})
const viewComment = () => {
+39 -2
View File
@@ -19,7 +19,7 @@
color="subtle"
class="block text-foreground-2 hover:text-foreground overflow-hidden max-w-full !justify-start"
full-width
:disabled="!!modelCard.progress || !canEdit"
:disabled="!!modelCard.progress || !canEdit || isReceiveSettingsMissing"
@click.stop="openVersionsDialog = true"
>
<span>
@@ -52,10 +52,16 @@
:model-id="modelCard.modelId"
:workspace-slug="modelCard.workspaceSlug"
:selected-version-id="modelCard.selectedVersionId"
:settings="modelCard.settings"
@next="handleVersionSelection"
@update:settings="handleUpdateSettings"
/>
</CommonDialog>
<template #states>
<CommonModelNotification
v-if="isReceiveSettingsMissing"
:notification="receiveSettingsMissingNotification"
/>
<CommonModelNotification
v-if="expiredNotification"
:notification="expiredNotification"
@@ -95,6 +101,7 @@ import type { VersionListItemFragment } from '~/lib/common/generated/gql/graphql
import { useMixpanel } from '~/lib/core/composables/mixpanel'
import { useInterval, watchOnce } from '@vueuse/core'
import { useAccountStore } from '~~/store/accounts'
import type { CardSetting } from '~/lib/models/card/setting'
const { trackEvent } = useMixpanel()
const app = useNuxtApp()
@@ -114,14 +121,44 @@ const projectAccount = computed(() =>
accountStore.accountWithFallback(props.project.accountId, props.project.serverUrl)
)
app.$baseBinding.on('documentChanged', () => {
app.$baseBinding?.on('documentChanged', () => {
openVersionsDialog.value = false
})
const isReceiveSettingsMissing = computed(
() =>
store.receiveSettings &&
store.receiveSettings.length > 0 &&
!props.modelCard.settings
)
const receiveSettingsMissingNotification = computed(() => {
const notification = {} as ModelCardNotification
notification.dismissible = false
notification.level = 'danger'
notification.text = 'Load settings are corrupted for some reason.'
notification.cta = {
name: 'Refresh',
action: async () => {
await store.patchModel(props.modelCard.modelCardId, {
settings: store.receiveSettings
})
}
}
return notification
})
const isExpired = computed(() => {
return props.modelCard.latestVersionId !== props.modelCard.selectedVersionId
})
const handleUpdateSettings = async (settings: CardSetting[]) => {
await store.patchModel(props.modelCard.modelCardId, {
settings
})
}
// Cancels any in progress receive AND load selected version
const handleVersionSelection = async (
selectedVersion: VersionListItemFragment,
+125 -27
View File
@@ -4,6 +4,8 @@
:model-card="modelCard"
:project="project"
:can-edit="canEdit"
:cta-disabled="ctaDisabled"
:cta-disabled-message="ctaDisabledMessage"
@manual-publish-or-load="sendOrCancel"
>
<div class="flex max-[275px]:w-full overflow-hidden my-2">
@@ -13,11 +15,10 @@
size="sm"
color="subtle"
class="block text-foreground-2 hover:text-foreground overflow-hidden max-w-full !justify-start"
:disabled="!!modelCard.progress || !props.canEdit"
:disabled="!!modelCard.progress || !props.canEdit || isSendSettingsMissing"
full-width
@click.stop="openFilterDialog = true"
>
<!-- Sending&nbsp; -->
<span class="font-bold">{{ modelCard.sendFilter?.name }}:&nbsp;</span>
<span class="truncate">{{ modelCard.sendFilter?.summary }}</span>
</FormButton>
@@ -31,13 +32,23 @@
<FilterListSelect :filter="modelCard.sendFilter" @update:filter="updateFilter" />
<div class="mt-4 flex justify-end items-center space-x-2">
<!-- TODO: Ux wise, users might want to just save the selection and publish it later. -->
<FormButton size="sm" color="outline" @click.stop="saveFilter()">
<FormButton
size="sm"
color="outline"
:disabled="isSaveDisabled"
@click.stop="saveFilter()"
>
Save
</FormButton>
<FormButton size="sm" @click.stop="saveFilterAndSend()">
Save & Publish
</FormButton>
<div v-tippy="!canCreateVersionPerm ? canCreateVersionMessage : ''">
<FormButton
size="sm"
:disabled="!canCreateVersionPerm || isSaveDisabled"
@click.stop="saveFilterAndSend()"
>
Save & Publish
</FormButton>
</div>
</div>
</CommonDialog>
@@ -80,6 +91,10 @@
</form>
</CommonDialog>
<template #states>
<CommonModelNotification
v-if="isSendSettingsMissing"
:notification="sendSettingsMissingNotification"
/>
<CommonModelNotification
v-if="expiredNotification"
:notification="expiredNotification"
@@ -104,7 +119,7 @@
</ModelCardBase>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ref, onMounted, computed } from 'vue'
import ModelCardBase from '~/components/model/CardBase.vue'
import { Square3Stack3DIcon } from '@heroicons/vue/20/solid'
import type { ModelCardNotification } from '~/lib/models/card/notification'
@@ -113,13 +128,22 @@ import type { ProjectModelGroup } from '~/store/hostApp'
import { useHostAppStore } from '~/store/hostApp'
import { useMixpanel } from '~/lib/core/composables/mixpanel'
import { ToastNotificationType, ValidationHelpers } from '@speckle/ui-components'
import { provideApolloClient, useMutation } from '@vue/apollo-composable'
import {
provideApolloClient,
useMutation,
useSubscription
} from '@vue/apollo-composable'
import { useAccountStore, type DUIAccount } from '~/store/accounts'
import { setVersionMessageMutation } from '~/lib/graphql/mutationsAndQueries'
const hostAppStore = useHostAppStore()
import { workspacePlanUsageUpdatedSubscription } from '~/lib/workspaces/graphql/subscriptions'
import { useCheckGraphql } from '~/lib/core/composables/useCheckGraphql'
const store = useHostAppStore()
const accountStore = useAccountStore()
const { trackEvent } = useMixpanel()
const app = useNuxtApp()
const { canCreateModelIngestion } = useCheckGraphql()
const cardBase = ref<InstanceType<typeof ModelCardBase>>()
const props = defineProps<{
@@ -128,37 +152,87 @@ const props = defineProps<{
canEdit: boolean
}>()
const store = useHostAppStore()
const account = accountStore.accounts.find(
(acc) => acc.accountInfo.id === props.project.accountId
) as DUIAccount
const clientId = account.accountInfo.id
const openFilterDialog = ref(false)
app.$baseBinding.on('documentChanged', () => {
app.$baseBinding?.on('documentChanged', () => {
openFilterDialog.value = false
})
const canCreateVersionPerm = ref(true)
const canCreateVersionMessage = ref<string | null>(null)
const checkPermissions = async () => {
const res = await canCreateModelIngestion(
props.modelCard.projectId,
props.modelCard.modelId,
props.modelCard.accountId
)
if (res.queryAvailable) {
canCreateVersionPerm.value = res.authorized
canCreateVersionMessage.value = res.message || null
}
}
const ctaDisabled = computed(
() => !canCreateVersionPerm.value || !!props.modelCard.progress
)
const ctaDisabledMessage = computed(() => canCreateVersionMessage.value || undefined)
const { onResult: onWorkspacePlanUsageUpdated } = useSubscription(
workspacePlanUsageUpdatedSubscription,
() => ({
input: {
workspaceId: props.modelCard.workspaceId as string
}
}),
() => ({ clientId })
)
onWorkspacePlanUsageUpdated(() => {
void checkPermissions()
})
const sendOrCancel = () => {
if (!props.canEdit) {
// check for progress first to allow cancelling even if permissions changed
if (props.modelCard.progress) {
store.sendModelCancel(props.modelCard.modelCardId)
return
}
if (props.modelCard.progress) store.sendModelCancel(props.modelCard.modelCardId)
else store.sendModel(props.modelCard.modelCardId, 'ModelCardButton')
if (!props.canEdit || !canCreateVersionPerm.value) {
return
}
store.sendModel(props.modelCard.modelCardId, 'ModelCardButton')
hasSetVersionMessage.value = false
}
let newFilter: ISendFilter
const newFilter = ref<ISendFilter>()
const updateFilter = (filter: ISendFilter) => {
newFilter = filter
newFilter.value = filter
}
const isSaveDisabled = computed(() => {
const filterToCheck = newFilter.value || props.modelCard.sendFilter
return !store.validateSendFilter(filterToCheck).valid
})
const saveFilter = async () => {
if (!newFilter.value) return // Safety check
void trackEvent('DUI3 Action', {
name: 'Publish Card Filter Change',
filter: newFilter.typeDiscriminator
filter: newFilter.value.typeDiscriminator
})
// do not reset idmap while creating a new one because it is managed by host app
newFilter.idMap = props.modelCard.sendFilter?.idMap
newFilter.value.idMap = props.modelCard.sendFilter?.idMap
await store.patchModel(props.modelCard.modelCardId, {
sendFilter: newFilter,
sendFilter: newFilter.value,
expired: true
})
openFilterDialog.value = false
@@ -169,11 +243,6 @@ const isUpdatingVersionMessage = ref(false)
const hasSetVersionMessage = ref(false)
const versionMessage = ref<string>()
const accountStore = useAccountStore()
const account = accountStore.accounts.find(
(acc) => acc.accountInfo.id === props.project.accountId
) as DUIAccount
const setVersionMessage = async (message: string) => {
if (!props.modelCard.latestCreatedVersionId) {
return
@@ -199,14 +268,14 @@ const setVersionMessage = async (message: string) => {
if (res?.data?.versionMutations.update.id) {
// seemed to noisy, and autoclose does not work for some reason.
// nicer ux to just close the dialog
// hostAppStore.setNotification({
// store.setNotification({
// type: ToastNotificationType.Info,
// title: 'Version message saved',
// autoClose: true
// })
hasSetVersionMessage.value = true
} else {
hostAppStore.setNotification({
store.setNotification({
type: ToastNotificationType.Danger,
title: 'Request failed',
description: 'Failed to update version message.',
@@ -223,6 +292,27 @@ const saveFilterAndSend = async () => {
hasSetVersionMessage.value = false
}
const isSendSettingsMissing = computed(
() => store.sendSettings && store.sendSettings.length > 0 && !props.modelCard.settings
)
const sendSettingsMissingNotification = computed(() => {
const notification = {} as ModelCardNotification
notification.dismissible = false
notification.level = 'danger'
notification.text = 'Publish settings are corrupted for some reason.'
notification.cta = {
name: 'Refresh',
action: async () => {
await store.patchModel(props.modelCard.modelCardId, {
settings: store.sendSettings
})
}
}
return notification
})
const expiredNotification = computed(() => {
if (!props.modelCard.expired) return
@@ -236,6 +326,10 @@ const expiredNotification = computed(() => {
const ctaType = props.modelCard.progress ? 'Restart' : 'Update'
notification.cta = {
name: ctaType,
disabled: !canCreateVersionPerm.value,
tooltipText: !canCreateVersionPerm.value
? canCreateVersionMessage.value || 'Publish limit reached'
: undefined,
action: async () => {
hasSetVersionMessage.value = false
if (props.modelCard.progress) {
@@ -312,4 +406,8 @@ const latestVersionNotification = computed(() => {
}
return notification
})
onMounted(() => {
void checkPermissions()
})
</script>
@@ -27,28 +27,29 @@ import type { CardSetting, CardSettingValue } from '~/lib/models/card/setting'
import type { JsonFormsChangeEvent } from '@jsonforms/vue'
import { cloneDeep, omit } from 'lodash-es'
import type { JsonSchema } from '@jsonforms/core'
import { useHostAppStore } from '~/store/hostApp'
// import { useHostAppStore } from '~/store/hostApp'
const props = defineProps<{
settings?: CardSetting[]
defaultSettings: CardSetting[]
expandable: boolean
}>()
const emit = defineEmits<{ (e: 'update:settings', value: CardSetting[]): void }>()
const store = useHostAppStore()
// const store = useHostAppStore()
const defaultSendSettings = computed(() => store.sendSettings)
const sendSettings = ref<CardSetting[] | undefined>(
cloneDeep(props.settings ?? defaultSendSettings.value) // need to prevent mutation!
// const defaultSendSettings = computed(() => store.sendSettings)
const settings = ref<CardSetting[] | undefined>(
cloneDeep(props.settings ?? props.defaultSettings) // need to prevent mutation!
)
const showSettings = ref(!props.expandable)
const settingsJsonForms = computed(() => {
if (sendSettings.value === undefined) return {}
if (settings.value === undefined) return {}
const obj: JsonSchema = { type: 'object', properties: {} }
sendSettings.value.forEach((setting: CardSetting) => {
settings.value.forEach((setting: CardSetting) => {
const mappedSetting = omit({ ...setting, $id: setting.id }, ['id'])
if (obj && obj.properties) {
obj.properties[setting.id] = mappedSetting
@@ -60,8 +61,8 @@ const settingsJsonForms = computed(() => {
type DataType = Record<string, unknown>
const data = computed(() => {
const settingValues = {} as DataType
if (sendSettings.value) {
sendSettings.value.forEach((setting) => {
if (settings.value) {
settings.value.forEach((setting) => {
settingValues[setting.id as string] = setting.value
})
}
@@ -69,14 +70,14 @@ const data = computed(() => {
})
const onParamsFormChange = (e: JsonFormsChangeEvent) => {
if (sendSettings.value === undefined) return
sendSettings.value?.forEach((setting) => {
if (settings.value === undefined) return
settings.value?.forEach((setting) => {
if (setting) {
if (setting.value !== (e.data as DataType)[setting.id]) {
setting.value = (e.data as DataType)[setting.id] as CardSettingValue
}
}
})
emit('update:settings', sendSettings.value)
emit('update:settings', settings.value)
}
</script>
+30 -1
View File
@@ -5,7 +5,12 @@
:title="title"
:show-back-button="step !== 1"
@back="step--"
@fully-closed="step = 1"
@fully-closed="
() => {
step = 1
settingsWereChanged = false
}
"
>
<div>
<div v-if="step === 1">
@@ -37,6 +42,7 @@
:workspace-slug="selectedWorkspace?.slug"
:from-wizard="true"
@next="selectVersionAndAddModel"
@update:settings="handleUpdateSettings"
/>
</div>
</div>
@@ -55,10 +61,13 @@ import { useHostAppStore } from '~/store/hostApp'
import { useAccountStore } from '~/store/accounts'
import { ReceiverModelCard } from '~/lib/models/card/receiver'
import { useMixpanel } from '~/lib/core/composables/mixpanel'
import { useSettingsTracking } from '~/lib/core/composables/trackSettings'
import { useAddByUrl } from '~/lib/core/composables/addByUrl'
import { getSlugFromHostAppNameAndVersion } from '~/lib/common/helpers/hostAppSlug'
import type { CardSetting } from '~/lib/models/card/setting'
const { trackEvent } = useMixpanel()
const { trackSettingsChange } = useSettingsTracking()
const showReceiveDialog = defineModel<boolean>('open', { default: false })
@@ -83,6 +92,8 @@ const selectedAccountId = ref<string>(activeAccount.value?.accountInfo.id as str
const selectedWorkspace = ref<WorkspaceListWorkspaceItemFragment>()
const selectedProject = ref<ProjectListProjectItemFragment>()
const selectedModel = ref<ModelListModelItemFragment>()
const receieveSettings = ref<CardSetting[] | undefined>(undefined)
const settingsWereChanged = ref(false)
const { tryParseUrl, urlParsedData, urlParseError } = useAddByUrl()
const updateSearchText = (text: string | undefined) => {
@@ -131,6 +142,11 @@ const title = computed(() => {
return ''
})
const handleUpdateSettings = (settings: CardSetting[]) => {
receieveSettings.value = settings
settingsWereChanged.value = true
}
// accountId, serverUrl, ModelListModelItemFragment, VersionListItemFragment
const selectVersionAndAddModel = async (
version: VersionListItemFragment,
@@ -148,6 +164,18 @@ const selectVersionAndAddModel = async (
m.typeDiscriminator === 'ReceiverModelCard'
) as ReceiverModelCard
// track settings only if user changed them on receive
// compare against existing model settings if it exists, otherwise compare against defaults
if (settingsWereChanged.value && receieveSettings.value) {
trackSettingsChange(
'Load Settings Changed',
receieveSettings.value,
existingModel?.settings || hostAppStore.receiveSettings || [],
selectedAccountId.value,
true
)
}
if (existingModel) {
emit('close')
// Patch the existing model card with new versions!
@@ -173,6 +201,7 @@ const selectVersionAndAddModel = async (
)
const modelCard = new ReceiverModelCard()
modelCard.settings = receieveSettings.value
modelCard.accountId = selectedAccountId.value
modelCard.serverUrl = activeAccount.value.accountInfo.serverInfo.url
+3 -2
View File
@@ -1,11 +1,12 @@
<template>
<div class="space-y-4">
<FilterListSelect @update:filter="updateFilter" />
<SendSettings
<ModelSettings
v-if="hasSendSettings"
expandable
:default-settings="(store.sendSettings as unknown as CardSetting[])"
@update:settings="updateSettings"
></SendSettings>
></ModelSettings>
</div>
</template>
<script setup lang="ts">
+11 -7
View File
@@ -6,11 +6,12 @@
:title="`Settings`"
fullscreen="none"
>
<SendSettings
<ModelSettings
:expandable="false"
:default-settings="(store.sendSettings as unknown as CardSetting[])"
:settings="props.settings"
@update:settings="updateSettings"
></SendSettings>
></ModelSettings>
<div class="mt-4 flex justify-end items-center space-x-2">
<FormButton size="sm" color="outline" @click="showSettingsDialog = false">
Cancel
@@ -22,11 +23,11 @@
</template>
<script setup lang="ts">
import { useMixpanel } from '~/lib/core/composables/mixpanel'
import { useSettingsTracking } from '~/lib/core/composables/trackSettings'
import { useHostAppStore } from '~/store/hostApp'
import type { CardSetting } from '~/lib/models/card/setting'
const { trackEvent } = useMixpanel()
const { trackSettingsChange } = useSettingsTracking()
const props = defineProps<{
settings?: CardSetting[]
@@ -47,9 +48,12 @@ const updateSettings = (settings: CardSetting[]) => {
}
const saveSettings = async () => {
void trackEvent('DUI3 Action', {
name: 'Send Settings Updated'
})
trackSettingsChange(
'Model Card Settings Updated',
newSettings,
store.sendSettings || []
)
await store.patchModel(props.modelCardId, {
settings: newSettings,
expired: true
+126 -20
View File
@@ -5,7 +5,12 @@
:title="title"
:show-back-button="step !== 1"
@back="step--"
@fully-closed="step = 1"
@fully-closed="
() => {
step = 1
settingsWereChanged = false
}
"
>
<div v-if="step === 1">
<WizardProjectSelector
@@ -16,7 +21,6 @@
@search-text-update="updateSearchText"
/>
</div>
<!-- Model selector wizard -->
<div v-if="step === 2 && selectedProject && selectedAccountId">
<WizardModelSelector
:project="selectedProject"
@@ -27,15 +31,26 @@
@next="selectModel"
/>
</div>
<!-- Version selector wizard -->
<div v-if="step === 3">
<SendFiltersAndSettings
v-model="filter"
@update:filter="(f) => (filter = f)"
@update:settings="(s) => (settings = s)"
@update:settings="
(s) => {
settings = s
settingsWereChanged = true
}
"
/>
<div class="mt-2">
<FormButton full-width @click="addModel">Publish</FormButton>
<div v-tippy="publishTooltipMessage" class="mt-2">
<FormButton
full-width
:disabled="isPublishDisabled"
:loading="isLoadingPermissions"
@click="addModel"
>
Publish
</FormButton>
</div>
</div>
<div v-if="urlParseError" class="p-2 text-danger">
@@ -45,6 +60,7 @@
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { useSubscription } from '@vue/apollo-composable'
import type {
ModelListModelItemFragment,
ProjectListProjectItemFragment
@@ -53,11 +69,16 @@ import type { ISendFilter } from '~/lib/models/card/send'
import { SenderModelCard } from '~/lib/models/card/send'
import { useHostAppStore } from '~/store/hostApp'
import { useAccountStore } from '~/store/accounts'
import { useSelectionStore } from '~/store/selection'
import { useMixpanel } from '~/lib/core/composables/mixpanel'
import { useSettingsTracking } from '~/lib/core/composables/trackSettings'
import type { CardSetting } from '~/lib/models/card/setting'
import { useAddByUrl } from '~/lib/core/composables/addByUrl'
import { useCheckGraphql } from '~/lib/core/composables/useCheckGraphql'
import { workspacePlanUsageUpdatedSubscription } from '~/lib/workspaces/graphql/subscriptions'
const { trackEvent } = useMixpanel()
const { trackSettingsChange } = useSettingsTracking()
const showSendDialog = defineModel<boolean>('open', { default: false })
@@ -72,8 +93,32 @@ const selectedProject = ref<ProjectListProjectItemFragment>()
const selectedModel = ref<ModelListModelItemFragment>()
const filter = ref<ISendFilter | undefined>(undefined)
const settings = ref<CardSetting[] | undefined>(undefined)
const settingsWereChanged = ref(false)
const { tryParseUrl, urlParsedData, urlParseError } = useAddByUrl()
const { canCreateModelIngestion, canCreateVersion } = useCheckGraphql()
const canPublish = ref(false)
const publishLimitMessage = ref<string | undefined>(undefined)
const isLoadingPermissions = ref(false)
const hostAppStore = useHostAppStore()
const selectionStore = useSelectionStore()
const publishValidation = computed(() => hostAppStore.validateSendFilter(filter.value))
const isPublishDisabled = computed(() => {
return (
!canPublish.value || isLoadingPermissions.value || !publishValidation.value.valid
)
})
const publishTooltipMessage = computed(() => {
if (!publishValidation.value.valid) return publishValidation.value.reason
if (!canPublish.value && !isLoadingPermissions.value)
return publishLimitMessage.value || ''
return ''
})
const updateSearchText = (text: string | undefined) => {
urlParseError.value = undefined
if (!text) return
@@ -89,9 +134,72 @@ watch(urlParsedData, (newVal) => {
watch(showSendDialog, (newVal) => {
if (newVal) {
urlParseError.value = undefined
void selectionStore.refreshSelectionFromHostApp()
}
})
const checkPermissions = async () => {
if (!selectedProject.value || !selectedModel.value) return
isLoadingPermissions.value = true
try {
const res = await canCreateModelIngestion(
selectedProject.value.id,
selectedModel.value.id,
selectedAccountId.value
)
if (res.queryAvailable) {
canPublish.value = res.authorized
publishLimitMessage.value = res.message || undefined
} else {
// check legacy canCreateVersion in else block
const legacyRes = await canCreateVersion(
selectedProject.value.id,
selectedModel.value.id,
selectedAccountId.value
)
canPublish.value = legacyRes.authorized
publishLimitMessage.value = legacyRes.message || undefined
}
} finally {
isLoadingPermissions.value = false
}
}
watch(step, async (newVal, oldVal) => {
if (newVal > oldVal) {
if (newVal === 3) {
await checkPermissions()
}
return // exit fast on forward
}
if (newVal === 1) {
selectedProject.value = undefined
selectedModel.value = undefined
}
if (newVal === 2) selectedModel.value = undefined
})
const workspaceId = computed(() => selectedProject.value?.workspace?.id)
const { onResult: onUsageUpdate } = useSubscription(
workspacePlanUsageUpdatedSubscription,
() => ({
input: {
workspaceId: workspaceId.value || ''
}
}),
() => ({
enabled: !!workspaceId.value && step.value === 3,
clientId: selectedAccountId.value
})
)
onUsageUpdate(() => {
void checkPermissions()
})
const selectProject = (accountId: string, project: ProjectListProjectItemFragment) => {
step.value++
selectedAccountId.value = accountId
@@ -112,20 +220,6 @@ const selectModel = (model: ModelListModelItemFragment) => {
void trackEvent('DUI3 Action', { name: 'Publish Wizard', step: 'model selected' })
}
// Clears data if going backwards in the wizard
watch(step, (newVal, oldVal) => {
if (newVal > oldVal) {
return // exit fast on forward
}
if (newVal === 1) {
selectedProject.value = undefined
selectedModel.value = undefined
}
if (newVal === 2) selectedModel.value = undefined
})
const hostAppStore = useHostAppStore()
// accountId, serverUrl, projectId, modelId, sendFilter, settings
const addModel = async () => {
void trackEvent('DUI3 Action', {
@@ -139,6 +233,18 @@ const addModel = async () => {
m.modelId === selectedModel.value?.id &&
m.typeDiscriminator.includes('SenderModelCard')
) as SenderModelCard
// track settings only if user changed them
// compare against existing model card settings
if (settingsWereChanged.value && settings.value) {
trackSettingsChange(
'Publish Settings Changed',
settings.value,
existingModel?.settings || hostAppStore.sendSettings || [],
selectedAccountId.value,
true
)
}
if (existingModel) {
emit('close')
// Patch the existing model card with new send filter and non-expired state!
-163
View File
@@ -1,163 +0,0 @@
<template>
<div
:class="[
'text-foreground-on-primary flex shrink-0 items-center justify-center overflow-hidden rounded-full font-semibold uppercase transition',
sizeClasses,
bgClasses,
borderClasses,
hoverClasses,
activeClasses
]"
>
<slot>
<div
v-if="user?.avatar"
class="h-full w-full bg-cover bg-center bg-no-repeat"
:style="{ backgroundImage: `url('${user.avatar}')` }"
/>
<div
v-else-if="initials"
class="flex h-full w-full select-none items-center justify-center"
>
{{ initials }}
</div>
<div v-else><UserCircleIcon :class="iconClasses" /></div>
</slot>
</div>
</template>
<script setup lang="ts">
import { UserCircleIcon } from '@heroicons/vue/20/solid'
type UserAvatar = {
name: string
avatar?: string | null | undefined
}
type UserAvatarSize = 'xs' | 'sm' | 'base' | 'lg' | 'xl' | 'editable'
const props = withDefaults(
defineProps<{
user?: UserAvatar
size?: UserAvatarSize
hoverEffect?: boolean
active?: boolean
noBorder?: boolean
noBackground?: boolean
}>(),
{
user: undefined,
size: 'base',
hoverEffect: false
}
)
const initials = computed(() => {
if (!props.user?.name.length) return
const parts = props.user.name.split(' ')
const firstLetter = parts[0]?.[0] || ''
const secondLetter = parts[1]?.[0] || ''
if (props.size === 'sm' || props.size === 'xs') return firstLetter
return firstLetter + secondLetter
})
const borderClasses = computed(() => {
if (props.noBorder) return ''
return 'border-2 border-foundation'
})
const bgClasses = computed(() => {
if (props.noBackground) return ''
return 'bg-primary'
})
const hoverClasses = computed(() => {
if (props.hoverEffect)
return 'hover:border-primary focus:border-primary active:scale-95'
return ''
})
const activeClasses = computed(() => {
if (props.active) return 'border-primary'
return ''
})
const heightClasses = computed(() => {
const size = props.size
switch (size) {
case 'xs':
return 'h-5'
case 'sm':
return 'h-6'
case 'lg':
return 'h-10'
case 'xl':
return 'h-14'
case 'editable':
return 'h-60'
case 'base':
default:
return 'h-8'
}
})
const widthClasses = computed(() => {
const size = props.size
switch (size) {
case 'xs':
return 'w-5'
case 'sm':
return 'w-6'
case 'lg':
return 'w-10'
case 'xl':
return 'w-14'
case 'editable':
return 'w-60'
case 'base':
default:
return 'w-8'
}
})
const textClasses = computed(() => {
const size = props.size
switch (size) {
case 'xs':
return 'text-tiny'
case 'sm':
return 'text-xs'
case 'lg':
return 'text-md'
case 'xl':
return 'text-2xl'
case 'editable':
return 'h1'
case 'base':
default:
return 'text-sm'
}
})
const iconClasses = computed(() => {
const size = props.size
switch (size) {
case 'xs':
return 'w-3 h-3'
case 'sm':
return 'w-3 h-3'
case 'lg':
return 'w-5 h-5'
case 'xl':
return 'w-8 h-8'
case 'editable':
return 'w-20 h-20'
case 'base':
default:
return 'w-4 h-4'
}
})
const sizeClasses = computed(
() => `${widthClasses.value} ${heightClasses.value} ${textClasses.value}`
)
</script>
-27
View File
@@ -32,33 +32,6 @@
</FormButton>
</div>
</div>
<div
v-if="
canCreateModelResult &&
!canCreateModelResult.project.permissions.canCreateModel.authorized
"
>
<CommonAlert title="Cannot create new models" color="info" hide-icon>
<template #description>
{{ canCreateModelResult.project.permissions.canCreateModel.message }}
<FormButton
v-if="workspaceSlug"
full-width
color="primary"
size="sm"
class="mt-2"
@click="
$openUrl(
`${account.accountInfo.serverInfo.url}/settings/workspaces/${workspaceSlug}/billing`
)
"
>
Explore Plans
</FormButton>
</template>
</CommonAlert>
</div>
<div class="relative grid grid-cols-1 gap-2">
<CommonLoadingBar v-if="loading" loading />
+52 -70
View File
@@ -3,12 +3,18 @@
<div class="space-y-2 relative">
<div v-if="workspacesEnabled && workspaces" class="flex items-center space-x-2">
<div class="flex-grow min-w-0">
<!-- NO WORKSPACE YET -->
<div v-if="workspaces.length === 0">
<FormButton
full-width
class="flex items-center"
@click="$openUrl('https://app.speckle.systems/workspaces/actions/create')"
@click="
$openUrl(
`${activeAccount.accountInfo.serverInfo.url.replace(
/\/$/,
''
)}/workspaces/actions/create`
)
"
>
<div class="min-w-0 truncate flex-grow">
<span>{{ 'Create a workspace' }}</span>
@@ -31,7 +37,7 @@
<WorkspaceAvatar
:size="'xs'"
:name="selectedWorkspace.name || ''"
:logo="selectedWorkspace.logo"
:logo="selectedWorkspace.logoUrl"
/>
<div class="min-w-0 truncate flex-grow text-left">
<span>{{ selectedWorkspace.name }}</span>
@@ -72,13 +78,7 @@
color="foundation"
/>
<div class="flex justify-between items-center space-x-2">
<div
v-tippy="
canCreateProject
? 'Create new project'
: canCreateProjectPermissionCheck?.message
"
>
<div v-if="canCreateProject" v-tippy="'Create new project'">
<FormButton
color="outline"
:disabled="!canCreateProject"
@@ -88,6 +88,22 @@
<PlusIcon class="w-4 -mx-2" />
</FormButton>
</div>
<div
v-else
v-tippy="
canCreateProject
? 'Create new project'
: canCreateProjectPermissionCheck?.message
"
>
<FormButton
color="primary"
:class="`p-1.5 bg-foundation rounded text-foreground border`"
@click="upgradePlanButtonAction"
>
<ArrowUpCircleIcon class="w-4 -mx-2" />
</FormButton>
</div>
<CommonDialog
v-model:open="showProjectCreateDialog"
:title="`Create new project`"
@@ -132,28 +148,6 @@
</div>
</div>
</div>
<div
v-if="
canCreateProjectPermissionCheck &&
!canCreateProjectPermissionCheck.authorized
"
>
<CommonAlert color="info" hide-icon>
<template #description>
{{ canCreateProjectPermissionCheck.message }}
<FormButton
v-if="showUpgradeButton"
full-width
class="mt-2"
color="primary"
size="sm"
@click="upgradeButtonAction()"
>
Upgrade now
</FormButton>
</template>
</CommonAlert>
</div>
<WizardPersonalProjectsWarning v-if="isPersonalProjectsAsWorkspace" />
@@ -202,7 +196,7 @@
<script setup lang="ts">
import { ChevronDownIcon, ArrowTopRightOnSquareIcon } from '@heroicons/vue/24/outline'
import { storeToRefs } from 'pinia'
import { PlusIcon } from '@heroicons/vue/20/solid'
import { PlusIcon, ArrowUpCircleIcon } from '@heroicons/vue/20/solid'
import type { DUIAccount } from '~/store/accounts'
import { useAccountStore } from '~/store/accounts'
import {
@@ -330,19 +324,18 @@ const activeWorkspace = computed(() => {
}
}
const activeWorkspace = activeWorkspaceResult.value?.activeUser
const activeLimitedWorkspace = activeWorkspaceResult.value?.activeUser
?.activeWorkspace as WorkspaceListWorkspaceItemFragment
// fallback to activeWorkspace query result
if (activeWorkspace) {
return activeWorkspace
if (activeLimitedWorkspace) {
const activeWorkspace = workspaces.value?.find(
(w) => w.id === activeLimitedWorkspace.id
)
if (activeWorkspace) return activeWorkspace
}
// if activeWorkspace is null will mean that it is personal projects - this fallback wont be the case soon
return {
id: 'personalProject',
name: 'Personal Projects'
} as WorkspaceListWorkspaceItemFragment
return workspaces.value?.[0] // fallback to first workspace if none is active
})
const selectedWorkspace = ref<WorkspaceListWorkspaceItemFragment | undefined>(
@@ -494,35 +487,6 @@ const canCreateProjectPermissionCheck = computed(() => {
return null
})
const upgradeButtonAction = () => {
if (!canCreateProjectPermissionCheck.value) return
if (canCreateProjectPermissionCheck.value.code === 'WorkspaceNoEditorSeat') {
// open url to workspace/settings/users
$openUrl(
`${account.value.accountInfo.serverInfo.url}/settings/workspaces/${selectedWorkspace.value?.slug}/members`
)
return
}
if (canCreateProjectPermissionCheck.value.code === 'WorkspaceLimitsReached') {
// open url to workspace/billing
$openUrl(
`${account.value.accountInfo.serverInfo.url}/settings/workspaces/${selectedWorkspace.value?.slug}/billing`
)
return
}
}
const showUpgradeButton = computed(() => {
if (!canCreateProjectPermissionCheck.value) return false
if (
canCreateProjectPermissionCheck.value.code === 'WorkspaceNoEditorSeat' ||
canCreateProjectPermissionCheck.value.code === 'WorkspaceLimitsReached'
) {
return true
}
return false
})
const isCreatingProject = ref(false)
const showProjectCreateDialog = ref(false)
@@ -611,6 +575,24 @@ const createNewPersonalProject = async (name: string) => {
isCreatingProject.value = false
}
const upgradePlanButtonAction = () => {
if (!canCreateProjectPermissionCheck.value) return
if (canCreateProjectPermissionCheck.value.code === 'WorkspaceNoEditorSeat') {
// open url to workspace/settings/users
$openUrl(
`${account.value.accountInfo.serverInfo.url}/settings/workspaces/${selectedWorkspace.value?.slug}/members`
)
return
}
if (canCreateProjectPermissionCheck.value.code === 'WorkspaceLimitsReached') {
// open url to workspace/billing
$openUrl(
`${account.value.accountInfo.serverInfo.url}/settings/workspaces/${selectedWorkspace.value?.slug}/billing`
)
return
}
}
const loadMore = () => {
fetchMore({
variables: { cursor: projectsResult.value?.activeUser?.projects.cursor },
+25 -1
View File
@@ -13,6 +13,17 @@
Upgrade
</FormButton>
</div>
<div v-if="hasReceiveSettings">
<ModelSettings
class="mb-2"
expandable
:settings="settings"
:default-settings="(store.receiveSettings as unknown as CardSetting[])"
@update:settings="updateSettings"
></ModelSettings>
<hr />
</div>
<div v-if="latestVersion" class="grid grid-cols-2 gap-3 max-[275px]:grid-cols-1">
<WizardListVersionCard
v-for="(version, index) in versions"
@@ -44,27 +55,40 @@ import { useQuery } from '@vue/apollo-composable'
import { modelVersionsQuery } from '~/lib/graphql/mutationsAndQueries'
import type { VersionListItemFragment } from '~/lib/common/generated/gql/graphql'
import { useAccountStore } from '~/store/accounts'
import type { CardSetting } from '~/lib/models/card/setting'
import { useHostAppStore } from '~/store/hostApp'
defineEmits<{
const emit = defineEmits<{
(
e: 'next',
version: VersionListItemFragment,
latestVersion: VersionListItemFragment
): void
(e: 'update:settings', settings: CardSetting[]): void
}>()
const props = defineProps<{
accountId: string
projectId: string
modelId: string
settings?: CardSetting[]
selectedVersionId?: string
workspaceSlug?: string
fromWizard?: boolean
}>()
const store = useHostAppStore()
const accountStore = useAccountStore()
const serverUrl = computed(() => accountStore.activeAccount.accountInfo.serverInfo.url)
const hasReceiveSettings = computed(
() => store.receiveSettings && store.receiveSettings.length > 0
)
const updateSettings = (settings: CardSetting[]) => {
emit('update:settings', settings)
}
const {
result: modelVersionResults,
loading,
+1 -1
View File
@@ -14,7 +14,7 @@
<WorkspaceAvatar
:size="'sm'"
:name="workspace.name || ''"
:logo="workspace.logo"
:logo="workspace.logoUrl"
/>
<div class="min-w-0 grow">
<div class="truncate overflow-hidden min-w-0 flex items-center space-x-2">
+1 -7
View File
@@ -33,13 +33,7 @@ defineEmits<{
(e: 'workspace:selected', result: WorkspaceListWorkspaceItemFragment): void
}>()
const workspacesWithPersonalProjects = computed(() => [
...props.workspaces.filter((w) => w.creationState?.completed !== false),
{
id: 'personalProject',
name: 'Personal Projects'
} as WorkspaceListWorkspaceItemFragment
])
const workspacesWithPersonalProjects = computed(() => [...props.workspaces])
const toggleDialog = () => {
showWorkspaceSelectorDialog.value = !showWorkspaceSelectorDialog.value
View File
+15 -2
View File
@@ -8,7 +8,7 @@
v-if="hasNoModelCards"
class="px-3 text-body-3xs text-foreground-2 justify-center bg-red-200/1 py-2 flex items-center w-full space-x-2"
>
<span>Version {{ hostApp.connectorVersion }}</span>
<span>Version {{ hostApp.connectorVersion || 'dev' }}</span>
<FormButton
size="sm"
text
@@ -19,6 +19,17 @@
>
Toggle theme
</FormButton>
<FormButton
v-if="hostApp.hostAppName?.toLowerCase() === 'revit'"
size="sm"
text
color="subtle"
:icon-right="WrenchScrewdriverIcon"
hide-text
@click="app.$showDevTools()"
>
Open dev tools
</FormButton>
</div>
</div>
</template>
@@ -27,7 +38,9 @@
import { storeToRefs } from 'pinia'
import { useHostAppStore } from '~/store/hostApp'
import { useConfigStore } from '~/store/config'
import { MoonIcon, SunIcon } from '@heroicons/vue/24/outline'
import { MoonIcon, SunIcon, WrenchScrewdriverIcon } from '@heroicons/vue/24/outline'
const app = useNuxtApp()
const uiConfigStore = useConfigStore()
const { isDarkTheme } = storeToRefs(uiConfigStore)
+29
View File
@@ -0,0 +1,29 @@
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
const CHALLENGE_KEY = 'speckle_challenge'
const CHALLENGE_URL_KEY = 'speckle_url_challenge'
export function useAuthManager() {
const generateChallenge = (url: string): string => {
let result = ''
for (let i = 0; i < 12; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length))
}
localStorage.setItem(CHALLENGE_KEY, result) // <-- persist it
localStorage.setItem(CHALLENGE_URL_KEY, url)
return result
}
const getChallenge = (): string | null => {
return localStorage.getItem(CHALLENGE_KEY)
}
const getChallengeUrl = (): string | null => {
return localStorage.getItem(CHALLENGE_URL_KEY)
}
return {
getChallenge,
getChallengeUrl,
generateChallenge
}
}
@@ -7,6 +7,7 @@ export const IAccountBindingKey = 'accountsBinding'
export interface IAccountBinding extends IBinding<IAccountBindingEvents> {
getAccounts: () => Promise<Account[]>
addAccount: (accountId: string, account: Account) => Promise<void>
removeAccount: (accountId: string) => Promise<void>
}
@@ -15,6 +16,7 @@ export type Account = {
id: string
isDefault: boolean
token: string
refreshToken: string
serverInfo: {
name: string
url: string
@@ -31,3 +33,46 @@ export type Account = {
}
export interface IAccountBindingEvents extends IBindingSharedEvents {}
export class MockedAccountBinding implements IAccountBinding {
public async getAccounts() {
const config = useRuntimeConfig()
return (await [
{
id: 'whatever',
isDefault: true,
token: config.public.speckleToken,
serverInfo: {
name: 'test',
url: config.public.speckleUrl,
frontend2: true
},
userInfo: {
id: 'whatever',
avatar: 'whatever',
email: ''
}
}
]) as Account[]
}
public async addAccount(accountId: string, account: Account) {
return await console.log('no way dude', accountId, account)
}
public async removeAccount(accountId: string) {
return await console.log('no way dude', accountId)
}
public async showDevTools() {
await console.log('No way dude')
}
public async openUrl(url: string) {
await window.open(url)
}
public on() {
return
}
}
@@ -57,3 +57,65 @@ export type ToastAction = {
url: string
name: string
}
export class MockedBaseBinding implements IBasicConnectorBinding {
public async getSourceApplicationName() {
return await 'headless'
}
public async getSourceApplicationVersion() {
return await 'dev'
}
public async getConnectorVersion() {
return await 'dev'
}
public async getDocumentInfo() {
return (await {
id: 'whatever',
name: 'test',
location: 'whocares'
}) as DocumentInfo
}
public async getDocumentState() {
return (await { models: [] }) as DocumentModelStore
}
public async addModel(_model: IModelCard) {
await console.log('no way dude')
}
public async removeModel(_model: IModelCard) {
await console.log('no way dude')
}
public async removeModels(_models: IModelCard[]) {
await console.log('no way dude')
}
public async updateModel(_model: IModelCard) {
await console.log('no way dude')
}
public async highlightModel(_modelCardId: string) {
await console.log('no way dude')
}
public async highlightObjects(_objectIds: string[]) {
await console.log('no way dude')
}
public async showDevTools() {
await console.log('No way dude')
}
public async openUrl(url: string) {
await window.open(url)
}
public on() {
return
}
}
+54 -2
View File
@@ -1,4 +1,3 @@
import { BaseBridge } from '~/lib/bridge/base'
import type {
IBinding,
IBindingSharedEvents
@@ -15,6 +14,7 @@ export const IConfigBindingKey = 'configBinding'
export interface IConfigBinding extends IBinding<IConfigBindingEvents> {
getIsDevMode: () => Promise<boolean>
getConfig: () => Promise<ConnectorConfig>
getGlobalConfig: () => Promise<GlobalConfig>
updateConfig: (config: ConnectorConfig) => void
setUserSelectedAccountId: (accountId: string) => void
getUserSelectedAccountId: () => Promise<AccountsConfig>
@@ -25,6 +25,10 @@ export interface IConfigBinding extends IBinding<IConfigBindingEvents> {
export interface IConfigBindingEvents extends IBindingSharedEvents {}
export type GlobalConfig = {
isUpdateNotificationDisabled: boolean
}
export type ConnectorConfig = {
darkTheme: boolean
}
@@ -38,4 +42,52 @@ export type WorkspacesConfig = {
}
// Useless, but will do for now :)
export class MockedConfigBinding extends BaseBridge {}
export class MockedConfigBinding implements IConfigBinding {
public async getIsDevMode() {
return await true
}
public async getConfig() {
return await { darkTheme: false }
}
public async getGlobalConfig() {
return await { isUpdateNotificationDisabled: true }
}
public async updateConfig() {
return await console.log('')
}
public async setUserSelectedAccountId(accountId: string) {
return await console.log(accountId)
}
public async setUserSelectedWorkspaceId(workspaceId: string) {
return await console.log(workspaceId)
}
public async getAccountsConfig() {
return (await { userSelectedAccountId: 'whatever' }) as AccountsConfig
}
public async getWorkspacesConfig() {
return (await { userSelectedWorkspaceId: 'whatever' }) as WorkspacesConfig
}
public async getUserSelectedAccountId() {
return (await { userSelectedAccountId: 'whatever' }) as AccountsConfig
}
public async showDevTools() {
await console.log('No way dude')
}
public async openUrl(url: string) {
await window.open(url)
}
public on() {
return
}
}
@@ -24,3 +24,29 @@ export interface IReceiveBindingEvents
conversionResults: ConversionResult[]
}) => void
}
export class MockedReceiveBinding implements IReceiveBinding {
public async getReceiveSettings() {
return await []
}
public async receive(_modelCardId: string) {
return await console.log('no way dude')
}
public async cancelReceive(_modelCardId: string) {
return await console.log('no way dude')
}
public async showDevTools() {
await console.log('No way dude')
}
public async openUrl(url: string) {
await window.open(url)
}
public on() {
return
}
}
@@ -0,0 +1,158 @@
import type {
IBinding,
IBindingSharedEvents
} from '~/lib/bindings/definitions/IBinding'
export const IRevitMapperBindingKey = 'revitMapperBinding'
export interface IRevitMapperBinding extends IBinding<IMapperBindingEvents> {
// Gets list of defined layers in doc
getAvailableLayers: () => Promise<LayerOption[]>
// Object methods
assignObjectsToCategory: (objectIds: string[], categoryValue: string) => Promise<void>
clearObjectsCategoryAssignment: (objectIds: string[]) => Promise<void>
clearAllObjectsCategoryAssignments: () => Promise<void>
getCurrentObjectsMappings: () => Promise<CategoryMapping[]>
getCategoryMappingsForObjects: (objectIds: string[]) => Promise<string[]>
// Layer methods
assignLayerToCategory: (layerIds: string[], categoryValue: string) => Promise<void>
clearLayerCategoryAssignment: (layerIds: string[]) => Promise<void>
clearAllLayerCategoryAssignments: () => Promise<void>
getCurrentLayerMappings: () => Promise<LayerCategoryMapping[]>
getEffectiveObjectsForLayerMapping: (
layerIds: string[],
categoryValue: string
) => Promise<string[]>
getCategoryMappingsForLayers: (layerIds: string[]) => Promise<string[]>
}
export interface IMapperBindingEvents extends IBindingSharedEvents {
mappingsChanged: (mappings: CategoryMapping[]) => void
layersChanged: (layers: LayerOption[]) => void
}
export interface Category {
value: string
label: string
}
export interface CategoryMapping {
categoryValue: string
categoryLabel: string
objectIds: string[]
objectCount: number
}
export interface LayerCategoryMapping {
categoryValue: string
categoryLabel: string
layerIds: string[]
layerNames: string[]
layerCount: number
}
export interface LayerOption {
id: string
name: string
}
// Mock implementation for dev/testing
export class MockedMapperBinding implements IRevitMapperBinding {
private mockMappings: CategoryMapping[] = []
public assignObjectsToCategory(
objectIds: string[],
categoryValue: string
): Promise<void> {
console.log('Mock: Assigning objects to category', { objectIds, categoryValue })
return Promise.resolve()
}
public getAvailableLayers(): Promise<LayerOption[]> {
return Promise.resolve([
{ id: 'layer1', name: 'Ground Floor' },
{ id: 'layer2', name: 'Ground Floor/Walls' },
{ id: 'layer3', name: 'Ground Floor/Walls/Interior' },
{ id: 'layer4', name: 'Second Floor' }
])
}
public clearObjectsCategoryAssignment(objectIds: string[]): Promise<void> {
console.log('Mock: Clearing category assignment', { objectIds })
return Promise.resolve()
}
public clearAllObjectsCategoryAssignments(): Promise<void> {
console.log('Mock: Clearing all assignments')
this.mockMappings = []
return Promise.resolve()
}
public getCurrentObjectsMappings(): Promise<CategoryMapping[]> {
return Promise.resolve(this.mockMappings)
}
public assignLayerToCategory(
layerIds: string[],
categoryValue: string
): Promise<void> {
console.log('Mock: Assigning layers to category', { layerIds, categoryValue })
return Promise.resolve()
}
public clearLayerCategoryAssignment(layerIds: string[]): Promise<void> {
console.log('Mock: Clearing layer category assignment', { layerIds })
return Promise.resolve()
}
public clearAllLayerCategoryAssignments(): Promise<void> {
console.log('Mock: Clearing all layer assignments')
return Promise.resolve()
}
public getCurrentLayerMappings(): Promise<LayerCategoryMapping[]> {
return Promise.resolve([])
}
public getEffectiveObjectsForLayerMapping(
layerIds: string[],
categoryValue: string
): Promise<string[]> {
console.log('Mock: Getting effective objects for layer mapping', {
layerIds,
categoryValue
})
return Promise.resolve(['obj1', 'obj2', 'obj3'])
}
public getCategoryMappingsForObjects(objectIds: string[]): Promise<string[]> {
console.log('Mock: Getting category mappings for objects', { objectIds })
// Mock returning some categories for testing
return Promise.resolve(
objectIds.length > 1 ? ['OST_Walls', 'OST_Doors'] : ['OST_Walls']
)
}
public getCategoryMappingsForLayers(layerIds: string[]): Promise<string[]> {
console.log('Mock: Getting category mappings for layers', { layerIds })
return Promise.resolve(
layerIds.length > 1 ? ['OST_Floors', 'OST_Ceilings'] : ['OST_Floors']
)
}
public showDevTools(): Promise<void> {
console.log('Braaaaa, no way!')
return Promise.resolve()
}
public openUrl(url: string): Promise<void> {
window.open(url)
return Promise.resolve()
}
public on() {
// Mock event handler
}
}
@@ -17,3 +17,24 @@ export type SelectionInfo = {
summary?: string
selectedObjectIds: string[]
}
export class MockedSelectionBinding implements ISelectionBinding {
public async getSelection() {
return (await {
summary: '2 objects selected over mock binding',
selectedObjectIds: ['1', '2', '3']
}) as SelectionInfo
}
public async showDevTools() {
await console.log('No way dude')
}
public async openUrl(url: string) {
await window.open(url)
}
public on() {
return
}
}
+31
View File
@@ -26,6 +26,7 @@ export interface ISendBindingEvents
modelCardId: string
versionId: string
sendConversionResults: ConversionResult[]
ingestionId?: string
}) => void
setIdMap: (args: {
modelCardId: string
@@ -38,3 +39,33 @@ export interface ISendBindingEvents
triggerCancel: (modelCardId: string) => void
triggerCreateVersion: (args: CreateVersionArgs) => void
}
export class MockedSendBinding implements ISendBinding {
public async getSendFilters() {
return await []
}
public async getSendSettings() {
return await []
}
public async send(_modelCardId: string) {
return await console.log('no way dude')
}
public async cancelSend(_modelCardId: string) {
return await console.log('no way dude')
}
public async showDevTools() {
await console.log('No way dude')
}
public async openUrl(url: string) {
await window.open(url)
}
public on() {
return
}
}
+17 -5
View File
@@ -1,6 +1,4 @@
/* eslint-disable @typescript-eslint/require-await */
import { BaseBridge } from '~~/lib/bridge/base'
import type {
IBinding,
IBindingSharedEvents
@@ -38,9 +36,11 @@ export type ComplexType = {
count: number
}
export class MockedTestBinding extends BaseBridge {
export class MockedTestBinding implements ITestBinding {
public async sayHi(name: string, count: number, sayHelloNotHi: boolean) {
return `Hello from mocked bindings. Args: name = ${name}, count = ${count}, sayHelloNotHi = ${sayHelloNotHi.toString()}.`
return [
`Hello from mocked bindings. Args: name = ${name}, count = ${count}, sayHelloNotHi = ${sayHelloNotHi.toString()}.`
]
}
public async goAway() {
@@ -56,6 +56,18 @@ export class MockedTestBinding extends BaseBridge {
}
public async triggerEvent(eventName: string) {
return eventName
return console.log(eventName)
}
public async showDevTools() {
await console.log('No way dude')
}
public async openUrl(url: string) {
await window.open(url)
}
public on() {
return
}
}
+69 -15
View File
@@ -16,6 +16,9 @@ import type { Emitter } from 'nanoevents'
import { useDesktopService } from '~/lib/core/composables/desktopService'
import type { ToastNotification } from '@speckle/ui-components'
import { ToastNotificationType } from '@speckle/ui-components'
import { useModelIngestion } from '../ingestion/composables/useModelIngestion'
import type { ISenderModelCard } from '../models/card/send'
import { useCheckGraphql } from '~/lib/core/composables/useCheckGraphql'
export type SendBatchViaBrowserArgs = {
modelCardId: string
@@ -466,24 +469,75 @@ export class ArchicadBridge {
}
private async createVersion(args: CreateVersionArgs) {
const accountStore = useAccountStore()
const { accounts } = storeToRefs(accountStore)
const account = accounts.value.find((acc) => acc.accountInfo.id === args.accountId)
const hostAppStore = useHostAppStore()
const { completeIngestionWithVersion } = useModelIngestion()
const { canCreateModelIngestion } = useCheckGraphql()
const createVersion = provideApolloClient((account as DUIAccount).client)(() =>
useMutation(createVersionMutation)
const modelCard = hostAppStore.models.find(
(model) => model.modelCardId === args.modelCardId
) as ISenderModelCard
const canCreateIngestion = await canCreateModelIngestion(
modelCard.projectId,
modelCard.modelId,
modelCard.accountId
)
const hostAppStore = useHostAppStore()
const result = await createVersion.mutate({
input: {
modelId: args.modelId,
objectId: args.referencedObjectId,
sourceApplication: hostAppStore.hostAppName,
projectId: args.projectId
if (canCreateIngestion.queryAvailable) {
const ingestionId = hostAppStore.activeIngestions[args.modelCardId]
if (!ingestionId) {
hostAppStore.setNotification({
type: ToastNotificationType.Danger,
title: 'Ingestion Failed',
description: 'Ingestion ID not found to create version.'
})
throw new Error(`Ingestion failed: Ingestion ID not found to create version.`)
}
})
return result?.data?.versionMutations?.create?.id
const res = await completeIngestionWithVersion(
modelCard,
ingestionId,
args.referencedObjectId
)
if (res?.statusData.__typename === 'ModelIngestionSuccessStatus') {
return res?.statusData.versionId
}
if (res?.statusData.__typename === 'ModelIngestionFailedStatus') {
const errorReason = res?.statusData.errorReason || 'Unknown error'
hostAppStore.setNotification({
type: ToastNotificationType.Danger,
title: 'Ingestion Failed',
description: errorReason
})
throw new Error(`Ingestion failed: ${errorReason}.`)
}
hostAppStore.setNotification({
type: ToastNotificationType.Danger,
title: 'Ingestion Error',
description: 'Ingestion status does not match expected types.'
})
throw new Error(
`Ingestion status does not match with the expected types as success or failure.`
)
} else {
const accountStore = useAccountStore()
const account = accountStore.getAccountClient(args.accountId)
const { mutate } = provideApolloClient(account)(() =>
useMutation(createVersionMutation)
)
const result = await mutate({
input: {
modelId: args.modelId,
objectId: args.referencedObjectId,
sourceApplication: args.sourceApplication || 'Archicad',
projectId: args.projectId
}
})
return result?.data?.versionMutations?.create?.id
}
}
}
+79 -25
View File
@@ -19,6 +19,9 @@ import type {
ReceiveViaBrowserArgs,
CreateVersionArgs
} from '~/lib/bridge/server'
import { useModelIngestion } from '../ingestion/composables/useModelIngestion'
import type { ISenderModelCard } from '../models/card/send'
import { useCheckGraphql } from '~/lib/core/composables/useCheckGraphql'
declare let sketchup: {
exec: (data: Record<string, unknown>) => void
@@ -127,15 +130,17 @@ export class SketchupBridge extends BaseBridge {
objectId: result.data.project.model.version.referencedObject as string
})
const updateProgress = (e: {
const updateProgress = (_: {
stage: ProgressStage
current: number
total: number
}) => {
const progress = e.current / e.total
// TODO: replace object loader with loader 2, for now progress is not return total and it end up with infinity
// const progress = e.current / e.total
hostAppStore.handleModelProgressEvents({
modelCardId: eventPayload.modelCardId,
progress: { status: 'Downloading', progress }
progress: { status: 'Downloading' }
})
}
@@ -295,40 +300,89 @@ export class SketchupBridge extends BaseBridge {
sourceApplication: 'sketchup',
message: message || 'send from sketchup'
}
const versionId = await this.createVersion(args)
const hostAppStore = useHostAppStore()
// TODO: Alignment needed
hostAppStore.setModelSendResult({
modelCardId: args.modelCardId,
versionId: versionId as string,
sendConversionResults
})
try {
const versionId = await this.createVersion(args)
hostAppStore.setModelSendResult({
modelCardId: args.modelCardId,
versionId: versionId as string,
sendConversionResults
})
} catch (err) {
hostAppStore.setHostAppError({
message: (err as Error).message || 'Unknown error occurred',
error: (err as Error).toString(),
stackTrace: (err as Error).stack || ''
})
}
}
public async createVersion(args: CreateVersionArgs) {
const accountStore = useAccountStore()
const hostAppStore = useHostAppStore()
const accountStore = useAccountStore()
const { accounts } = storeToRefs(accountStore)
const account = accounts.value.find((acc) => acc.accountInfo.id === args.accountId)
const { completeIngestionWithVersion } = useModelIngestion()
const createVersion = provideApolloClient((account as DUIAccount).client)(() =>
useMutation(createVersionMutation)
const modelCard = hostAppStore.models.find(
(model) => model.modelCardId === args.modelCardId
)
// sketchup versions are provided as 2 digit. i.e. 22, 23, 24
// we are safe with this string concatanation for 77 years
const hostAppName = `SketchUp 20${hostAppStore.hostAppVersion}`
if (!modelCard) {
throw new Error('Model card not found') // ctor
}
const result = await createVersion.mutate({
input: {
modelId: args.modelId,
objectId: args.referencedObjectId,
sourceApplication: hostAppName,
projectId: args.projectId
const { canCreateModelIngestion } = useCheckGraphql()
const canCreateIngestion = await canCreateModelIngestion(
modelCard.projectId,
modelCard.modelId,
modelCard.accountId
)
if (canCreateIngestion.queryAvailable) {
const ingestionId = hostAppStore.activeIngestions[args.modelCardId]
if (!ingestionId) {
throw new Error(`Ingestion failed: Ingestion ID not found to create version.`)
}
})
return result?.data?.versionMutations?.create?.id
const res = await completeIngestionWithVersion(
modelCard as ISenderModelCard,
ingestionId,
args.referencedObjectId
)
if (res?.statusData.__typename === 'ModelIngestionSuccessStatus') {
return res?.statusData.versionId
}
if (res?.statusData.__typename === 'ModelIngestionFailedStatus') {
throw new Error(
`Ingestion failed: ${res?.statusData.errorReason || 'Unknown error'}.`
)
}
throw new Error(
`Ingestion status does not match with the expected types as success or failure.`
)
} else {
// for the self hosters that does not have available graphql for ingestions
const createVersion = provideApolloClient((account as DUIAccount).client)(() =>
useMutation(createVersionMutation)
)
// sketchup versions are provided as 2 digit. i.e. 22, 23, 24
// we are safe with this string concatanation for 77 years
const hostAppName = `SketchUp 20${hostAppStore.hostAppVersion}`
const result = await createVersion.mutate({
input: {
modelId: args.modelId,
objectId: args.referencedObjectId,
sourceApplication: hostAppName,
projectId: args.projectId
}
})
return result?.data?.versionMutations?.create?.id
}
}
public async create(): Promise<boolean> {
+81 -15
View File
@@ -14,7 +14,7 @@ import type { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-
* Learn more about it here: https://the-guild.dev/graphql/codegen/plugins/presets/preset-client#reducing-bundle-size
*/
type Documents = {
"\n mutation SetActiveWorkspaceMutation($slug: String) {\n activeUserMutations {\n setActiveWorkspace(slug: $slug)\n }\n }\n": typeof types.SetActiveWorkspaceMutationDocument,
"\n mutation SetActiveWorkspaceMutation($slug: String) {\n activeUserMutations {\n setActiveWorkspace(slug: $slug) {\n id\n }\n }\n }\n": typeof types.SetActiveWorkspaceMutationDocument,
"\n mutation VersionMutations($input: CreateVersionInput!) {\n versionMutations {\n create(input: $input) {\n id\n }\n }\n }\n": typeof types.VersionMutationsDocument,
"\n mutation Update($input: UpdateVersionInput!) {\n versionMutations {\n update(input: $input) {\n id\n }\n }\n }\n": typeof types.UpdateDocument,
"\n mutation MarkReceivedVersion($input: MarkReceivedVersionInput!) {\n versionMutations {\n markReceived(input: $input)\n }\n }\n": typeof types.MarkReceivedVersionDocument,
@@ -22,7 +22,7 @@ type Documents = {
"\n mutation CreateProject($input: ProjectCreateInput) {\n projectMutations {\n create(input: $input) {\n ...ProjectListProjectItem\n }\n }\n }\n": typeof types.CreateProjectDocument,
"\n mutation CreateProjectInWorkspace($input: WorkspaceProjectCreateInput!) {\n workspaceMutations {\n projects {\n create(input: $input) {\n ...ProjectListProjectItem\n }\n }\n }\n }\n": typeof types.CreateProjectInWorkspaceDocument,
"\n mutation StreamAccessRequestCreate($input: String!) {\n streamAccessRequestCreate(streamId: $input) {\n id\n }\n }\n": typeof types.StreamAccessRequestCreateDocument,
"\n fragment WorkspaceListWorkspaceItem on Workspace {\n id\n slug\n name\n description\n createdAt\n updatedAt\n logo\n role\n readOnly\n creationState {\n completed\n }\n permissions {\n canCreateProject {\n authorized\n code\n message\n }\n }\n }\n": typeof types.WorkspaceListWorkspaceItemFragmentDoc,
"\n fragment WorkspaceListWorkspaceItem on Workspace {\n id\n slug\n name\n description\n createdAt\n updatedAt\n logoUrl\n role\n readOnly\n permissions {\n canCreateProject {\n authorized\n code\n message\n }\n }\n }\n": typeof types.WorkspaceListWorkspaceItemFragmentDoc,
"\n fragment AutomateFunctionItem on AutomateFunction {\n name\n isFeatured\n id\n creator {\n name\n }\n releases {\n items {\n inputSchema\n }\n }\n }\n": typeof types.AutomateFunctionItemFragmentDoc,
"\n mutation CreateAutomation($projectId: ID!, $input: ProjectAutomationCreateInput!) {\n projectMutations {\n automationMutations(projectId: $projectId) {\n create(input: $input) {\n id\n name\n }\n }\n }\n }\n": typeof types.CreateAutomationDocument,
"\n fragment AutomateFunctionRunItem on AutomateFunctionRun {\n id\n status\n statusMessage\n results\n contextView\n function {\n id\n name\n logo\n }\n }\n": typeof types.AutomateFunctionRunItemFragmentDoc,
@@ -33,7 +33,8 @@ type Documents = {
"\n query CanCreatePersonalProject {\n activeUser {\n permissions {\n canCreatePersonalProject {\n authorized\n code\n message\n payload\n }\n }\n }\n }\n": typeof types.CanCreatePersonalProjectDocument,
"\n query CanCreateProjectInWorkspace($workspaceId: String!) {\n workspace(id: $workspaceId) {\n permissions {\n canCreateProject {\n authorized\n code\n message\n payload\n }\n }\n }\n }\n": typeof types.CanCreateProjectInWorkspaceDocument,
"\n query CanCreateModelInProject($projectId: String!) {\n project(id: $projectId) {\n permissions {\n canCreateModel {\n authorized\n code\n message\n }\n }\n }\n }\n": typeof types.CanCreateModelInProjectDocument,
"\n query ActiveWorkspace {\n activeUser {\n activeWorkspace {\n ...WorkspaceListWorkspaceItem\n }\n }\n }\n": typeof types.ActiveWorkspaceDocument,
"\n query CanCreateVersion($projectId: String!, $modelId: String!) {\n project(id: $projectId) {\n model(id: $modelId) {\n permissions {\n canCreateVersion {\n authorized\n code\n message\n errorMessage\n }\n }\n }\n }\n }\n": typeof types.CanCreateVersionDocument,
"\n query ActiveWorkspace {\n activeUser {\n activeWorkspace {\n id\n name\n }\n }\n }\n": typeof types.ActiveWorkspaceDocument,
"\n fragment ProjectListProjectItem on Project {\n id\n name\n role\n updatedAt\n workspaceId\n workspace {\n id\n name\n slug\n role\n }\n models {\n totalCount\n }\n permissions {\n canLoad {\n authorized\n code\n message\n }\n canPublish {\n authorized\n code\n message\n }\n }\n }\n": typeof types.ProjectListProjectItemFragmentDoc,
"\n query ProjectListQuery($limit: Int!, $filter: UserProjectsFilter, $cursor: String) {\n activeUser {\n id\n projects(limit: $limit, filter: $filter, cursor: $cursor) {\n totalCount\n cursor\n items {\n ...ProjectListProjectItem\n }\n }\n }\n }\n": typeof types.ProjectListQueryDocument,
"\n fragment ModelListModelItem on Model {\n displayName\n name\n id\n previewUrl\n updatedAt\n versions(limit: 1) {\n totalCount\n items {\n ...VersionListItem\n }\n }\n }\n": typeof types.ModelListModelItemFragmentDoc,
@@ -44,7 +45,7 @@ type Documents = {
"\n query ProjectAddByUrlQueryWithVersion(\n $projectId: String!\n $modelId: String!\n $versionId: String!\n ) {\n project(id: $projectId) {\n ...ProjectListProjectItem\n model(id: $modelId) {\n ...ModelListModelItem\n version(id: $versionId) {\n ...VersionListItem\n }\n }\n }\n }\n": typeof types.ProjectAddByUrlQueryWithVersionDocument,
"\n query ProjectAddByUrlQueryWithoutVersion($projectId: String!, $modelId: String!) {\n project(id: $projectId) {\n ...ProjectListProjectItem\n model(id: $modelId) {\n ...ModelListModelItem\n }\n }\n }\n": typeof types.ProjectAddByUrlQueryWithoutVersionDocument,
"\n query ProjectDetails($projectId: String!) {\n project(id: $projectId) {\n id\n role\n name\n workspace {\n name\n slug\n readOnly\n role\n }\n team {\n user {\n avatar\n id\n name\n }\n }\n visibility\n permissions {\n canLoad {\n authorized\n code\n message\n }\n canPublish {\n authorized\n code\n message\n }\n }\n }\n }\n": typeof types.ProjectDetailsDocument,
"\n query AutomateFunctions {\n automateFunctions {\n items {\n ...AutomateFunctionItem\n }\n }\n }\n": typeof types.AutomateFunctionsDocument,
"\n query AutomateFunctions($workspaceId: String!) {\n workspace(id: $workspaceId) {\n automateFunctions {\n items {\n ...AutomateFunctionItem\n }\n }\n }\n }\n": typeof types.AutomateFunctionsDocument,
"\n query ModelDetails($modelId: String!, $projectId: String!) {\n project(id: $projectId) {\n id\n name\n model(id: $modelId) {\n id\n displayName\n name\n versions {\n totalCount\n items {\n id\n }\n }\n author {\n id\n name\n avatar\n }\n }\n }\n }\n": typeof types.ModelDetailsDocument,
"\n query VersionDetails($projectId: String!, $versionId: String!, $modelId: String!) {\n project(id: $projectId) {\n id\n name\n model(id: $modelId) {\n id\n name\n versions(limit: 1) {\n items {\n id\n createdAt\n sourceApplication\n authorUser {\n id\n }\n }\n }\n version(id: $versionId) {\n id\n referencedObject\n message\n sourceApplication\n createdAt\n previewUrl\n }\n }\n }\n }\n": typeof types.VersionDetailsDocument,
"\n query ServerInfo {\n serverInfo {\n workspaces {\n workspacesEnabled\n }\n }\n }\n": typeof types.ServerInfoDocument,
@@ -52,11 +53,21 @@ type Documents = {
"\n subscription ProjectTriggeredAutomationsStatusUpdated($projectId: String!) {\n projectTriggeredAutomationsStatusUpdated(projectId: $projectId) {\n type\n version {\n id\n }\n model {\n id\n }\n project {\n id\n }\n run {\n ...AutomationRunItem\n }\n }\n }\n": typeof types.ProjectTriggeredAutomationsStatusUpdatedDocument,
"\n subscription OnUserProjectsUpdated {\n userProjectsUpdated {\n id\n project {\n id\n visibility\n team {\n id\n role\n }\n }\n }\n }\n": typeof types.OnUserProjectsUpdatedDocument,
"\n subscription ProjectUpdated($projectId: String!) {\n projectUpdated(id: $projectId) {\n id\n project {\n visibility\n }\n }\n }\n": typeof types.ProjectUpdatedDocument,
"\n subscription Subscription($target: ViewerUpdateTrackingTarget!) {\n viewerUserActivityBroadcasted(target: $target) {\n userName\n userId\n sessionId\n user {\n name\n id\n avatar\n }\n status\n }\n }\n": typeof types.SubscriptionDocument,
"\n subscription ModelViewingSubscription($target: ViewerUpdateTrackingTarget!) {\n viewerUserActivityBroadcasted(target: $target) {\n userName\n userId\n sessionId\n user {\n name\n id\n avatar\n }\n status\n }\n }\n": typeof types.ModelViewingSubscriptionDocument,
"\n subscription ProjectCommentsUpdated($target: ViewerUpdateTrackingTarget!) {\n projectCommentsUpdated(target: $target) {\n comment {\n author {\n avatar\n id\n name\n }\n id\n hasParent\n parent {\n id\n }\n }\n type\n }\n }\n": typeof types.ProjectCommentsUpdatedDocument,
"\n mutation CreateModelIngestion($input: ModelIngestionCreateInput!) {\n projectMutations {\n modelIngestionMutations {\n create(input: $input) {\n id\n }\n }\n }\n }\n": typeof types.CreateModelIngestionDocument,
"\n mutation UpdateModelIngestionProgress($input: ModelIngestionUpdateInput!) {\n projectMutations {\n modelIngestionMutations {\n updateProgress(input: $input) {\n id\n }\n }\n }\n }\n": typeof types.UpdateModelIngestionProgressDocument,
"\n mutation CompleteModelIngestionWithVersion($input: ModelIngestionSuccessInput!) {\n projectMutations {\n modelIngestionMutations {\n completeWithVersion(input: $input) {\n id\n statusData {\n __typename\n ... on ModelIngestionProcessingStatus {\n status\n progressMessage\n progress\n }\n ... on ModelIngestionSuccessStatus {\n status\n versionId\n }\n ... on ModelIngestionFailedStatus {\n errorStacktrace\n errorReason\n status\n }\n ... on ModelIngestionCancelledStatus {\n cancellationMessage\n status\n }\n ... on ModelIngestionQueuedStatus {\n progressMessage\n status\n }\n }\n }\n }\n }\n }\n": typeof types.CompleteModelIngestionWithVersionDocument,
"\n mutation FailModelIngestionWithError($input: ModelIngestionFailedInput!) {\n projectMutations {\n modelIngestionMutations {\n failWithError(input: $input) {\n id\n }\n }\n }\n }\n": typeof types.FailModelIngestionWithErrorDocument,
"\n mutation FailModelIngestionWithCancel($input: ModelIngestionCancelledInput!) {\n projectMutations {\n modelIngestionMutations {\n failWithCancel(input: $input) {\n id\n }\n }\n }\n }\n": typeof types.FailModelIngestionWithCancelDocument,
"\n query CanCreateIngestion($modelId: String!, $projectId: String!) {\n project(id: $projectId) {\n model(id: $modelId) {\n permissions {\n canCreateIngestion {\n authorized\n code\n message\n }\n }\n }\n }\n }\n": typeof types.CanCreateIngestionDocument,
"\n subscription ProjectModelIngestionUpdated(\n $input: ProjectModelIngestionSubscriptionInput!\n ) {\n projectModelIngestionUpdated(input: $input) {\n type\n modelIngestion {\n id\n statusData {\n __typename\n ... on ModelIngestionSuccessStatus {\n status\n versionId\n }\n ... on ModelIngestionProcessingStatus {\n status\n progressMessage\n progress\n }\n ... on ModelIngestionFailedStatus {\n status\n errorReason\n }\n ... on ModelIngestionCancelledStatus {\n status\n cancellationMessage\n }\n ... on ModelIngestionQueuedStatus {\n status\n progressMessage\n }\n }\n }\n }\n }\n": typeof types.ProjectModelIngestionUpdatedDocument,
"\n fragment IssuesItem on Issue {\n id\n status\n title\n priority\n viewerState\n identifier\n resourceIdString\n activities(input: { limit: 1, sortDirection: asc }) {\n totalCount\n items {\n actor {\n id\n user {\n name\n id\n avatar\n }\n }\n eventType\n createdAt\n }\n }\n replies {\n totalCount\n items {\n id\n author {\n id\n user {\n name\n id\n avatar\n }\n }\n createdAt\n description {\n doc\n }\n }\n }\n description {\n doc\n }\n labels {\n hexColor\n id\n name\n }\n author {\n id\n user {\n id\n name\n avatar\n }\n }\n dueDate\n assignee {\n id\n user {\n id\n avatar\n name\n }\n }\n }\n": typeof types.IssuesItemFragmentDoc,
"\n query IssuesList($projectId: String!) {\n project(id: $projectId) {\n id\n issues {\n totalCount\n items {\n ...IssuesItem\n }\n }\n }\n }\n": typeof types.IssuesListDocument,
"\n subscription WorkspacePlanUsageUpdated($input: WorkspacePlanUsageSubscriptionInput!) {\n workspacePlanUsageUpdated(input: $input)\n }\n": typeof types.WorkspacePlanUsageUpdatedDocument,
};
const documents: Documents = {
"\n mutation SetActiveWorkspaceMutation($slug: String) {\n activeUserMutations {\n setActiveWorkspace(slug: $slug)\n }\n }\n": types.SetActiveWorkspaceMutationDocument,
"\n mutation SetActiveWorkspaceMutation($slug: String) {\n activeUserMutations {\n setActiveWorkspace(slug: $slug) {\n id\n }\n }\n }\n": types.SetActiveWorkspaceMutationDocument,
"\n mutation VersionMutations($input: CreateVersionInput!) {\n versionMutations {\n create(input: $input) {\n id\n }\n }\n }\n": types.VersionMutationsDocument,
"\n mutation Update($input: UpdateVersionInput!) {\n versionMutations {\n update(input: $input) {\n id\n }\n }\n }\n": types.UpdateDocument,
"\n mutation MarkReceivedVersion($input: MarkReceivedVersionInput!) {\n versionMutations {\n markReceived(input: $input)\n }\n }\n": types.MarkReceivedVersionDocument,
@@ -64,7 +75,7 @@ const documents: Documents = {
"\n mutation CreateProject($input: ProjectCreateInput) {\n projectMutations {\n create(input: $input) {\n ...ProjectListProjectItem\n }\n }\n }\n": types.CreateProjectDocument,
"\n mutation CreateProjectInWorkspace($input: WorkspaceProjectCreateInput!) {\n workspaceMutations {\n projects {\n create(input: $input) {\n ...ProjectListProjectItem\n }\n }\n }\n }\n": types.CreateProjectInWorkspaceDocument,
"\n mutation StreamAccessRequestCreate($input: String!) {\n streamAccessRequestCreate(streamId: $input) {\n id\n }\n }\n": types.StreamAccessRequestCreateDocument,
"\n fragment WorkspaceListWorkspaceItem on Workspace {\n id\n slug\n name\n description\n createdAt\n updatedAt\n logo\n role\n readOnly\n creationState {\n completed\n }\n permissions {\n canCreateProject {\n authorized\n code\n message\n }\n }\n }\n": types.WorkspaceListWorkspaceItemFragmentDoc,
"\n fragment WorkspaceListWorkspaceItem on Workspace {\n id\n slug\n name\n description\n createdAt\n updatedAt\n logoUrl\n role\n readOnly\n permissions {\n canCreateProject {\n authorized\n code\n message\n }\n }\n }\n": types.WorkspaceListWorkspaceItemFragmentDoc,
"\n fragment AutomateFunctionItem on AutomateFunction {\n name\n isFeatured\n id\n creator {\n name\n }\n releases {\n items {\n inputSchema\n }\n }\n }\n": types.AutomateFunctionItemFragmentDoc,
"\n mutation CreateAutomation($projectId: ID!, $input: ProjectAutomationCreateInput!) {\n projectMutations {\n automationMutations(projectId: $projectId) {\n create(input: $input) {\n id\n name\n }\n }\n }\n }\n": types.CreateAutomationDocument,
"\n fragment AutomateFunctionRunItem on AutomateFunctionRun {\n id\n status\n statusMessage\n results\n contextView\n function {\n id\n name\n logo\n }\n }\n": types.AutomateFunctionRunItemFragmentDoc,
@@ -75,7 +86,8 @@ const documents: Documents = {
"\n query CanCreatePersonalProject {\n activeUser {\n permissions {\n canCreatePersonalProject {\n authorized\n code\n message\n payload\n }\n }\n }\n }\n": types.CanCreatePersonalProjectDocument,
"\n query CanCreateProjectInWorkspace($workspaceId: String!) {\n workspace(id: $workspaceId) {\n permissions {\n canCreateProject {\n authorized\n code\n message\n payload\n }\n }\n }\n }\n": types.CanCreateProjectInWorkspaceDocument,
"\n query CanCreateModelInProject($projectId: String!) {\n project(id: $projectId) {\n permissions {\n canCreateModel {\n authorized\n code\n message\n }\n }\n }\n }\n": types.CanCreateModelInProjectDocument,
"\n query ActiveWorkspace {\n activeUser {\n activeWorkspace {\n ...WorkspaceListWorkspaceItem\n }\n }\n }\n": types.ActiveWorkspaceDocument,
"\n query CanCreateVersion($projectId: String!, $modelId: String!) {\n project(id: $projectId) {\n model(id: $modelId) {\n permissions {\n canCreateVersion {\n authorized\n code\n message\n errorMessage\n }\n }\n }\n }\n }\n": types.CanCreateVersionDocument,
"\n query ActiveWorkspace {\n activeUser {\n activeWorkspace {\n id\n name\n }\n }\n }\n": types.ActiveWorkspaceDocument,
"\n fragment ProjectListProjectItem on Project {\n id\n name\n role\n updatedAt\n workspaceId\n workspace {\n id\n name\n slug\n role\n }\n models {\n totalCount\n }\n permissions {\n canLoad {\n authorized\n code\n message\n }\n canPublish {\n authorized\n code\n message\n }\n }\n }\n": types.ProjectListProjectItemFragmentDoc,
"\n query ProjectListQuery($limit: Int!, $filter: UserProjectsFilter, $cursor: String) {\n activeUser {\n id\n projects(limit: $limit, filter: $filter, cursor: $cursor) {\n totalCount\n cursor\n items {\n ...ProjectListProjectItem\n }\n }\n }\n }\n": types.ProjectListQueryDocument,
"\n fragment ModelListModelItem on Model {\n displayName\n name\n id\n previewUrl\n updatedAt\n versions(limit: 1) {\n totalCount\n items {\n ...VersionListItem\n }\n }\n }\n": types.ModelListModelItemFragmentDoc,
@@ -86,7 +98,7 @@ const documents: Documents = {
"\n query ProjectAddByUrlQueryWithVersion(\n $projectId: String!\n $modelId: String!\n $versionId: String!\n ) {\n project(id: $projectId) {\n ...ProjectListProjectItem\n model(id: $modelId) {\n ...ModelListModelItem\n version(id: $versionId) {\n ...VersionListItem\n }\n }\n }\n }\n": types.ProjectAddByUrlQueryWithVersionDocument,
"\n query ProjectAddByUrlQueryWithoutVersion($projectId: String!, $modelId: String!) {\n project(id: $projectId) {\n ...ProjectListProjectItem\n model(id: $modelId) {\n ...ModelListModelItem\n }\n }\n }\n": types.ProjectAddByUrlQueryWithoutVersionDocument,
"\n query ProjectDetails($projectId: String!) {\n project(id: $projectId) {\n id\n role\n name\n workspace {\n name\n slug\n readOnly\n role\n }\n team {\n user {\n avatar\n id\n name\n }\n }\n visibility\n permissions {\n canLoad {\n authorized\n code\n message\n }\n canPublish {\n authorized\n code\n message\n }\n }\n }\n }\n": types.ProjectDetailsDocument,
"\n query AutomateFunctions {\n automateFunctions {\n items {\n ...AutomateFunctionItem\n }\n }\n }\n": types.AutomateFunctionsDocument,
"\n query AutomateFunctions($workspaceId: String!) {\n workspace(id: $workspaceId) {\n automateFunctions {\n items {\n ...AutomateFunctionItem\n }\n }\n }\n }\n": types.AutomateFunctionsDocument,
"\n query ModelDetails($modelId: String!, $projectId: String!) {\n project(id: $projectId) {\n id\n name\n model(id: $modelId) {\n id\n displayName\n name\n versions {\n totalCount\n items {\n id\n }\n }\n author {\n id\n name\n avatar\n }\n }\n }\n }\n": types.ModelDetailsDocument,
"\n query VersionDetails($projectId: String!, $versionId: String!, $modelId: String!) {\n project(id: $projectId) {\n id\n name\n model(id: $modelId) {\n id\n name\n versions(limit: 1) {\n items {\n id\n createdAt\n sourceApplication\n authorUser {\n id\n }\n }\n }\n version(id: $versionId) {\n id\n referencedObject\n message\n sourceApplication\n createdAt\n previewUrl\n }\n }\n }\n }\n": types.VersionDetailsDocument,
"\n query ServerInfo {\n serverInfo {\n workspaces {\n workspacesEnabled\n }\n }\n }\n": types.ServerInfoDocument,
@@ -94,8 +106,18 @@ const documents: Documents = {
"\n subscription ProjectTriggeredAutomationsStatusUpdated($projectId: String!) {\n projectTriggeredAutomationsStatusUpdated(projectId: $projectId) {\n type\n version {\n id\n }\n model {\n id\n }\n project {\n id\n }\n run {\n ...AutomationRunItem\n }\n }\n }\n": types.ProjectTriggeredAutomationsStatusUpdatedDocument,
"\n subscription OnUserProjectsUpdated {\n userProjectsUpdated {\n id\n project {\n id\n visibility\n team {\n id\n role\n }\n }\n }\n }\n": types.OnUserProjectsUpdatedDocument,
"\n subscription ProjectUpdated($projectId: String!) {\n projectUpdated(id: $projectId) {\n id\n project {\n visibility\n }\n }\n }\n": types.ProjectUpdatedDocument,
"\n subscription Subscription($target: ViewerUpdateTrackingTarget!) {\n viewerUserActivityBroadcasted(target: $target) {\n userName\n userId\n sessionId\n user {\n name\n id\n avatar\n }\n status\n }\n }\n": types.SubscriptionDocument,
"\n subscription ModelViewingSubscription($target: ViewerUpdateTrackingTarget!) {\n viewerUserActivityBroadcasted(target: $target) {\n userName\n userId\n sessionId\n user {\n name\n id\n avatar\n }\n status\n }\n }\n": types.ModelViewingSubscriptionDocument,
"\n subscription ProjectCommentsUpdated($target: ViewerUpdateTrackingTarget!) {\n projectCommentsUpdated(target: $target) {\n comment {\n author {\n avatar\n id\n name\n }\n id\n hasParent\n parent {\n id\n }\n }\n type\n }\n }\n": types.ProjectCommentsUpdatedDocument,
"\n mutation CreateModelIngestion($input: ModelIngestionCreateInput!) {\n projectMutations {\n modelIngestionMutations {\n create(input: $input) {\n id\n }\n }\n }\n }\n": types.CreateModelIngestionDocument,
"\n mutation UpdateModelIngestionProgress($input: ModelIngestionUpdateInput!) {\n projectMutations {\n modelIngestionMutations {\n updateProgress(input: $input) {\n id\n }\n }\n }\n }\n": types.UpdateModelIngestionProgressDocument,
"\n mutation CompleteModelIngestionWithVersion($input: ModelIngestionSuccessInput!) {\n projectMutations {\n modelIngestionMutations {\n completeWithVersion(input: $input) {\n id\n statusData {\n __typename\n ... on ModelIngestionProcessingStatus {\n status\n progressMessage\n progress\n }\n ... on ModelIngestionSuccessStatus {\n status\n versionId\n }\n ... on ModelIngestionFailedStatus {\n errorStacktrace\n errorReason\n status\n }\n ... on ModelIngestionCancelledStatus {\n cancellationMessage\n status\n }\n ... on ModelIngestionQueuedStatus {\n progressMessage\n status\n }\n }\n }\n }\n }\n }\n": types.CompleteModelIngestionWithVersionDocument,
"\n mutation FailModelIngestionWithError($input: ModelIngestionFailedInput!) {\n projectMutations {\n modelIngestionMutations {\n failWithError(input: $input) {\n id\n }\n }\n }\n }\n": types.FailModelIngestionWithErrorDocument,
"\n mutation FailModelIngestionWithCancel($input: ModelIngestionCancelledInput!) {\n projectMutations {\n modelIngestionMutations {\n failWithCancel(input: $input) {\n id\n }\n }\n }\n }\n": types.FailModelIngestionWithCancelDocument,
"\n query CanCreateIngestion($modelId: String!, $projectId: String!) {\n project(id: $projectId) {\n model(id: $modelId) {\n permissions {\n canCreateIngestion {\n authorized\n code\n message\n }\n }\n }\n }\n }\n": types.CanCreateIngestionDocument,
"\n subscription ProjectModelIngestionUpdated(\n $input: ProjectModelIngestionSubscriptionInput!\n ) {\n projectModelIngestionUpdated(input: $input) {\n type\n modelIngestion {\n id\n statusData {\n __typename\n ... on ModelIngestionSuccessStatus {\n status\n versionId\n }\n ... on ModelIngestionProcessingStatus {\n status\n progressMessage\n progress\n }\n ... on ModelIngestionFailedStatus {\n status\n errorReason\n }\n ... on ModelIngestionCancelledStatus {\n status\n cancellationMessage\n }\n ... on ModelIngestionQueuedStatus {\n status\n progressMessage\n }\n }\n }\n }\n }\n": types.ProjectModelIngestionUpdatedDocument,
"\n fragment IssuesItem on Issue {\n id\n status\n title\n priority\n viewerState\n identifier\n resourceIdString\n activities(input: { limit: 1, sortDirection: asc }) {\n totalCount\n items {\n actor {\n id\n user {\n name\n id\n avatar\n }\n }\n eventType\n createdAt\n }\n }\n replies {\n totalCount\n items {\n id\n author {\n id\n user {\n name\n id\n avatar\n }\n }\n createdAt\n description {\n doc\n }\n }\n }\n description {\n doc\n }\n labels {\n hexColor\n id\n name\n }\n author {\n id\n user {\n id\n name\n avatar\n }\n }\n dueDate\n assignee {\n id\n user {\n id\n avatar\n name\n }\n }\n }\n": types.IssuesItemFragmentDoc,
"\n query IssuesList($projectId: String!) {\n project(id: $projectId) {\n id\n issues {\n totalCount\n items {\n ...IssuesItem\n }\n }\n }\n }\n": types.IssuesListDocument,
"\n subscription WorkspacePlanUsageUpdated($input: WorkspacePlanUsageSubscriptionInput!) {\n workspacePlanUsageUpdated(input: $input)\n }\n": types.WorkspacePlanUsageUpdatedDocument,
};
/**
@@ -115,7 +137,7 @@ export function graphql(source: string): unknown;
/**
* 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 SetActiveWorkspaceMutation($slug: String) {\n activeUserMutations {\n setActiveWorkspace(slug: $slug)\n }\n }\n"): (typeof documents)["\n mutation SetActiveWorkspaceMutation($slug: String) {\n activeUserMutations {\n setActiveWorkspace(slug: $slug)\n }\n }\n"];
export function graphql(source: "\n mutation SetActiveWorkspaceMutation($slug: String) {\n activeUserMutations {\n setActiveWorkspace(slug: $slug) {\n id\n }\n }\n }\n"): (typeof documents)["\n mutation SetActiveWorkspaceMutation($slug: String) {\n activeUserMutations {\n setActiveWorkspace(slug: $slug) {\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.
*/
@@ -147,7 +169,7 @@ export function graphql(source: "\n mutation StreamAccessRequestCreate($input:
/**
* 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 WorkspaceListWorkspaceItem on Workspace {\n id\n slug\n name\n description\n createdAt\n updatedAt\n logo\n role\n readOnly\n creationState {\n completed\n }\n permissions {\n canCreateProject {\n authorized\n code\n message\n }\n }\n }\n"): (typeof documents)["\n fragment WorkspaceListWorkspaceItem on Workspace {\n id\n slug\n name\n description\n createdAt\n updatedAt\n logo\n role\n readOnly\n creationState {\n completed\n }\n permissions {\n canCreateProject {\n authorized\n code\n message\n }\n }\n }\n"];
export function graphql(source: "\n fragment WorkspaceListWorkspaceItem on Workspace {\n id\n slug\n name\n description\n createdAt\n updatedAt\n logoUrl\n role\n readOnly\n permissions {\n canCreateProject {\n authorized\n code\n message\n }\n }\n }\n"): (typeof documents)["\n fragment WorkspaceListWorkspaceItem on Workspace {\n id\n slug\n name\n description\n createdAt\n updatedAt\n logoUrl\n role\n readOnly\n permissions {\n canCreateProject {\n authorized\n code\n message\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -191,7 +213,11 @@ export function graphql(source: "\n query CanCreateModelInProject($projectId: S
/**
* 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 ActiveWorkspace {\n activeUser {\n activeWorkspace {\n ...WorkspaceListWorkspaceItem\n }\n }\n }\n"): (typeof documents)["\n query ActiveWorkspace {\n activeUser {\n activeWorkspace {\n ...WorkspaceListWorkspaceItem\n }\n }\n }\n"];
export function graphql(source: "\n query CanCreateVersion($projectId: String!, $modelId: String!) {\n project(id: $projectId) {\n model(id: $modelId) {\n permissions {\n canCreateVersion {\n authorized\n code\n message\n errorMessage\n }\n }\n }\n }\n }\n"): (typeof documents)["\n query CanCreateVersion($projectId: String!, $modelId: String!) {\n project(id: $projectId) {\n model(id: $modelId) {\n permissions {\n canCreateVersion {\n authorized\n code\n message\n errorMessage\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 query ActiveWorkspace {\n activeUser {\n activeWorkspace {\n id\n name\n }\n }\n }\n"): (typeof documents)["\n query ActiveWorkspace {\n activeUser {\n activeWorkspace {\n id\n name\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -235,7 +261,7 @@ export function graphql(source: "\n query ProjectDetails($projectId: String!) {
/**
* 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 AutomateFunctions {\n automateFunctions {\n items {\n ...AutomateFunctionItem\n }\n }\n }\n"): (typeof documents)["\n query AutomateFunctions {\n automateFunctions {\n items {\n ...AutomateFunctionItem\n }\n }\n }\n"];
export function graphql(source: "\n query AutomateFunctions($workspaceId: String!) {\n workspace(id: $workspaceId) {\n automateFunctions {\n items {\n ...AutomateFunctionItem\n }\n }\n }\n }\n"): (typeof documents)["\n query AutomateFunctions($workspaceId: String!) {\n workspace(id: $workspaceId) {\n automateFunctions {\n items {\n ...AutomateFunctionItem\n }\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -267,11 +293,51 @@ export function graphql(source: "\n subscription ProjectUpdated($projectId: Str
/**
* 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 Subscription($target: ViewerUpdateTrackingTarget!) {\n viewerUserActivityBroadcasted(target: $target) {\n userName\n userId\n sessionId\n user {\n name\n id\n avatar\n }\n status\n }\n }\n"): (typeof documents)["\n subscription Subscription($target: ViewerUpdateTrackingTarget!) {\n viewerUserActivityBroadcasted(target: $target) {\n userName\n userId\n sessionId\n user {\n name\n id\n avatar\n }\n status\n }\n }\n"];
export function graphql(source: "\n subscription ModelViewingSubscription($target: ViewerUpdateTrackingTarget!) {\n viewerUserActivityBroadcasted(target: $target) {\n userName\n userId\n sessionId\n user {\n name\n id\n avatar\n }\n status\n }\n }\n"): (typeof documents)["\n subscription ModelViewingSubscription($target: ViewerUpdateTrackingTarget!) {\n viewerUserActivityBroadcasted(target: $target) {\n userName\n userId\n sessionId\n user {\n name\n id\n avatar\n }\n status\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 subscription ProjectCommentsUpdated($target: ViewerUpdateTrackingTarget!) {\n projectCommentsUpdated(target: $target) {\n comment {\n author {\n avatar\n id\n name\n }\n id\n hasParent\n parent {\n id\n }\n }\n type\n }\n }\n"): (typeof documents)["\n subscription ProjectCommentsUpdated($target: ViewerUpdateTrackingTarget!) {\n projectCommentsUpdated(target: $target) {\n comment {\n author {\n avatar\n id\n name\n }\n id\n hasParent\n parent {\n id\n }\n }\n type\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 CreateModelIngestion($input: ModelIngestionCreateInput!) {\n projectMutations {\n modelIngestionMutations {\n create(input: $input) {\n id\n }\n }\n }\n }\n"): (typeof documents)["\n mutation CreateModelIngestion($input: ModelIngestionCreateInput!) {\n projectMutations {\n modelIngestionMutations {\n create(input: $input) {\n id\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 UpdateModelIngestionProgress($input: ModelIngestionUpdateInput!) {\n projectMutations {\n modelIngestionMutations {\n updateProgress(input: $input) {\n id\n }\n }\n }\n }\n"): (typeof documents)["\n mutation UpdateModelIngestionProgress($input: ModelIngestionUpdateInput!) {\n projectMutations {\n modelIngestionMutations {\n updateProgress(input: $input) {\n id\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 CompleteModelIngestionWithVersion($input: ModelIngestionSuccessInput!) {\n projectMutations {\n modelIngestionMutations {\n completeWithVersion(input: $input) {\n id\n statusData {\n __typename\n ... on ModelIngestionProcessingStatus {\n status\n progressMessage\n progress\n }\n ... on ModelIngestionSuccessStatus {\n status\n versionId\n }\n ... on ModelIngestionFailedStatus {\n errorStacktrace\n errorReason\n status\n }\n ... on ModelIngestionCancelledStatus {\n cancellationMessage\n status\n }\n ... on ModelIngestionQueuedStatus {\n progressMessage\n status\n }\n }\n }\n }\n }\n }\n"): (typeof documents)["\n mutation CompleteModelIngestionWithVersion($input: ModelIngestionSuccessInput!) {\n projectMutations {\n modelIngestionMutations {\n completeWithVersion(input: $input) {\n id\n statusData {\n __typename\n ... on ModelIngestionProcessingStatus {\n status\n progressMessage\n progress\n }\n ... on ModelIngestionSuccessStatus {\n status\n versionId\n }\n ... on ModelIngestionFailedStatus {\n errorStacktrace\n errorReason\n status\n }\n ... on ModelIngestionCancelledStatus {\n cancellationMessage\n status\n }\n ... on ModelIngestionQueuedStatus {\n progressMessage\n status\n }\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 FailModelIngestionWithError($input: ModelIngestionFailedInput!) {\n projectMutations {\n modelIngestionMutations {\n failWithError(input: $input) {\n id\n }\n }\n }\n }\n"): (typeof documents)["\n mutation FailModelIngestionWithError($input: ModelIngestionFailedInput!) {\n projectMutations {\n modelIngestionMutations {\n failWithError(input: $input) {\n id\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 FailModelIngestionWithCancel($input: ModelIngestionCancelledInput!) {\n projectMutations {\n modelIngestionMutations {\n failWithCancel(input: $input) {\n id\n }\n }\n }\n }\n"): (typeof documents)["\n mutation FailModelIngestionWithCancel($input: ModelIngestionCancelledInput!) {\n projectMutations {\n modelIngestionMutations {\n failWithCancel(input: $input) {\n id\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 CanCreateIngestion($modelId: String!, $projectId: String!) {\n project(id: $projectId) {\n model(id: $modelId) {\n permissions {\n canCreateIngestion {\n authorized\n code\n message\n }\n }\n }\n }\n }\n"): (typeof documents)["\n query CanCreateIngestion($modelId: String!, $projectId: String!) {\n project(id: $projectId) {\n model(id: $modelId) {\n permissions {\n canCreateIngestion {\n authorized\n code\n message\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 subscription ProjectModelIngestionUpdated(\n $input: ProjectModelIngestionSubscriptionInput!\n ) {\n projectModelIngestionUpdated(input: $input) {\n type\n modelIngestion {\n id\n statusData {\n __typename\n ... on ModelIngestionSuccessStatus {\n status\n versionId\n }\n ... on ModelIngestionProcessingStatus {\n status\n progressMessage\n progress\n }\n ... on ModelIngestionFailedStatus {\n status\n errorReason\n }\n ... on ModelIngestionCancelledStatus {\n status\n cancellationMessage\n }\n ... on ModelIngestionQueuedStatus {\n status\n progressMessage\n }\n }\n }\n }\n }\n"): (typeof documents)["\n subscription ProjectModelIngestionUpdated(\n $input: ProjectModelIngestionSubscriptionInput!\n ) {\n projectModelIngestionUpdated(input: $input) {\n type\n modelIngestion {\n id\n statusData {\n __typename\n ... on ModelIngestionSuccessStatus {\n status\n versionId\n }\n ... on ModelIngestionProcessingStatus {\n status\n progressMessage\n progress\n }\n ... on ModelIngestionFailedStatus {\n status\n errorReason\n }\n ... on ModelIngestionCancelledStatus {\n status\n cancellationMessage\n }\n ... on ModelIngestionQueuedStatus {\n status\n progressMessage\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 fragment IssuesItem on Issue {\n id\n status\n title\n priority\n viewerState\n identifier\n resourceIdString\n activities(input: { limit: 1, sortDirection: asc }) {\n totalCount\n items {\n actor {\n id\n user {\n name\n id\n avatar\n }\n }\n eventType\n createdAt\n }\n }\n replies {\n totalCount\n items {\n id\n author {\n id\n user {\n name\n id\n avatar\n }\n }\n createdAt\n description {\n doc\n }\n }\n }\n description {\n doc\n }\n labels {\n hexColor\n id\n name\n }\n author {\n id\n user {\n id\n name\n avatar\n }\n }\n dueDate\n assignee {\n id\n user {\n id\n avatar\n name\n }\n }\n }\n"): (typeof documents)["\n fragment IssuesItem on Issue {\n id\n status\n title\n priority\n viewerState\n identifier\n resourceIdString\n activities(input: { limit: 1, sortDirection: asc }) {\n totalCount\n items {\n actor {\n id\n user {\n name\n id\n avatar\n }\n }\n eventType\n createdAt\n }\n }\n replies {\n totalCount\n items {\n id\n author {\n id\n user {\n name\n id\n avatar\n }\n }\n createdAt\n description {\n doc\n }\n }\n }\n description {\n doc\n }\n labels {\n hexColor\n id\n name\n }\n author {\n id\n user {\n id\n name\n avatar\n }\n }\n dueDate\n assignee {\n id\n user {\n id\n avatar\n name\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 IssuesList($projectId: String!) {\n project(id: $projectId) {\n id\n issues {\n totalCount\n items {\n ...IssuesItem\n }\n }\n }\n }\n"): (typeof documents)["\n query IssuesList($projectId: String!) {\n project(id: $projectId) {\n id\n issues {\n totalCount\n items {\n ...IssuesItem\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 subscription WorkspacePlanUsageUpdated($input: WorkspacePlanUsageSubscriptionInput!) {\n workspacePlanUsageUpdated(input: $input)\n }\n"): (typeof documents)["\n subscription WorkspacePlanUsageUpdated($input: WorkspacePlanUsageSubscriptionInput!) {\n workspacePlanUsageUpdated(input: $input)\n }\n"];
export function graphql(source: string) {
return (documents as any)[source] ?? {};
File diff suppressed because one or more lines are too long
+13 -7
View File
@@ -6,16 +6,17 @@ interface CustomProperties {
[key: string]: object | string | boolean | number | undefined | null
}
// Cached email and server
// Cached email, server, and userId
const lastEmail: Ref<string | undefined> = ref(undefined)
const lastServer: Ref<string | undefined> = ref(undefined)
const lastUserId: Ref<string | undefined> = ref(undefined)
/**
* Get Mixpanel functions
* In DUI3, quite likely to change distinct id of the track operation since we can trigger repetitive calls that belongs to different account.
* Also we have some operations that explicitly not belong to any account, i.e. first "Send" or "Load" click,
* with this case we use default account on manager to get "email" and "server" and cache them for later anonymous track.
* In each call we update "lastEmail" and "lastServer" for the following potential anonymous tracks.
* with this case we use default account on manager to get "email", "server", and "userId" and cache them for later anonymous track.
* In each call we update "lastEmail", "lastServer", and "lastUserId" for the following potential anonymous tracks.
*/
export function useMixpanel() {
const hostApp = useHostAppStore()
@@ -42,11 +43,13 @@ export function useMixpanel() {
const account = accounts.find((a) => a.accountInfo.id === accountId)
lastEmail.value = account?.accountInfo.userInfo.email
lastServer.value = account?.accountInfo.serverInfo.url
lastUserId.value = account?.accountInfo.userInfo.id
} else {
// do not set if they cached already
if (lastEmail.value === undefined || lastServer.value === undefined) {
lastEmail.value = activeAccount.accountInfo.userInfo.email
lastServer.value = activeAccount.accountInfo.serverInfo.url
lastUserId.value = activeAccount.accountInfo.userInfo.id
}
}
@@ -62,9 +65,9 @@ export function useMixpanel() {
}
const hashedEmail =
'@' + md5(lastEmail.value.toLowerCase() as string).toUpperCase()
const hashedServer = md5(
new URL(lastServer.value).hostname.toLowerCase() as string
).toUpperCase()
const serverUrl = new URL(lastServer.value)
const serverHostname = serverUrl.hostname.toLowerCase()
const hashedServer = md5(serverHostname).toUpperCase()
// Get os info from userAgent text
// taken from original mixpanel implementation
@@ -84,6 +87,8 @@ export function useMixpanel() {
distinct_id: hashedEmail,
// eslint-disable-next-line camelcase
server_id: hashedServer,
// eslint-disable-next-line camelcase
server_domain: serverHostname,
token: mixpanelTokenId as string,
type: isAction ? 'action' : undefined,
hostApp: hostApp.hostAppName,
@@ -91,7 +96,8 @@ export function useMixpanel() {
ui: 'dui3', // Not sure about this but we need to put something to distiguish some events, like "Send", "Receive", alternatively we can have "SendDUI3" not sure!
// eslint-disable-next-line camelcase
core_version: hostApp.connectorVersion,
email: lastEmail,
email: lastEmail.value,
userId: lastUserId.value,
...customProperties
}
+42
View File
@@ -0,0 +1,42 @@
import { useMixpanel } from '~/lib/core/composables/mixpanel'
import type { CardSetting } from '~/lib/models/card/setting'
export function useSettingsTracking() {
const { trackEvent } = useMixpanel()
function trackSettingsChange(
eventName: string,
settings: CardSetting[],
defaultSettings: CardSetting[],
accountId?: string,
requireChanges: boolean = false
) {
// building dynamic properties
// since this can change based on HostApp
const settingProperties: Record<string, string | boolean | number> = {
name: eventName
}
let hasAnyChange = false
settings.forEach((setting) => {
const defaultSetting = defaultSettings.find((s) => s.id === setting.id)
if (defaultSetting) {
const isDefault = setting.value === defaultSetting.value
if (!isDefault) {
hasAnyChange = true
}
// if user selects default, just use 'default'
settingProperties['setting_' + setting.id] = isDefault
? `${setting.value} (default)`
: setting.value
}
})
// only track if user changed a setting
if (!requireChanges || hasAnyChange) {
void trackEvent('DUI3 Action', settingProperties, accountId)
}
}
return { trackSettingsChange }
}
+71
View File
@@ -0,0 +1,71 @@
import { canCreateVersionQuery } from '~/lib/graphql/mutationsAndQueries'
import { canCreateModelIngestionQuery } from '~/lib/ingestion/graphql/queries'
import { useAccountStore } from '~/store/accounts'
// use this composable whenever we need to check against available graphqls over servers
export function useCheckGraphql() {
/**
* Checks the ingestions available for the server,
* if available, returns with respond by appending `queryAvailable = true`
* otherwise, returns fake result object with `queryAvailable = false`
*/
const canCreateModelIngestion = async (
projectId: string,
modelId: string,
accountId: string
) => {
const accountsStore = useAccountStore()
const client = accountsStore.getAccountClient(accountId)
try {
const result = await client.query({
query: canCreateModelIngestionQuery,
variables: {
projectId,
modelId
},
fetchPolicy: 'network-only'
})
return {
...result.data.project.model.permissions.canCreateIngestion,
queryAvailable: true
}
} catch {
return { queryAvailable: false, authorized: false, message: undefined }
}
}
/**
* Checks if user can create a version for the given model.
* Used to validate before starting a publish operation.
*/
const canCreateVersion = async (
projectId: string,
modelId: string,
accountId: string
) => {
const accountsStore = useAccountStore()
const client = accountsStore.getAccountClient(accountId)
try {
const result = await client.query({
query: canCreateVersionQuery,
variables: {
projectId,
modelId
},
fetchPolicy: 'network-only'
})
return result.data.project.model.permissions.canCreateVersion
} catch (error) {
// If we can't check, allow the attempt - server will reject if not allowed
console.error('Failed to check canCreateVersion:', error)
return { authorized: true, message: null }
}
}
return {
canCreateVersion,
canCreateModelIngestion
}
}
+22 -2
View File
@@ -1,6 +1,7 @@
import type { JsonFormsRendererRegistryEntry } from '@jsonforms/core'
import {
and,
hasType,
isBooleanControl,
isDateControl,
isDateTimeControl,
@@ -11,12 +12,15 @@ import {
isOneOfEnumControl,
isStringControl,
isTimeControl,
rankWith
rankWith,
schemaMatches,
uiTypeIs
} from '@jsonforms/core'
import { vanillaRenderers } from '@jsonforms/vue-vanilla'
import BooleanControlRenderer from '~/components/form/json/BooleanControlRenderer.vue'
import DateControlRenderer from '~/components/form/json/DateControlRenderer.vue'
import DateTimeControlRenderer from '~/components/form/json/DateTimeControlRenderer.vue'
import MultiEnumControlRenderer from '~/components/form/json/MultiEnumControlRenderer.vue'
import EnumControlRenderer from '~/components/form/json/EnumControlRenderer.vue'
import EnumOneOfControlRenderer from '~/components/form/json/EnumOneOfControlRenderer.vue'
import IntegerControlRenderer from '~/components/form/json/IntegerControlRenderer.vue'
@@ -75,6 +79,21 @@ export const timeControlRenderer: JsonFormsRendererRegistryEntry = {
tester: rankWith(4, isTimeControl)
}
export const multiEnumControlRenderer: JsonFormsRendererRegistryEntry = {
renderer: MultiEnumControlRenderer as unknown,
tester: rankWith(
6,
and(
uiTypeIs('Control'),
and(
schemaMatches(
(schema) => hasType(schema, 'array') && !Array.isArray(schema.items)
)
)
)
)
}
export const renderers: JsonFormsRendererRegistryEntry[] = markRaw([
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
...vanillaRenderers,
@@ -87,5 +106,6 @@ export const renderers: JsonFormsRendererRegistryEntry[] = markRaw([
numberControlRenderer,
dateControlRenderer,
dateTimeControlRenderer,
timeControlRenderer
timeControlRenderer,
multiEnumControlRenderer
])
+30 -11
View File
@@ -3,7 +3,9 @@ import { graphql } from '~~/lib/common/generated/gql'
export const setActiveWorkspaceMutation = graphql(`
mutation SetActiveWorkspaceMutation($slug: String) {
activeUserMutations {
setActiveWorkspace(slug: $slug)
setActiveWorkspace(slug: $slug) {
id
}
}
}
`)
@@ -84,12 +86,9 @@ export const workspaceListFragment = graphql(`
description
createdAt
updatedAt
logo
logoUrl
role
readOnly
creationState {
completed
}
permissions {
canCreateProject {
authorized
@@ -247,11 +246,29 @@ export const canCreateModelInProjectQuery = graphql(`
}
`)
export const canCreateVersionQuery = graphql(`
query CanCreateVersion($projectId: String!, $modelId: String!) {
project(id: $projectId) {
model(id: $modelId) {
permissions {
canCreateVersion {
authorized
code
message
errorMessage
}
}
}
}
}
`)
export const activeWorkspaceQuery = graphql(`
query ActiveWorkspace {
activeUser {
activeWorkspace {
...WorkspaceListWorkspaceItem
id
name
}
}
}
@@ -456,10 +473,12 @@ export const projectDetailsQuery = graphql(`
`)
export const automateFunctionsQuery = graphql(`
query AutomateFunctions {
automateFunctions {
items {
...AutomateFunctionItem
query AutomateFunctions($workspaceId: String!) {
workspace(id: $workspaceId) {
automateFunctions {
items {
...AutomateFunctionItem
}
}
}
}
@@ -604,7 +623,7 @@ export const projectUpdatedSubscription = graphql(`
`)
export const modelViewingSubscription = graphql(`
subscription Subscription($target: ViewerUpdateTrackingTarget!) {
subscription ModelViewingSubscription($target: ViewerUpdateTrackingTarget!) {
viewerUserActivityBroadcasted(target: $target) {
userName
userId
@@ -0,0 +1,319 @@
import {
provideApolloClient,
useMutation,
useSubscription
} from '@vue/apollo-composable'
import { useAccountStore } from '~/store/accounts'
import { useHostAppStore } from '~/store/hostApp'
import {
completeModelIngestionWithVersion,
createModelIngestion,
updateModelIngestionProgress,
failModelIngestionWithError,
failModelIngestionWithCancel
} from '../graphql/mutations'
import { projectModelIngestionUpdatedSubscription } from '../graphql/subscriptions'
import type {
SourceDataInput,
ProjectModelIngestionUpdatedSubscription
} from '~~/lib/common/generated/gql/graphql'
import type { ISenderModelCard } from '~/lib/models/card/send'
import { storeToRefs } from 'pinia'
import { ToastNotificationType } from '@speckle/ui-components'
/**
* New way of creating versions.
* It is essential for server to track limits on versions.
* The flow is as follows:
* 0. Check if the user has enough limits to create a new version (this is handled outside of this composable)
* 1. Start a new ingestion
* 2. Update the ingestion with the new data when connector throws progress via 'setModelProgress' event
* 3. Complete the version with the root object id that passed by connector or server/sketchup bridges in JS
*/
export const useModelIngestion = () => {
const store = useHostAppStore()
const accountStore = useAccountStore()
const startIngestion = async (
senderModelCard: ISenderModelCard,
progressMessage: string,
sourceData: SourceDataInput
) => {
const { activeIngestions } = storeToRefs(store)
const client = accountStore.getAccountClient(senderModelCard.accountId)
const { mutate } = provideApolloClient(client)(() =>
useMutation(createModelIngestion)
)
const res = await mutate({
input: {
projectId: senderModelCard.projectId,
modelId: senderModelCard.modelId,
progressMessage,
sourceData,
maxIdleTimeoutSeconds: 7200 // 2h
}
})
if (res?.errors?.length) {
const msg = res.errors[0].message
store.setNotification({
type: ToastNotificationType.Danger,
title: 'Ingestion Error',
description: msg
})
throw new Error(msg)
}
const ingestionId = res?.data?.projectMutations.modelIngestionMutations.create.id
if (ingestionId) {
activeIngestions.value[senderModelCard.modelCardId] = ingestionId
}
return res?.data?.projectMutations.modelIngestionMutations.create
}
const updateIngestion = async (
senderModelCard: ISenderModelCard,
ingestionId: string,
progressMessage: string,
progress?: number
) => {
const client = accountStore.getAccountClient(senderModelCard.accountId)
const { mutate } = provideApolloClient(client)(() =>
useMutation(updateModelIngestionProgress)
)
const res = await mutate({
input: {
projectId: senderModelCard.projectId,
ingestionId,
progressMessage,
progress
}
})
if (res?.errors?.length) {
const msg = res.errors[0].message
store.setNotification({
type: ToastNotificationType.Danger,
title: 'Ingestion Error',
description: msg
})
throw new Error(msg)
}
return res?.data?.projectMutations.modelIngestionMutations.updateProgress
}
const failIngestion = async (
senderModelCard: ISenderModelCard,
ingestionId: string,
errorReason: string,
errorStacktrace?: string
) => {
const client = accountStore.getAccountClient(senderModelCard.accountId)
const { mutate } = provideApolloClient(client)(() =>
useMutation(failModelIngestionWithError)
)
const res = await mutate({
input: {
projectId: senderModelCard.projectId,
ingestionId,
errorReason,
errorStacktrace
}
})
if (res?.errors?.length) {
const msg = res.errors[0].message
store.setNotification({
type: ToastNotificationType.Danger,
title: 'Ingestion Error',
description: msg
})
throw new Error(msg)
}
const { activeIngestions } = storeToRefs(store)
// clean the failed ingestion
activeIngestions.value = Object.fromEntries(
Object.entries(activeIngestions.value).filter(
([key]) => key !== senderModelCard.modelCardId
)
)
}
const cancelIngestion = async (
senderModelCard: ISenderModelCard,
ingestionId: string,
cancellationMessage: string = 'Cancelled by user'
) => {
const client = accountStore.getAccountClient(senderModelCard.accountId)
const { mutate } = provideApolloClient(client)(() =>
useMutation(failModelIngestionWithCancel)
)
const res = await mutate({
input: {
projectId: senderModelCard.projectId,
ingestionId,
cancellationMessage
}
})
if (res?.errors?.length) {
const msg = res.errors[0].message
store.setNotification({
type: ToastNotificationType.Danger,
title: 'Ingestion Error',
description: msg
})
throw new Error(msg)
}
const { activeIngestions } = storeToRefs(store)
// clean the cancelled ingestion
activeIngestions.value = Object.fromEntries(
Object.entries(activeIngestions.value).filter(
([key]) => key !== senderModelCard.modelCardId
)
)
}
const completeIngestionWithVersion = async (
senderModelCard: ISenderModelCard,
ingestionId: string,
rootObjectId: string
) => {
const client = accountStore.getAccountClient(senderModelCard.accountId)
const { mutate } = provideApolloClient(client)(() =>
useMutation(completeModelIngestionWithVersion)
)
const res = await mutate({
input: {
projectId: senderModelCard.projectId,
ingestionId,
rootObjectId
}
})
if (res?.errors?.length) {
const msg = res.errors[0].message
store.setNotification({
type: ToastNotificationType.Danger,
title: 'Ingestion Error',
description: msg
})
throw new Error(msg)
}
const { activeIngestions } = storeToRefs(store)
// clean the completed ingestion
activeIngestions.value = Object.fromEntries(
Object.entries(activeIngestions.value).filter(
([key]) => key !== senderModelCard.modelCardId
)
)
return res?.data?.projectMutations.modelIngestionMutations.completeWithVersion
}
// Tracks active ingestion subscriptions so they can be stopped on cancel or terminal state
const activeSubscriptions: Record<string, () => void> = {}
/**
* Subscribes to ingestion status updates for a given ingestionId.
* Used when the connector (.NET SDK) handles the ingestion and passes the ingestionId
* back to the DUI via setModelSendResult. The DUI then subscribes to track
* the server-side processing state until a terminal status is reached.
*
* Manages model card state directly: updates progress, sets versionId on success,
* sets error on failure, and clears progress on terminal states.
*/
const subscribeToIngestion = (
senderModelCard: ISenderModelCard,
ingestionId: string
) => {
const client = accountStore.getAccountClient(senderModelCard.accountId)
senderModelCard.progress = { status: 'Remote processing...' }
const { onResult, onError, stop } = provideApolloClient(client)(() =>
useSubscription(projectModelIngestionUpdatedSubscription, () => ({
input: {
projectId: senderModelCard.projectId,
ingestionReference: { ingestionId }
}
}))
)
activeSubscriptions[senderModelCard.modelCardId] = stop
onResult((result) => {
const data = result.data as ProjectModelIngestionUpdatedSubscription | undefined
const statusData = data?.projectModelIngestionUpdated?.modelIngestion?.statusData
if (!statusData) return
switch (statusData.__typename) {
case 'ModelIngestionSuccessStatus':
senderModelCard.latestCreatedVersionId = statusData.versionId
senderModelCard.progress = undefined
unsubscribeFromIngestion(senderModelCard.modelCardId)
break
case 'ModelIngestionProcessingStatus':
senderModelCard.progress = {
status: statusData.progressMessage,
progress: statusData.progress ?? undefined
}
break
case 'ModelIngestionFailedStatus':
senderModelCard.error = {
errorMessage: statusData.errorReason,
dismissible: true
}
senderModelCard.progress = undefined
unsubscribeFromIngestion(senderModelCard.modelCardId)
break
case 'ModelIngestionCancelledStatus':
senderModelCard.progress = undefined
unsubscribeFromIngestion(senderModelCard.modelCardId)
break
case 'ModelIngestionQueuedStatus':
senderModelCard.progress = {
status: statusData.progressMessage
}
break
}
})
onError((err) => {
console.error('Ingestion subscription error:', err)
unsubscribeFromIngestion(senderModelCard.modelCardId)
})
}
const unsubscribeFromIngestion = (modelCardId: string) => {
const stop = activeSubscriptions[modelCardId]
if (stop) {
stop()
delete activeSubscriptions[modelCardId]
}
}
return {
startIngestion,
updateIngestion,
failIngestion,
cancelIngestion,
completeIngestionWithVersion,
subscribeToIngestion,
unsubscribeFromIngestion
}
}
+86
View File
@@ -0,0 +1,86 @@
import { graphql } from '~~/lib/common/generated/gql'
export const createModelIngestion = graphql(`
mutation CreateModelIngestion($input: ModelIngestionCreateInput!) {
projectMutations {
modelIngestionMutations {
create(input: $input) {
id
}
}
}
}
`)
export const updateModelIngestionProgress = graphql(`
mutation UpdateModelIngestionProgress($input: ModelIngestionUpdateInput!) {
projectMutations {
modelIngestionMutations {
updateProgress(input: $input) {
id
}
}
}
}
`)
export const completeModelIngestionWithVersion = graphql(`
mutation CompleteModelIngestionWithVersion($input: ModelIngestionSuccessInput!) {
projectMutations {
modelIngestionMutations {
completeWithVersion(input: $input) {
id
statusData {
__typename
... on ModelIngestionProcessingStatus {
status
progressMessage
progress
}
... on ModelIngestionSuccessStatus {
status
versionId
}
... on ModelIngestionFailedStatus {
errorStacktrace
errorReason
status
}
... on ModelIngestionCancelledStatus {
cancellationMessage
status
}
... on ModelIngestionQueuedStatus {
progressMessage
status
}
}
}
}
}
}
`)
export const failModelIngestionWithError = graphql(`
mutation FailModelIngestionWithError($input: ModelIngestionFailedInput!) {
projectMutations {
modelIngestionMutations {
failWithError(input: $input) {
id
}
}
}
}
`)
export const failModelIngestionWithCancel = graphql(`
mutation FailModelIngestionWithCancel($input: ModelIngestionCancelledInput!) {
projectMutations {
modelIngestionMutations {
failWithCancel(input: $input) {
id
}
}
}
}
`)
+17
View File
@@ -0,0 +1,17 @@
import { graphql } from '~~/lib/common/generated/gql'
export const canCreateModelIngestionQuery = graphql(`
query CanCreateIngestion($modelId: String!, $projectId: String!) {
project(id: $projectId) {
model(id: $modelId) {
permissions {
canCreateIngestion {
authorized
code
message
}
}
}
}
}
`)
+38
View File
@@ -0,0 +1,38 @@
import { graphql } from '~~/lib/common/generated/gql'
export const projectModelIngestionUpdatedSubscription = graphql(`
subscription ProjectModelIngestionUpdated(
$input: ProjectModelIngestionSubscriptionInput!
) {
projectModelIngestionUpdated(input: $input) {
type
modelIngestion {
id
statusData {
__typename
... on ModelIngestionSuccessStatus {
status
versionId
}
... on ModelIngestionProcessingStatus {
status
progressMessage
progress
}
... on ModelIngestionFailedStatus {
status
errorReason
}
... on ModelIngestionCancelledStatus {
status
cancellationMessage
}
... on ModelIngestionQueuedStatus {
status
progressMessage
}
}
}
}
}
`)
+71
View File
@@ -0,0 +1,71 @@
import { graphql } from '~~/lib/common/generated/gql'
export const issueFragment = graphql(`
fragment IssuesItem on Issue {
id
status
title
priority
viewerState
identifier
resourceIdString
activities(input: { limit: 1, sortDirection: asc }) {
totalCount
items {
actor {
id
user {
name
id
avatar
}
}
eventType
createdAt
}
}
replies {
totalCount
items {
id
author {
id
user {
name
id
avatar
}
}
createdAt
description {
doc
}
}
}
description {
doc
}
labels {
hexColor
id
name
}
author {
id
user {
id
name
avatar
}
}
dueDate
assignee {
id
user {
id
avatar
name
}
}
}
`)
+15
View File
@@ -0,0 +1,15 @@
import { graphql } from '~~/lib/common/generated/gql'
export const issuesListQuery = graphql(`
query IssuesList($projectId: String!) {
project(id: $projectId) {
id
issues {
totalCount
items {
...IssuesItem
}
}
}
}
`)
+7
View File
@@ -0,0 +1,7 @@
export type Label = {
id: string
name: string
hexColor?: string
}
export type LabelsValue = Label[]
+85
View File
@@ -0,0 +1,85 @@
import md5 from '~/lib/common/helpers/md5'
import { useAccountStore } from '~/store/accounts'
import { useHostAppStore } from '~/store/hostApp'
const SEQ_URL = 'https://seq-dev.speckle.systems/api/events/raw'
type LogLevel = 'Verbose' | 'Debug' | 'Information' | 'Warning' | 'Error' | 'Fatal'
const collectCommonProperties = () => {
const { accounts, activeAccount } = useAccountStore()
const hashedEmail =
'@' +
md5(activeAccount.accountInfo.userInfo.email.toLowerCase() as string).toUpperCase()
return {
user: {
id: activeAccount.accountInfo.userInfo.id,
distinctId: hashedEmail
},
dui3: true,
accountCount: accounts.length
}
}
const collectResources = () => {
const hostAppStore = useHostAppStore()
return {
'@ra': {
connector: {
slug: hostAppStore.hostAppName,
hostAppVersion: hostAppStore.hostAppVersion,
version: hostAppStore.connectorVersion
},
service: {
version: hostAppStore.connectorVersion // this needs alignment with .NET SDK, actually this should be connector.version instead service.version
}
}
}
}
// const collectServices = () => {
// const hostAppStore = useHostAppStore()
// return {
// '@sa': {
// service: {
// version: hostAppStore.connectorVersion
// }
// }
// }
// }
export const logToSeq = async (
level: LogLevel,
message: string,
properties: Record<string, unknown> = {}
) => {
try {
const logEvent = {
'@t': new Date().toISOString(),
'@l': level,
'@m': message,
...collectResources(),
// ...collectServices(),
...collectCommonProperties(),
...properties
}
const response = await fetch(SEQ_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/vnd.serilog.clef',
'X-Seq-ApiKey': 'y5YnBp12ZE1Czh4tzZWn'
},
body: JSON.stringify(logEvent) + '\n'
})
if (!response.ok) {
const errorText = await response.text()
console.error(
`[Seq Logger] Failed to log: ${response.status} ${response.statusText} - ${errorText}`
)
}
} catch (err) {
console.error('[Seq Logger] Failed to log', err)
}
}
+153
View File
@@ -0,0 +1,153 @@
import type { CategoryOption } from './types'
/**
* Hardcoded Revit BuiltInCategories for the Interop Lite mapper.
*/
export const REVIT_CATEGORIES: readonly CategoryOption[] = [
// INFRASTRUCTURE
{ value: 'OST_BridgeAbutments', label: 'Abutments' },
{ value: 'OST_AbutmentFoundations', label: 'Abutment Foundations' },
{ value: 'OST_AbutmentPiles', label: 'Abutment Piles' },
{ value: 'OST_AbutmentWalls', label: 'Abutment Walls' },
{ value: 'OST_ApproachSlabs', label: 'Approach Slabs' },
{ value: 'OST_BridgeBearings', label: 'Bearings' },
{ value: 'OST_BridgeCables', label: 'Bridge Cables' },
{ value: 'OST_BridgeDecks', label: 'Bridge Decks' },
{ value: 'OST_BridgeFraming', label: 'Bridge Framing' },
{ value: 'OST_BridgeArches', label: 'Bridge Arches' },
{ value: 'OST_BridgeFramingCrossBracing', label: 'Bridge Framing - Cross Bracing' },
{ value: 'OST_BridgeFramingDiaphragms', label: 'Bridge Framing - Diaphragms' },
{ value: 'OST_BridgeGirders', label: 'Bridge Framing - Girders' },
{ value: 'OST_BridgeFramingTrusses', label: 'Bridge Framing - Trusses' },
{ value: 'OST_ExpansionJoints', label: 'Expansion Joints' },
{ value: 'OST_BridgePiers', label: 'Piers' },
{ value: 'OST_PierCaps', label: 'Pier Caps' },
{ value: 'OST_PierColumns', label: 'Pier Columns' },
{ value: 'OST_BridgeFoundations', label: 'Pier Foundations' },
{ value: 'OST_PierPiles', label: 'Pier Piles' },
{ value: 'OST_BridgeTowers', label: 'Pier Towers' },
{ value: 'OST_PierWalls', label: 'Pier Walls' },
{ value: 'OST_StructuralTendons', label: 'Structural Tendons' },
{ value: 'OST_VibrationManagement', label: 'Vibration Management' },
{ value: 'OST_VibrationDampers', label: 'Vibration Dampers' },
{ value: 'OST_VibrationIsolators', label: 'Vibration Isolators' },
// ARCHITECTURE
{ value: 'OST_AudioVisualDevices', label: 'Audio Visual Devices' },
{ value: 'OST_Casework', label: 'Casework' },
{ value: 'OST_Ceilings', label: 'Ceilings' },
{ value: 'OST_Columns', label: 'Columns' },
{ value: 'OST_CurtainWallPanels', label: 'Curtain Panels' },
// { value: 'OST_CurtaSystem', label: 'Curtain Systems' }, excluded as part of CNX-2299
{ value: 'OST_CurtainWallMullions', label: 'Curtain Wall Mullions' },
{ value: 'OST_Doors', label: 'Doors' },
{ value: 'OST_Entourage', label: 'Entourage' },
{ value: 'OST_FireProtection', label: 'Fire Protection' },
{ value: 'OST_Floors', label: 'Floors' },
{ value: 'OST_FoodServiceEquipment', label: 'Food Service Equipment' },
{ value: 'OST_Furniture', label: 'Furniture' },
{ value: 'OST_FurnitureSystems', label: 'Furniture Systems' },
{ value: 'OST_GenericModel', label: 'Generic Models' },
{ value: 'OST_Hardscape', label: 'Hardscape' },
{ value: 'OST_Lines', label: 'Lines' },
{ value: 'OST_Mass', label: 'Mass' },
{ value: 'OST_MechanicalControlDevices', label: 'Mechanical Control Devices' },
{ value: 'OST_MechanicalEquipment', label: 'Mechanical Equipment' },
{ value: 'OST_MedicalEquipment', label: 'Medical Equipment' },
{ value: 'OST_Parking', label: 'Parking' },
{ value: 'OST_Parts', label: 'Parts' },
{ value: 'OST_Planting', label: 'Planting' },
{ value: 'OST_PlumbingEquipment', label: 'Plumbing Equipment' },
{ value: 'OST_PlumbingFixtures', label: 'Plumbing Fixtures' },
{ value: 'OST_StairsRailing', label: 'Railings' },
{ value: 'OST_StairsRailingBaluster', label: 'Railings - Balusters' },
{ value: 'OST_RailingSupport', label: 'Railings - Supports' },
{ value: 'OST_RailingTermination', label: 'Railings - Terminations' },
{ value: 'OST_Ramps', label: 'Ramps' },
{ value: 'OST_Roads', label: 'Roads' },
{ value: 'OST_Roofs', label: 'Roofs' },
{ value: 'OST_Signage', label: 'Signage' },
{ value: 'OST_Site', label: 'Site' },
{ value: 'OST_SpecialtyEquipment', label: 'Specialty Equipment' },
{ value: 'OST_Stairs', label: 'Stairs' },
{ value: 'OST_TemporaryStructure', label: 'Temporary Structures' },
{ value: 'OST_Topography', label: 'Topography' },
{ value: 'OST_Toposolid', label: 'Toposolid' },
{ value: 'OST_VerticalCirculation', label: 'Vertical Circulation' },
{ value: 'OST_Walls', label: 'Walls' },
{ value: 'OST_Windows', label: 'Windows' },
// ELECTRICAL
{ value: 'OST_CableTrayFitting', label: 'Cable Tray Fittings' },
{ value: 'OST_CableTray', label: 'Cable Trays' },
{ value: 'OST_CommunicationDevices', label: 'Communication Devices' },
{ value: 'OST_ConduitFittings', label: 'Conduit Fittings' },
{ value: 'OST_Conduit', label: 'Conduits' },
{ value: 'OST_DataDevices', label: 'Data Devices' },
{ value: 'OST_ElectricalEquipment', label: 'Electrical Equipment' },
{ value: 'OST_ElectricalFixtures', label: 'Electrical Fixtures' },
{ value: 'OST_FireAlarmDevices', label: 'Fire Alarm Devices' },
{ value: 'OST_LighintgDevices', label: 'Lighting Devices' },
{ value: 'OST_LightingFixtures', label: 'Lighting Fixtures' },
{ value: 'OST_NurseCallDevices', label: 'Nurse Call Devices' },
{ value: 'OST_SecurityDevices', label: 'Security Devices' },
{ value: 'OST_TelephoneDevices', label: 'Telephone Devices' },
// STRUCTURE
{ value: 'OST_Coupler', label: 'Structural Rebar Couplers' },
{ value: 'OST_FabricAreas', label: 'Structural Fabric Areas' },
{ value: 'OST_StructConnections', label: 'Structural Connections' },
{ value: 'OST_StructConnectionAnchors', label: 'Structural Connections - Anchors' },
{ value: 'OST_StructConnectionBolts', label: 'Structural Connections - Bolts' },
{ value: 'OST_StructConnectionPlates', label: 'Structural Connections - Plates' },
{ value: 'OST_StructConnectionProfiles', label: 'Structural Connections - Profiles' },
{
value: 'OST_StructConnectionShearStuds',
label: 'Structural Connections - Shear Studs'
},
{ value: 'OST_StructConnectionWelds', label: 'Structural Connections - Welds' },
// { value: 'OST_StructuralColumns', label: 'Structural Columns' }, excluded as part of CNX-2299
{ value: 'OST_StructuralFoundation', label: 'Structural Foundations' },
{ value: 'OST_StructuralFraming', label: 'Structural Framing' },
{ value: 'OST_StructuralTruss', label: 'Structural Trusses' },
{ value: 'OST_Rebar', label: 'Structural Rebar' },
// MECHANICAL
{ value: 'OST_DuctTerminal', label: 'Air Terminals' },
{ value: 'OST_DuctAccessory', label: 'Duct Accessories' },
{ value: 'OST_DuctFitting', label: 'Duct Fittings' },
{ value: 'OST_PlaceHolderDucts', label: 'Duct Placeholders' },
{ value: 'OST_DuctCurves', label: 'Ducts' },
{ value: 'OST_MEPAncillaryFraming', label: 'MEP Ancillary Framing' },
// PIPING
{ value: 'OST_PipeAccessory', label: 'Pipe Accessories' },
{ value: 'OST_PipeFitting', label: 'Pipe Fittings' },
{ value: 'OST_PlaceHolderPipes', label: 'Pipe Placeholders' },
{ value: 'OST_PipeSegments', label: 'Pipe Segments' },
{ value: 'OST_PipeCurves', label: 'Pipes' },
{ value: 'OST_Sprinklers', label: 'Sprinklers' },
// GENERAL/MULTI-DISCIPLINE
{ value: 'OST_CableTrayRun', label: 'Cable Tray Runs' },
{ value: 'OST_Coordination_Model', label: 'Coordination Model' },
{ value: 'OST_DuctSystem', label: 'Duct Systems' },
{ value: 'OST_PipingSystem', label: 'Piping Systems' },
{ value: 'OST_StructuralFramingSystem', label: 'Structural Beam Systems' },
{ value: 'OST_StructuralStiffener', label: 'Structural Stiffeners' }
] as const
/**
* Get available categories sorted alphabetically by label.
*/
export function getAvailableCategories(): CategoryOption[] {
return [...REVIT_CATEGORIES].sort((a, b) => a.label.localeCompare(b.label))
}
/**
* Gets the human-readable label for a category value.
*/
export function getCategoryLabel(categoryValue: string): string {
const category = REVIT_CATEGORIES.find((c) => c.value === categoryValue)
return category?.label ?? categoryValue
}
+4
View File
@@ -0,0 +1,4 @@
export interface CategoryOption {
value: string // e.g. "OST_Walls"
label: string // e.g. "Walls"
}
+1
View File
@@ -15,6 +15,7 @@ export type ModelCardNotification = {
name: string
tooltipText?: string
action: () => void
disabled?: boolean
}
/**
* If set, will display a view report button next to cta
+57
View File
@@ -1,6 +1,24 @@
import { computed } from 'vue'
import type {
ISendFilter,
SendFilterSelect,
RevitCategoriesSendFilter,
RevitViewsSendFilter
} from '~/lib/models/card/send'
import { ValidationHelpers } from '@speckle/ui-components'
import type { GenericValidateFunction } from 'vee-validate'
export const isSelectFilter = (f: ISendFilter): f is SendFilterSelect =>
f.type === 'Select' || 'selectedItems' in f
export const isRevitCategoriesFilter = (
f: ISendFilter
): f is RevitCategoriesSendFilter =>
f.id === 'revitCategories' || f.id === 'archicadLayers'
export const isRevitViewsFilter = (f: ISendFilter): f is RevitViewsSendFilter =>
f.id === 'revitViews'
export const isEmail = ValidationHelpers.isEmail
export const isOneOrMultipleEmails = ValidationHelpers.isOneOrMultipleEmails
@@ -42,3 +60,42 @@ export function useModelNameValidationRules() {
isValidModelName
])
}
export type FilterValidationResult = { valid: boolean; reason?: string }
export function validateFilter(
filter: ISendFilter | undefined,
context: { selectionCount: number }
): FilterValidationResult {
if (!filter) return { valid: false, reason: 'No filter selected' }
// Selection Filter check
if (filter.name === 'Selection' || filter.id === 'selection') {
return context.selectionCount > 0
? { valid: true }
: { valid: false, reason: 'No objects selected to publish' }
}
// List-based filters (Rhino Layers, etc.)
if (isSelectFilter(filter)) {
return (filter.selectedItems?.length ?? 0) > 0
? { valid: true }
: { valid: false, reason: 'No items selected to publish' }
}
// Category-based filters
if (isRevitCategoriesFilter(filter)) {
return (filter.selectedCategories?.length ?? 0) > 0
? { valid: true }
: { valid: false, reason: 'No categories selected to publish' }
}
// View-based filters
if (isRevitViewsFilter(filter)) {
return filter.selectedView?.trim()
? { valid: true }
: { valid: false, reason: 'No view selected to publish' }
}
return { valid: true }
}
+7
View File
@@ -0,0 +1,7 @@
import { graphql } from '~~/lib/common/generated/gql'
export const workspacePlanUsageUpdatedSubscription = graphql(`
subscription WorkspacePlanUsageUpdated($input: WorkspacePlanUsageSubscriptionInput!) {
workspacePlanUsageUpdated(input: $input)
}
`)
+2 -1
View File
@@ -10,7 +10,8 @@ export default defineNuxtConfig({
'@nuxt/eslint',
'@nuxtjs/tailwindcss',
'@speckle/ui-components-nuxt',
'@pinia/nuxt'
'@pinia/nuxt',
'@nuxt/image'
],
alias: {
// Rewriting all lodash calls to lodash-es for proper tree-shaking & chunk splitting
+18
View File
@@ -30,6 +30,7 @@
"@jsonforms/core": "3.1.0",
"@jsonforms/vue": "3.1.0",
"@jsonforms/vue-vanilla": "3.1.0",
"@nuxt/image": "^1.10.0",
"@pinia/nuxt": "^0.4.11",
"@speckle/objectloader": "^2.25.0",
"@speckle/objectsender": "^2.25.0",
@@ -37,6 +38,22 @@
"@speckle/tailwind-theme": "2.25.0",
"@speckle/ui-components": "^2.25.0",
"@speckle/ui-components-nuxt": "^2.25.0",
"@tiptap/core": "2.10.3",
"@tiptap/extension-bold": "2.10.3",
"@tiptap/extension-document": "2.10.3",
"@tiptap/extension-hard-break": "2.10.3",
"@tiptap/extension-history": "2.10.3",
"@tiptap/extension-italic": "2.10.3",
"@tiptap/extension-link": "2.10.3",
"@tiptap/extension-mention": "2.10.3",
"@tiptap/extension-paragraph": "2.10.3",
"@tiptap/extension-placeholder": "2.10.3",
"@tiptap/extension-strike": "2.10.3",
"@tiptap/extension-text": "2.10.3",
"@tiptap/extension-underline": "2.10.3",
"@tiptap/pm": "2.10.3",
"@tiptap/suggestion": "2.10.3",
"@tiptap/vue-3": "2.10.3",
"@vue/apollo-composable": "^4.0.0-beta.5",
"@vueuse/core": "^9.13.0",
"apollo-upload-client": "^17.0.0",
@@ -46,6 +63,7 @@
"graphql": "^16.6.0",
"graphql-tag": "^2.12.6",
"lodash-es": "^4.17.21",
"lucide-vue-next": "^0.537.0",
"nanoevents": "^8.0.0",
"pinia": "^2.1.4",
"portal-vue": "^3.0.0",
+101
View File
@@ -0,0 +1,101 @@
<template>
<div class="flex items-center justify-center"><InfiniteLoading /></div>
</template>
<script setup lang="ts">
import { md5 } from '@speckle/shared'
import { ToastNotificationType } from '@speckle/ui-components'
import { useRoute, useRouter } from 'vue-router'
import { useAuthManager } from '~/lib/authn/useAuthManager'
import type { Account } from '~/lib/bindings/definitions/IAccountBinding'
import { useHostAppStore } from '~/store/hostApp'
const route = useRoute()
const router = useRouter()
const { getChallenge, getChallengeUrl } = useAuthManager()
const { $accountBinding } = useNuxtApp()
const hostApp = useHostAppStore()
onMounted(async () => {
try {
const origin = getChallengeUrl()
const accessCode = route.query.access_code as string | undefined
if (accessCode && origin) {
const challenge = getChallenge()
const body = {
appId: 'sdui',
appSecret: 'sdui',
accessCode,
challenge
}
// Exchange the access code for a real token (optional)
const response = await fetch(new URL('/auth/token', origin), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
})
if (!response.ok) {
const errorText = await response.text()
hostApp.setNotification({
title: 'Log In',
type: ToastNotificationType.Danger,
description: `Token exchange failed with status ${response.status}: ${errorText}`
})
// Stop processing and redirect immediately on failure
return router.replace('/')
}
const { token, refreshToken } = (await response.json()) as {
token: string
refreshToken: string
}
const graphqlQuery = {
query:
'query { activeUser { id name email company avatar } serverInfo { name company adminContact description version } }'
}
const userAndServerInfoResponse = await fetch(new URL('/graphql', origin), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}` // Add the token as a Bearer token
},
body: JSON.stringify(graphqlQuery)
})
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const userAndServerInfo = await userAndServerInfoResponse.json()
const accountId = md5(
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
userAndServerInfo.data.activeUser.email + origin
).toUpperCase()
const account: Account = {
id: accountId,
token,
refreshToken,
isDefault: true,
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
serverInfo: { url: origin, ...userAndServerInfo.data.serverInfo },
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
userInfo: userAndServerInfo.data.activeUser
}
await $accountBinding.addAccount(accountId, account)
} else {
throw new Error('No access code is found.')
}
} catch (error) {
hostApp.setNotification({
type: ToastNotificationType.Danger,
title: 'Failed to add your Speckle account.',
description: error as string
})
} finally {
router.replace('/')
}
})
</script>
+51 -2
View File
@@ -1,6 +1,6 @@
<template>
<div>
<div v-if="store.hostAppName">
<div v-if="(store.hostAppName && app.$isRunningOnConnector) || app.$isDev">
<div v-if="!config.isDevMode" class="px-1">
<CommonUpdateAlert />
</div>
@@ -96,6 +96,7 @@
Getting started
</FormButton>
</div>
<!--
<FormButton
text
@@ -114,6 +115,29 @@
</span>
</FormButton> -->
</div>
<!--Apply Revit Categories section (only if mapper binding exists)-->
<div
v-if="app.$revitMapperBinding"
class="mt-2 bg-highlight-1 rounded-md p-2"
>
<h1
class="text-heading-sm w-full bg-gradient-to-r from-blue-500 via-blue-400 to-blue-600 inline-block py-1 text-transparent bg-clip-text"
>
Assign Revit Categories
</h1>
<div class="text-foreground-2 text-body-xs">
Set Rhino geometry categories for accurate DirectShape loading in Revit.
<FormButton
size="sm"
color="outline"
class="my-2"
full-width
@click="$router.push('/revit-mapper')"
>
Assign categories
</FormButton>
</div>
</div>
</LayoutPanel>
</div>
<div v-if="accounts.length !== 0 && !hasNoModelCards" class="space-y-2 pb-24">
@@ -138,6 +162,27 @@
</div>
</div>
</div>
<div
v-else-if="!app.$isRunningOnConnector"
class="flex-1 flex flex-col items-center justify-center py-12 gap-y-6"
>
<section
class="w-full max-w-md flex flex-col gap-y-8 items-center mx-auto py-12 px-6 border rounded-2xl border-outline-2"
>
<NuxtImg
:src="`/assets/images/pleading_spockle.svg`"
alt=""
class="w-20"
width="120"
/>
<h1 class="text-2xl md:text-3xl font-semibold text-foreground text-center">
Connector is not detected.
</h1>
<FormButton color="outline" @click="openSpeckleConnectors">
Get them here
</FormButton>
</section>
</div>
<div v-else>
<div class="fixed h-screen w-screen flex items-center pointer-events-none">
<LayoutPanel fancy-glow class="transition pointer-events-auto w-full">
@@ -191,7 +236,7 @@ const { trackEvent } = useMixpanel()
const showSendDialog = ref(false)
const showReceiveDialog = ref(false)
app.$baseBinding.on('documentChanged', () => {
app.$baseBinding?.on('documentChanged', () => {
showSendDialog.value = false
showReceiveDialog.value = false
})
@@ -223,4 +268,8 @@ const hasNoValidProjects = computed(() => {
const reload = () => {
window.location.reload()
}
const openSpeckleConnectors = () => {
window.open('https://app.speckle.systems/connectors', '_blank')
}
</script>
+693
View File
@@ -0,0 +1,693 @@
<template>
<div class="flex flex-col space-y-2">
<div class="px-2 space-y-1">
<FormButton to="/" size="sm" :icon-left="ArrowLeftIcon" class="my-1">
Home
</FormButton>
<hr />
</div>
<!-- Step 1: Mapping Mode Selection -->
<div class="px-2">
<p class="h5">Assign by</p>
<div class="space-y-2 my-2">
<FormSelectBase
:model-value="selectedMappingMode"
name="mappingMode"
label="Assign by"
class="w-full"
fixed-height
size="sm"
:items="mappingModeOptions"
:allow-unset="false"
mount-menu-on-body
@update:model-value="(value) => handleModeChange(value as string)"
>
<template #something-selected="{ value }">
<span class="text-primary text-xs">{{ value }}</span>
</template>
<template #option="{ item }">
<span class="text-xs">{{ item }}</span>
</template>
</FormSelectBase>
<!-- Mode-specific content -->
<div v-if="selectedMappingMode === 'Selection'">
<MapperSelectionMapper
:has-selection="(selectionInfo?.selectedObjectIds?.length || 0) > 0"
:selection-summary="selectionInfo?.summary || ''"
/>
</div>
<MapperLayerMapper
v-if="selectedMappingMode === 'Layer'"
v-model:selected-layers="selectedLayers"
:layer-options="layerOptions"
/>
</div>
</div>
<!-- Step 2: Category Selection -->
<div v-if="hasTargetsSelected" class="px-2">
<p class="h5">Target Category</p>
<div class="space-y-2 my-2">
<div class="flex space-x-2 items-center">
<div class="flex-1">
<FormSelectBase
key="label"
v-model="revitMapperStore.selectedCategory"
name="categoryMapping"
:placeholder="dropdownPlaceholder"
label="Target Category"
fixed-height
size="sm"
search
:search-placeholder="''"
:filter-predicate="searchFilterPredicate"
:items="categoryOptions"
:allow-unset="false"
mount-menu-on-body
>
<template #something-selected>
<span class="text-primary text-xs">
{{ displayLabel }}
</span>
</template>
<template #option="{ item }">
<span class="text-xs">{{ item.label }}</span>
</template>
</FormSelectBase>
</div>
<!-- Apply button -->
<FormButton
color="primary"
size="sm"
:disabled="!revitMapperStore.selectedCategory?.value"
@click="assignToCategory()"
>
Apply
</FormButton>
</div>
</div>
</div>
<hr />
<!-- Step 3: Mappings Summary Tables -->
<div
v-if="currentMappings.length > 0 || currentLayerMappings.length > 0"
class="px-2"
>
<p class="h5">
{{ `Assigned Categories (${currentMappings.length > 0 ? 'Object' : 'Layer'})` }}
</p>
<!-- Object Mappings Section -->
<div v-if="currentMappings.length > 0" class="my-2">
<div class="space-y-1">
<MapperMappedElementItem
v-for="mapping in currentMappings"
:key="mapping.categoryValue"
:category-label="mapping.categoryLabel"
:count-text="`${mapping.objectCount} object${
mapping.objectCount !== 1 ? 's' : ''
}`"
@select="selectMappedObjects(mapping)"
@clear="clearMapping(mapping)"
/>
</div>
</div>
<!-- Layer Mappings Section -->
<div v-if="currentLayerMappings.length > 0" class="my-2">
<div class="space-y-1">
<MapperMappedElementItem
v-for="layerMapping in currentLayerMappings"
:key="layerMapping.categoryValue"
:category-label="layerMapping.categoryLabel"
:count-text="`${layerMapping.layerCount} layer${
layerMapping.layerCount !== 1 ? 's' : ''
}`"
:tooltip-text="`Layers: ${layerMapping.layerNames.join(', ')}`"
@select="selectMappedLayers(layerMapping)"
@clear="clearLayerMapping(layerMapping)"
/>
</div>
</div>
<!-- Clear All and Select All buttons -->
<div class="flex justify-end space-x-2">
<!-- Selection mode buttons -->
<div
v-if="selectedMappingMode === 'Selection' && currentMappings.length > 0"
class="flex space-x-2"
>
<FormButton size="sm" color="outline" @click="selectAllMappedObjects()">
Select All
</FormButton>
<FormButton size="sm" color="danger" @click="clearAllMappings()">
Clear All Objects
</FormButton>
</div>
<!-- Layer mode buttons -->
<div
v-else-if="selectedMappingMode === 'Layer' && currentLayerMappings.length > 0"
class="flex space-x-2"
>
<FormButton size="sm" color="outline" @click="selectAllMappedLayers()">
Select All
</FormButton>
<FormButton size="sm" color="danger" @click="clearAllLayerMappings()">
Clear All Layers
</FormButton>
</div>
</div>
<!-- Mode Confirmation Dialog -->
<CommonDialog
v-model:open="showModeConfirmDialog"
title="Switch Category Assignment Mode"
fullscreen="none"
>
<div class="text-sm text-foreground">
{{ conflictMessage }}
</div>
<div class="mt-4 flex justify-end space-x-2">
<FormButton size="sm" color="outline" @click="cancelModeChange()">
Cancel
</FormButton>
<FormButton size="sm" color="danger" @click="confirmModeChange()">
Clear & Switch
</FormButton>
</div>
</CommonDialog>
</div>
</div>
</template>
<script setup lang="ts">
// === IMPORTS ===
import { storeToRefs } from 'pinia'
import { ArrowLeftIcon } from '@heroicons/vue/20/solid'
import { useSelectionStore } from '~/store/selection'
import { useRevitMapper } from '~/store/revitMapper'
import type {
Category,
CategoryMapping,
LayerCategoryMapping
} from '~/lib/bindings/definitions/IRevitMapperBinding'
// Import categories
import { getAvailableCategories, getCategoryLabel } from '~/lib/mapper/revit-categories'
import { useMixpanel } from '~/lib/core/composables/mixpanel'
// === STORES ===
const selectionStore = useSelectionStore()
const revitMapperStore = useRevitMapper()
const { selectionInfo } = storeToRefs(selectionStore)
const { trackEvent } = useMixpanel()
// === STATE ===
const selectedMappingMode = ref<string | undefined>(undefined)
const mappingModeOptions = ['Selection', 'Layer']
const categoryOptions = ref<Category[]>([])
const mappings = ref<CategoryMapping[]>([])
// Layer-specific state
const selectedLayers = ref<LayerOption[]>([])
const layerOptions = ref<LayerOption[]>([])
const layerMappings = ref<LayerCategoryMapping[]>([])
// Mode switching state
const showModeConfirmDialog = ref(false)
const pendingMode = ref<string>('')
const conflictMessage = ref('')
// === TYPES ===
interface LayerOption {
id: string
name: string
}
// === MAPPING CATEGORY STATE MGMT ===
const app = useNuxtApp()
const { $revitMapperBinding, $baseBinding } = app
// const categoryState = useRevitCategoryState(categoryOptions, $revitMapperBinding)
// === COMPUTED ===
const hasTargetsSelected = computed(() => {
if (selectedMappingMode.value === 'Selection') {
return (selectionInfo.value?.selectedObjectIds?.length || 0) > 0
} else if (selectedMappingMode.value === 'Layer') {
return selectedLayers.value.length > 0
}
return false
})
// Show appropriate mappings based on current mode
const currentMappings = computed(() => {
return selectedMappingMode.value === 'Selection' ? mappings.value : []
})
const currentLayerMappings = computed(() => {
return selectedMappingMode.value === 'Layer' ? layerMappings.value : []
})
const dropdownPlaceholder = computed(() => {
if (revitMapperStore.categoryStatus) {
return revitMapperStore.categoryStatus?.isMultiple
? 'Multiple categories'
: 'Select a category'
}
return undefined
})
const displayLabel = computed(() => {
const multiple = revitMapperStore.categoryStatus?.isMultiple
return multiple
? 'Multiple categories'
: revitMapperStore.selectedCategory?.label || ''
})
// === METHODS ===
// Search predicate for category dropdown
const searchFilterPredicate = (item: Category, query: string) => {
return item.label.toLowerCase().includes(query.toLowerCase())
}
// Handle mode changes with conflict checking
const handleModeChange = (newMode: string) => {
// If switching to same mode, do nothing
if (newMode === selectedMappingMode.value) return
// Check for conflicts - ONLY show dialog if there are existing mappings
if (newMode === 'Layer' && mappings.value.length > 0) {
// Switching to Layer mode with existing object mappings
pendingMode.value = newMode
conflictMessage.value = `Switching to Layer assignment mode will clear all current object category assignments. Continue?`
showModeConfirmDialog.value = true
} else if (newMode === 'Selection' && layerMappings.value.length > 0) {
// Switching to Selection mode with existing layer mappings
pendingMode.value = newMode
conflictMessage.value = `Switching to Selection assignment mode will clear all current layer category assignments. Continue?`
showModeConfirmDialog.value = true
} else {
// No conflicts, switch directly (no existing mappings or switching to same mode)
selectedMappingMode.value = newMode
}
}
// Cancel mode change
const cancelModeChange = () => {
showModeConfirmDialog.value = false
pendingMode.value = ''
conflictMessage.value = ''
}
// Confirm mode change and clear conflicting mappings
const confirmModeChange = async () => {
try {
if (pendingMode.value === 'Layer') {
// Clear all object mappings before switching to Layer mode
await $revitMapperBinding?.clearAllObjectsCategoryAssignments()
} else if (pendingMode.value === 'Selection') {
// Clear all layer mappings before switching to Selection mode
await $revitMapperBinding?.clearAllLayerCategoryAssignments()
}
// Track the manual mode switch
trackEvent('DUI3 Action', {
name: 'Mapper Mode Changed',
mode: selectedMappingMode.value
})
// Switch mode
selectedMappingMode.value = pendingMode.value
await refreshMappings()
// Close dialog
showModeConfirmDialog.value = false
pendingMode.value = ''
conflictMessage.value = ''
} catch (error) {
console.error('Failed to clear category assignments during mode switch:', error)
}
}
// Assign selected objects/layers to the chosen category
const assignToCategory = async () => {
if (!revitMapperStore.selectedCategory?.value || !hasTargetsSelected.value) return
try {
let assignedCount = 0
const { selectedCategory } = storeToRefs(revitMapperStore)
const categoryValue = selectedCategory?.value?.value
if (selectedMappingMode.value === 'Selection' && categoryValue) {
const objectIds = selectionInfo.value?.selectedObjectIds || []
await $revitMapperBinding?.assignObjectsToCategory(objectIds, categoryValue)
assignedCount = objectIds.length
// Track the assignment
trackEvent('DUI3 Action', {
name: 'Mapper Assign Category',
category: categoryValue,
count: assignedCount,
mappingType: 'object'
})
} else if (selectedMappingMode.value === 'Layer' && categoryValue) {
const layerIds = selectedLayers.value.map((layer) => layer.id)
await $revitMapperBinding?.assignLayerToCategory(layerIds, categoryValue)
assignedCount = selectedLayers.value.length
// Track the assignment
trackEvent('DUI3 Action', {
name: 'Mapper Assign Category',
category: categoryValue,
count: assignedCount,
mappingType: 'layer'
})
selectedLayers.value = []
}
selectedCategory.value = undefined
await refreshMappings()
} catch (error) {
console.error('Failed to assign to category:', error)
}
}
// Clear a specific object mapping
const clearMapping = async (mapping: CategoryMapping) => {
try {
await $revitMapperBinding?.clearObjectsCategoryAssignment(mapping.objectIds)
await refreshMappings()
} catch (error) {
console.error('Failed to clear category assignment:', error)
}
}
// Clear all object mappings
const clearAllMappings = async () => {
try {
await $revitMapperBinding?.clearAllObjectsCategoryAssignments()
await refreshMappings()
} catch (error) {
console.error('Failed to clear all category assignments:', error)
}
}
// Clear a specific layer mapping
const clearLayerMapping = async (layerMapping: LayerCategoryMapping) => {
try {
await $revitMapperBinding?.clearLayerCategoryAssignment(layerMapping.layerIds)
await refreshMappings()
} catch (error) {
console.error('Failed to clear layer assignment:', error)
}
}
// Clear all layer mappings
const clearAllLayerMappings = async () => {
try {
await $revitMapperBinding?.clearAllLayerCategoryAssignments()
await refreshMappings()
} catch (error) {
console.error('Failed to clear all layer assignments:', error)
}
}
// Select mapped objects in Rhino
const selectMappedObjects = async (mapping: CategoryMapping) => {
try {
await $baseBinding?.highlightObjects(mapping.objectIds)
} catch (error) {
console.error('Failed to highlight objects:', error)
}
}
// Select mapped layers (highlight objects AND restore UI state)
const selectMappedLayers = async (layerMapping: LayerCategoryMapping) => {
try {
// 1. Highlight objects in Rhino
const effectiveObjectIds =
(await $revitMapperBinding?.getEffectiveObjectsForLayerMapping(
layerMapping.layerIds,
layerMapping.categoryValue
)) || []
if (effectiveObjectIds.length > 0) {
await $baseBinding?.highlightObjects(effectiveObjectIds)
}
// 2. Restore UI state - populate layer selection
const layersToSelect = layerOptions.value.filter((layer) =>
layerMapping.layerIds.includes(layer.id)
)
selectedLayers.value = layersToSelect
// 3. Pre-select category in dropdown
const categoryToSelect = categoryOptions.value.find(
(cat) => cat.value === layerMapping.categoryValue
)
const { selectedCategory, currentCategories } = storeToRefs(revitMapperStore)
selectedCategory.value = categoryToSelect
// 4. Update reactive state
currentCategories.value = [layerMapping.categoryValue]
} catch (error) {
console.error('Failed to highlight effective objects:', error)
}
}
// Select all mapped objects (Selection mode)
const selectAllMappedObjects = async () => {
try {
const allObjectIds = currentMappings.value.flatMap((mapping) => mapping.objectIds)
if (allObjectIds.length > 0) {
await $baseBinding?.highlightObjects(allObjectIds)
}
} catch (error) {
console.error('Failed to select all objects with categories assigned:', error)
}
}
// Select all objects affected by layer mappings (Layer mode)
const selectAllMappedLayers = async () => {
try {
const allEffectiveObjectIds: string[] = []
// Get effective objects for each layer mapping
for (const layerMapping of currentLayerMappings.value) {
const effectiveObjectIds =
(await $revitMapperBinding?.getEffectiveObjectsForLayerMapping(
layerMapping.layerIds,
layerMapping.categoryValue
)) || []
allEffectiveObjectIds.push(...effectiveObjectIds)
}
// Remove duplicates and highlight
const uniqueObjectIds = [...new Set(allEffectiveObjectIds)]
if (uniqueObjectIds.length > 0) {
await $baseBinding?.highlightObjects(uniqueObjectIds)
}
} catch (error) {
console.error(
'Failed to select all objects with categories assigned by layer:',
error
)
}
}
// Load available categories, layers, and current mappings
const loadData = async () => {
try {
const [categories, rawMappings, rawLayerMappings, layers] = await Promise.all([
getAvailableCategories() || [],
$revitMapperBinding?.getCurrentObjectsMappings() || [],
$revitMapperBinding?.getCurrentLayerMappings() || [],
loadAvailableLayers()
])
categoryOptions.value = categories
// Transform mappings to include human-readable labels
mappings.value = rawMappings.map((mapping) => ({
...mapping,
categoryLabel: getCategoryLabel(mapping.categoryValue)
}))
layerMappings.value = rawLayerMappings.map((mapping) => ({
...mapping,
categoryLabel: getCategoryLabel(mapping.categoryValue)
}))
layerOptions.value = layers
// IMPORTANT: Determine initial mapping mode based on existing mappings
// This preserves the user's last used mode and prevents mixed state scenarios
if (!selectedMappingMode.value) {
if (rawLayerMappings.length > 0 && rawMappings.length === 0) {
// Only layer mappings exist - user was in Layer mode
selectedMappingMode.value = 'Layer'
} else if (rawMappings.length > 0 && rawLayerMappings.length === 0) {
// Only object mappings exist - user was in Selection mode
selectedMappingMode.value = 'Selection'
} else if (rawLayerMappings.length > 0 && rawMappings.length > 0) {
// Mixed state detected - this shouldn't happen, but default to Selection
// and let the conflict handling take care of it
selectedMappingMode.value = 'Selection'
console.warn(
'Mixed assignment state detected - both object and layer assignments exist'
)
} else {
// No existing mappings - default to Selection mode
selectedMappingMode.value = 'Selection'
}
}
} catch (error) {
console.error('Failed to load categorizer data:', error)
// Fallback to Selection mode if loading fails
if (!selectedMappingMode.value) {
selectedMappingMode.value = 'Selection'
}
}
}
// Refresh both object and layer mappings
const refreshMappings = async () => {
try {
if (!$revitMapperBinding) {
console.warn('No Revit category assignment binding available')
return
}
const [rawMappings, rawLayerMappings] = await Promise.all([
$revitMapperBinding.getCurrentObjectsMappings(),
$revitMapperBinding.getCurrentLayerMappings()
])
// Transform to resolve labels
mappings.value = rawMappings.map((mapping) => ({
...mapping,
categoryLabel: getCategoryLabel(mapping.categoryValue)
}))
layerMappings.value = rawLayerMappings.map((mapping) => ({
...mapping,
categoryLabel: getCategoryLabel(mapping.categoryValue)
}))
} catch (error) {
console.error('Failed to refresh category assignments:', error)
}
}
// Load available layers from Rhino document
const loadAvailableLayers = async (): Promise<LayerOption[]> => {
try {
// Call the backend method to get available layers
const layers = (await $revitMapperBinding?.getAvailableLayers()) || []
return layers
} catch (error) {
console.error('Failed to load layers:', error)
return []
}
}
// === WATCHER ===
// Main watcher
watch(
() => ({
mode: selectedMappingMode.value,
objectIds: selectionInfo.value?.selectedObjectIds || [],
layerIds: selectedLayers.value.map((l) => l.id)
}),
async ({ mode, objectIds, layerIds }) => {
if (mode === 'Selection') {
await revitMapperStore.updateFromTargets(objectIds, false)
} else if (mode === 'Layer') {
// In Layer mode, we need to watch both manual layer selection AND object selection
// This keeps dropdowns clear when objects are deselected (like Selection mode)
// while still supporting manual layer selection
if (layerIds.length > 0) {
// User has manually selected layers in UI - use layer mode logic
await revitMapperStore.updateFromTargets(layerIds, true)
} else {
// No manual layer selection - use object mode logic (like Selection mode)
// This handles the case where selectMappedLayers populated the UI but objects were deselected
await revitMapperStore.updateFromTargets(objectIds, false)
}
}
},
{ immediate: true, deep: true }
)
// This handles clearing selectedLayers when objects are deselected in Layer mode
watch(
() => selectionInfo.value?.selectedObjectIds?.length || 0,
async (newCount, oldCount) => {
// Only act in Layer mode when selection count goes to 0 and we have selected layers
if (
selectedMappingMode.value === 'Layer' &&
newCount === 0 &&
oldCount > 0 &&
selectedLayers.value.length > 0
) {
// nextTick to avoid interfering with the main watcher? not nice :(
await nextTick()
selectedLayers.value = []
}
}
)
// === LIFECYCLE ===
onMounted(async () => {
await selectionStore.refreshSelectionFromHostApp()
await loadData()
// Listen for mappings changes
$revitMapperBinding?.on('mappingsChanged', (newMappings: CategoryMapping[]) => {
mappings.value = newMappings.map((mapping) => ({
...mapping,
categoryLabel: getCategoryLabel(mapping.categoryValue)
}))
refreshLayerMappings()
})
// Listen for layer list changes
$revitMapperBinding?.on('layersChanged', (newLayers: LayerOption[]) => {
layerOptions.value = newLayers
selectedLayers.value = []
})
// Track mapper opened with the initial mode (after loadData determines it)
trackEvent('DUI3 Action', {
name: 'Mapper Opened',
mode: selectedMappingMode.value
})
})
// Refresh just layer mappings
const refreshLayerMappings = async () => {
try {
const rawLayerMappings =
(await $revitMapperBinding?.getCurrentLayerMappings()) || []
layerMappings.value = rawLayerMappings.map((mapping) => ({
...mapping,
categoryLabel: getCategoryLabel(mapping.categoryValue)
}))
} catch (error) {
console.error('Failed to refresh layer category assignments:', error)
}
}
</script>
+18 -3
View File
@@ -1,7 +1,10 @@
<template>
<div class="flex flex-col space-y-2">
<div class="px-2 mt-2">
<FormButton to="/" size="sm" :icon-left="ArrowLeftIcon">Home</FormButton>
<FormButton to="/" size="sm" :icon-left="ArrowLeftIcon" class="my-2">
Home
</FormButton>
<hr />
<p class="h5">Document info</p>
<p class="text-sm text-foreground-2 py-2">
Current document info. This should change on document swaps, closure, opening,
@@ -11,6 +14,7 @@
<pre>{{ documentInfo }}</pre>
</div>
</div>
<hr />
<div class="px-2">
<p class="h5">Send Filters</p>
<p class="text-sm text-foreground-2 space-x-2">Available send filters:</p>
@@ -32,6 +36,7 @@
<pre>{{ sendFilters }}</pre>
</div>
</div>
<hr />
<div class="px-2">
<p class="h5 mb-4">Chromium 65 Scrollable Dialogs Test</p>
<FormButton @click="showBigDialog = !showBigDialog">Show Big Dialog</FormButton>
@@ -41,6 +46,14 @@
</div>
</CommonDialog>
</div>
<hr />
<div class="px-2">
<p class="h5">Settings</p>
<div class="border rounded-lg p-1">
<ConfigDialog></ConfigDialog>
</div>
</div>
<hr />
<div class="px-2">
<p class="h5">Selection info</p>
<p class="text-sm text-foreground-2 py-2">
@@ -56,6 +69,7 @@
<pre>{{ selectionInfo }}</pre>
</div>
</div>
<hr />
<div class="px-2">
<p class="h5">Document State</p>
<p class="text-sm text-foreground-2 py-2">
@@ -70,6 +84,7 @@
<pre>{{ projectModelGroups }}</pre>
</div>
</div>
<hr />
<div class="px-2">
<p class="h5">Binding tests</p>
<p class="text-sm text-foreground-2 py-2">
@@ -223,7 +238,7 @@ const runTests = async () => {
}
}
$testBindings.on('emptyTestEvent', () => {
$testBindings?.on('emptyTestEvent', () => {
setTimeout(() => {
const myTest = tests.value.find((t) => t.name === 'Simple event capture')
@@ -233,7 +248,7 @@ $testBindings.on('emptyTestEvent', () => {
}, 1000)
})
$testBindings.on('testEvent', (args: TestEventArgs) => {
$testBindings?.on('testEvent', (args: TestEventArgs) => {
setTimeout(() => {
const myTest = tests.value.find((t) => t.name === 'Event capture with args')
+96 -27
View File
@@ -4,26 +4,53 @@ import { SketchupBridge } from '~/lib/bridge/sketchup'
import type { IBasicConnectorBinding } from '~/lib/bindings/definitions/IBasicConnectorBinding'
import type { IAccountBinding } from '~/lib/bindings/definitions/IAccountBinding'
import { IAccountBindingKey } from '~/lib/bindings/definitions/IAccountBinding'
import {
IAccountBindingKey,
MockedAccountBinding
} from '~/lib/bindings/definitions/IAccountBinding'
import type { ITestBinding } from '~/lib/bindings/definitions/ITestBinding'
import { ITestBindingKey } from '~/lib/bindings/definitions/ITestBinding'
import {
ITestBindingKey,
MockedTestBinding
} from '~/lib/bindings/definitions/ITestBinding'
import type { IConfigBinding } from '~/lib/bindings/definitions/IConfigBinding'
import { IConfigBindingKey } from '~/lib/bindings/definitions/IConfigBinding'
import {
IConfigBindingKey,
MockedConfigBinding
} from '~/lib/bindings/definitions/IConfigBinding'
import { IBasicConnectorBindingKey } from '~/lib/bindings/definitions/IBasicConnectorBinding'
import {
IBasicConnectorBindingKey,
MockedBaseBinding
} from '~/lib/bindings/definitions/IBasicConnectorBinding'
import type { ISendBinding } from '~/lib/bindings/definitions/ISendBinding'
import { ISendBindingKey } from '~/lib/bindings/definitions/ISendBinding'
import {
ISendBindingKey,
MockedSendBinding
} from '~/lib/bindings/definitions/ISendBinding'
import type { IReceiveBinding } from '~/lib/bindings/definitions/IReceiveBinding'
import { IReceiveBindingKey } from '~/lib/bindings/definitions/IReceiveBinding'
import {
IReceiveBindingKey,
MockedReceiveBinding
} from '~/lib/bindings/definitions/IReceiveBinding'
import type { ISelectionBinding } from '~/lib/bindings/definitions/ISelectionBinding'
import { ISelectionBindingKey } from '~/lib/bindings/definitions/ISelectionBinding'
import {
ISelectionBindingKey,
MockedSelectionBinding
} from '~/lib/bindings/definitions/ISelectionBinding'
import type { ITopLevelExpectionHandlerBinding } from '~/lib/bindings/definitions/ITopLevelExceptionHandlerBinding'
import { ITopLevelExpectionHandlerBindingKey } from '~/lib/bindings/definitions/ITopLevelExceptionHandlerBinding'
import type { IRevitMapperBinding } from '~/lib/bindings/definitions/IRevitMapperBinding'
import {
IRevitMapperBindingKey,
MockedMapperBinding
} from '~/lib/bindings/definitions/IRevitMapperBinding'
// Makes TS happy
declare let globalThis: Record<string, unknown> & {
CefSharp?: { BindObjectAsync: (name: string) => Promise<void> }
@@ -32,34 +59,73 @@ declare let globalThis: Record<string, unknown> & {
DG?: { LoadObject: (name: string) => Promise<void> }
}
const isWebview = () => !!(globalThis.chrome && globalThis.chrome.webview)
const isSketchup = () => !!globalThis.sketchup
const isCefSharp = () => !!globalThis.CefSharp
const isArchicad = () => isCefSharp() && !!globalThis.DG
const isConnector = () => isWebview() || isSketchup() || isCefSharp() || isArchicad()
/**
* Here we are loading any bindings that we expect to have from all
* connectors. If some are not present, that's okay - we're going to
* strip or customize functionality from the ui itself.
*/
export default defineNuxtPlugin(async () => {
const isRunningOnConnector = isConnector()
globalThis['isRunningOnConnector'] = isRunningOnConnector
const isDev = import.meta.dev
globalThis['isDev'] = isDev
if (!isRunningOnConnector) {
// The state that we wouldn't wanna show any connector related visuals on production like in dui.speckle.systems
console.warn(
'⚠️ You are a bad boy because you are not running DUI in a connector! ⚠️'
)
}
// Registers a set of non existent bindings as a test.
const nonExistantBindings = await tryHoistBinding('nonExistantBindings')
// Registers some default test bindings.
const testBindings = await tryHoistBinding<ITestBinding>(ITestBindingKey)
const testBindings =
isRunningOnConnector || !isDev
? await tryHoistBinding<ITestBinding>(ITestBindingKey)
: hoistMockBinding(new MockedTestBinding(), ITestBindingKey)
// Actual bindings follow below.
const configBinding = await tryHoistBinding<IConfigBinding>(IConfigBindingKey)
const configBinding =
isRunningOnConnector || !isDev
? await tryHoistBinding<IConfigBinding>(IConfigBindingKey)
: hoistMockBinding(new MockedConfigBinding(), IConfigBindingKey)
const accountBinding = await tryHoistBinding<IAccountBinding>(IAccountBindingKey)
const accountBinding =
isRunningOnConnector || !isDev
? await tryHoistBinding<IAccountBinding>(IAccountBindingKey)
: hoistMockBinding(new MockedAccountBinding(), IAccountBindingKey)
const baseBinding = await tryHoistBinding<IBasicConnectorBinding>(
IBasicConnectorBindingKey
)
const baseBinding =
isRunningOnConnector || !isDev
? await tryHoistBinding<IBasicConnectorBinding>(IBasicConnectorBindingKey)
: hoistMockBinding(new MockedBaseBinding(), IBasicConnectorBindingKey)
const sendBinding = await tryHoistBinding<ISendBinding>(ISendBindingKey)
const sendBinding =
isRunningOnConnector || !isDev
? await tryHoistBinding<ISendBinding>(ISendBindingKey)
: hoistMockBinding(new MockedSendBinding(), ISendBindingKey)
const receiveBinding = await tryHoistBinding<IReceiveBinding>(IReceiveBindingKey)
const receiveBinding =
isRunningOnConnector || !isDev
? await tryHoistBinding<IReceiveBinding>(IReceiveBindingKey)
: hoistMockBinding(new MockedReceiveBinding(), IReceiveBindingKey)
const selectionBinding = await tryHoistBinding<ISelectionBinding>(
ISelectionBindingKey
)
const selectionBinding =
isRunningOnConnector || !isDev
? await tryHoistBinding<ISelectionBinding>(ISelectionBindingKey)
: hoistMockBinding(new MockedSelectionBinding(), ISendBindingKey)
const revitMapperBinding =
isRunningOnConnector || !isDev
? await tryHoistBinding<IRevitMapperBinding>(IRevitMapperBindingKey)
: hoistMockBinding(new MockedMapperBinding(), IRevitMapperBindingKey)
const topLevelExceptionHandlerBinding =
await tryHoistBinding<ITopLevelExpectionHandlerBinding>(
@@ -78,6 +144,8 @@ export default defineNuxtPlugin(async () => {
return {
provide: {
isRunningOnConnector,
isDev,
nonExistantBindings,
testBindings,
configBinding,
@@ -88,7 +156,8 @@ export default defineNuxtPlugin(async () => {
selectionBinding,
topLevelExceptionHandlerBinding,
showDevTools,
openUrl
openUrl,
revitMapperBinding
}
}
})
@@ -137,11 +206,11 @@ const tryHoistBinding = async <T>(name: string) => {
return bridge as unknown as T
}
// const hoistMockBinding = (mockBinding: BaseBridge, name: string) => {
// globalThis[name] = mockBinding
// console.log(
// `%c✔ Mocked ${name} binding added succesfully.`,
// 'color: green; font-weight: bold; font-size: small'
// )
// return mockBinding
// }
const hoistMockBinding = <T>(mockBinding: T, name: string) => {
globalThis[name] = mockBinding
console.log(
`%c✔ Mocked ${name} binding added succesfully.`,
'color: green; font-weight: bold; font-size: small'
)
return mockBinding
}
+10
View File
@@ -7,6 +7,7 @@ import Intercom, {
trackEvent
} from '@intercom/messenger-js-sdk'
import { useAccountStore } from '~/store/accounts'
import { useHostAppStore } from '~/store/hostApp'
import { storeToRefs } from 'pinia'
const disabledRoutes: string[] = []
@@ -15,7 +16,9 @@ export const useIntercom = () => {
const route = useRoute()
const accountStore = useAccountStore()
const hostAppStore = useHostAppStore()
const { activeAccount } = storeToRefs(accountStore)
const { isDistributedBySpeckle } = storeToRefs(hostAppStore)
const isInitialized = ref(false)
@@ -80,6 +83,13 @@ export const useIntercom = () => {
}
})
// we listen to changes in the host app distribution status that fetched on updateConnector composable after the intercom is initialized, we cant simply rely on activeAccount watcher
watch(isDistributedBySpeckle, (newValue) => {
if (!newValue) {
shutdownIntercom()
}
})
watch(activeAccount, (newValue) => {
if (newValue) {
if (!isInitialized.value) {
+48
View File
@@ -0,0 +1,48 @@
<svg width="94" height="97" viewBox="0 0 94 97" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M93.9875 11.8396L13.3384 16.262V96.33L93.9875 91.9076V11.8396Z" fill="#047EFB"/>
<path d="M80.6801 -9.55722e-05L0.0146484 4.41339L13.3205 16.249L93.986 11.8356L80.6801 -9.55722e-05Z" fill="#7BBCFF"/>
<path d="M13.3328 16.2685L0.0107422 4.40778V84.4705L13.3328 96.3312V16.2685Z" fill="#313BCF"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M34.4877 66.6814C35.7855 65.708 37.6268 65.9711 38.6002 67.2689C39.6031 68.6061 44.6573 73.4377 53.8752 73.4377C63.093 73.4377 68.1473 68.6061 69.1502 67.2689C70.1236 65.9711 71.9648 65.708 73.2627 66.6814C74.5605 67.6548 74.8236 69.4961 73.8502 70.7939C71.9156 73.3734 65.2198 79.3127 53.8752 79.3127C42.5305 79.3127 35.8348 73.3734 33.9002 70.7939C32.9268 69.4961 33.1898 67.6548 34.4877 66.6814Z" fill="url(#paint0_radial_5165_11144)"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M34.4877 66.6814C35.7855 65.708 37.6268 65.9711 38.6002 67.2689C39.6031 68.6061 44.6573 73.4377 53.8752 73.4377C63.093 73.4377 68.1473 68.6061 69.1502 67.2689C70.1236 65.9711 71.9648 65.708 73.2627 66.6814C74.5605 67.6548 74.8236 69.4961 73.8502 70.7939C71.9156 73.3734 65.2198 79.3127 53.8752 79.3127C42.5305 79.3127 35.8348 73.3734 33.9002 70.7939C32.9268 69.4961 33.1898 67.6548 34.4877 66.6814Z" fill="url(#paint1_radial_5165_11144)"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M34.4877 66.6814C35.7855 65.708 37.6268 65.9711 38.6002 67.2689C39.6031 68.6061 44.6573 73.4377 53.8752 73.4377C63.093 73.4377 68.1473 68.6061 69.1502 67.2689C70.1236 65.9711 71.9648 65.708 73.2627 66.6814C74.5605 67.6548 74.8236 69.4961 73.8502 70.7939C71.9156 73.3734 65.2198 79.3127 53.8752 79.3127C42.5305 79.3127 35.8348 73.3734 33.9002 70.7939C32.9268 69.4961 33.1898 67.6548 34.4877 66.6814Z" fill="url(#paint2_radial_5165_11144)"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M39.6562 29.375C39.6562 28.5638 38.9987 27.9062 38.1875 27.9062C35.9131 27.9062 33.0689 28.7414 30.5953 30.3459C28.1034 31.9623 25.8589 34.4462 25.0126 37.8313C24.8159 38.6182 25.2943 39.4157 26.0813 39.6124C26.8682 39.8091 27.6657 39.3307 27.8624 38.5437C28.4849 36.0538 30.157 34.1315 32.1938 32.8103C34.2488 31.4774 36.5452 30.8438 38.1875 30.8438C38.9987 30.8438 39.6562 30.1862 39.6562 29.375Z" fill="url(#paint3_linear_5165_11144)"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M77.6399 32.8386C79.3816 34.4921 80.4348 36.4815 80.8562 37.7457C81.1127 38.5153 81.9445 38.9311 82.7141 38.6746C83.4836 38.4181 83.8995 37.5863 83.643 36.8168C83.0852 35.1435 81.7885 32.7267 79.6624 30.7083C77.5114 28.6661 74.4714 27 70.4996 27C69.6884 27 69.0309 27.6576 69.0309 28.4688C69.0309 29.2799 69.6884 29.9375 70.4996 29.9375C73.5778 29.9375 75.9232 31.2089 77.6399 32.8386Z" fill="url(#paint4_linear_5165_11144)"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M39.9309 45.9902C43.1513 45.9902 44.186 48.0385 44.3321 48.6262C44.7232 50.2007 46.3167 51.16 47.8911 50.7688C49.4656 50.3777 50.4249 48.7842 50.0337 47.2097C49.374 44.554 46.3799 40.1152 39.9309 40.1152C33.482 40.1152 30.4879 44.554 29.8281 47.2097C29.437 48.7842 30.3963 50.3777 31.9708 50.7688C33.5452 51.16 35.1387 50.2007 35.5298 48.6262C35.6758 48.0385 36.7106 45.9902 39.9309 45.9902Z" fill="url(#paint5_radial_5165_11144)"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M68.1902 45.9902C71.4106 45.9902 72.4453 48.0385 72.5913 48.6262C72.9825 50.2007 74.5759 51.16 76.1504 50.7688C77.7249 50.3777 78.6842 48.7842 78.293 47.2097C77.6333 44.554 74.6391 40.1152 68.1902 40.1152C61.7413 40.1152 58.7472 44.554 58.0874 47.2097C57.6963 48.7842 58.6556 50.3777 60.23 50.7688C61.8045 51.16 63.398 50.2007 63.7891 48.6262C63.9351 48.0385 64.9699 45.9902 68.1902 45.9902Z" fill="url(#paint6_radial_5165_11144)"/>
<defs>
<radialGradient id="paint0_radial_5165_11144" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(53.8752 62.7893) rotate(90) scale(18.1758 28.851)">
<stop offset="0.464452" stop-color="#241A1A"/>
<stop offset="0.663489" stop-color="#554248"/>
<stop offset="1" stop-color="#4E2553"/>
<stop offset="1" stop-color="#4A274E"/>
</radialGradient>
<radialGradient id="paint1_radial_5165_11144" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(53.8752 70.4119) rotate(90) scale(26.0743 25.7783)">
<stop offset="0.483976" stop-color="#4F302E" stop-opacity="0"/>
<stop offset="1" stop-color="#4F302E"/>
</radialGradient>
<radialGradient id="paint2_radial_5165_11144" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(72.5442 67.9849) rotate(39.3327) scale(2.9658 2.13554)">
<stop stop-color="#5B4A50"/>
<stop offset="1" stop-color="#5B4A50" stop-opacity="0"/>
</radialGradient>
<linearGradient id="paint3_linear_5165_11144" x1="37.2083" y1="40.8312" x2="37.2083" y2="35.5437" gradientUnits="userSpaceOnUse">
<stop offset="0.0299084" stop-color="#524049"/>
<stop offset="1" stop-color="#4A2C42"/>
</linearGradient>
<linearGradient id="paint4_linear_5165_11144" x1="81.2704" y1="39.925" x2="81.2704" y2="34.6375" gradientUnits="userSpaceOnUse">
<stop offset="0.0299084" stop-color="#524049"/>
<stop offset="1" stop-color="#4A2C42"/>
</linearGradient>
<radialGradient id="paint5_radial_5165_11144" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(39.931 47.918) rotate(90) scale(9.73047 14.1637)">
<stop offset="0.337213" stop-color="#4D274B"/>
<stop offset="0.628228" stop-color="#514047"/>
<stop offset="1" stop-color="#4E2553"/>
<stop offset="1" stop-color="#4A342F"/>
</radialGradient>
<radialGradient id="paint6_radial_5165_11144" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(68.1902 47.918) rotate(90) scale(9.73047 14.1637)">
<stop offset="0.337213" stop-color="#4D274B"/>
<stop offset="0.628228" stop-color="#514047"/>
<stop offset="1" stop-color="#4E2553"/>
<stop offset="1" stop-color="#4A342F"/>
</radialGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 5.8 KiB

+21
View File
@@ -0,0 +1,21 @@
<svg width="117" height="121" viewBox="0 0 117 121" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1246_23056)">
<path d="M115.033 15.0278L16.3086 20.4414V118.454L115.033 113.04V15.0278Z" fill="#136CFF"/>
<path d="M73.4005 93.725C73.1345 93.7395 72.8685 93.7124 72.6025 93.6412C68.0572 92.4058 63.2665 92.6668 58.7212 94.3975C57.368 94.9102 55.9088 94.2468 55.4672 92.9073C55.0256 91.5678 55.7694 90.0624 57.1278 89.5442C62.7113 87.4167 68.6124 87.0952 74.1959 88.6143C75.5518 88.9846 76.2956 90.4089 75.8565 91.7964C75.5027 92.9092 74.4903 93.6656 73.4005 93.725Z" fill="#0A2948"/>
<path d="M97.5176 53.6163C95.1855 60.8748 88.5173 66.3561 80.7464 66.7796C80.3745 66.7999 80 66.8073 79.623 66.8045C75.8086 66.7707 72.2834 65.5028 69.4219 63.2453C69.5148 64.5262 69.6181 65.8247 69.7731 67.227C70.1657 70.8115 72.0561 73.5506 75.2378 75.1464C77.3555 76.2105 79.8864 76.6805 82.5025 76.7042C86.9807 76.7433 91.7223 75.4743 95.0874 73.6568C98.6436 71.7354 100.955 68.4997 101.43 64.7769C101.903 61.075 100.516 57.2303 97.5176 53.6163Z" fill="white"/>
<path d="M74.3206 43.2249C70.971 44.8364 69.2923 49.477 69.2148 57.2442L69.3156 57.4153C71.6321 60.7159 75.444 62.8594 79.8731 62.8986C80.1675 62.9007 80.4619 62.8951 80.7537 62.8791C87.5562 62.5084 93.2766 57.1643 94.3199 50.3863C94.0229 50.1297 93.7492 49.8666 93.4341 49.6136C83.8399 41.9007 76.7327 42.0621 74.3206 43.2249Z" fill="#071F36"/>
<path d="M80.7486 66.7772C88.5196 66.3537 95.1877 60.8726 97.5198 53.6146C96.6056 52.5136 95.5286 51.437 94.3148 50.3887C93.2715 57.1661 87.5511 62.5098 80.7486 62.8805C80.4568 62.8964 80.1624 62.902 79.868 62.8999C75.4389 62.8607 71.627 60.7174 69.3105 57.4171L69.2097 57.246C69.1917 59.068 69.264 61.0696 69.4241 63.243C72.2856 65.5004 75.8108 66.7683 79.6252 66.802C80.0023 66.8074 80.3767 66.7974 80.7486 66.7772Z" fill="#01D1FD"/>
<path d="M74.48 54.2018C76.0589 54.1157 77.3389 52.7583 77.3389 51.1698C77.3389 49.5814 76.0589 48.3635 74.48 48.4495C72.9011 48.5356 71.6211 49.893 71.6211 51.4815C71.6211 53.0699 72.9011 54.2878 74.48 54.2018Z" fill="white"/>
<path d="M50.5822 68.4173C42.8113 68.8408 36.1431 64.0862 33.811 57.0819C30.8127 61.0227 29.4284 65.0185 29.8984 68.6664C30.3762 72.3372 32.685 75.3237 36.2412 76.8549C39.6063 78.3057 44.3453 79.058 48.826 78.5306C51.4422 78.2218 53.9731 77.4785 56.0908 76.1811C59.2699 74.2386 61.1604 71.2934 61.5555 67.666C61.7104 66.2494 61.8137 64.9448 61.9067 63.6564C59.0582 66.2095 55.5278 67.8672 51.7056 68.3171C51.3285 68.3662 50.9541 68.397 50.5822 68.4173Z" fill="white"/>
<path d="M50.5779 64.5182C50.8697 64.5023 51.1642 64.4759 51.4586 64.4416C55.8877 63.9197 59.6995 61.3607 62.0161 57.8077L62.1168 57.6255C62.0367 49.867 60.3607 45.4092 57.0111 44.1628C54.599 43.2628 47.4892 43.8763 37.8975 52.6322C37.5825 52.9196 37.3113 53.2124 37.0117 53.5015C38.0551 60.1683 43.7754 64.8889 50.5779 64.5182Z" fill="#071F36"/>
<path d="M62.1186 57.627L62.0179 57.8091C59.7014 61.3618 55.8895 63.9206 51.4604 64.4425C51.166 64.4767 50.8716 64.5032 50.5797 64.5191C43.7773 64.8898 38.0569 60.1695 37.0136 53.5059C35.7997 54.6865 34.7228 55.8804 33.8086 57.0811C36.1406 64.0849 42.8088 68.8392 50.5797 68.4158C50.9516 68.3955 51.3261 68.3621 51.7032 68.3182C55.5254 67.8683 59.0557 66.2108 61.9043 63.6577C62.0644 61.4643 62.1393 59.4521 62.1186 57.627Z" fill="#01D1FD"/>
<path d="M44.4386 55.8289C46.0139 55.6443 47.2122 54.2124 47.1115 52.633C47.0134 51.0535 45.655 49.922 44.0796 50.1066C42.5042 50.2912 41.3059 51.7231 41.4041 53.3026C41.5048 54.882 42.8632 56.0135 44.4386 55.8289Z" fill="white"/>
<path d="M98.7479 0.534875L0.00390625 5.9375L16.2919 20.4257L115.036 15.0231L98.7479 0.534875Z" fill="#6FA3FF"/>
<path d="M16.3038 20.4495L-0.00390625 5.93054V103.937L16.3038 118.456V20.4495Z" fill="#0357E1"/>
</g>
<defs>
<clipPath id="clip0_1246_23056">
<rect width="116.289" height="120" fill="white" transform="translate(0 0.536133)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 15 KiB

+47 -10
View File
@@ -16,6 +16,7 @@ import { getMainDefinition } from '@apollo/client/utilities'
import { setContext } from '@apollo/client/link/context'
import { useHostAppStore } from '~/store/hostApp'
import { ToastNotificationType } from '@speckle/ui-components'
import { logToSeq } from '~/lib/logger/composables/useLogger'
export type DUIAccount = {
/** account info coming from the host app */
@@ -83,7 +84,13 @@ export const useAccountStore = defineStore('accountStore', () => {
if (!acc.client) continue
if (!acc.accountInfo.serverInfo.frontend2) continue
try {
await acc.client.query({ query: accountTestQuery })
await acc.client.query({
query: accountTestQuery,
context: {
url: acc.accountInfo.serverInfo.url
}
})
acc.isValid = true
} catch {
// TODO: properly dispose and kill this client. It's unclear how to do it.
@@ -100,7 +107,8 @@ export const useAccountStore = defineStore('accountStore', () => {
const refreshAccounts = async () => {
isLoading.value = true
const accs = await $accountBinding.getAccounts()
const accs = (await $accountBinding?.getAccounts()) || []
const newAccs: DUIAccount[] = []
for (const acc of accs) {
@@ -112,6 +120,24 @@ export const useAccountStore = defineStore('accountStore', () => {
// Handle apollo client errors as top level
const errorLink = onError((res: ErrorResponse) => {
logToSeq('Error', 'Apollo GraphQL Error (DUI3)', {
operationName: res.operation?.operationName ?? 'Unknown',
serverUrl: res.operation.getContext().url as string,
graphQLErrors: res.graphQLErrors?.map((err) => ({
message: err.message,
path: err.path,
code: err.extensions?.code,
locations: err.locations
})),
networkError: res.networkError
? {
message: res.networkError.message,
name: res.networkError.name,
stack: res.networkError.stack
}
: undefined
})
if (res.graphQLErrors) {
if (
res.graphQLErrors?.some(
@@ -139,14 +165,13 @@ export const useAccountStore = defineStore('accountStore', () => {
// hostAppStore.setNotification(notification)
}
// if (res.networkError) {
// const notification: ToastNotification = {
// type: ToastNotificationType.Danger,
// title: 'Network Error',
// description: res.networkError.message
// }
// hostAppStore.setNotification(notification)
// }
if (res.networkError && !navigator.onLine) {
hostAppStore.setNotification({
type: ToastNotificationType.Danger,
title: 'No Internet Connection',
description: 'Please check your network connection and try again.'
})
}
})
const link = splitLink(
@@ -260,12 +285,23 @@ export const useAccountStore = defineStore('accountStore', () => {
}
const accountByServerUrl = (serverUrl: string) => {
if (activeAccount.value.accountInfo.serverInfo.url === serverUrl) {
return activeAccount.value
}
const accountMatchWithServerUrl = accounts.value.find(
(acc) => acc.accountInfo.serverInfo.url === serverUrl
)
if (accountMatchWithServerUrl) return accountMatchWithServerUrl
}
const getAccountClient = (accountId: string) => {
return (
accounts.value.find(
(account) => account.accountInfo.id === accountId
) as DUIAccount
).client
}
const provideClients = () => {
provideApolloClients(apolloClients)
}
@@ -292,6 +328,7 @@ export const useAccountStore = defineStore('accountStore', () => {
return {
isLoading,
accounts,
getAccountClient,
defaultAccount,
activeAccount,
userSelectedAccount,
+222 -33
View File
@@ -13,7 +13,10 @@ import type {
RevitViewsSendFilter,
SendFilterSelect
} from '~/lib/models/card/send'
import { useSelectionStore } from '~/store/selection'
import { validateFilter } from '~/lib/validation'
import type { ToastNotification } from '@speckle/ui-components'
import { ToastNotificationType } from '@speckle/ui-components'
import type { Nullable } from '@speckle/shared'
import type { HostAppError } from '~/lib/bridge/errorHandler'
import type { ConversionResult } from '~/lib/conversions/conversionResult'
@@ -27,6 +30,9 @@ import {
} from '~/lib/core/composables/updateConnector'
import { provideApolloClient, useMutation } from '@vue/apollo-composable'
import { createVersionMutation } from '~/lib/graphql/mutationsAndQueries'
import type { BaseBridge } from '~/lib/bridge/base'
import { useModelIngestion } from '~/lib/ingestion/composables/useModelIngestion'
import { useCheckGraphql } from '~/lib/core/composables/useCheckGraphql'
export type ProjectModelGroup = {
projectId: string
@@ -42,7 +48,15 @@ export const useHostAppStore = defineStore('hostAppStore', () => {
const { $openUrl } = useNuxtApp()
const accountsStore = useAccountStore()
const { checkUpdate } = useUpdateConnector()
const {
startIngestion,
updateIngestion,
failIngestion,
cancelIngestion,
completeIngestionWithVersion,
subscribeToIngestion,
unsubscribeFromIngestion
} = useModelIngestion()
const isDistributedBySpeckle = ref<boolean>(true)
const latestAvailableVersion = ref<Version | null>(null)
@@ -59,9 +73,14 @@ export const useHostAppStore = defineStore('hostAppStore', () => {
const availableViews = ref<string[]>() // TODO: later we can align views with -> const revitAvailableViews = ref<ISendFilterSelectItem[]>()
const navisworksAvailableSavedSets = ref<ISendFilterSelectItem[]>()
const isUpdateNotificationDisabled = ref(false)
// Different host apps can have different kind of ISendFilterSelect send filters, and we collect them here to generalize the component we use in `ListSelect`
const availableSelectSendFilters = ref<Record<string, SendFilterSelect>>({})
// kvp for modelCardId - ingestionId
const activeIngestions = ref<Record<string, string>>({})
const dismissNotification = () => {
currentNotification.value = null
}
@@ -78,9 +97,11 @@ export const useHostAppStore = defineStore('hostAppStore', () => {
$openUrl(latestAvailableVersion.value?.Url as string)
}
const isConnectorUpToDate = computed(
() => connectorVersion.value === latestAvailableVersion.value?.Number
)
const isConnectorUpToDate = computed(() => {
if (!isDistributedBySpeckle.value) return true
if (!latestAvailableVersion.value?.Number || !connectorVersion.value) return true
return connectorVersion.value === latestAvailableVersion.value.Number
})
const setHostAppError = (error: Nullable<HostAppError>) => {
hostAppError.value = error
@@ -90,6 +111,11 @@ export const useHostAppStore = defineStore('hostAppStore', () => {
isDistributedBySpeckle.value = val
}
const shouldHandleIngestion = computed(() => {
const hostAppsThatUsesDUIForGraphql = ['sketchup', 'archicad', 'Vectorworks']
return hostAppsThatUsesDUIForGraphql.includes(hostAppName.value as string)
})
/**
* Model Card Operations
*/
@@ -196,6 +222,7 @@ export const useHostAppStore = defineStore('hostAppStore', () => {
}
const sendSettings = ref<CardSetting[]>()
const receiveSettings = ref<CardSetting[]>()
/**
* Send filters
@@ -275,20 +302,59 @@ export const useHostAppStore = defineStore('hostAppStore', () => {
const account = accountStore.accounts.find(
(acc) => acc.accountInfo.id === args.accountId
)
try {
const createVersion = provideApolloClient((account as DUIAccount).client)(() =>
useMutation(createVersionMutation)
)
await createVersion.mutate({
input: {
modelId: args.modelId,
objectId: args.referencedObjectId,
sourceApplication: args.sourceApplication,
projectId: args.projectId
// Check if we have an ingestion ID for this model.
// If so, we are in the "New Business Model" flow and should use completeIngestionWithVersion.
const modelCard = documentModelStore.value.models.find(
(m) => m.modelId === args.modelId && m.projectId === args.projectId
) as ISenderModelCard
const { canCreateModelIngestion } = useCheckGraphql()
const canCreateIngestion = await canCreateModelIngestion(
args.projectId,
args.modelId,
args.accountId
)
if (canCreateIngestion.queryAvailable) {
const ingestionId = modelCard
? activeIngestions.value[modelCard.modelCardId]
: undefined
if (ingestionId && modelCard) {
try {
await completeIngestionWithVersion(
modelCard,
ingestionId,
args.referencedObjectId
)
} catch (err) {
console.error(`completeIngestionWithVersion failed: ${err}`)
}
})
} catch (err) {
console.error(`triggerCreateVersion is failed: ${err}`)
} else {
setNotification({
type: ToastNotificationType.Danger,
title: 'Publish Error',
description: 'Could not complete publish: Ingestion ID missing.'
})
}
} else {
// Fallback to legacy flow (Old Server)
try {
const createVersion = provideApolloClient((account as DUIAccount).client)(() =>
useMutation(createVersionMutation)
)
await createVersion.mutate({
input: {
modelId: args.modelId,
objectId: args.referencedObjectId,
sourceApplication: args.sourceApplication,
projectId: args.projectId
}
})
} catch (err) {
console.error(`triggerCreateVersion is failed: ${err}`)
}
}
})
@@ -304,6 +370,14 @@ export const useHostAppStore = defineStore('hostAppStore', () => {
*/
app.$sendBinding?.on('refreshSendFilters', () => void refreshSendFilters())
const validateSendFilter = (filter?: ISendFilter) => {
const selectionStore = useSelectionStore()
return validateFilter(filter, {
selectionCount: selectionStore.selectionInfo.selectedObjectIds?.length ?? 0
})
}
/**
* Send functionality
*/
@@ -312,10 +386,57 @@ export const useHostAppStore = defineStore('hostAppStore', () => {
* Tells the host app to start sending a specific model card. This will reach inside the host application.
* @param modelId
*/
const sendModel = (modelCardId: string, actionSource: string) => {
const sendModel = async (modelCardId: string, actionSource: string) => {
const model = documentModelStore.value.models.find(
(m) => m.modelCardId === modelCardId
) as ISenderModelCard
const { canCreateModelIngestion, canCreateVersion } = useCheckGraphql()
const canCreateIngestion = await canCreateModelIngestion(
model.projectId,
model.modelId,
model.accountId
)
// for the connectors that don't have SDK to handle graqhql
if (shouldHandleIngestion.value && canCreateIngestion.queryAvailable) {
const sourceData = {
sourceApplicationSlug: hostAppName.value || 'unknown',
sourceApplicationVersion: hostAppVersion.value?.toString() || 'unknown'
}
if (canCreateIngestion.authorized) {
await startIngestion(model, 'Starting to publish', sourceData)
model.progress = { status: 'Converting the objects...' }
} else {
setNotification({
type: ToastNotificationType.Warning,
title: 'Cannot publish',
description: canCreateIngestion.message
})
return
}
} else {
// for the self hosters that does not have available graphql for ingestions
const canCreate = await canCreateVersion(
model.projectId,
model.modelId,
model.accountId
)
if (!canCreate.authorized) {
setNotification({
type: ToastNotificationType.Warning,
title: 'Cannot publish',
description: canCreate.message || 'Workspace limits have been reached'
})
return
}
}
model.latestCreatedVersionId = undefined
model.error = undefined
model.progress = { status: 'Starting to send...' }
model.expired = false
model.report = undefined
if (model.expired) {
// user sends via "Update" button
void trackEvent(
@@ -340,11 +461,7 @@ export const useHostAppStore = defineStore('hostAppStore', () => {
model.accountId
)
}
model.latestCreatedVersionId = undefined
model.error = undefined
model.progress = { status: 'Starting to send...' }
model.expired = false
model.report = undefined
// You should stop asking why if you saw anything related autocad..
// It solves the press "escape" issue.
// Because probably we don't give enough time to acad complete it's previos task and it stucks.
@@ -371,6 +488,17 @@ export const useHostAppStore = defineStore('hostAppStore', () => {
model.error = undefined
void trackEvent('DUI3 Action', { name: 'Send Cancel' }, model.accountId)
model.latestCreatedVersionId = undefined
// Clean up any active ingestion subscription from SDK-based connectors
unsubscribeFromIngestion(modelCardId)
// Cancel the ingestion if applicable
if (shouldHandleIngestion.value) {
const ingestionId = activeIngestions.value[modelCardId]
if (ingestionId) {
await cancelIngestion(model, ingestionId, 'Cancelled by user')
}
}
}
app.$sendBinding?.on('setModelsExpired', (modelCardIds) => {
@@ -387,13 +515,22 @@ export const useHostAppStore = defineStore('hostAppStore', () => {
modelCardId: string
versionId: string
sendConversionResults: ConversionResult[]
ingestionId?: string
}) => {
const model = documentModelStore.value.models.find(
(m) => m.modelCardId === args.modelCardId
) as ISenderModelCard
model.latestCreatedVersionId = args.versionId
// Conversion results are always valid regardless of ingestion state
model.report = args.sendConversionResults
model.progress = undefined
if (args.ingestionId) {
// Connector handled ingestion via SDK — composable subscribes and manages model card state to 'Version created' bla bla
subscribeToIngestion(model, args.ingestionId)
} else {
// Legacy path or no ingestion — behave as before
model.latestCreatedVersionId = args.versionId
model.progress = undefined
}
}
app.$sendBinding?.on('setModelSendResult', setModelSendResult)
@@ -473,7 +610,7 @@ export const useHostAppStore = defineStore('hostAppStore', () => {
app.$receiveBinding?.on('setModelReceiveResult', setModelReceiveResult)
// GENERIC STUFF
const handleModelProgressEvents = (args: {
const handleModelProgressEvents = async (args: {
modelCardId: string
progress?: ModelCardProgress
}) => {
@@ -481,9 +618,24 @@ export const useHostAppStore = defineStore('hostAppStore', () => {
(m) => m.modelCardId === args.modelCardId
) as IModelCard
model.progress = args.progress
if (
model.typeDiscriminator.includes('SenderModelCard') &&
shouldHandleIngestion.value // for the connectors that don't have SDK to handle graqhql
) {
const ingestionId = activeIngestions.value[args.modelCardId]
if (ingestionId) {
await updateIngestion(
model,
ingestionId,
args.progress?.status || 'Progressing',
args.progress?.progress || 0
)
}
}
}
const setModelError = (args: {
const setModelError = async (args: {
modelCardId: string
error: string | { errorMessage: string; dismissible?: boolean }
}) => {
@@ -499,6 +651,19 @@ export const useHostAppStore = defineStore('hostAppStore', () => {
dismissible: boolean
}
}
// Fail the ingestion if applicable
if (
model.typeDiscriminator.includes('SenderModelCard') &&
shouldHandleIngestion.value
) {
const ingestionId = activeIngestions.value[args.modelCardId]
if (ingestionId) {
const errorMessage =
typeof args.error === 'string' ? args.error : args.error.errorMessage
await failIngestion(model as ISenderModelCard, ingestionId, errorMessage)
}
}
}
// NOTE: all bindings that need to send these model events should register.
@@ -521,7 +686,7 @@ export const useHostAppStore = defineStore('hostAppStore', () => {
app.$sendBinding?.on('setModelError', setModelError)
app.$receiveBinding?.on('setModelError', setModelError)
app.$baseBinding.on('setModelError', setModelError)
app.$baseBinding?.on('setModelError', setModelError)
/**
* Used internally in this store store only for initialisation.
@@ -534,8 +699,22 @@ export const useHostAppStore = defineStore('hostAppStore', () => {
const getConnectorVersion = async () => {
connectorVersion.value = await app.$baseBinding.getConnectorVersion()
const canGetGlobalConfig = ['getGlobalConfig', 'GetGlobalConfig'].some((name) =>
(app.$configBinding as unknown as BaseBridge).availableMethodNames.includes(name)
)
if (canGetGlobalConfig) {
const globalConfig = await app.$configBinding.getGlobalConfig()
if (globalConfig) {
isUpdateNotificationDisabled.value = globalConfig.isUpdateNotificationDisabled
}
}
// Checks whether new version available for the connector or not and throws a toast notification if any.
await checkUpdate()
if (app.$isRunningOnConnector && !isUpdateNotificationDisabled.value) {
await checkUpdate()
}
}
/**
@@ -620,6 +799,10 @@ export const useHostAppStore = defineStore('hostAppStore', () => {
sendSettings.value = await app.$sendBinding.getSendSettings()
}
const getReceiveSettings = async () => {
receiveSettings.value = await app.$receiveBinding.getReceiveSettings()
}
const tryToUpgradeModelCardSettings = (
settings: CardSetting[],
typeDiscriminator: string
@@ -678,7 +861,7 @@ export const useHostAppStore = defineStore('hostAppStore', () => {
})
}
app.$baseBinding.on(
app.$baseBinding?.on(
'documentChanged',
() =>
setTimeout(async () => {
@@ -698,13 +881,15 @@ export const useHostAppStore = defineStore('hostAppStore', () => {
await refreshDocumentModelStore()
await refreshSendFilters()
await getSendSettings()
await getReceiveSettings()
tryToUpgradeModelCardSettings(sendSettings.value || [], 'SenderModelCard')
// Intercom shenanningans below
// Do not poke intercom in ancient revit version
if (
hostAppName.value?.toLowerCase() === 'revit' &&
hostAppVersion.value?.includes('2022')
(hostAppName.value?.toLowerCase() === 'revit' &&
hostAppVersion.value?.includes('2022')) ||
!isDistributedBySpeckle.value
)
return
@@ -724,6 +909,7 @@ export const useHostAppStore = defineStore('hostAppStore', () => {
hostAppName,
hostAppVersion,
connectorVersion,
activeIngestions,
isConnectorUpToDate,
latestAvailableVersion,
documentInfo,
@@ -731,6 +917,7 @@ export const useHostAppStore = defineStore('hostAppStore', () => {
models,
sendFilters,
sendSettings,
receiveSettings,
selectionFilter,
everythingFilter,
currentNotification,
@@ -740,6 +927,7 @@ export const useHostAppStore = defineStore('hostAppStore', () => {
navisworksAvailableSavedSets,
availableSelectSendFilters,
isDistributedBySpeckle,
isUpdateNotificationDisabled,
setIsDistributedBySpeckle,
setNotification,
setModelError,
@@ -760,6 +948,7 @@ export const useHostAppStore = defineStore('hostAppStore', () => {
getSendSettings,
setModelSendResult,
setModelReceiveResult,
handleModelProgressEvents
handleModelProgressEvents,
validateSendFilter
}
})
+75
View File
@@ -0,0 +1,75 @@
import { defineStore } from 'pinia'
import type { Category } from '~/lib/bindings/definitions/IRevitMapperBinding'
import { REVIT_CATEGORIES } from '~/lib/mapper/revit-categories'
export const useRevitMapper = defineStore('revitMapper', () => {
const app = useNuxtApp()
const { $revitMapperBinding } = app
const currentCategories = ref<string[]>([])
const selectedCategory = ref<Category | undefined>()
const categoryOptions = REVIT_CATEGORIES
const categoryStatus = computed(() => {
if (currentCategories.value.length === 0) {
return undefined
}
if (currentCategories.value.length === 1) {
const category = categoryOptions.find(
(cat) => cat.value === currentCategories.value[0]
)
return category ? { label: category.label, value: category.value } : undefined
}
return {
label: 'Multiple categories',
value: 'multiple',
isMultiple: true
}
})
const updateFromTargets = async (
targetIds: string[],
isLayerMode: boolean
): Promise<void> => {
if (!targetIds.length || !$revitMapperBinding) {
clear()
return
}
try {
// Call connector method based on mode
const categories = isLayerMode
? await $revitMapperBinding.getCategoryMappingsForLayers(targetIds)
: await $revitMapperBinding.getCategoryMappingsForObjects(targetIds)
currentCategories.value = categories
// Update dropdown selection based on categories found
if (categories.length === 1) {
selectedCategory.value = categoryOptions.find(
(cat) => cat.value === categories[0]
)
} else {
// Multiple or no categories - clear dropdown selection
selectedCategory.value = undefined
}
} catch (error) {
console.error('Failed to get category mappings:', error)
clear()
}
}
const clear = () => {
currentCategories.value = []
selectedCategory.value = undefined
}
return {
currentCategories,
selectedCategory,
categoryStatus,
updateFromTargets,
clear
}
})
+965 -16
View File
File diff suppressed because it is too large Load Diff