Compare commits
121 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 771863b117 | |||
| a69de13f16 | |||
| d2b0d35119 | |||
| b026659460 | |||
| 009cc77bab | |||
| a8b802b7e3 | |||
| 6fc3df4a0d | |||
| f47f19c02d | |||
| 85f806368a | |||
| 35ddce1f90 | |||
| a37b3389d6 | |||
| ed4aa92ce1 | |||
| 60f3bed254 | |||
| 2f412df64a | |||
| c7e0929eca | |||
| eef0a59719 | |||
| 19f306756c | |||
| 305b100d34 | |||
| f2cc0d55e3 | |||
| fdfef1d496 | |||
| 5174af78cc | |||
| ede6e99440 | |||
| 9c708c64a0 | |||
| 41e635c8ef | |||
| 095ccf114d | |||
| a95fd9bdfe | |||
| bc665a008c | |||
| 00a6a66ee0 | |||
| b0157af3c8 | |||
| 9b065bf921 | |||
| 99ebd403c7 | |||
| a166b86657 | |||
| 185ba0f50a | |||
| 49cabaa1bc | |||
| 11b6d5254e | |||
| aa5d59ba5b | |||
| 1cdd5c89a4 | |||
| 047dbff259 | |||
| ffff7366c3 | |||
| 4ecd6fbee9 | |||
| 54039daa32 | |||
| b7e347f3f0 | |||
| c8f85c3874 | |||
| 85405d10dd | |||
| d8fdc2c3c5 | |||
| 034d8645c6 | |||
| d797a65fab | |||
| 028c9d2ac1 | |||
| b2695e77f5 | |||
| 669afe81cf | |||
| 48bb180899 | |||
| 3b4aa93858 | |||
| 4ebf702ab2 | |||
| 6e6bd423a0 | |||
| 57ef9685b6 | |||
| 2ff5849739 | |||
| e55c0ca7dd | |||
| 8ef79f7a7c | |||
| 79951f7cf7 | |||
| cf70ddc79b | |||
| 4e16813c75 | |||
| ee4e7576ad | |||
| 3ba11c983b | |||
| d92dcf5342 | |||
| 55afedab68 | |||
| a8d948ec71 | |||
| 65a9d3e485 | |||
| be631746b9 | |||
| 20c43f2108 | |||
| af0de85ef7 | |||
| 7c54845a05 | |||
| cbec244443 | |||
| a2a9ab1f4b | |||
| 2f87c34272 | |||
| 7fde35e639 | |||
| 97765d84ca | |||
| 9d15be73ad | |||
| 45f763a2ce | |||
| 3c6bda7af9 | |||
| 007794dae2 | |||
| f8912338cb | |||
| f932fa46a3 | |||
| 292d2bf0bb | |||
| 43340d9b52 | |||
| 4898a9e2e9 | |||
| 19b982e2e3 | |||
| e3cf896c14 | |||
| 34d855212f | |||
| 8d159547d4 | |||
| 0c3ee8b38f | |||
| c51f282644 | |||
| 1faea0aec2 | |||
| 5e97acf4c0 | |||
| 9c7413d630 | |||
| 89acd7ca80 | |||
| 2e51a2fb3c | |||
| 644754262c | |||
| b28129c30e | |||
| 305ad36cac | |||
| 554d0ee478 | |||
| 33fd9c65e3 | |||
| ebaee49fe8 | |||
| b877c1d321 | |||
| be4dc87b3e | |||
| 94ddc486aa | |||
| ac5984d184 | |||
| 8dae170592 | |||
| d6f371c7d2 | |||
| cbf9e8d578 | |||
| 6f10519e6f | |||
| 590b8b8872 | |||
| 2514b6c606 | |||
| af37112a5f | |||
| d224b33bc8 | |||
| 377cfc2f65 | |||
| fdd13170f5 | |||
| e70310654b | |||
| 8bb14d3389 | |||
| 93e4762b6a | |||
| 21a4bd4076 | |||
| 82c95aab58 |
+3
-1
@@ -15,4 +15,6 @@ dist
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/sdks
|
||||
!.yarn/versions
|
||||
!.yarn/versions
|
||||
|
||||
.claude
|
||||
|
||||
@@ -13,15 +13,20 @@
|
||||
import { useMixpanel } from '~/lib/core/composables/mixpanel'
|
||||
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)
|
||||
const hostAppStore = useHostAppStore()
|
||||
const { connectorVersion, hostAppName, hostAppVersion } = storeToRefs(hostAppStore)
|
||||
|
||||
useHead({
|
||||
// Title suffix
|
||||
titleTemplate: (titleChunk) =>
|
||||
titleChunk ? `${titleChunk as string} - Speckle DUIv3` : 'Speckle DUIv3',
|
||||
title: computed(
|
||||
() =>
|
||||
`CNX: (hostApp: ${hostAppName.value}:v${hostAppVersion.value}),(version: ${connectorVersion.value})`
|
||||
),
|
||||
htmlAttrs: {
|
||||
lang: 'en',
|
||||
class: computed(() => (isDarkTheme.value ? `dark` : ``))
|
||||
@@ -50,5 +55,10 @@ onMounted(() => {
|
||||
uniqueEmails.add(email)
|
||||
}
|
||||
})
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { $intercom } = useNuxtApp() // needed her for initialisation
|
||||
|
||||
logToSeq('Information', 'DUI3 initialized')
|
||||
})
|
||||
</script>
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import type { CodegenConfig } from '@graphql-codegen/cli'
|
||||
|
||||
const config: CodegenConfig = {
|
||||
schema: 'http://127.0.0.1:3000/graphql',
|
||||
schema: 'https://app.speckle.systems/graphql',
|
||||
documents: ['{lib,components,layouts,pages,middleware}/**/*.{vue,js,ts}'],
|
||||
ignoreNoDocuments: true, // for better experience with the watcher
|
||||
generates: {
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
<template>
|
||||
<div v-if="!hidden" class="flex flex-col space-y-2">
|
||||
<!-- idle: server URL + sign in button -->
|
||||
<template v-if="state === 'idle'">
|
||||
<div class="flex space-x-2">
|
||||
<FormButton
|
||||
v-if="canAddAccount"
|
||||
full-width
|
||||
color="outline"
|
||||
@click="openBrowserAuth()"
|
||||
>
|
||||
Log in with OAuth token
|
||||
</FormButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- waiting: instructions + code input -->
|
||||
<template v-if="state === 'waiting' || state === 'submitting'">
|
||||
<div class="text-foreground-2 space-y-2 border rounded-lg p-2">
|
||||
<div class="text-sm text-center">
|
||||
Check your browser: authorize the app, then copy the exchange code and paste
|
||||
it below.
|
||||
</div>
|
||||
<div class="py-2"><CommonLoadingBar :loading="state === 'waiting'" /></div>
|
||||
<FormTextInput
|
||||
v-model="exchangeCode"
|
||||
name="exchangeCode"
|
||||
:show-label="false"
|
||||
placeholder="Paste exchange code here"
|
||||
color="foundation"
|
||||
autocomplete="off"
|
||||
:disabled="state === 'submitting'"
|
||||
/>
|
||||
<FormButton
|
||||
full-width
|
||||
:disabled="!exchangeCode?.trim() || state === 'submitting'"
|
||||
@click="submitCode()"
|
||||
>
|
||||
{{ state === 'submitting' ? 'Signing in...' : 'Submit' }}
|
||||
</FormButton>
|
||||
|
||||
<div v-if="showHelp" class="p-2 rounded-md space-y-1">
|
||||
<div class="text-sm text-center">Having trouble?</div>
|
||||
<div class="flex justify-center">
|
||||
<span>
|
||||
<FormButton size="sm" text @click="retryFlow()">Retry</FormButton>
|
||||
or
|
||||
<FormButton text size="sm" @click="$openUrl('https://speckle.community')">
|
||||
Get in touch with us
|
||||
</FormButton>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- error -->
|
||||
<template v-if="state === 'error'">
|
||||
<div class="text-foreground-2 space-y-2">
|
||||
<div class="text-sm text-center text-red-500">
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
<FormButton full-width @click="retryFlow()">Try again</FormButton>
|
||||
<FormButton text size="sm" full-width @click="emit('backToSignIn')">
|
||||
Back
|
||||
</FormButton>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useAuthManager } from '~/lib/authn/useAuthManager'
|
||||
import { useTokenExchange, supportsOAuthToken } from '~/lib/authn/useTokenExchange'
|
||||
import { useMixpanel } from '~/lib/core/composables/mixpanel'
|
||||
import { useAccountStore } from '~/store/accounts'
|
||||
import type { BaseBridge } from '~/lib/bridge/base'
|
||||
|
||||
const props = defineProps<{
|
||||
serverUrl: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'backToSignIn'): void
|
||||
}>()
|
||||
|
||||
const app = useNuxtApp()
|
||||
const { generateLocalChallenge } = useAuthManager()
|
||||
const { exchangeAccessCode } = useTokenExchange()
|
||||
const { trackEvent } = useMixpanel()
|
||||
const accountStore = useAccountStore()
|
||||
|
||||
const { $accountBinding } = useNuxtApp()
|
||||
const canAddAccount = ['AddAccount', 'addAccount'].some((name) =>
|
||||
($accountBinding as unknown as BaseBridge).availableMethodNames.includes(name)
|
||||
)
|
||||
|
||||
const state = ref<'idle' | 'waiting' | 'submitting' | 'error'>('idle')
|
||||
const exchangeCode = ref<string | undefined>()
|
||||
const errorMessage = ref('')
|
||||
const showHelp = ref(false)
|
||||
const hidden = ref(false)
|
||||
|
||||
const checkServerSupport = async (url: string) => {
|
||||
const serverUrl = url ? new URL(url).origin : 'https://app.speckle.systems'
|
||||
hidden.value = !(await supportsOAuthToken(serverUrl))
|
||||
}
|
||||
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
onMounted(() => checkServerSupport(props.serverUrl))
|
||||
watch(
|
||||
() => props.serverUrl,
|
||||
(url) => {
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
debounceTimer = setTimeout(() => checkServerSupport(url), 500)
|
||||
}
|
||||
)
|
||||
|
||||
let currentCodeVerifier = ''
|
||||
let currentCodeChallenge = ''
|
||||
let currentServerUrl = ''
|
||||
|
||||
const openBrowserAuth = async () => {
|
||||
currentServerUrl = props.serverUrl
|
||||
? new URL(props.serverUrl).origin
|
||||
: 'https://app.speckle.systems'
|
||||
|
||||
const { codeVerifier, codeChallenge } = await generateLocalChallenge()
|
||||
currentCodeVerifier = codeVerifier
|
||||
currentCodeChallenge = codeChallenge
|
||||
const authUrl = `${currentServerUrl}/authn/verify/sdui/${codeChallenge}?returnExchangeToken=true&code_challenge_method=S256`
|
||||
app.$openUrl(authUrl)
|
||||
|
||||
state.value = 'waiting'
|
||||
exchangeCode.value = undefined
|
||||
showHelp.value = false
|
||||
|
||||
setTimeout(() => {
|
||||
if (state.value === 'waiting') {
|
||||
showHelp.value = true
|
||||
}
|
||||
}, 10_000)
|
||||
}
|
||||
|
||||
const submitCode = async () => {
|
||||
const code = exchangeCode.value?.trim()
|
||||
if (!code || !currentCodeChallenge || !currentServerUrl) return
|
||||
|
||||
state.value = 'submitting'
|
||||
try {
|
||||
await exchangeAccessCode(
|
||||
currentServerUrl,
|
||||
code,
|
||||
currentCodeChallenge,
|
||||
currentCodeVerifier
|
||||
)
|
||||
void trackEvent('DUI Account Added')
|
||||
// Refresh accounts so the watcher in Menu.vue detects the new account and closes the dialog
|
||||
await accountStore.refreshAccounts()
|
||||
} catch (error) {
|
||||
errorMessage.value =
|
||||
error instanceof Error ? error.message : 'Failed to sign in. Please try again.'
|
||||
state.value = 'error'
|
||||
}
|
||||
}
|
||||
|
||||
const retryFlow = () => {
|
||||
state.value = 'idle'
|
||||
exchangeCode.value = undefined
|
||||
errorMessage.value = ''
|
||||
showHelp.value = false
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,132 @@
|
||||
<template>
|
||||
<div class="flex flex-col space-y-2">
|
||||
<div v-if="isDesktopServiceAvailable">
|
||||
<div v-show="!isAddingAccount" class="text-foreground-2 space-y-2">
|
||||
<div class="flex space-x-2">
|
||||
<FormButton full-width color="outline" @click="startAccountAddFlow()">
|
||||
Log in (Legacy)
|
||||
</FormButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-show="isAddingAccount"
|
||||
class="text-foreground-2 mt-2 mb-4 space-y-2 border rounded-lg p-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="p-2 rounded-md space-y-1">
|
||||
<div class="text-sm text-center">Having trouble?</div>
|
||||
<div class="flex justify-center">
|
||||
<span>
|
||||
<FormButton text size="sm" @click="$openUrl('https://speckle.community')">
|
||||
Get in touch with us
|
||||
</FormButton>
|
||||
</span>
|
||||
</div>
|
||||
</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 props = defineProps<{
|
||||
serverUrl: string
|
||||
}>()
|
||||
|
||||
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 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 = props.serverUrl
|
||||
? `http://localhost:29364/auth/add-account?serverUrl=${
|
||||
new URL(props.serverUrl).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)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
isDesktopServiceAvailable.value = await pingDesktopService()
|
||||
})
|
||||
</script>
|
||||
@@ -39,17 +39,23 @@
|
||||
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 class="flex flex-col space-y-4 p-2">
|
||||
<FormTextInput
|
||||
v-model="customServerUrl"
|
||||
name="Server to sign in"
|
||||
show-label
|
||||
placeholder="https://app.speckle.systems"
|
||||
color="foundation"
|
||||
autocomplete="off"
|
||||
show-clear
|
||||
/>
|
||||
<div class="space-y-2">
|
||||
<AccountsSignInFlow :server-url="customServerUrl" />
|
||||
<AccountsExchangeTokenSignInFlow :server-url="customServerUrl" />
|
||||
<AccountsLegacySignInFlow
|
||||
v-if="!canStartAuthAccount"
|
||||
:server-url="customServerUrl"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CommonDialog>
|
||||
@@ -58,6 +64,7 @@
|
||||
</CommonDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { XMarkIcon } from '@heroicons/vue/20/solid'
|
||||
@@ -65,11 +72,18 @@ import type { DUIAccount } from '~/store/accounts'
|
||||
import { useAccountStore } from '~/store/accounts'
|
||||
import { useMixpanel } from '~/lib/core/composables/mixpanel'
|
||||
import { useDesktopService } from '~/lib/core/composables/desktopService'
|
||||
import type { BaseBridge } from '~/lib/bridge/base'
|
||||
|
||||
const { trackEvent } = useMixpanel()
|
||||
const app = useNuxtApp()
|
||||
const { $openUrl } = useNuxtApp()
|
||||
const { pingDesktopService } = useDesktopService()
|
||||
const { $accountBinding } = useNuxtApp()
|
||||
const canStartAuthAccount = ['AuthenticateAccount', 'authenticateAccount'].some(
|
||||
(name) =>
|
||||
($accountBinding as unknown as BaseBridge).availableMethodNames.includes(name)
|
||||
)
|
||||
|
||||
const customServerUrl = ref<string>('https://app.speckle.systems')
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -86,7 +100,7 @@ defineEmits<{
|
||||
}>()
|
||||
|
||||
const showAddNewAccount = ref(false)
|
||||
// const showAccountsDialog = ref(false)
|
||||
const signInMode = ref<'default' | 'exchange' | 'legacy'>('default')
|
||||
|
||||
const showAccountsDialog = defineModel<boolean>('open', {
|
||||
required: false,
|
||||
@@ -95,7 +109,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 +120,13 @@ watch(showAccountsDialog, (newVal) => {
|
||||
}
|
||||
})
|
||||
|
||||
watch(showAddNewAccount, (newVal) => {
|
||||
if (newVal) {
|
||||
// reset the sign-in mode on every add account sub-dialog
|
||||
signInMode.value = 'default'
|
||||
}
|
||||
})
|
||||
|
||||
const accountStore = useAccountStore()
|
||||
const { accounts, activeAccount, userSelectedAccount, isLoading } =
|
||||
storeToRefs(accountStore)
|
||||
@@ -144,7 +165,6 @@ const user = computed(() => {
|
||||
// acc = currentSelectedAccount
|
||||
// }
|
||||
// }
|
||||
|
||||
return {
|
||||
name: activeAccount.value.accountInfo.userInfo.name,
|
||||
avatar: activeAccount.value.accountInfo.userInfo.avatar
|
||||
|
||||
@@ -1,116 +1,53 @@
|
||||
<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>
|
||||
|
||||
<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 class="flex flex-col space-y-2">
|
||||
<FormButton v-if="canAddAccount" full-width @click="logIn()">Log in</FormButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useIntervalFn } from '@vueuse/core'
|
||||
import { useAccountStore } from '~~/store/accounts'
|
||||
import { useAuthManager } from '~/lib/authn/useAuthManager'
|
||||
import type { BaseBridge } from '~/lib/bridge/base'
|
||||
import { useAccountStore } from '~/store/accounts'
|
||||
import { useHostAppStore } from '~/store/hostApp'
|
||||
import { ToastNotificationType } from '@speckle/ui-components'
|
||||
import { useMixpanel } from '~/lib/core/composables/mixpanel'
|
||||
|
||||
const props = defineProps<{
|
||||
serverUrl: string
|
||||
}>()
|
||||
|
||||
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 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 hostAppStore = useHostAppStore()
|
||||
const { $accountBinding } = useNuxtApp()
|
||||
const canAddAccount = ['AddAccount', 'addAccount'].some((name) =>
|
||||
($accountBinding as unknown as BaseBridge).availableMethodNames.includes(name)
|
||||
)
|
||||
const canStartAuthAccount = ['AuthenticateAccount', 'authenticateAccount'].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',
|
||||
const logIn = async () => {
|
||||
const serverUrl = props.serverUrl
|
||||
? new URL(props.serverUrl).origin
|
||||
: 'https://app.speckle.systems'
|
||||
if (canStartAuthAccount) {
|
||||
const acc = await $accountBinding.authenticateAccount(serverUrl)
|
||||
if (acc.token) {
|
||||
await accountStore.refreshAccounts()
|
||||
} else {
|
||||
hostAppStore.setNotification({
|
||||
title: 'Log In',
|
||||
type: ToastNotificationType.Info,
|
||||
description:
|
||||
'Sign in timed out. This may have happened because you tried adding an existing account.'
|
||||
"Log in could not completed. Make sure you have logged in successfully, otherwise try 'Log in with OAuth token'"
|
||||
})
|
||||
// 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
|
||||
} else {
|
||||
const { codeChallenge } = await generateChallenge(serverUrl)
|
||||
const authUrl = `${serverUrl}/authn/verify/sdui/${codeChallenge}?code_challenge_method=S256`
|
||||
window.location.href = authUrl
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -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>
|
||||
@@ -9,7 +9,13 @@
|
||||
<Component :is="iconAndColor.icon" :class="`w-4 h-4 ${iconAndColor.color}`" />
|
||||
</div>
|
||||
<div :class="`text-xs ${iconAndColor.color}`">
|
||||
{{ result.category }}: {{ result.objectIds.length }} affected elements
|
||||
{{ result.category }}:
|
||||
{{
|
||||
'objectIds' in props.result
|
||||
? props.result.objectIds.length
|
||||
: props.result.objectAppIds.length
|
||||
}}
|
||||
affected elements
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="result.message" class="text-xs text-foreground-2 pl-5">
|
||||
@@ -19,16 +25,13 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { useQuery } from '@vue/apollo-composable'
|
||||
import {
|
||||
XMarkIcon,
|
||||
InformationCircleIcon,
|
||||
ExclamationTriangleIcon
|
||||
} from '@heroicons/vue/24/outline'
|
||||
import type { Automate } from '@speckle/shared'
|
||||
import { objectQuery } from '~/lib/graphql/mutationsAndQueries'
|
||||
import type { IModelCard } from '~/lib/models/card'
|
||||
import { useAccountStore } from '~/store/accounts'
|
||||
|
||||
type ObjectResult = Automate.AutomateTypes.ResultsSchema['values']['objectResults'][0]
|
||||
|
||||
@@ -37,41 +40,16 @@ const props = defineProps<{
|
||||
result: ObjectResult
|
||||
functionId?: string
|
||||
}>()
|
||||
|
||||
const accStore = useAccountStore()
|
||||
const app = useNuxtApp()
|
||||
const projectAccount = computed(() =>
|
||||
accStore.accountWithFallback(props.modelCard.accountId, props.modelCard.serverUrl)
|
||||
)
|
||||
const clientId = projectAccount.value.accountInfo.id
|
||||
|
||||
const applicationIds = ref<string[]>([])
|
||||
|
||||
type Data = {
|
||||
applicationId?: string
|
||||
}
|
||||
|
||||
// Loop over each objectId to run the query and collect application IDs
|
||||
props.result.objectIds.forEach((objectId) => {
|
||||
const { result: objectResult } = useQuery(
|
||||
objectQuery,
|
||||
() => ({
|
||||
projectId: props.modelCard.projectId,
|
||||
objectId
|
||||
}),
|
||||
() => ({ clientId })
|
||||
)
|
||||
|
||||
watch(objectResult, (newValue) => {
|
||||
const data = newValue?.project.object?.data as Data | undefined
|
||||
const applicationId = data?.applicationId
|
||||
if (applicationId && !applicationIds.value.includes(applicationId)) {
|
||||
applicationIds.value.push(applicationId)
|
||||
}
|
||||
})
|
||||
const applicationIds = computed(() => {
|
||||
// Old schema ignore
|
||||
if ('objectIds' in props.result) return []
|
||||
return Object.values(props.result.objectAppIds).filter((id) => id !== null)
|
||||
})
|
||||
|
||||
const handleClick = async () => {
|
||||
if (applicationIds.value.length === 0) return
|
||||
await app.$baseBinding.highlightObjects(applicationIds.value)
|
||||
}
|
||||
|
||||
|
||||
@@ -17,14 +17,27 @@
|
||||
</div>
|
||||
<div class="flex items-center group">
|
||||
<FormButton
|
||||
v-if="notification.cta"
|
||||
v-if="notification.secondaryCta"
|
||||
v-tippy="notification.secondaryCta.tooltipText"
|
||||
size="sm"
|
||||
:color="notificationButtonColor(notification.level)"
|
||||
color="outline"
|
||||
full-width
|
||||
@click.stop="notification.cta?.action"
|
||||
class="mr-1"
|
||||
@click.stop="notification.secondaryCta.action"
|
||||
>
|
||||
{{ notification.cta.name }}
|
||||
{{ notification.secondaryCta.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
|
||||
@@ -47,10 +60,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useTimeoutFn } from '@vueuse/core'
|
||||
import type {
|
||||
ModelCardNotification,
|
||||
ModelCardNotificationLevel
|
||||
} from '~/lib/models/card/notification'
|
||||
import type { ModelCardNotification } from '~/lib/models/card/notification'
|
||||
import { XMarkIcon } from '@heroicons/vue/24/outline'
|
||||
const props = defineProps<{
|
||||
notification: ModelCardNotification
|
||||
@@ -62,20 +72,20 @@ if (props.notification.timeout) {
|
||||
useTimeoutFn(() => emit('dismiss'), props.notification.timeout)
|
||||
}
|
||||
|
||||
const notificationButtonColor = (notificationLevel: ModelCardNotificationLevel) => {
|
||||
switch (notificationLevel) {
|
||||
case 'info':
|
||||
return 'outline'
|
||||
case 'danger':
|
||||
return 'danger'
|
||||
case 'success':
|
||||
return 'primary'
|
||||
case 'warning':
|
||||
return 'danger'
|
||||
default:
|
||||
return 'outline'
|
||||
}
|
||||
}
|
||||
// const notificationButtonColor = (notificationLevel: ModelCardNotificationLevel) => {
|
||||
// switch (notificationLevel) {
|
||||
// case 'info':
|
||||
// return 'outline'
|
||||
// case 'danger':
|
||||
// return 'danger'
|
||||
// case 'success':
|
||||
// return 'primary'
|
||||
// case 'warning':
|
||||
// return 'danger'
|
||||
// default:
|
||||
// return 'outline'
|
||||
// }
|
||||
// }
|
||||
|
||||
const textClassColor = computed(() => {
|
||||
switch (props.notification.level) {
|
||||
|
||||
@@ -18,18 +18,24 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="opacity-0 group-hover:opacity-100 transition flex">
|
||||
<div
|
||||
:class="
|
||||
isPersonalProject ? '' : 'opacity-0 group-hover:opacity-100 transition flex'
|
||||
"
|
||||
>
|
||||
<button
|
||||
v-tippy="'Open project in browser'"
|
||||
class="hover:text-primary flex items-center space-x-2 p-2"
|
||||
v-tippy="projectNavigatorTippy"
|
||||
class="hover:text-primary flex items-center space-x-2 p-2 relative animate-pulse"
|
||||
>
|
||||
<ArrowTopRightOnSquareIcon
|
||||
class="w-4"
|
||||
@click.stop="
|
||||
$openUrl(projectUrl),
|
||||
trackEvent('DUI3 Action', { name: 'Project View' }, project.accountId)
|
||||
"
|
||||
/>
|
||||
<div class="relative w-4 h-4">
|
||||
<ArrowTopRightOnSquareIcon
|
||||
class="w-4 h-4"
|
||||
@click.stop="
|
||||
$openUrl(projectUrl),
|
||||
trackEvent('DUI3 Action', { name: 'Project View' }, project.accountId)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</button>
|
||||
@@ -130,6 +136,13 @@ const projectAccount = computed(() =>
|
||||
accountStore.accountWithFallback(props.project.accountId, props.project.serverUrl)
|
||||
)
|
||||
|
||||
const isPersonalProject = computed(() => !projectDetails.value?.workspace)
|
||||
const projectNavigatorTippy = computed(() =>
|
||||
isPersonalProject.value
|
||||
? 'Move personal project into a workspace'
|
||||
: 'Open project in browser'
|
||||
)
|
||||
|
||||
const clientId = projectAccount.value.accountInfo.id
|
||||
|
||||
const { result: projectDetailsResult, refetch: refetchProjectDetails } = useQuery(
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
<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
|
||||
"
|
||||
color="neutral"
|
||||
size="xs"
|
||||
hide-icon
|
||||
class="mb-2 mt-1"
|
||||
class="mt-1"
|
||||
>
|
||||
<template #description>
|
||||
<div class="flex items-center">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -12,8 +12,11 @@
|
||||
full-width
|
||||
color="foundation"
|
||||
/>
|
||||
<FormButton color="outline" size="sm" @click="selectAllCategories">
|
||||
{{ allSelected ? 'Deselect all' : 'Select all' }}
|
||||
</FormButton>
|
||||
</div>
|
||||
<div class="flex space-y-1 flex-col">
|
||||
<div class="flex space-y-1 flex-col max-h-48 simple-scrollbar overflow-auto">
|
||||
<div
|
||||
v-for="cat in selectedCategoriesObjects.sort((a, b) =>
|
||||
a.name.localeCompare(b.name)
|
||||
@@ -93,6 +96,25 @@ const searchResults = computed(() => {
|
||||
|
||||
const selectedCategories = ref<string[]>(props.filter.selectedCategories || [])
|
||||
|
||||
const selectAllCategories = () => {
|
||||
if (allSelected.value) {
|
||||
selectedCategories.value = []
|
||||
return
|
||||
}
|
||||
availableCategories.value.forEach((cat) => {
|
||||
const index = selectedCategories.value.indexOf(cat.id)
|
||||
if (index !== -1) {
|
||||
return
|
||||
} else {
|
||||
selectedCategories.value.push(cat.id)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const allSelected = computed(() => {
|
||||
return availableCategories.value.length === selectedCategories.value.length
|
||||
})
|
||||
|
||||
const selectOrUnselectCategory = (id: string) => {
|
||||
const index = selectedCategories.value.indexOf(id)
|
||||
if (index !== -1) {
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -25,6 +25,7 @@
|
||||
</div>
|
||||
<div class="relative group flex items-center">
|
||||
<FormButton
|
||||
v-if="app.$receiveBinding"
|
||||
v-tippy="'Load a model from Speckle into this file'"
|
||||
color="outline"
|
||||
size="sm"
|
||||
@@ -48,14 +49,32 @@
|
||||
>
|
||||
<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'"
|
||||
@click="
|
||||
app.$openUrl(
|
||||
`https://www.speckle.systems/connectors/${hostAppStore.hostAppName}?utm=dui`
|
||||
`https://docs.speckle.systems/connectors/${hostAppStore.hostAppName}?utm=dui`
|
||||
)
|
||||
"
|
||||
>
|
||||
@@ -63,7 +82,11 @@
|
||||
class="w-4 text-foreground-disabled group-hover:text-foreground-2"
|
||||
/>
|
||||
</HeaderButton>
|
||||
<HeaderButton v-tippy="'Send us feedback'" @click="showFeedbackDialog = true">
|
||||
<HeaderButton
|
||||
v-if="hostAppStore.isDistributedBySpeckle"
|
||||
v-tippy="'Send us feedback'"
|
||||
@click="openFeedbackDialog()"
|
||||
>
|
||||
<ChatBubbleLeftIcon
|
||||
class="w-4 text-foreground-disabled group-hover:text-foreground-2"
|
||||
/>
|
||||
@@ -95,8 +118,22 @@ 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
|
||||
})
|
||||
|
||||
const { $intercom } = useNuxtApp()
|
||||
|
||||
const openFeedbackDialog = () => {
|
||||
if (
|
||||
(hostAppStore.hostAppName?.toLowerCase() === 'revit' &&
|
||||
hostAppStore.hostAppVersion?.includes('2022')) ||
|
||||
!hostAppStore.isDistributedBySpeckle
|
||||
) {
|
||||
showFeedbackDialog.value = true
|
||||
} else {
|
||||
$intercom.show()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -5,21 +5,35 @@
|
||||
>
|
||||
Welcome to Speckle
|
||||
</h1>
|
||||
<div v-if="isDesktopServiceAvailable">
|
||||
<AccountsSignInFlow />
|
||||
<div v-if="isDesktopServiceAvailable || canAddAccount">
|
||||
<div class="flex flex-col space-y-4 p-2">
|
||||
<FormTextInput
|
||||
v-model="customServerUrl"
|
||||
name="Server to sign in"
|
||||
:show-label="false"
|
||||
placeholder="https://app.speckle.systems"
|
||||
color="foundation"
|
||||
autocomplete="off"
|
||||
show-clear
|
||||
/>
|
||||
<div class="space-y-2">
|
||||
<AccountsSignInFlow :server-url="customServerUrl" />
|
||||
<AccountsExchangeTokenSignInFlow :server-url="customServerUrl" />
|
||||
<AccountsLegacySignInFlow :server-url="customServerUrl" />
|
||||
</div>
|
||||
</div>
|
||||
</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 +41,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 customServerUrl = ref<string>('https://app.speckle.systems')
|
||||
|
||||
const { $accountBinding } = useNuxtApp()
|
||||
const canAddAccount = ['AddAccount', 'addAccount'].some((name) =>
|
||||
($accountBinding as unknown as BaseBridge).availableMethodNames.includes(name)
|
||||
)
|
||||
|
||||
const isDesktopServiceAvailable = ref(false) // this should be false default because there is a delay if /ping is not successful.
|
||||
|
||||
onMounted(async () => {
|
||||
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
|
||||
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>
|
||||
@@ -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" :model-card="modelCard" />
|
||||
</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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,195 @@
|
||||
<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 w-full"
|
||||
:doc="issue.description?.doc"
|
||||
></IssuesBasicTiptap>
|
||||
|
||||
<div v-if="app.$parametersBinding && hasObjectDeltas" class="w-full pt-1 pb-1">
|
||||
<FormButton
|
||||
class="w-full justify-center"
|
||||
:disabled="isApplying || isResolved"
|
||||
@click="applyChanges"
|
||||
>
|
||||
{{
|
||||
isApplying ? 'Applying...' : isResolved ? 'Issue resolved' : 'Apply changes'
|
||||
}}
|
||||
</FormButton>
|
||||
</div>
|
||||
<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>
|
||||
created this issue ·
|
||||
{{ 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>
|
||||
replied ·
|
||||
{{ 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 { computed, ref } from 'vue'
|
||||
import { useQuery } from '@vue/apollo-composable'
|
||||
import { ResourceMetaType, IssueStatus } from '~/lib/common/generated/gql/graphql'
|
||||
import { issueResourceMetaSearchQuery } from '~/lib/issues/graphql/queries'
|
||||
import type { IssuesItemFragment } from '~/lib/common/generated/gql/graphql'
|
||||
import type { IModelCard } from '~/lib/models/card'
|
||||
import dayjs from 'dayjs'
|
||||
import { Calendar } from 'lucide-vue-next'
|
||||
|
||||
const props = defineProps<{
|
||||
issue: IssuesItemFragment
|
||||
modelCard: IModelCard
|
||||
}>()
|
||||
|
||||
const app = useNuxtApp()
|
||||
const isApplying = ref(false)
|
||||
|
||||
const isResolved = computed(() => {
|
||||
return props.issue.status === IssueStatus.Resolved
|
||||
})
|
||||
|
||||
const queryVariables = computed(() => ({
|
||||
workspaceId: props.modelCard.workspaceId!,
|
||||
projectId: props.modelCard.projectId,
|
||||
resourceType: ResourceMetaType.Issue,
|
||||
resourceId: props.issue.id,
|
||||
metaType: 'objectDeltas'
|
||||
}))
|
||||
|
||||
const queryOptions = computed(() => ({
|
||||
fetchPolicy: 'cache-and-network' as const,
|
||||
enabled: !!props.modelCard.workspaceId,
|
||||
clientId: props.modelCard.accountId
|
||||
}))
|
||||
|
||||
const { result: resourceMetaResult } = useQuery(
|
||||
issueResourceMetaSearchQuery,
|
||||
queryVariables,
|
||||
queryOptions
|
||||
)
|
||||
|
||||
const hasObjectDeltas = computed<boolean>(() => {
|
||||
const metadata = resourceMetaResult.value?.resourceMetaSearch
|
||||
return Array.isArray(metadata) && metadata.length > 0
|
||||
})
|
||||
|
||||
const objectDeltasPayload = computed<unknown>(() => {
|
||||
if (!hasObjectDeltas.value) return null
|
||||
const metadata = resourceMetaResult.value?.resourceMetaSearch
|
||||
|
||||
if (Array.isArray(metadata) && metadata.length > 0) {
|
||||
return metadata[0]?.data as unknown
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
const applyChanges = async () => {
|
||||
if (!objectDeltasPayload.value) return
|
||||
|
||||
isApplying.value = true
|
||||
try {
|
||||
const payload =
|
||||
typeof objectDeltasPayload.value === 'string'
|
||||
? objectDeltasPayload.value
|
||||
: JSON.stringify(objectDeltasPayload.value)
|
||||
|
||||
if (app.$parametersBinding) {
|
||||
await app.$parametersBinding.update(payload)
|
||||
} else {
|
||||
console.warn('IParametersBinding not available in this host app')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to apply changes:', error)
|
||||
} finally {
|
||||
isApplying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
@@ -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 = () => {
|
||||
|
||||
@@ -1,186 +0,0 @@
|
||||
<template>
|
||||
<div class="p-0">
|
||||
<slot name="activator" :toggle="toggleDialog"></slot>
|
||||
<CommonDialog
|
||||
v-model:open="showModelCreateDialog"
|
||||
:title="canCreateModelInWorkspace ? `Create new model` : errorMessage?.title"
|
||||
fullscreen="none"
|
||||
>
|
||||
<form v-if="canCreateModelInWorkspace" @submit="onSubmitCreateNewModel">
|
||||
<div class="text-body-2xs mb-2 ml-1">Model name</div>
|
||||
<FormTextInput
|
||||
v-model="newModelName"
|
||||
class="text-xs"
|
||||
autocomplete="off"
|
||||
name="name"
|
||||
label="Model name"
|
||||
color="foundation"
|
||||
:show-clear="!!newModelName"
|
||||
:placeholder="hostAppStore.documentInfo?.name"
|
||||
:rules="[
|
||||
ValidationHelpers.isRequired,
|
||||
ValidationHelpers.isStringOfLength({ minLength: 3 })
|
||||
]"
|
||||
full-width
|
||||
/>
|
||||
<div class="mt-4 flex justify-end items-center space-x-2 w-full">
|
||||
<FormButton size="sm" text @click="showModelCreateDialog = false">
|
||||
Cancel
|
||||
</FormButton>
|
||||
<FormButton size="sm" submit :disabled="isCreatingModel">Create</FormButton>
|
||||
</div>
|
||||
</form>
|
||||
<div v-else class="m-2">
|
||||
{{ errorMessage?.description }}
|
||||
<div class="flex mt-2 space-x-2 justify-end">
|
||||
<FormButton size="sm" color="outline" @click="showModelCreateDialog = false">
|
||||
Close
|
||||
</FormButton>
|
||||
<FormButton
|
||||
v-if="errorMessage?.cta"
|
||||
size="sm"
|
||||
submit
|
||||
@click="errorMessage?.cta?.action(), (showModelCreateDialog = false)"
|
||||
>
|
||||
{{ errorMessage?.cta?.name }}
|
||||
</FormButton>
|
||||
</div>
|
||||
</div>
|
||||
</CommonDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useMutation, provideApolloClient, useQuery } from '@vue/apollo-composable'
|
||||
import type { ModelListModelItemFragment } from '~/lib/common/generated/gql/graphql'
|
||||
import { useForm } from 'vee-validate'
|
||||
import { ValidationHelpers } from '@speckle/ui-components'
|
||||
import type { DUIAccount } from '~/store/accounts'
|
||||
import { useAccountStore } from '~/store/accounts'
|
||||
import { useMixpanel } from '~/lib/core/composables/mixpanel'
|
||||
import { useHostAppStore } from '~/store/hostApp'
|
||||
import {
|
||||
canCreateModelInProjectQuery,
|
||||
createModelMutation
|
||||
} from '~/lib/graphql/mutationsAndQueries'
|
||||
|
||||
type WorkspacePermissionMessage = {
|
||||
title: string
|
||||
description: string
|
||||
cta?: {
|
||||
name: string
|
||||
action: () => void
|
||||
}
|
||||
}
|
||||
|
||||
const { $openUrl } = useNuxtApp()
|
||||
|
||||
const showModelCreateDialog = ref(false)
|
||||
const isCreatingModel = ref(false)
|
||||
|
||||
const props = defineProps<{
|
||||
projectId: string
|
||||
workspaceId?: string
|
||||
workspaceSlug?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'model:created', model: ModelListModelItemFragment): void
|
||||
}>()
|
||||
|
||||
const { trackEvent } = useMixpanel()
|
||||
const accountStore = useAccountStore()
|
||||
const hostAppStore = useHostAppStore()
|
||||
const { activeAccount } = storeToRefs(accountStore)
|
||||
|
||||
const accountId = computed(() => activeAccount.value.accountInfo.id)
|
||||
const newModelName = ref<string>()
|
||||
const errorMessage = ref<WorkspacePermissionMessage>()
|
||||
|
||||
const toggleDialog = () => {
|
||||
showModelCreateDialog.value = !showModelCreateDialog.value
|
||||
}
|
||||
|
||||
const account = computed(() => {
|
||||
return accountStore.accounts.find(
|
||||
(acc) => acc.accountInfo.id === accountId.value
|
||||
) as DUIAccount
|
||||
})
|
||||
|
||||
const canCreateModelInWorkspace = ref<boolean>()
|
||||
|
||||
const { result: canCreateModelInWorkspaceResult } = useQuery(
|
||||
canCreateModelInProjectQuery,
|
||||
() => ({ projectId: props.projectId }),
|
||||
() => ({
|
||||
clientId: accountId.value,
|
||||
debounce: 500,
|
||||
fetchPolicy: 'network-only'
|
||||
})
|
||||
)
|
||||
|
||||
watch(canCreateModelInWorkspaceResult, (val) => {
|
||||
if (val?.project.permissions.canCreateModel.code !== 'OK') {
|
||||
switch (val?.project.permissions.canCreateModel.code) {
|
||||
case 'WorkspaceLimitsReached':
|
||||
errorMessage.value = {
|
||||
title: 'Plan limit reached',
|
||||
description:
|
||||
'The model limit for this workspace has been reached. Upgrade the workspace plan to create or move more models.',
|
||||
cta: {
|
||||
name: 'Explore Plans',
|
||||
action: () =>
|
||||
$openUrl(
|
||||
`${account.value.accountInfo.serverInfo.url}/settings/workspaces/${props.workspaceSlug}/billing`
|
||||
)
|
||||
}
|
||||
}
|
||||
break
|
||||
// TODO: we should add more cases later according to `code`
|
||||
default:
|
||||
errorMessage.value = {
|
||||
title: 'Workspace warning',
|
||||
description: val?.project.permissions.canCreateModel.message ?? 'error'
|
||||
}
|
||||
break
|
||||
}
|
||||
canCreateModelInWorkspace.value = false
|
||||
} else {
|
||||
canCreateModelInWorkspace.value = true
|
||||
}
|
||||
})
|
||||
|
||||
const createNewModel = async (name: string) => {
|
||||
isCreatingModel.value = true
|
||||
|
||||
void trackEvent('DUI3 Action', { name: 'Model Create' }, account.value.accountInfo.id)
|
||||
|
||||
const { mutate } = provideApolloClient(account.value.client)(() =>
|
||||
useMutation(createModelMutation)
|
||||
)
|
||||
const res = await mutate({ input: { projectId: props.projectId, name } })
|
||||
if (res?.data?.modelMutations.create) {
|
||||
emit('model:created', res?.data?.modelMutations.create)
|
||||
// refetch() // Sorts the list with newly created model otherwise it will put the model at the bottom.
|
||||
// emit('next', res?.data?.modelMutations.create)
|
||||
} else {
|
||||
let errorMessage = 'Undefined error'
|
||||
if (res?.errors && res?.errors.length !== 0) {
|
||||
errorMessage = res?.errors[0].message
|
||||
}
|
||||
|
||||
hostAppStore.setNotification({
|
||||
type: 1,
|
||||
title: 'Failed to create model',
|
||||
description: errorMessage
|
||||
})
|
||||
}
|
||||
isCreatingModel.value = false
|
||||
}
|
||||
|
||||
const { handleSubmit } = useForm<{ name: string }>()
|
||||
const onSubmitCreateNewModel = handleSubmit(() => {
|
||||
void createNewModel(newModelName.value as string)
|
||||
})
|
||||
</script>
|
||||
@@ -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 || noReadAccess"
|
||||
: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,
|
||||
@@ -178,7 +215,7 @@ const expiredNotification = computed(() => {
|
||||
if (props.modelCard.latestVersionId === props.modelCard.selectedVersionId) return
|
||||
const notification = {} as ModelCardNotification
|
||||
notification.dismissible = true
|
||||
notification.level = 'info'
|
||||
notification.level = 'success'
|
||||
notification.text = 'Newer version available!'
|
||||
notification.cta = {
|
||||
name: 'Update',
|
||||
@@ -203,13 +240,6 @@ const receiveResultNotificationText = computed(() => {
|
||||
return 'Model loaded!'
|
||||
})
|
||||
|
||||
const receiveResultNotificationLevel = computed(() => {
|
||||
if (failRate.value > 80) {
|
||||
return 'warning'
|
||||
}
|
||||
return 'info'
|
||||
})
|
||||
|
||||
const receiveResultNotification = computed(() => {
|
||||
if (
|
||||
!props.modelCard.bakedObjectIds ||
|
||||
@@ -219,7 +249,7 @@ const receiveResultNotification = computed(() => {
|
||||
|
||||
const notification = {} as ModelCardNotification
|
||||
notification.dismissible = true
|
||||
notification.level = receiveResultNotificationLevel.value
|
||||
notification.level = 'success'
|
||||
notification.text = receiveResultNotificationText.value
|
||||
notification.report = props.modelCard.report
|
||||
notification.cta = {
|
||||
@@ -269,10 +299,6 @@ const latestVersionCreatedAt = computed(() => {
|
||||
return dayjs(props.modelCard.latestVersionCreatedAt).from(dayjs())
|
||||
})
|
||||
|
||||
const noReadAccess = computed(() => {
|
||||
return props.canEdit
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
refetch()
|
||||
})
|
||||
|
||||
+234
-19
@@ -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 -->
|
||||
<span class="font-bold">{{ modelCard.sendFilter?.name }}: </span>
|
||||
<span class="truncate">{{ modelCard.sendFilter?.summary }}</span>
|
||||
</FormButton>
|
||||
@@ -31,17 +32,69 @@
|
||||
<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>
|
||||
|
||||
<CommonDialog
|
||||
v-model:open="showSetMessageDialog"
|
||||
title="Version message"
|
||||
fullscreen="none"
|
||||
>
|
||||
<form @submit="setVersionMessage(versionMessage as string)">
|
||||
<div class="text-body-2xs mb-2 ml-1">
|
||||
Describe your latest changes to help keep track of design intent.
|
||||
</div>
|
||||
<FormTextArea
|
||||
v-model="versionMessage"
|
||||
class="text-xs"
|
||||
placeholder="Moved elements to prevent clash"
|
||||
autocomplete="off"
|
||||
name="name"
|
||||
label="Version message"
|
||||
color="foundation"
|
||||
:show-clear="!!versionMessage"
|
||||
:rules="[ValidationHelpers.isStringOfLength({ minLength: 3 })]"
|
||||
full-width
|
||||
/>
|
||||
<CommonLoadingBar v-if="isUpdatingVersionMessage" loading />
|
||||
<div class="mt-4 flex justify-end items-center space-x-2 w-full">
|
||||
<FormButton size="sm" text @click="showSetMessageDialog = false">
|
||||
Cancel
|
||||
</FormButton>
|
||||
<FormButton
|
||||
size="sm"
|
||||
submit
|
||||
:disabled="
|
||||
isUpdatingVersionMessage || !versionMessage || versionMessage.length < 3
|
||||
"
|
||||
>
|
||||
Save
|
||||
</FormButton>
|
||||
</div>
|
||||
</form>
|
||||
</CommonDialog>
|
||||
<template #states>
|
||||
<CommonModelNotification
|
||||
v-if="isSendSettingsMissing"
|
||||
:notification="sendSettingsMissingNotification"
|
||||
/>
|
||||
<CommonModelNotification
|
||||
v-if="expiredNotification"
|
||||
:notification="expiredNotification"
|
||||
@@ -66,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'
|
||||
@@ -74,9 +127,23 @@ import type { ISendFilter, ISenderModelCard } from '~/lib/models/card/send'
|
||||
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,
|
||||
useSubscription
|
||||
} from '@vue/apollo-composable'
|
||||
import { useAccountStore, type DUIAccount } from '~/store/accounts'
|
||||
import { setVersionMessageMutation } from '~/lib/graphql/mutationsAndQueries'
|
||||
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<{
|
||||
@@ -85,46 +152,167 @@ 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
|
||||
}
|
||||
|
||||
const showSetMessageDialog = ref(false)
|
||||
const isUpdatingVersionMessage = ref(false)
|
||||
const hasSetVersionMessage = ref(false)
|
||||
const versionMessage = ref<string>()
|
||||
|
||||
const setVersionMessage = async (message: string) => {
|
||||
if (!props.modelCard.latestCreatedVersionId) {
|
||||
return
|
||||
}
|
||||
|
||||
void trackEvent('DUI3 Action', {
|
||||
name: 'Set version message'
|
||||
})
|
||||
|
||||
isUpdatingVersionMessage.value = true
|
||||
const { mutate } = provideApolloClient(account.client)(() =>
|
||||
useMutation(setVersionMessageMutation)
|
||||
)
|
||||
|
||||
const res = await mutate({
|
||||
input: {
|
||||
projectId: props.project.projectId,
|
||||
versionId: props.modelCard.latestCreatedVersionId,
|
||||
message
|
||||
}
|
||||
})
|
||||
|
||||
if (res?.data?.versionMutations.update.id) {
|
||||
// seemed to noisy, and autoclose does not work for some reason.
|
||||
// nicer ux to just close the dialog
|
||||
// store.setNotification({
|
||||
// type: ToastNotificationType.Info,
|
||||
// title: 'Version message saved',
|
||||
// autoClose: true
|
||||
// })
|
||||
hasSetVersionMessage.value = true
|
||||
} else {
|
||||
store.setNotification({
|
||||
type: ToastNotificationType.Danger,
|
||||
title: 'Request failed',
|
||||
description: 'Failed to update version message.',
|
||||
autoClose: true
|
||||
})
|
||||
}
|
||||
showSetMessageDialog.value = false
|
||||
isUpdatingVersionMessage.value = false
|
||||
}
|
||||
|
||||
const saveFilterAndSend = async () => {
|
||||
await saveFilter()
|
||||
store.sendModel(props.modelCard.modelCardId, 'Filter')
|
||||
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
|
||||
|
||||
@@ -138,7 +326,12 @@ 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) {
|
||||
await store.sendModelCancel(props.modelCard.modelCardId)
|
||||
}
|
||||
@@ -188,11 +381,33 @@ const latestVersionNotification = computed(() => {
|
||||
notification.level = sendResultNotificationLevel.value
|
||||
notification.text = sendResultNotificationText.value
|
||||
notification.report = props.modelCard.report
|
||||
|
||||
// NOTE: this prevents us displaying the set message button for non-updated
|
||||
// connectors that send over the root object id over instead of the commit id
|
||||
if (
|
||||
props.modelCard.latestCreatedVersionId.length === 10 &&
|
||||
!hasSetVersionMessage.value
|
||||
) {
|
||||
notification.secondaryCta = {
|
||||
name: 'Set message',
|
||||
tooltipText: 'Describe your changes',
|
||||
action: () => {
|
||||
showSetMessageDialog.value = true
|
||||
versionMessage.value = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
notification.cta = {
|
||||
name: 'View version',
|
||||
name: 'View',
|
||||
tooltipText: 'Check your model in the browser!',
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-member-access
|
||||
action: () => cardBase.value?.viewModel()
|
||||
}
|
||||
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>
|
||||
@@ -1,220 +0,0 @@
|
||||
<template>
|
||||
<div class="p-0">
|
||||
<slot name="activator" :toggle="toggleDialog"></slot>
|
||||
<CommonDialog
|
||||
v-model:open="showProjectCreateDialog"
|
||||
:title="`Create new project`"
|
||||
fullscreen="none"
|
||||
>
|
||||
<form @submit="onSubmitCreateNewProject">
|
||||
<div class="text-body-2xs mb-2 ml-1">Project name</div>
|
||||
<FormTextInput
|
||||
v-model="newProjectName"
|
||||
class="text-xs"
|
||||
placeholder="A Beautiful Home, A Small Bridge..."
|
||||
autocomplete="off"
|
||||
name="name"
|
||||
label="Project name"
|
||||
color="foundation"
|
||||
:show-clear="!!newProjectName"
|
||||
:rules="[
|
||||
ValidationHelpers.isRequired,
|
||||
ValidationHelpers.isStringOfLength({ minLength: 3 })
|
||||
]"
|
||||
full-width
|
||||
/>
|
||||
<div class="mt-4 flex justify-end items-center space-x-2 w-full">
|
||||
<FormButton size="sm" text @click="showProjectCreateDialog = false">
|
||||
Cancel
|
||||
</FormButton>
|
||||
<FormButton
|
||||
size="sm"
|
||||
submit
|
||||
:disabled="isCreatingProject || !canCreateProject"
|
||||
>
|
||||
Create
|
||||
</FormButton>
|
||||
</div>
|
||||
</form>
|
||||
</CommonDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useMutation, provideApolloClient, useQuery } from '@vue/apollo-composable'
|
||||
import type { ProjectListProjectItemFragment } from '~/lib/common/generated/gql/graphql'
|
||||
import {
|
||||
canCreatePersonalProjectQuery,
|
||||
canCreateProjectInWorkspaceQuery,
|
||||
createProjectInWorkspaceMutation,
|
||||
createProjectMutation
|
||||
} from '~/lib/graphql/mutationsAndQueries'
|
||||
import type { DUIAccount } from '~/store/accounts'
|
||||
import { useAccountStore } from '~/store/accounts'
|
||||
import { useMixpanel } from '~/lib/core/composables/mixpanel'
|
||||
import { useHostAppStore } from '~/store/hostApp'
|
||||
import { useForm } from 'vee-validate'
|
||||
import { ValidationHelpers } from '@speckle/ui-components'
|
||||
|
||||
const showProjectCreateDialog = ref(false)
|
||||
const isCreatingProject = ref(false)
|
||||
|
||||
const props = defineProps<{ workspaceId?: string }>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'project:created', result: ProjectListProjectItemFragment): void
|
||||
}>()
|
||||
|
||||
const { trackEvent } = useMixpanel()
|
||||
const accountStore = useAccountStore()
|
||||
const hostAppStore = useHostAppStore()
|
||||
const { activeAccount } = storeToRefs(accountStore)
|
||||
|
||||
const accountId = computed(() => activeAccount.value.accountInfo.id)
|
||||
const newProjectName = ref<string>()
|
||||
|
||||
const errorMessageForWorkspace = ref<string>()
|
||||
const errorMessageForPersonalProject = ref<string>()
|
||||
|
||||
const toggleDialog = () => {
|
||||
showProjectCreateDialog.value = !showProjectCreateDialog.value
|
||||
}
|
||||
|
||||
const account = computed(() => {
|
||||
return accountStore.accounts.find(
|
||||
(acc) => acc.accountInfo.id === accountId.value
|
||||
) as DUIAccount
|
||||
})
|
||||
|
||||
const canCreateProject = computed(() =>
|
||||
props.workspaceId === 'personalProject'
|
||||
? canCreatePersonalProject.value
|
||||
: canCreateProjectInWorkspace.value
|
||||
)
|
||||
|
||||
const { result: canCreatePersonalProjectResult } = useQuery(
|
||||
canCreatePersonalProjectQuery,
|
||||
() => ({}),
|
||||
() => ({
|
||||
clientId: accountId.value,
|
||||
debounce: 500,
|
||||
fetchPolicy: 'network-only'
|
||||
})
|
||||
)
|
||||
|
||||
watch(canCreatePersonalProjectResult, (val) => {
|
||||
if (val?.activeUser?.permissions.canCreatePersonalProject.code !== 'OK') {
|
||||
errorMessageForPersonalProject.value =
|
||||
val?.activeUser?.permissions.canCreatePersonalProject.message
|
||||
}
|
||||
})
|
||||
|
||||
const canCreatePersonalProject = computed(() => {
|
||||
try {
|
||||
return (
|
||||
canCreatePersonalProjectResult.value?.activeUser?.permissions
|
||||
.canCreatePersonalProject.code === 'OK'
|
||||
)
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
})
|
||||
|
||||
const { result: canCreateProjectInWorkspaceResult } = useQuery(
|
||||
canCreateProjectInWorkspaceQuery,
|
||||
() => ({ workspaceId: props.workspaceId ?? 'null' }), // TODO: i do not know the potential cause here
|
||||
() => ({
|
||||
clientId: accountId.value,
|
||||
debounce: 500,
|
||||
fetchPolicy: 'network-only'
|
||||
})
|
||||
)
|
||||
|
||||
watch(canCreateProjectInWorkspaceResult, (val) => {
|
||||
if (val?.workspace.permissions.canCreateProject.code !== 'OK') {
|
||||
errorMessageForWorkspace.value = val?.workspace.permissions.canCreateProject.message
|
||||
}
|
||||
})
|
||||
|
||||
const canCreateProjectInWorkspace = computed(() => {
|
||||
try {
|
||||
return (
|
||||
canCreateProjectInWorkspaceResult.value?.workspace.permissions.canCreateProject
|
||||
.code === 'OK'
|
||||
)
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
})
|
||||
|
||||
const { handleSubmit } = useForm<{ name: string }>()
|
||||
const onSubmitCreateNewProject = handleSubmit(() => {
|
||||
// TODO: Chat with Fabians
|
||||
// This works, but if we use handleSubmit(args) > args.name -> it is undefined in Production on netlify, but works fine on local dev
|
||||
void createNewProject(newProjectName.value as string)
|
||||
})
|
||||
|
||||
const createNewProject = async (name: string) => {
|
||||
isCreatingProject.value = true
|
||||
|
||||
if (props.workspaceId !== 'personalProject' && props.workspaceId !== undefined) {
|
||||
createNewProjectInWorkspace(name)
|
||||
isCreatingProject.value = false
|
||||
return
|
||||
}
|
||||
|
||||
void trackEvent(
|
||||
'DUI3 Action',
|
||||
{ name: 'Project Create', workspace: false },
|
||||
account.value.accountInfo.id
|
||||
)
|
||||
const { mutate } = provideApolloClient(account.value.client)(() =>
|
||||
useMutation(createProjectMutation)
|
||||
)
|
||||
const res = await mutate({ input: { name } })
|
||||
if (res?.data?.projectMutations.create) {
|
||||
emit('project:created', res?.data?.projectMutations.create)
|
||||
} else {
|
||||
let errorMessage = 'Undefined error'
|
||||
if (res?.errors && res?.errors.length !== 0) {
|
||||
errorMessage = res?.errors[0].message
|
||||
}
|
||||
|
||||
hostAppStore.setNotification({
|
||||
type: 1,
|
||||
title: 'Failed to create project',
|
||||
description: errorMessage
|
||||
})
|
||||
}
|
||||
isCreatingProject.value = false
|
||||
}
|
||||
|
||||
const createNewProjectInWorkspace = async (name: string) => {
|
||||
void trackEvent(
|
||||
'DUI3 Action',
|
||||
{ name: 'Project Create', workspace: true },
|
||||
account.value.accountInfo.id
|
||||
)
|
||||
const { mutate } = provideApolloClient(account.value.client)(() =>
|
||||
useMutation(createProjectInWorkspaceMutation)
|
||||
)
|
||||
const res = await mutate({
|
||||
input: { name, workspaceId: props.workspaceId as string }
|
||||
})
|
||||
if (res?.data?.workspaceMutations.projects.create) {
|
||||
emit('project:created', res?.data?.workspaceMutations.projects.create)
|
||||
} else {
|
||||
let errorMessage = 'Undefined error'
|
||||
if (res?.errors && res?.errors.length !== 0) {
|
||||
errorMessage = res?.errors[0].message
|
||||
}
|
||||
|
||||
hostAppStore.setNotification({
|
||||
type: 1,
|
||||
title: 'Failed to create project',
|
||||
description: errorMessage
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,141 +0,0 @@
|
||||
<template>
|
||||
<div class="p-0">
|
||||
<slot name="activator" :toggle="toggleDialog"></slot>
|
||||
<CommonDialog
|
||||
v-model:open="showProjectCreateDialog"
|
||||
:title="`Create new project`"
|
||||
fullscreen="none"
|
||||
>
|
||||
<form @submit="onSubmitCreateNewProject">
|
||||
<div class="text-body-2xs mb-2 ml-1">Project name</div>
|
||||
<FormTextInput
|
||||
v-model="newProjectName"
|
||||
class="text-xs"
|
||||
placeholder="A Beautiful Home, A Small Bridge..."
|
||||
autocomplete="off"
|
||||
name="name"
|
||||
label="Project name"
|
||||
color="foundation"
|
||||
:show-clear="!!newProjectName"
|
||||
:rules="[
|
||||
ValidationHelpers.isRequired,
|
||||
ValidationHelpers.isStringOfLength({ minLength: 3 })
|
||||
]"
|
||||
full-width
|
||||
/>
|
||||
<div class="mt-4 flex justify-end items-center space-x-2 w-full">
|
||||
<FormButton size="sm" text @click="showProjectCreateDialog = false">
|
||||
Cancel
|
||||
</FormButton>
|
||||
<FormButton
|
||||
size="sm"
|
||||
submit
|
||||
:disabled="isCreatingProject || !canCreatePersonalProject"
|
||||
>
|
||||
Create
|
||||
</FormButton>
|
||||
</div>
|
||||
</form>
|
||||
</CommonDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useMutation, provideApolloClient, useQuery } from '@vue/apollo-composable'
|
||||
import type { ProjectListProjectItemFragment } from '~/lib/common/generated/gql/graphql'
|
||||
import {
|
||||
canCreatePersonalProjectQuery,
|
||||
createProjectMutation
|
||||
} from '~/lib/graphql/mutationsAndQueries'
|
||||
import type { DUIAccount } from '~/store/accounts'
|
||||
import { useAccountStore } from '~/store/accounts'
|
||||
import { useMixpanel } from '~/lib/core/composables/mixpanel'
|
||||
import { useHostAppStore } from '~/store/hostApp'
|
||||
import { useForm } from 'vee-validate'
|
||||
import { ValidationHelpers } from '@speckle/ui-components'
|
||||
|
||||
const showProjectCreateDialog = ref(false)
|
||||
const isCreatingProject = ref(false)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'project:created', result: ProjectListProjectItemFragment): void
|
||||
}>()
|
||||
|
||||
const { trackEvent } = useMixpanel()
|
||||
const accountStore = useAccountStore()
|
||||
const hostAppStore = useHostAppStore()
|
||||
const { activeAccount } = storeToRefs(accountStore)
|
||||
|
||||
const accountId = computed(() => activeAccount.value.accountInfo.id)
|
||||
const newProjectName = ref<string>()
|
||||
|
||||
const errorMessage = ref<string>()
|
||||
|
||||
const toggleDialog = () => {
|
||||
showProjectCreateDialog.value = !showProjectCreateDialog.value
|
||||
}
|
||||
|
||||
const account = computed(() => {
|
||||
return accountStore.accounts.find(
|
||||
(acc) => acc.accountInfo.id === accountId.value
|
||||
) as DUIAccount
|
||||
})
|
||||
|
||||
const canCreatePersonalProject = ref<boolean>(false)
|
||||
|
||||
const { result: canCreatePersonalProjectResult } = useQuery(
|
||||
canCreatePersonalProjectQuery,
|
||||
() => ({}),
|
||||
() => ({
|
||||
clientId: accountId.value,
|
||||
debounce: 500,
|
||||
fetchPolicy: 'network-only'
|
||||
})
|
||||
)
|
||||
|
||||
watch(canCreatePersonalProjectResult, (val) => {
|
||||
if (val?.activeUser?.permissions.canCreatePersonalProject.code !== 'OK') {
|
||||
errorMessage.value = val?.activeUser?.permissions.canCreatePersonalProject.message
|
||||
canCreatePersonalProject.value = false
|
||||
} else {
|
||||
canCreatePersonalProject.value = true
|
||||
}
|
||||
})
|
||||
|
||||
const { handleSubmit } = useForm<{ name: string }>()
|
||||
const onSubmitCreateNewProject = handleSubmit(() => {
|
||||
// TODO: Chat with Fabians
|
||||
// This works, but if we use handleSubmit(args) > args.name -> it is undefined in Production on netlify, but works fine on local dev
|
||||
void createNewProject(newProjectName.value as string)
|
||||
})
|
||||
|
||||
const createNewProject = async (name: string) => {
|
||||
isCreatingProject.value = true
|
||||
|
||||
void trackEvent(
|
||||
'DUI3 Action',
|
||||
{ name: 'Project Create', workspace: false },
|
||||
account.value.accountInfo.id
|
||||
)
|
||||
const { mutate } = provideApolloClient(account.value.client)(() =>
|
||||
useMutation(createProjectMutation)
|
||||
)
|
||||
const res = await mutate({ input: { name } })
|
||||
if (res?.data?.projectMutations.create) {
|
||||
emit('project:created', res?.data?.projectMutations.create)
|
||||
} else {
|
||||
let errorMessage = 'Undefined error'
|
||||
if (res?.errors && res?.errors.length !== 0) {
|
||||
errorMessage = res?.errors[0].message
|
||||
}
|
||||
|
||||
hostAppStore.setNotification({
|
||||
type: 1,
|
||||
title: 'Failed to create project',
|
||||
description: errorMessage
|
||||
})
|
||||
}
|
||||
isCreatingProject.value = false
|
||||
}
|
||||
</script>
|
||||
@@ -1,198 +0,0 @@
|
||||
<template>
|
||||
<div class="p-0">
|
||||
<slot name="activator" :toggle="toggleDialog"></slot>
|
||||
<CommonDialog
|
||||
v-model:open="showProjectCreateDialog"
|
||||
:title="canCreateProjectInWorkspace ? `Create new project` : errorMessage?.title"
|
||||
fullscreen="none"
|
||||
>
|
||||
<form v-if="canCreateProjectInWorkspace" @submit="onSubmitCreateNewProject">
|
||||
<div class="text-body-2xs mb-2 ml-1">Project name</div>
|
||||
<FormTextInput
|
||||
v-model="newProjectName"
|
||||
class="text-xs"
|
||||
placeholder="A Beautiful Home, A Small Bridge..."
|
||||
autocomplete="off"
|
||||
name="name"
|
||||
label="Project name"
|
||||
color="foundation"
|
||||
:show-clear="!!newProjectName"
|
||||
:rules="[
|
||||
ValidationHelpers.isRequired,
|
||||
ValidationHelpers.isStringOfLength({ minLength: 3 })
|
||||
]"
|
||||
full-width
|
||||
/>
|
||||
<div class="mt-4 flex justify-end items-center space-x-2 w-full">
|
||||
<FormButton
|
||||
size="sm"
|
||||
color="outline"
|
||||
@click="showProjectCreateDialog = false"
|
||||
>
|
||||
Cancel
|
||||
</FormButton>
|
||||
<FormButton size="sm" submit :disabled="isCreatingProject">Create</FormButton>
|
||||
</div>
|
||||
</form>
|
||||
<div v-else class="m-2">
|
||||
{{ errorMessage?.description }}
|
||||
<div class="flex mt-2 space-x-2 justify-end">
|
||||
<FormButton
|
||||
size="sm"
|
||||
color="outline"
|
||||
@click="showProjectCreateDialog = false"
|
||||
>
|
||||
Close
|
||||
</FormButton>
|
||||
<FormButton
|
||||
v-if="errorMessage?.cta"
|
||||
size="sm"
|
||||
submit
|
||||
@click="errorMessage?.cta?.action(), (showProjectCreateDialog = false)"
|
||||
>
|
||||
{{ errorMessage?.cta?.name }}
|
||||
</FormButton>
|
||||
</div>
|
||||
</div>
|
||||
</CommonDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useMutation, provideApolloClient, useQuery } from '@vue/apollo-composable'
|
||||
import type {
|
||||
ProjectListProjectItemFragment,
|
||||
WorkspaceListWorkspaceItemFragment
|
||||
} from '~/lib/common/generated/gql/graphql'
|
||||
import {
|
||||
canCreateProjectInWorkspaceQuery,
|
||||
createProjectInWorkspaceMutation
|
||||
} from '~/lib/graphql/mutationsAndQueries'
|
||||
import type { DUIAccount } from '~/store/accounts'
|
||||
import { useAccountStore } from '~/store/accounts'
|
||||
import { useMixpanel } from '~/lib/core/composables/mixpanel'
|
||||
import { useHostAppStore } from '~/store/hostApp'
|
||||
import { useForm } from 'vee-validate'
|
||||
import { ValidationHelpers } from '@speckle/ui-components'
|
||||
|
||||
type WorkspacePermissionMessage = {
|
||||
title: string
|
||||
description: string
|
||||
cta?: {
|
||||
name: string
|
||||
action: () => void
|
||||
}
|
||||
}
|
||||
|
||||
const { $openUrl } = useNuxtApp()
|
||||
|
||||
const showProjectCreateDialog = ref(false)
|
||||
const isCreatingProject = ref(false)
|
||||
|
||||
const props = defineProps<{ workspace?: WorkspaceListWorkspaceItemFragment }>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'project:created', result: ProjectListProjectItemFragment): void
|
||||
}>()
|
||||
|
||||
const { trackEvent } = useMixpanel()
|
||||
const accountStore = useAccountStore()
|
||||
const hostAppStore = useHostAppStore()
|
||||
const { activeAccount } = storeToRefs(accountStore)
|
||||
|
||||
const accountId = computed(() => activeAccount.value.accountInfo.id)
|
||||
const newProjectName = ref<string>()
|
||||
|
||||
const errorMessage = ref<WorkspacePermissionMessage>()
|
||||
|
||||
const toggleDialog = () => {
|
||||
showProjectCreateDialog.value = !showProjectCreateDialog.value
|
||||
}
|
||||
|
||||
const account = computed(() => {
|
||||
return accountStore.accounts.find(
|
||||
(acc) => acc.accountInfo.id === accountId.value
|
||||
) as DUIAccount
|
||||
})
|
||||
|
||||
const canCreateProjectInWorkspace = ref<boolean>()
|
||||
|
||||
const { result: canCreateProjectInWorkspaceResult } = useQuery(
|
||||
canCreateProjectInWorkspaceQuery,
|
||||
() => ({ workspaceId: props.workspace?.id ?? 'null' }), // TODO: i do not know the potential cause here
|
||||
() => ({
|
||||
clientId: accountId.value,
|
||||
debounce: 500,
|
||||
fetchPolicy: 'network-only'
|
||||
})
|
||||
)
|
||||
|
||||
watch(canCreateProjectInWorkspaceResult, (val) => {
|
||||
if (val?.workspace.permissions.canCreateProject.code !== 'OK') {
|
||||
switch (val?.workspace.permissions.canCreateProject.code) {
|
||||
case 'WorkspaceLimitsReached':
|
||||
errorMessage.value = {
|
||||
title: 'Plan limit reached',
|
||||
description:
|
||||
'The project limit for this workspace has been reached. Upgrade the workspace plan to create or move more projects.',
|
||||
cta: {
|
||||
name: 'Explore Plans',
|
||||
action: () =>
|
||||
$openUrl(
|
||||
`${account.value.accountInfo.serverInfo.url}/settings/workspaces/${props.workspace?.slug}/billing`
|
||||
)
|
||||
}
|
||||
}
|
||||
break
|
||||
// TODO: we should add more cases later according to `code`
|
||||
default:
|
||||
errorMessage.value = {
|
||||
title: 'Workspace warning',
|
||||
description: val?.workspace.permissions.canCreateProject.message ?? 'error'
|
||||
}
|
||||
break
|
||||
}
|
||||
canCreateProjectInWorkspace.value = false
|
||||
} else {
|
||||
canCreateProjectInWorkspace.value = true
|
||||
}
|
||||
})
|
||||
|
||||
const { handleSubmit } = useForm<{ name: string }>()
|
||||
const onSubmitCreateNewProject = handleSubmit(() => {
|
||||
// TODO: Chat with Fabians
|
||||
// This works, but if we use handleSubmit(args) > args.name -> it is undefined in Production on netlify, but works fine on local dev
|
||||
void createNewProjectInWorkspace(newProjectName.value as string)
|
||||
})
|
||||
|
||||
const createNewProjectInWorkspace = async (name: string) => {
|
||||
isCreatingProject.value = true
|
||||
void trackEvent(
|
||||
'DUI3 Action',
|
||||
{ name: 'Project Create', workspace: true },
|
||||
account.value.accountInfo.id
|
||||
)
|
||||
const { mutate } = provideApolloClient(account.value.client)(() =>
|
||||
useMutation(createProjectInWorkspaceMutation)
|
||||
)
|
||||
const res = await mutate({
|
||||
input: { name, workspaceId: props.workspace?.id as string }
|
||||
})
|
||||
if (res?.data?.workspaceMutations.projects.create) {
|
||||
emit('project:created', res?.data?.workspaceMutations.projects.create)
|
||||
} else {
|
||||
let errorMessage = 'Undefined error'
|
||||
if (res?.errors && res?.errors.length !== 0) {
|
||||
errorMessage = res?.errors[0].message
|
||||
}
|
||||
|
||||
hostAppStore.setNotification({
|
||||
type: 1,
|
||||
title: 'Failed to create project',
|
||||
description: errorMessage
|
||||
})
|
||||
}
|
||||
isCreatingProject.value = false
|
||||
}
|
||||
</script>
|
||||
@@ -5,13 +5,19 @@
|
||||
: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">
|
||||
<WizardProjectSelector
|
||||
:is-sender="false"
|
||||
:show-new-project="false"
|
||||
:url-parse-error="urlParseError"
|
||||
@next="selectProject"
|
||||
@search-text-update="updateSearchText"
|
||||
/>
|
||||
@@ -36,10 +42,11 @@
|
||||
:workspace-slug="selectedWorkspace?.slug"
|
||||
:from-wizard="true"
|
||||
@next="selectVersionAndAddModel"
|
||||
@update:settings="handleUpdateSettings"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="urlParseError" class="p-2 text-xs text-danger">{{ urlParseError }}</div>
|
||||
<div v-if="urlParseError" class="p-2 text-danger">{{ urlParseError }}</div>
|
||||
</CommonDialog>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
@@ -54,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 })
|
||||
|
||||
@@ -82,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) => {
|
||||
@@ -130,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,
|
||||
@@ -147,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!
|
||||
@@ -172,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
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
+128
-21
@@ -5,17 +5,22 @@
|
||||
: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
|
||||
is-sender
|
||||
disable-no-write-access-projects
|
||||
:url-parse-error="urlParseError"
|
||||
@next="selectProject"
|
||||
@search-text-update="updateSearchText"
|
||||
/>
|
||||
</div>
|
||||
<!-- Model selector wizard -->
|
||||
<div v-if="step === 2 && selectedProject && selectedAccountId">
|
||||
<WizardModelSelector
|
||||
:project="selectedProject"
|
||||
@@ -26,24 +31,36 @@
|
||||
@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-xs text-danger">
|
||||
<div v-if="urlParseError" class="p-2 text-danger">
|
||||
{{ urlParseError }}
|
||||
</div>
|
||||
</CommonDialog>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useSubscription } from '@vue/apollo-composable'
|
||||
import type {
|
||||
ModelListModelItemFragment,
|
||||
ProjectListProjectItemFragment
|
||||
@@ -52,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 })
|
||||
|
||||
@@ -71,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
|
||||
@@ -88,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
|
||||
@@ -111,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', {
|
||||
@@ -138,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!
|
||||
|
||||
@@ -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>
|
||||
@@ -13,22 +13,24 @@
|
||||
full-width
|
||||
color="foundation"
|
||||
/>
|
||||
<ModelCreateDialog
|
||||
:project-id="project.id"
|
||||
:workspace-id="workspaceId"
|
||||
:workspace-slug="workspaceSlug"
|
||||
@model:created="(result: ModelListModelItemFragment) => handleModelCreated(result)"
|
||||
<div
|
||||
v-tippy="
|
||||
canCreateModelResult?.project.permissions.canCreateModel.authorized
|
||||
? 'Create new model'
|
||||
: canCreateModelResult?.project.permissions.canCreateModel.message
|
||||
"
|
||||
>
|
||||
<template #activator="{ toggle }">
|
||||
<button
|
||||
v-tippy="'New model'"
|
||||
class="p-1.5 bg-foundation hover:bg-primary-muted rounded text-foreground border"
|
||||
@click="toggle()"
|
||||
>
|
||||
<PlusIcon class="w-4" />
|
||||
</button>
|
||||
</template>
|
||||
</ModelCreateDialog>
|
||||
<FormButton
|
||||
color="outline"
|
||||
:disabled="
|
||||
!canCreateModelResult?.project.permissions.canCreateModel.authorized
|
||||
"
|
||||
:class="`p-1.5 bg-foundation hover:bg-primary-muted rounded text-foreground border`"
|
||||
@click="showNewModelDialog = true"
|
||||
>
|
||||
<PlusIcon class="w-4 -mx-2" />
|
||||
</FormButton>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative grid grid-cols-1 gap-2">
|
||||
<CommonLoadingBar v-if="loading" loading />
|
||||
@@ -37,6 +39,7 @@
|
||||
v-for="model in models"
|
||||
:key="model.id"
|
||||
:model="model"
|
||||
:token="token"
|
||||
@click="handleModelSelect(model)"
|
||||
/>
|
||||
|
||||
@@ -77,6 +80,20 @@
|
||||
</template>
|
||||
</CommonDialog>
|
||||
<FormButton
|
||||
v-if="
|
||||
models?.length === 0 &&
|
||||
!!searchText &&
|
||||
canCreateModelResult?.project.permissions.canCreateModel?.authorized
|
||||
"
|
||||
full-width
|
||||
color="outline"
|
||||
:disabled="isCreatingModel"
|
||||
@click="createNewModel(searchText)"
|
||||
>
|
||||
Create "{{ searchText }}"
|
||||
</FormButton>
|
||||
<FormButton
|
||||
v-else
|
||||
color="outline"
|
||||
full-width
|
||||
:disabled="hasReachedEnd"
|
||||
@@ -91,7 +108,7 @@
|
||||
title="Create new model"
|
||||
fullscreen="none"
|
||||
>
|
||||
<form @submit="onSubmit">
|
||||
<form @submit="createNewModel(newModelName as string)">
|
||||
<FormTextInput
|
||||
v-model="newModelName"
|
||||
:rules="rules"
|
||||
@@ -107,7 +124,9 @@
|
||||
<FormButton size="sm" text @click="showNewModelDialog = false">
|
||||
Cancel
|
||||
</FormButton>
|
||||
<FormButton size="sm" submit :disabled="isCreatingModel">Create</FormButton>
|
||||
<FormButton size="sm" submit :disabled="isCreatingModel || !newModelName">
|
||||
Create
|
||||
</FormButton>
|
||||
</div>
|
||||
</form>
|
||||
</CommonDialog>
|
||||
@@ -122,10 +141,10 @@ import type {
|
||||
} from '~/lib/common/generated/gql/graphql'
|
||||
import { useModelNameValidationRules } from '~/lib/validation'
|
||||
import {
|
||||
canCreateModelInProjectQuery,
|
||||
createModelMutation,
|
||||
projectModelsQuery
|
||||
} from '~/lib/graphql/mutationsAndQueries'
|
||||
import { useForm } from 'vee-validate'
|
||||
import type { DUIAccount } from '~/store/accounts'
|
||||
import { useAccountStore } from '~/store/accounts'
|
||||
import { useHostAppStore } from '~/store/hostApp'
|
||||
@@ -152,13 +171,20 @@ const props = withDefaults(
|
||||
|
||||
const accountStore = useAccountStore()
|
||||
|
||||
const account = computed(
|
||||
() =>
|
||||
accountStore.accounts.find(
|
||||
(acc) => acc.accountInfo.id === props.accountId
|
||||
) as DUIAccount
|
||||
)
|
||||
|
||||
const showNewModelDialog = ref(false)
|
||||
const showSelectionHasProblemsDialog = ref(false)
|
||||
|
||||
const searchText = ref<string>()
|
||||
const newModelName = ref<string>()
|
||||
const newModelName = ref<string>(hostAppStore.documentInfo?.name ?? 'unnamed model')
|
||||
|
||||
watch(searchText, () => (newModelName.value = searchText.value))
|
||||
watch(searchText, () => (newModelName.value = searchText.value as string))
|
||||
|
||||
let selectedModel: ModelListModelItemFragment | undefined = undefined
|
||||
const existingModelProblem = ref(false)
|
||||
@@ -187,12 +213,6 @@ const confirmModelSelection = () => {
|
||||
}
|
||||
|
||||
const rules = useModelNameValidationRules()
|
||||
const { handleSubmit } = useForm<{ name: string }>()
|
||||
const onSubmit = handleSubmit(() => {
|
||||
// TODO: Chat with Fabians
|
||||
// This works, but if we use handleSubmit(args) > args.name -> it is undefined in Production on netlify, but works fine on local dev
|
||||
void createNewModel(newModelName.value as string)
|
||||
})
|
||||
|
||||
const handleModelCreated = (result: ModelListModelItemFragment) => {
|
||||
refetch() // Sorts the list with newly created project otherwise it will put the project at the bottom.
|
||||
@@ -200,21 +220,30 @@ const handleModelCreated = (result: ModelListModelItemFragment) => {
|
||||
}
|
||||
|
||||
const isCreatingModel = ref(false)
|
||||
|
||||
const createNewModel = async (name: string) => {
|
||||
if (!canCreateModelResult.value?.project.permissions.canCreateModel.authorized) {
|
||||
hostAppStore.setNotification({
|
||||
type: 1,
|
||||
title: 'Failed to create model',
|
||||
description:
|
||||
canCreateModelResult.value?.project.permissions.canCreateModel.message
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
isCreatingModel.value = true
|
||||
const account = accountStore.accounts.find(
|
||||
(acc) => acc.accountInfo.id === props.accountId
|
||||
) as DUIAccount
|
||||
|
||||
void trackEvent('DUI3 Action', { name: 'Model Create' }, account.accountInfo.id)
|
||||
void trackEvent('DUI3 Action', { name: 'Model Create' }, account.value.accountInfo.id)
|
||||
|
||||
const { mutate } = provideApolloClient(account.client)(() =>
|
||||
const { mutate } = provideApolloClient(account.value.client)(() =>
|
||||
useMutation(createModelMutation)
|
||||
)
|
||||
const res = await mutate({ input: { projectId: props.project.id, name } })
|
||||
if (res?.data?.modelMutations.create) {
|
||||
refetch() // Sorts the list with newly created model otherwise it will put the model at the bottom.
|
||||
emit('next', res?.data?.modelMutations.create)
|
||||
// emit('next', res?.data?.modelMutations.create)
|
||||
handleModelCreated(res?.data?.modelMutations.create)
|
||||
} else {
|
||||
let errorMessage = 'Undefined error'
|
||||
if (res?.errors && res?.errors.length !== 0) {
|
||||
@@ -230,6 +259,15 @@ const createNewModel = async (name: string) => {
|
||||
isCreatingModel.value = false
|
||||
}
|
||||
|
||||
const { result: canCreateModelResult } = useQuery(
|
||||
canCreateModelInProjectQuery,
|
||||
() => ({ projectId: props.project.id }),
|
||||
() => ({
|
||||
clientId: props.accountId,
|
||||
fetchPolicy: 'network-only'
|
||||
})
|
||||
)
|
||||
|
||||
const {
|
||||
result: projectModelsResult,
|
||||
loading,
|
||||
@@ -247,6 +285,7 @@ const {
|
||||
() => ({ clientId: props.accountId, debounce: 500, fetchPolicy: 'cache-and-network' })
|
||||
)
|
||||
|
||||
const token = computed(() => account.value.accountInfo.token)
|
||||
const models = computed(() => projectModelsResult.value?.project.models.items)
|
||||
const totalCount = computed(() => projectModelsResult.value?.project.models.totalCount)
|
||||
const hasReachedEnd = ref(false)
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
class="px-3 py-1 rounded-md shadow transition overflow-hidden bg-foundation border-foundation-2 hover:shadow-md border-1 group"
|
||||
>
|
||||
<div class="flex flex-col sm:flex-row sm:gap-2 text-foreground">
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="text-body-xs">
|
||||
<h1
|
||||
class="mb-1 text-sm font-semibold w-full inline-block py-1 bg-clip-text"
|
||||
>
|
||||
Move your projects to a workspace
|
||||
</h1>
|
||||
<p class="mb-2">
|
||||
<span class="text-xs">
|
||||
Personal projects are being phased out. Move your projects to a
|
||||
workspace to create new projects and models, invite new project
|
||||
collaborators, and view comments and versions older than 7 days. By
|
||||
January 1st 2026, all projects will be archived if not moved into a
|
||||
workspace.
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<FormButton
|
||||
color="primary"
|
||||
size="sm"
|
||||
class="mb-2"
|
||||
@click="
|
||||
$openUrl(
|
||||
`${accountStore.activeAccount.accountInfo.serverInfo.url}/projects`
|
||||
)
|
||||
"
|
||||
>
|
||||
Move projects
|
||||
</FormButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useAccountStore } from '~/store/accounts'
|
||||
|
||||
const accountStore = useAccountStore()
|
||||
const { $openUrl } = useNuxtApp()
|
||||
</script>
|
||||
@@ -1,16 +1,20 @@
|
||||
<template>
|
||||
<div class="space-y-2">
|
||||
<div class="space-y-2 relative">
|
||||
<div
|
||||
v-if="workspacesEnabled && workspaces"
|
||||
class="flex items-center space-x-2 bg-foundation -mx-3 -mt-2 px-3 py-2 shadow-sm border-b"
|
||||
>
|
||||
<div v-if="workspacesEnabled && workspaces" class="flex items-center space-x-2">
|
||||
<div class="flex-grow min-w-0">
|
||||
<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>
|
||||
@@ -33,33 +37,35 @@
|
||||
<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>
|
||||
</div>
|
||||
<button
|
||||
v-if="selectedWorkspace.slug"
|
||||
v-tippy="'Open workspace in browser'"
|
||||
class="transition mr-1 opacity-70 hover:opacity-100"
|
||||
@click.stop="
|
||||
$openUrl(
|
||||
`${accountStore.activeAccount.accountInfo.serverInfo.url}/workspaces/${selectedWorkspace.slug}`
|
||||
)
|
||||
"
|
||||
>
|
||||
<ArrowTopRightOnSquareIcon class="w-3.5" />
|
||||
</button>
|
||||
<ChevronDownIcon class="h-3 w-3 shrink-0" />
|
||||
</button>
|
||||
</template>
|
||||
</WorkspaceMenu>
|
||||
</div>
|
||||
<div class="px-0.5 shrink-0">
|
||||
<div class="shrink-0 pt-1 px-1">
|
||||
<AccountsMenu
|
||||
:current-selected-account-id="accountId"
|
||||
@select="(e) => selectAccount(e)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- we can message to user about the non-workspace scenario -->
|
||||
<!-- <div v-if="workspaces && workspaces.length === 0">
|
||||
<CommonAlert size="xs" :color="'warning'">
|
||||
<template #description>
|
||||
You are listing legacy personal projects which will be deprecated end of
|
||||
2025. We suggest you to move your personal projects into a workspace before
|
||||
then.
|
||||
</template>
|
||||
</CommonAlert>
|
||||
</div> -->
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center space-x-1 justify-between">
|
||||
<FormTextInput
|
||||
@@ -72,36 +78,68 @@
|
||||
color="foundation"
|
||||
/>
|
||||
<div class="flex justify-between items-center space-x-2">
|
||||
<ProjectCreateWorkspaceDialog
|
||||
v-if="selectedWorkspace && selectedWorkspace.id !== 'personalProject'"
|
||||
:workspace="selectedWorkspace"
|
||||
@project:created="(result : ProjectListProjectItemFragment) => handleProjectCreated(result)"
|
||||
>
|
||||
<template #activator="{ toggle }">
|
||||
<button
|
||||
v-tippy="'New project in workspace'"
|
||||
class="p-1.5 bg-foundation hover:bg-primary-muted rounded text-foreground border"
|
||||
@click="toggle()"
|
||||
>
|
||||
<PlusIcon class="w-4" />
|
||||
</button>
|
||||
</template>
|
||||
</ProjectCreateWorkspaceDialog>
|
||||
<!-- TODO: once we deprecate personal projects, else block is bye bye -->
|
||||
<ProjectCreatePersonalDialog
|
||||
<div v-if="canCreateProject" v-tippy="'Create new project'">
|
||||
<FormButton
|
||||
color="outline"
|
||||
:disabled="!canCreateProject"
|
||||
:class="`p-1.5 bg-foundation hover:bg-primary-muted rounded text-foreground border`"
|
||||
@click="showProjectCreateDialog = true"
|
||||
>
|
||||
<PlusIcon class="w-4 -mx-2" />
|
||||
</FormButton>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
@project:created="(result : ProjectListProjectItemFragment) => handleProjectCreated(result)"
|
||||
v-tippy="
|
||||
canCreateProject
|
||||
? 'Create new project'
|
||||
: canCreateProjectPermissionCheck?.message
|
||||
"
|
||||
>
|
||||
<template #activator="{ toggle }">
|
||||
<button
|
||||
v-tippy="'New personal project'"
|
||||
class="p-1.5 bg-foundation hover:bg-primary-muted rounded text-foreground border"
|
||||
@click="toggle()"
|
||||
>
|
||||
<PlusIcon class="w-4" />
|
||||
</button>
|
||||
</template>
|
||||
</ProjectCreatePersonalDialog>
|
||||
<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`"
|
||||
fullscreen="none"
|
||||
>
|
||||
<form @submit="createProject(newProjectName as string)">
|
||||
<div class="text-body-2xs mb-2 ml-1">Project name</div>
|
||||
<FormTextInput
|
||||
v-model="newProjectName"
|
||||
class="text-xs"
|
||||
placeholder="A Beautiful Home, A Small Bridge..."
|
||||
autocomplete="off"
|
||||
name="name"
|
||||
label="Project name"
|
||||
color="foundation"
|
||||
:show-clear="!!newProjectName"
|
||||
:rules="[
|
||||
ValidationHelpers.isRequired,
|
||||
ValidationHelpers.isStringOfLength({ minLength: 3 })
|
||||
]"
|
||||
full-width
|
||||
/>
|
||||
<div class="mt-4 flex justify-end items-center space-x-2 w-full">
|
||||
<FormButton size="sm" text @click="showProjectCreateDialog = false">
|
||||
Cancel
|
||||
</FormButton>
|
||||
<FormButton
|
||||
size="sm"
|
||||
submit
|
||||
:disabled="isCreatingProject || !newProjectName"
|
||||
>
|
||||
Create
|
||||
</FormButton>
|
||||
</div>
|
||||
</form>
|
||||
</CommonDialog>
|
||||
<div v-if="!workspacesEnabled || !workspaces" class="mt-1">
|
||||
<AccountsMenu
|
||||
:current-selected-account-id="accountId"
|
||||
@@ -110,9 +148,12 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<CommonLoadingBar v-if="loading" loading />
|
||||
|
||||
<WizardPersonalProjectsWarning v-if="isPersonalProjectsAsWorkspace" />
|
||||
|
||||
<CommonLoadingBar v-if="loading || isCreatingProject" loading />
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-2 relative z-0">
|
||||
<div v-if="!urlParseError" class="grid grid-cols-1 gap-2 relative z-0">
|
||||
<WizardListProjectCard
|
||||
v-for="project in projects"
|
||||
:key="project.id"
|
||||
@@ -120,7 +161,27 @@
|
||||
:is-sender="isSender"
|
||||
@click="handleProjectCardClick(project)"
|
||||
/>
|
||||
<p v-if="projects?.length === 0 && !!searchText" class="text-sm">
|
||||
No projects found
|
||||
</p>
|
||||
<FormButton
|
||||
v-if="
|
||||
projects?.length === 0 &&
|
||||
!!searchText &&
|
||||
canCreateProjectPermissionCheck?.authorized
|
||||
"
|
||||
full-width
|
||||
color="outline"
|
||||
:disabled="isCreatingProject"
|
||||
class="block truncate overflow-hidden"
|
||||
@click="createProject(searchText)"
|
||||
>
|
||||
Create "{{
|
||||
searchText.length > 10 ? searchText.substring(0, 10) + '...' : searchText
|
||||
}}"
|
||||
</FormButton>
|
||||
<FormButton
|
||||
v-else
|
||||
full-width
|
||||
:disabled="hasReachedEnd"
|
||||
color="outline"
|
||||
@@ -135,24 +196,30 @@
|
||||
<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 {
|
||||
activeWorkspaceQuery,
|
||||
canCreatePersonalProjectQuery,
|
||||
createProjectInWorkspaceMutation,
|
||||
createProjectMutation,
|
||||
projectsListQuery,
|
||||
serverInfoQuery,
|
||||
setActiveWorkspaceMutation,
|
||||
workspacesListQuery
|
||||
} from '~/lib/graphql/mutationsAndQueries'
|
||||
import { useMutation, provideApolloClient, useQuery } from '@vue/apollo-composable'
|
||||
import { ValidationHelpers } from '@speckle/ui-components'
|
||||
import type {
|
||||
ProjectListProjectItemFragment,
|
||||
WorkspaceListWorkspaceItemFragment
|
||||
} from '~/lib/common/generated/gql/graphql'
|
||||
import { useMixpanel } from '~/lib/core/composables/mixpanel'
|
||||
import { useConfigStore } from '~/store/config'
|
||||
import { useHostAppStore } from '~/store/hostApp'
|
||||
|
||||
const hostAppStore = useHostAppStore()
|
||||
const { trackEvent } = useMixpanel()
|
||||
const { $openUrl } = useNuxtApp()
|
||||
|
||||
@@ -174,6 +241,7 @@ const props = withDefaults(
|
||||
* For the send wizard - not allowing selecting projects we can't write to.
|
||||
*/
|
||||
disableNoWriteAccessProjects?: boolean
|
||||
urlParseError?: string
|
||||
}>(),
|
||||
{
|
||||
showNewProject: true,
|
||||
@@ -255,15 +323,29 @@ const activeWorkspace = computed(() => {
|
||||
return previouslySelectedWorkspace
|
||||
}
|
||||
}
|
||||
// fallback to activeWorkspace query result
|
||||
return activeWorkspaceResult.value?.activeUser
|
||||
|
||||
const activeLimitedWorkspace = activeWorkspaceResult.value?.activeUser
|
||||
?.activeWorkspace as WorkspaceListWorkspaceItemFragment
|
||||
|
||||
// fallback to activeWorkspace query result
|
||||
if (activeLimitedWorkspace) {
|
||||
const activeWorkspace = workspaces.value?.find(
|
||||
(w) => w.id === activeLimitedWorkspace.id
|
||||
)
|
||||
if (activeWorkspace) return activeWorkspace
|
||||
}
|
||||
|
||||
return workspaces.value?.[0] // fallback to first workspace if none is active
|
||||
})
|
||||
|
||||
const selectedWorkspace = ref<WorkspaceListWorkspaceItemFragment | undefined>(
|
||||
activeWorkspace.value
|
||||
)
|
||||
|
||||
const isPersonalProjectsAsWorkspace = computed(
|
||||
() => selectedWorkspace.value?.id === 'personalProject'
|
||||
)
|
||||
|
||||
watch(
|
||||
workspaces,
|
||||
(newItems) => {
|
||||
@@ -332,12 +414,11 @@ const {
|
||||
limit: 10, // stupid hack, increased it since we do manual filter to be able to see more project, see below TODO note, once we have `personalOnly` filter, decrease back to 10
|
||||
filter: {
|
||||
search: (searchText.value || '').trim() || null,
|
||||
workspaceId:
|
||||
selectedWorkspace.value?.id === 'personalProject'
|
||||
? null
|
||||
: selectedWorkspace.value?.id,
|
||||
workspaceId: isPersonalProjectsAsWorkspace.value
|
||||
? null
|
||||
: selectedWorkspace.value?.id,
|
||||
includeImplicitAccess: true,
|
||||
personalOnly: selectedWorkspace.value?.id === 'personalProject'
|
||||
personalOnly: isPersonalProjectsAsWorkspace.value
|
||||
}
|
||||
}),
|
||||
() => ({
|
||||
@@ -349,7 +430,7 @@ const {
|
||||
)
|
||||
|
||||
const projects = computed(() =>
|
||||
selectedWorkspace.value?.id === 'personalProject' // TODO: we need to replace this logic with `personalOnly` filter when it is implemented into app.speckle.systems
|
||||
isPersonalProjectsAsWorkspace.value // TODO: we need to replace this logic with `personalOnly` filter when it is implemented into app.speckle.systems
|
||||
? projectsResult.value?.activeUser?.projects.items.filter(
|
||||
(i) => i.workspaceId === null
|
||||
)
|
||||
@@ -373,6 +454,152 @@ watch(projectsResult, (newVal) => {
|
||||
}
|
||||
})
|
||||
|
||||
const { result: canCreatePersonalProjectResult } = useQuery(
|
||||
canCreatePersonalProjectQuery,
|
||||
{},
|
||||
() => ({
|
||||
clientId: accountId.value
|
||||
})
|
||||
)
|
||||
|
||||
const canCreateProject = computed(() => {
|
||||
// If a workspace is selected, return that permission check
|
||||
if (selectedWorkspace.value && selectedWorkspace.value.permissions) {
|
||||
return selectedWorkspace.value.permissions.canCreateProject.authorized //as boolean
|
||||
}
|
||||
// Otherwise, check for personal projects
|
||||
if (canCreatePersonalProjectResult) {
|
||||
return canCreatePersonalProjectResult.value?.activeUser?.permissions
|
||||
.canCreatePersonalProject.authorized
|
||||
}
|
||||
// To be always safe, default to false
|
||||
return false
|
||||
})
|
||||
|
||||
const canCreateProjectPermissionCheck = computed(() => {
|
||||
if (selectedWorkspace.value && selectedWorkspace.value.permissions) {
|
||||
return selectedWorkspace.value.permissions.canCreateProject
|
||||
}
|
||||
if (canCreatePersonalProjectResult) {
|
||||
return canCreatePersonalProjectResult.value?.activeUser?.permissions
|
||||
.canCreatePersonalProject
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
const isCreatingProject = ref(false)
|
||||
const showProjectCreateDialog = ref(false)
|
||||
|
||||
const createProject = (name: string) => {
|
||||
if (
|
||||
canCreateProjectPermissionCheck.value &&
|
||||
!canCreateProjectPermissionCheck.value.authorized
|
||||
) {
|
||||
hostAppStore.setNotification({
|
||||
type: 1,
|
||||
title: 'Failed to create project',
|
||||
description: canCreateProjectPermissionCheck.value.message as string
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (isPersonalProjectsAsWorkspace.value || !selectedWorkspace.value) {
|
||||
return void createNewPersonalProject(name)
|
||||
} else {
|
||||
return void createNewWorkspaceProject(name)
|
||||
}
|
||||
}
|
||||
|
||||
const account = computed(() => {
|
||||
return accountStore.accounts.find(
|
||||
(acc) => acc.accountInfo.id === accountId.value
|
||||
) as DUIAccount
|
||||
})
|
||||
|
||||
const createNewWorkspaceProject = async (name: string) => {
|
||||
isCreatingProject.value = true
|
||||
void trackEvent(
|
||||
'DUI3 Action',
|
||||
{ name: 'Project Create', workspace: true },
|
||||
accountId.value
|
||||
)
|
||||
const { mutate, onError } = provideApolloClient(account.value.client)(() =>
|
||||
useMutation(createProjectInWorkspaceMutation)
|
||||
)
|
||||
|
||||
onError((err) => {
|
||||
hostAppStore.setNotification({
|
||||
type: 1,
|
||||
title: 'Failed to create project',
|
||||
description: err.cause?.message ?? err.message ?? 'Unknown error'
|
||||
})
|
||||
})
|
||||
|
||||
const res = await mutate({
|
||||
input: { name, workspaceId: selectedWorkspace.value?.id as string }
|
||||
})
|
||||
|
||||
if (res?.data?.workspaceMutations.projects.create) {
|
||||
handleProjectCreated(res?.data?.workspaceMutations.projects.create)
|
||||
}
|
||||
isCreatingProject.value = false
|
||||
}
|
||||
|
||||
const createNewPersonalProject = async (name: string) => {
|
||||
isCreatingProject.value = true
|
||||
|
||||
void trackEvent(
|
||||
'DUI3 Action',
|
||||
{ name: 'Project Create', workspace: false },
|
||||
account.value.accountInfo.id
|
||||
)
|
||||
|
||||
const { mutate, onError } = provideApolloClient(account.value.client)(() =>
|
||||
useMutation(createProjectMutation)
|
||||
)
|
||||
|
||||
onError((err) => {
|
||||
hostAppStore.setNotification({
|
||||
type: 1,
|
||||
title: 'Failed to create project',
|
||||
description: err.cause?.message ?? err.message ?? 'Unknown error'
|
||||
})
|
||||
})
|
||||
|
||||
const res = await mutate({ input: { name } })
|
||||
|
||||
if (res?.data?.projectMutations.create) {
|
||||
return handleProjectCreated(res?.data?.projectMutations.create)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
// catch SSO session expired / any other unhandled permission flags
|
||||
// redirecting to the workspace root will trigger the standard web authentication flow.
|
||||
if (selectedWorkspace.value?.slug) {
|
||||
$openUrl(
|
||||
`${account.value.accountInfo.serverInfo.url}/workspaces/${selectedWorkspace.value?.slug}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const loadMore = () => {
|
||||
fetchMore({
|
||||
variables: { cursor: projectsResult.value?.activeUser?.projects.cursor },
|
||||
|
||||
@@ -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,90 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="text-body-2xs mb-2 ml-1">Project workspace</div>
|
||||
<FormSelectBase
|
||||
key="name"
|
||||
v-model="selectedWorkspace"
|
||||
clearable
|
||||
label="Workspaces"
|
||||
placeholder="Nothing selected"
|
||||
name="Workspaces"
|
||||
:items="workspaces"
|
||||
:disabled-item-predicate="userCantCreateWorkspace"
|
||||
mount-menu-on-body
|
||||
>
|
||||
<template #something-selected="{ value }">
|
||||
<span>{{ isArray(value) ? value[0].name : value.name }}</span>
|
||||
</template>
|
||||
<template #option="{ item }">
|
||||
<div
|
||||
v-tippy="{
|
||||
content: item.readOnly
|
||||
? 'This workspace is read-only.'
|
||||
: item.role === 'workspace:guest'
|
||||
? 'You do not have write access on this workspace.'
|
||||
: undefined,
|
||||
disabled: !(item.readOnly || item.role === 'workspace:guest')
|
||||
}"
|
||||
class="flex items-center"
|
||||
>
|
||||
<span class="truncate">{{ item.name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</FormSelectBase>
|
||||
<div
|
||||
v-if="selectedWorkspace"
|
||||
class="text-body-sm caption rounded p-2 bg-blue-500/10 my-2"
|
||||
>
|
||||
Project will be created in the selected workspace.
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useQuery } from '@vue/apollo-composable'
|
||||
import { workspacesListQuery } from '~/lib/graphql/mutationsAndQueries'
|
||||
import type { WorkspaceListWorkspaceItemFragment } from '~/lib/common/generated/gql/graphql'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useAccountStore } from '~/store/accounts'
|
||||
import { isArray } from 'lodash-es'
|
||||
|
||||
const emit = defineEmits<{
|
||||
(
|
||||
e: 'update:selectedWorkspace',
|
||||
value: WorkspaceListWorkspaceItemFragment | undefined
|
||||
): void
|
||||
}>()
|
||||
|
||||
const accountStore = useAccountStore()
|
||||
const { activeAccount } = storeToRefs(accountStore)
|
||||
const accountId = computed(() => activeAccount.value.accountInfo.id)
|
||||
|
||||
const searchText = ref<string>()
|
||||
|
||||
const { result: workspacesResult } = useQuery(
|
||||
workspacesListQuery,
|
||||
() => ({
|
||||
limit: 5,
|
||||
filter: {
|
||||
search: (searchText.value || '').trim() || null
|
||||
}
|
||||
}),
|
||||
() => ({
|
||||
clientId: accountId.value,
|
||||
debounce: 500,
|
||||
fetchPolicy: 'network-only'
|
||||
})
|
||||
)
|
||||
|
||||
const workspaces = computed(() => workspacesResult.value?.activeUser?.workspaces.items)
|
||||
const selectedWorkspace = ref<WorkspaceListWorkspaceItemFragment>()
|
||||
|
||||
watch(selectedWorkspace, (newVal) => {
|
||||
emit('update:selectedWorkspace', newVal)
|
||||
})
|
||||
|
||||
// Utility function to check if the user cannot create a workspace
|
||||
const userCantCreateWorkspace = (item: WorkspaceListWorkspaceItemFragment) =>
|
||||
(!!item?.role && item.role === 'workspace:guest') || !!item.readOnly
|
||||
</script>
|
||||
@@ -4,9 +4,15 @@
|
||||
>
|
||||
<div class="flex items-center space-x-2 max-[275px]:space-x-0">
|
||||
<div class="max-[275px]:hidden">
|
||||
<div v-if="model.previewUrl" class="h-12 w-12">
|
||||
<div
|
||||
v-if="model.versions.totalCount === 0"
|
||||
class="h-12 w-12 bg-blue-500/10 rounded flex items-center justify-center"
|
||||
>
|
||||
<CubeTransparentIcon class="w-5 h-5 text-foreground-2" />
|
||||
</div>
|
||||
<div v-else-if="previewUrl" class="h-12 w-12">
|
||||
<img
|
||||
:src="model.previewUrl"
|
||||
:src="previewUrl"
|
||||
alt="preview image for model"
|
||||
class="h-12 w-12 object-cover"
|
||||
/>
|
||||
@@ -15,7 +21,7 @@
|
||||
v-else
|
||||
class="h-12 w-12 bg-blue-500/10 rounded flex items-center justify-center"
|
||||
>
|
||||
<CubeTransparentIcon class="w-5 h-5 text-foreground-2" />
|
||||
<CommonLoadingIcon />
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-w-0 w-full">
|
||||
@@ -52,9 +58,16 @@ import { ClockIcon } from '@heroicons/vue/24/outline'
|
||||
import type { SourceAppName } from '@speckle/shared'
|
||||
import { SourceApps } from '@speckle/shared'
|
||||
import type { ModelListModelItemFragment } from '~/lib/common/generated/gql/graphql'
|
||||
import { computedAsync } from '@vueuse/core'
|
||||
import { usePreviewUrl } from '~/lib/core/composables/previewUrl'
|
||||
|
||||
const props = defineProps<{
|
||||
model: ModelListModelItemFragment
|
||||
/**
|
||||
* Token to retrieve preview url
|
||||
* @note by convention we pass around `accountId` but it doesn't make sense to get token for every model card. more efficient with this way.
|
||||
*/
|
||||
token: string
|
||||
}>()
|
||||
|
||||
const folderPath = computed(() => {
|
||||
@@ -68,6 +81,11 @@ const updatedAgo = computed(() => {
|
||||
return dayjs(props.model.updatedAt).from(dayjs())
|
||||
})
|
||||
|
||||
const previewUrl = computedAsync(async () => {
|
||||
if (props.model.previewUrl === null) return
|
||||
return await usePreviewUrl(props.token, props.model.previewUrl)
|
||||
})
|
||||
|
||||
const sourceApp = computed(() => {
|
||||
if (props.model.versions.items.length === 0) return
|
||||
const version = props.model.versions.items[0]
|
||||
|
||||
@@ -24,7 +24,15 @@
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="flex items-center justify-center w-full h-24">
|
||||
<img :src="version.previewUrl" alt="version preview" />
|
||||
<div v-if="previewUrl">
|
||||
<img :src="previewUrl" alt="preview image for version" />
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="h-12 w-12 bg-blue-500/10 rounded flex items-center justify-center"
|
||||
>
|
||||
<CommonLoadingIcon />
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-1.5 border-t dark:border-gray-700">
|
||||
<div class="flex space-x-2 items-center min-w-0">
|
||||
@@ -100,9 +108,14 @@ import dayjs from 'dayjs'
|
||||
import type { SourceAppName } from '@speckle/shared'
|
||||
import { SourceApps } from '@speckle/shared'
|
||||
import type { VersionListItemFragment } from '~/lib/common/generated/gql/graphql'
|
||||
import { useAccountStore, type DUIAccount } from '~/store/accounts'
|
||||
import { computedAsync } from '@vueuse/core'
|
||||
import { usePreviewUrl } from '~/lib/core/composables/previewUrl'
|
||||
// import { objectQuery } from '~/lib/graphql/mutationsAndQueries'
|
||||
// import { useQuery } from '@vue/apollo-composable'
|
||||
|
||||
const accountStore = useAccountStore()
|
||||
|
||||
const props = defineProps<{
|
||||
version: VersionListItemFragment
|
||||
index: number
|
||||
@@ -120,6 +133,18 @@ const createdAgo = computed(() => {
|
||||
|
||||
const isLimited = computed(() => props.version.referencedObject === null)
|
||||
|
||||
const token = computed(() => {
|
||||
const account = accountStore.accounts.find(
|
||||
(acc) => acc.accountInfo.id === props.accountId
|
||||
) as DUIAccount
|
||||
return account.accountInfo.token
|
||||
})
|
||||
|
||||
const previewUrl = computedAsync(async () => {
|
||||
if (props.version.previewUrl === null) return
|
||||
return await usePreviewUrl(token.value, props.version.previewUrl)
|
||||
})
|
||||
|
||||
// NOTE!!!: This logic somehow caused regression on versionList fetchMore, but we do not know exactly why yet.
|
||||
// const { result: objectQueryResult } = useQuery(
|
||||
// objectQuery,
|
||||
|
||||
@@ -14,18 +14,34 @@
|
||||
<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">
|
||||
<span>{{ workspace.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
v-if="workspace.slug"
|
||||
v-tippy="'Open workspace in browser'"
|
||||
class="hidden transition mr-1 opacity-70 group-hover:block"
|
||||
@click.stop="
|
||||
$openUrl(
|
||||
`${accountStore.activeAccount.accountInfo.serverInfo.url}/workspaces/${workspace.slug}`
|
||||
)
|
||||
"
|
||||
>
|
||||
<ArrowTopRightOnSquareIcon class="w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import type { WorkspaceListWorkspaceItemFragment } from '~/lib/common/generated/gql/graphql'
|
||||
import { ArrowTopRightOnSquareIcon } from '@heroicons/vue/24/outline'
|
||||
import { useAccountStore } from '~/store/accounts'
|
||||
|
||||
const accountStore = useAccountStore()
|
||||
|
||||
defineProps<{
|
||||
workspace: WorkspaceListWorkspaceItemFragment
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
@select="
|
||||
$emit('workspace:selected', workspace), (showWorkspaceSelectorDialog = false)
|
||||
"
|
||||
></WorkspaceListItem>
|
||||
/>
|
||||
</CommonDialog>
|
||||
</div>
|
||||
</template>
|
||||
@@ -33,13 +33,7 @@ defineEmits<{
|
||||
(e: 'workspace:selected', result: WorkspaceListWorkspaceItemFragment): void
|
||||
}>()
|
||||
|
||||
const workspacesWithPersonalProjects = computed(() => [
|
||||
...props.workspaces,
|
||||
{
|
||||
id: 'personalProject',
|
||||
name: 'Personal Projects'
|
||||
} as WorkspaceListWorkspaceItemFragment
|
||||
])
|
||||
const workspacesWithPersonalProjects = computed(() => [...props.workspaces])
|
||||
|
||||
const toggleDialog = () => {
|
||||
showWorkspaceSelectorDialog.value = !showWorkspaceSelectorDialog.value
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
HOST=0.0.0.0
|
||||
HOST=127.0.0.1
|
||||
PORT=8082
|
||||
|
||||
NUXT_PUBLIC_MIXPANEL_TOKEN_ID=acd87c5a50b56df91a795e999812a3a4
|
||||
+15
-2
@@ -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)
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
const CHALLENGE_KEY = 'speckle_challenge'
|
||||
const CHALLENGE_URL_KEY = 'speckle_url_challenge'
|
||||
const CODE_VERIFIER_KEY = 'speckle_code_verifier'
|
||||
|
||||
function toBase64Url(buffer: Uint8Array): string {
|
||||
const base64 = btoa(String.fromCharCode(...buffer))
|
||||
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a cryptographically random base64url-encoded string.
|
||||
* 32 bytes → 43 characters after base64url encoding (within the RFC 7636 range of 43-128).
|
||||
*/
|
||||
function createCodeVerifier(): string {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(32))
|
||||
return toBase64Url(bytes)
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes SHA-256 hash of a string and returns it as base64url.
|
||||
* This is the PKCE code_challenge derivation from a code_verifier.
|
||||
*/
|
||||
async function computeS256Challenge(codeVerifier: string): Promise<string> {
|
||||
const data = new TextEncoder().encode(codeVerifier)
|
||||
const digest = await crypto.subtle.digest('SHA-256', data)
|
||||
return toBase64Url(new Uint8Array(digest))
|
||||
}
|
||||
|
||||
export interface ChallengeData {
|
||||
/** The raw random string (code_verifier in PKCE terms) */
|
||||
codeVerifier: string
|
||||
/** SHA-256 base64url hash of codeVerifier (code_challenge for S256 method) */
|
||||
codeChallenge: string
|
||||
}
|
||||
|
||||
export function useAuthManager() {
|
||||
/**
|
||||
* Generates a PKCE code_verifier + code_challenge pair and persists to localStorage.
|
||||
* Used by redirect-based sign-in flows (SignInFlow) that need to
|
||||
* recover the values after the browser navigates away and back.
|
||||
*/
|
||||
const generateChallenge = async (url: string): Promise<ChallengeData> => {
|
||||
const codeVerifier = createCodeVerifier()
|
||||
const codeChallenge = await computeS256Challenge(codeVerifier)
|
||||
localStorage.setItem(CHALLENGE_KEY, codeChallenge)
|
||||
localStorage.setItem(CODE_VERIFIER_KEY, codeVerifier)
|
||||
localStorage.setItem(CHALLENGE_URL_KEY, url)
|
||||
return { codeVerifier, codeChallenge }
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a PKCE code_verifier + code_challenge pair without persisting to localStorage.
|
||||
* Used by flows that keep values in memory (ExchangeTokenSignInFlow)
|
||||
* so they don't overwrite the redirect flow's stored data.
|
||||
*/
|
||||
const generateLocalChallenge = async (): Promise<ChallengeData> => {
|
||||
const codeVerifier = createCodeVerifier()
|
||||
const codeChallenge = await computeS256Challenge(codeVerifier)
|
||||
return { codeVerifier, codeChallenge }
|
||||
}
|
||||
|
||||
const getChallenge = (): string | null => {
|
||||
return localStorage.getItem(CHALLENGE_KEY)
|
||||
}
|
||||
|
||||
const getCodeVerifier = (): string | null => {
|
||||
return localStorage.getItem(CODE_VERIFIER_KEY)
|
||||
}
|
||||
|
||||
const getChallengeUrl = (): string | null => {
|
||||
return localStorage.getItem(CHALLENGE_URL_KEY)
|
||||
}
|
||||
|
||||
return {
|
||||
getChallenge,
|
||||
getCodeVerifier,
|
||||
getChallengeUrl,
|
||||
generateChallenge,
|
||||
generateLocalChallenge
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { md5 } from '@speckle/shared'
|
||||
import type { Account } from '~/lib/bindings/definitions/IAccountBinding'
|
||||
|
||||
/**
|
||||
* Checks if the server supports the new /oauth/token endpoint.
|
||||
* The server exposes GET /oauth/token returning 'supported' when available.
|
||||
*/
|
||||
export async function supportsOAuthToken(serverUrl: string): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(new URL('/oauth/token', serverUrl), { method: 'GET' })
|
||||
return res.ok
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function useTokenExchange() {
|
||||
const { $accountBinding } = useNuxtApp()
|
||||
|
||||
const exchangeAccessCode = async (
|
||||
rawServerUrl: string,
|
||||
accessCode: string,
|
||||
challenge: string,
|
||||
codeVerifier?: string
|
||||
): Promise<void> => {
|
||||
// Normalize to origin (strips trailing slash, path, etc.)
|
||||
// so account IDs stay consistent with connectors
|
||||
const serverUrl = new URL(rawServerUrl).origin
|
||||
const tokenHeaders = { 'Content-Type': 'application/json' }
|
||||
let tokenResponse: Response
|
||||
|
||||
// If we have a codeVerifier, try the new PKCE-based /oauth/token endpoint first
|
||||
if (codeVerifier && (await supportsOAuthToken(serverUrl))) {
|
||||
tokenResponse = await fetch(new URL('/oauth/token', serverUrl), {
|
||||
method: 'POST',
|
||||
headers: tokenHeaders,
|
||||
body: JSON.stringify({
|
||||
appId: 'sdui',
|
||||
appSecret: 'sdui',
|
||||
accessCode,
|
||||
codeVerifier
|
||||
})
|
||||
})
|
||||
} else {
|
||||
// Fall back to legacy /auth/token with plain challenge
|
||||
tokenResponse = await fetch(new URL('/auth/token', serverUrl), {
|
||||
method: 'POST',
|
||||
headers: tokenHeaders,
|
||||
body: JSON.stringify({
|
||||
appId: 'sdui',
|
||||
appSecret: 'sdui',
|
||||
accessCode,
|
||||
challenge
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
const errorText = await tokenResponse.text()
|
||||
throw new Error(
|
||||
`Token exchange failed with status ${tokenResponse.status}: ${errorText}`
|
||||
)
|
||||
}
|
||||
|
||||
const { token, refreshToken } = (await tokenResponse.json()) as {
|
||||
token: string
|
||||
refreshToken: string
|
||||
}
|
||||
|
||||
// Query user and server info
|
||||
const graphqlQuery = {
|
||||
query:
|
||||
'query { activeUser { id name email company avatar } serverInfo { name company adminContact description version } }'
|
||||
}
|
||||
|
||||
const userAndServerInfoResponse = await fetch(new URL('/graphql', serverUrl), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify(graphqlQuery)
|
||||
})
|
||||
|
||||
if (!userAndServerInfoResponse.ok) {
|
||||
throw new Error(
|
||||
`Failed to fetch user info with status ${userAndServerInfoResponse.status}`
|
||||
)
|
||||
}
|
||||
|
||||
// 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 + serverUrl
|
||||
).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: serverUrl, ...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)
|
||||
}
|
||||
|
||||
return { exchangeAccessCode }
|
||||
}
|
||||
@@ -7,7 +7,9 @@ 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>
|
||||
authenticateAccount: (serverUrl: string) => Promise<Account>
|
||||
}
|
||||
|
||||
// An almost 1-1 mapping of what we need from the Core accounts class.
|
||||
@@ -15,6 +17,7 @@ export type Account = {
|
||||
id: string
|
||||
isDefault: boolean
|
||||
token: string
|
||||
refreshToken: string
|
||||
serverInfo: {
|
||||
name: string
|
||||
url: string
|
||||
@@ -31,3 +34,65 @@ 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 authenticateAccount(serverUrl: string) {
|
||||
const config = useRuntimeConfig()
|
||||
return (await {
|
||||
id: 'whatever',
|
||||
isDefault: true,
|
||||
token: config.public.speckleToken,
|
||||
serverInfo: {
|
||||
name: 'test',
|
||||
url: serverUrl,
|
||||
frontend2: true
|
||||
},
|
||||
userInfo: {
|
||||
id: 'whatever',
|
||||
avatar: 'whatever',
|
||||
email: ''
|
||||
}
|
||||
}) as 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ export interface IBasicConnectorBinding
|
||||
highlightObjects: (objectIds: string[]) => Promise<void>
|
||||
removeModel: (model: IModelCard) => Promise<void>
|
||||
removeModels: (models: IModelCard[]) => Promise<void>
|
||||
updateParameters: (payload: string) => Promise<void>
|
||||
}
|
||||
|
||||
export interface IBasicConnectorBindingHostEvents
|
||||
@@ -57,3 +58,69 @@ 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 updateParameters(payload: string) {
|
||||
await console.log('Mock: updateParameters called with payload:', payload)
|
||||
}
|
||||
|
||||
public async showDevTools() {
|
||||
await console.log('No way dude')
|
||||
}
|
||||
|
||||
public async openUrl(url: string) {
|
||||
await window.open(url)
|
||||
}
|
||||
|
||||
public on() {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface IParametersBinding {
|
||||
update: (payload: string) => Promise<void>
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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> {
|
||||
|
||||
+136
-16
@@ -11,27 +11,84 @@ import type { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-
|
||||
* 3. It does not support dead code elimination, so it will add unused operations.
|
||||
*
|
||||
* Therefore it is highly recommended to use the babel or swc plugin for production.
|
||||
* Learn more about it here: https://the-guild.dev/graphql/codegen/plugins/presets/preset-client#reducing-bundle-size
|
||||
*/
|
||||
const documents = {
|
||||
"\n query AcccountTestQuery {\n serverInfo {\n version\n name\n company\n }\n }\n ": types.AcccountTestQueryDocument,
|
||||
"\n mutation SetActiveWorkspaceMutation($slug: String) {\n activeUserMutations {\n setActiveWorkspace(slug: $slug)\n }\n }\n": types.SetActiveWorkspaceMutationDocument,
|
||||
type Documents = {
|
||||
"\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,
|
||||
"\n mutation CreateModel($input: CreateModelInput!) {\n modelMutations {\n create(input: $input) {\n ...ModelListModelItem\n }\n }\n }\n": typeof types.CreateModelDocument,
|
||||
"\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 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,
|
||||
"\n fragment AutomationRunItem on AutomateRun {\n id\n status\n automation {\n id\n name\n }\n functionRuns {\n ...AutomateFunctionRunItem\n }\n }\n": typeof types.AutomationRunItemFragmentDoc,
|
||||
"\n query AutomationStatus($projectId: String!, $modelId: String!) {\n project(id: $projectId) {\n model(id: $modelId) {\n automationsStatus {\n id\n status\n automationRuns {\n ...AutomationRunItem\n }\n }\n }\n }\n }\n": typeof types.AutomationStatusDocument,
|
||||
"\n query WorkspaceListQuery(\n $limit: Int!\n $filter: UserWorkspacesFilter\n $cursor: String\n ) {\n activeUser {\n id\n workspaces(limit: $limit, filter: $filter, cursor: $cursor) {\n totalCount\n cursor\n items {\n ...WorkspaceListWorkspaceItem\n }\n }\n }\n }\n": typeof types.WorkspaceListQueryDocument,
|
||||
"\n query ActiveUser {\n activeUser {\n role\n id\n name\n }\n }\n": typeof types.ActiveUserDocument,
|
||||
"\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 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,
|
||||
"\n query ProjectModels(\n $projectId: String!\n $cursor: String\n $limit: Int!\n $filter: ProjectModelsFilter\n ) {\n project(id: $projectId) {\n id\n models(cursor: $cursor, limit: $limit, filter: $filter) {\n totalCount\n cursor\n items {\n ...ModelListModelItem\n }\n }\n }\n }\n": typeof types.ProjectModelsDocument,
|
||||
"\n fragment VersionListItem on Version {\n id\n referencedObject\n message\n sourceApplication\n authorUser {\n avatar\n id\n name\n }\n createdAt\n previewUrl\n }\n": typeof types.VersionListItemFragmentDoc,
|
||||
"\n query ModelVersions(\n $modelId: String!\n $projectId: String!\n $limit: Int!\n $cursor: String\n $filter: ModelVersionsFilter\n ) {\n project(id: $projectId) {\n id\n model(id: $modelId) {\n id\n versions(limit: $limit, cursor: $cursor, filter: $filter) {\n totalCount\n cursor\n items {\n ...VersionListItem\n }\n }\n }\n }\n }\n": typeof types.ModelVersionsDocument,
|
||||
"\n query ObjectQuery($projectId: String!, $objectId: String!) {\n project(id: $projectId) {\n object(id: $objectId) {\n id\n data\n }\n }\n }\n": typeof types.ObjectQueryDocument,
|
||||
"\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($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,
|
||||
"\n subscription OnProjectVersionsUpdate($projectId: String!) {\n projectVersionsUpdated(id: $projectId) {\n id\n type\n version {\n id\n createdAt\n message\n sourceApplication\n authorUser {\n id\n name\n avatar\n }\n model {\n id\n name\n displayName\n }\n }\n }\n }\n": typeof types.OnProjectVersionsUpdateDocument,
|
||||
"\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 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 query IssueResourceMetaSearch(\n $workspaceId: String!\n $resourceType: ResourceMetaType!\n $resourceId: String!\n $projectId: String\n $metaType: String\n ) {\n resourceMetaSearch(\n workspaceId: $workspaceId\n resourceType: $resourceType\n resourceId: $resourceId\n projectId: $projectId\n metaType: $metaType\n ) {\n data\n }\n }\n": typeof types.IssueResourceMetaSearchDocument,
|
||||
"\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 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,
|
||||
"\n mutation CreateModel($input: CreateModelInput!) {\n modelMutations {\n create(input: $input) {\n ...ModelListModelItem\n }\n }\n }\n": types.CreateModelDocument,
|
||||
"\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 }\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,
|
||||
"\n fragment AutomationRunItem on AutomateRun {\n id\n status\n automation {\n id\n name\n }\n functionRuns {\n ...AutomateFunctionRunItem\n }\n }\n": types.AutomationRunItemFragmentDoc,
|
||||
"\n query AutomationStatus($projectId: String!, $modelId: String!) {\n project(id: $projectId) {\n model(id: $modelId) {\n automationsStatus {\n id\n status\n automationRuns {\n ...AutomationRunItem\n }\n }\n }\n }\n }\n": types.AutomationStatusDocument,
|
||||
"\n query WorkspaceListQuery(\n $limit: Int!\n $filter: UserWorkspacesFilter\n $cursor: String\n ) {\n activeUser {\n id\n workspaces(limit: $limit, filter: $filter, cursor: $cursor) {\n totalCount\n cursor\n items {\n ...WorkspaceListWorkspaceItem\n }\n }\n }\n }\n": types.WorkspaceListQueryDocument,
|
||||
"\n query ActiveUser {\n activeUser {\n role\n id\n name\n }\n }\n": types.ActiveUserDocument,
|
||||
"\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,
|
||||
@@ -42,7 +99,7 @@ const 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,
|
||||
@@ -50,8 +107,19 @@ const 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 query IssueResourceMetaSearch(\n $workspaceId: String!\n $resourceType: ResourceMetaType!\n $resourceId: String!\n $projectId: String\n $metaType: String\n ) {\n resourceMetaSearch(\n workspaceId: $workspaceId\n resourceType: $resourceType\n resourceId: $resourceId\n projectId: $projectId\n metaType: $metaType\n ) {\n data\n }\n }\n": types.IssueResourceMetaSearchDocument,
|
||||
"\n subscription WorkspacePlanUsageUpdated($input: WorkspacePlanUsageSubscriptionInput!) {\n workspacePlanUsageUpdated(input: $input)\n }\n": types.WorkspacePlanUsageUpdatedDocument,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -71,15 +139,15 @@ 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 query AcccountTestQuery {\n serverInfo {\n version\n name\n company\n }\n }\n "): (typeof documents)["\n query AcccountTestQuery {\n serverInfo {\n version\n name\n company\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 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.
|
||||
*/
|
||||
export function graphql(source: "\n mutation VersionMutations($input: CreateVersionInput!) {\n versionMutations {\n create(input: $input) {\n id\n }\n }\n }\n"): (typeof documents)["\n mutation VersionMutations($input: CreateVersionInput!) {\n versionMutations {\n create(input: $input) {\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.
|
||||
*/
|
||||
export function graphql(source: "\n mutation Update($input: UpdateVersionInput!) {\n versionMutations {\n update(input: $input) {\n id\n }\n }\n }\n"): (typeof documents)["\n mutation Update($input: UpdateVersionInput!) {\n versionMutations {\n update(input: $input) {\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.
|
||||
*/
|
||||
@@ -103,7 +171,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 }\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 }\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.
|
||||
*/
|
||||
@@ -128,6 +196,10 @@ export function graphql(source: "\n query AutomationStatus($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 WorkspaceListQuery(\n $limit: Int!\n $filter: UserWorkspacesFilter\n $cursor: String\n ) {\n activeUser {\n id\n workspaces(limit: $limit, filter: $filter, cursor: $cursor) {\n totalCount\n cursor\n items {\n ...WorkspaceListWorkspaceItem\n }\n }\n }\n }\n"): (typeof documents)["\n query WorkspaceListQuery(\n $limit: Int!\n $filter: UserWorkspacesFilter\n $cursor: String\n ) {\n activeUser {\n id\n workspaces(limit: $limit, filter: $filter, cursor: $cursor) {\n totalCount\n cursor\n items {\n ...WorkspaceListWorkspaceItem\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 ActiveUser {\n activeUser {\n role\n id\n name\n }\n }\n"): (typeof documents)["\n query ActiveUser {\n activeUser {\n role\n id\n name\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
@@ -143,7 +215,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.
|
||||
*/
|
||||
@@ -187,7 +263,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.
|
||||
*/
|
||||
@@ -219,11 +295,55 @@ 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 query IssueResourceMetaSearch(\n $workspaceId: String!\n $resourceType: ResourceMetaType!\n $resourceId: String!\n $projectId: String\n $metaType: String\n ) {\n resourceMetaSearch(\n workspaceId: $workspaceId\n resourceType: $resourceType\n resourceId: $resourceId\n projectId: $projectId\n metaType: $metaType\n ) {\n data\n }\n }\n"): (typeof documents)["\n query IssueResourceMetaSearch(\n $workspaceId: String!\n $resourceType: ResourceMetaType!\n $resourceId: String!\n $projectId: String\n $metaType: String\n ) {\n resourceMetaSearch(\n workspaceId: $workspaceId\n resourceType: $resourceType\n resourceId: $resourceId\n projectId: $projectId\n metaType: $metaType\n ) {\n data\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] ?? {};
|
||||
|
||||
+3203
-405
File diff suppressed because one or more lines are too long
@@ -7,7 +7,8 @@ import type {
|
||||
} from '~/lib/common/generated/gql/graphql'
|
||||
import {
|
||||
projectAddByUrlQueryWithoutVersion,
|
||||
projectAddByUrlQueryWithVersion
|
||||
projectAddByUrlQueryWithVersion,
|
||||
userInfoAndServerRoleQuery
|
||||
} from '~/lib/graphql/mutationsAndQueries'
|
||||
import { omit } from 'lodash-es'
|
||||
import { useDebounceFn } from '@vueuse/core'
|
||||
@@ -60,6 +61,7 @@ export function useAddByUrl() {
|
||||
|
||||
const { projectId, modelId, versionId } = params
|
||||
const apollo = (acc as DUIAccount).client
|
||||
const userInfoRes = await apollo.query({ query: userInfoAndServerRoleQuery })
|
||||
|
||||
let project: ProjectListProjectItemFragment | undefined = undefined,
|
||||
model: ModelListModelItemFragment | undefined = undefined,
|
||||
@@ -113,7 +115,7 @@ export function useAddByUrl() {
|
||||
? project.permissions.canPublish.authorized
|
||||
: project.permissions.canLoad.authorized
|
||||
|
||||
if (!hasAccess) {
|
||||
if (!hasAccess && userInfoRes.data.activeUser?.role !== 'server:admin') {
|
||||
urlParseError.value = errorMessage
|
||||
return
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* @param previewUrl url that server returns but does not return the corresponding image if the project is private
|
||||
* @param token auth token to get proper image over url
|
||||
*/
|
||||
export async function usePreviewUrl(
|
||||
token: string,
|
||||
previewUrl?: string
|
||||
): Promise<string | undefined> {
|
||||
if (!previewUrl) return previewUrl
|
||||
const res = await fetch(previewUrl, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
})
|
||||
|
||||
if (!res.ok) return previewUrl //
|
||||
const blob = await res.blob()
|
||||
return URL.createObjectURL(blob)
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -1,8 +1,17 @@
|
||||
import type { ToastNotification } from '@speckle/ui-components'
|
||||
import { ToastNotificationType } from '@speckle/ui-components'
|
||||
import { useConfigStore } from '~/store/config'
|
||||
import { useHostAppStore } from '~/store/hostApp'
|
||||
|
||||
export class UpdateError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'FetchError'
|
||||
|
||||
// Required when extending Error in TypeScript
|
||||
Object.setPrototypeOf(this, new.target.prototype)
|
||||
}
|
||||
}
|
||||
|
||||
type Versions = {
|
||||
Versions: Version[]
|
||||
}
|
||||
@@ -18,31 +27,13 @@ export type Version = {
|
||||
|
||||
export function useUpdateConnector() {
|
||||
const hostApp = useHostAppStore()
|
||||
const config = useConfigStore()
|
||||
const { $openUrl } = useNuxtApp()
|
||||
|
||||
const versions = ref<Version[]>([])
|
||||
const latestAvailableVersion = ref<Version | null>(null)
|
||||
|
||||
const isUpToDate = computed(
|
||||
() => hostApp.connectorVersion === latestAvailableVersion.value?.Number
|
||||
)
|
||||
|
||||
async function checkUpdate() {
|
||||
try {
|
||||
await getVersions()
|
||||
if (!isUpToDate.value && !config.isDevMode) {
|
||||
const notification: ToastNotification = {
|
||||
type: ToastNotificationType.Success,
|
||||
title: `New connector update available`,
|
||||
description: latestAvailableVersion.value?.Number.replace('+0', ''), // TODO: currently versions end with "+0" Alan will have a look
|
||||
cta: {
|
||||
title: `Update`,
|
||||
onClick: () => downloadLatestVersion()
|
||||
}
|
||||
}
|
||||
hostApp.setNotification(notification)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
const notification: ToastNotification = {
|
||||
@@ -54,28 +45,39 @@ export function useUpdateConnector() {
|
||||
}
|
||||
|
||||
async function getVersions() {
|
||||
const response = await fetch(
|
||||
`https://releases.speckle.dev/manager2/feeds/${hostApp.hostAppName?.toLowerCase()}-v3.json`,
|
||||
{
|
||||
method: 'GET'
|
||||
try {
|
||||
// End point to get list of versions that deployed by Speckle's pipeline
|
||||
const response = await fetch(
|
||||
`https://releases.speckle.dev/manager2/feeds/${hostApp.hostAppName?.toLowerCase()}-v3.json`,
|
||||
{
|
||||
method: 'GET'
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
// It is the only way to understand the connector is distributed by Speckle or not.
|
||||
throw new UpdateError('Failed to fetch versions')
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch versions')
|
||||
const data = (await response.json()) as unknown as Versions
|
||||
const sortedVersions = data.Versions.sort(function (a: Version, b: Version) {
|
||||
return new Date(b.Date).getTime() - new Date(a.Date).getTime()
|
||||
})
|
||||
versions.value = sortedVersions
|
||||
latestAvailableVersion.value = sortedVersions[0]
|
||||
hostApp.setLatestAvailableVersion(sortedVersions[0])
|
||||
} catch (err) {
|
||||
if (err instanceof TypeError && err.message === 'Failed to fetch') {
|
||||
// When user has network issue in between, actually it is not so likely because regardless user need network to be able to render netlify page
|
||||
throw new Error('Network error')
|
||||
} else if (err instanceof UpdateError) {
|
||||
// We set the flag to use it in relavant places, hide some documentation related buttons etc..
|
||||
hostApp.setIsDistributedBySpeckle(false)
|
||||
} else {
|
||||
// Rest of the possibilites that we trigger toast
|
||||
throw new Error('Unknown error occurred')
|
||||
}
|
||||
}
|
||||
|
||||
const data = (await response.json()) as unknown as Versions
|
||||
const sortedVersions = data.Versions.sort(function (a: Version, b: Version) {
|
||||
return new Date(b.Date).getTime() - new Date(a.Date).getTime()
|
||||
})
|
||||
versions.value = sortedVersions
|
||||
latestAvailableVersion.value = sortedVersions[0]
|
||||
hostApp.setLatestAvailableVersion(sortedVersions[0])
|
||||
}
|
||||
|
||||
function downloadLatestVersion() {
|
||||
$openUrl(latestAvailableVersion.value?.Url as string)
|
||||
}
|
||||
|
||||
return { checkUpdate }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
])
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
`)
|
||||
@@ -18,6 +20,16 @@ export const createVersionMutation = graphql(`
|
||||
}
|
||||
`)
|
||||
|
||||
export const setVersionMessageMutation = graphql(`
|
||||
mutation Update($input: UpdateVersionInput!) {
|
||||
versionMutations {
|
||||
update(input: $input) {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
export const markReceivedVersionMutation = graphql(`
|
||||
mutation MarkReceivedVersion($input: MarkReceivedVersionInput!) {
|
||||
versionMutations {
|
||||
@@ -74,9 +86,16 @@ export const workspaceListFragment = graphql(`
|
||||
description
|
||||
createdAt
|
||||
updatedAt
|
||||
logo
|
||||
logoUrl
|
||||
role
|
||||
readOnly
|
||||
permissions {
|
||||
canCreateProject {
|
||||
authorized
|
||||
code
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
@@ -173,6 +192,16 @@ export const workspacesListQuery = graphql(`
|
||||
}
|
||||
`)
|
||||
|
||||
export const userInfoAndServerRoleQuery = graphql(`
|
||||
query ActiveUser {
|
||||
activeUser {
|
||||
role
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
export const canCreatePersonalProjectQuery = graphql(`
|
||||
query CanCreatePersonalProject {
|
||||
activeUser {
|
||||
@@ -217,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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -426,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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -574,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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`)
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`)
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`)
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
`)
|
||||
@@ -0,0 +1,35 @@
|
||||
import { graphql } from '~~/lib/common/generated/gql'
|
||||
|
||||
export const issuesListQuery = graphql(`
|
||||
query IssuesList($projectId: String!) {
|
||||
project(id: $projectId) {
|
||||
id
|
||||
issues {
|
||||
totalCount
|
||||
items {
|
||||
...IssuesItem
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
export const issueResourceMetaSearchQuery = graphql(`
|
||||
query IssueResourceMetaSearch(
|
||||
$workspaceId: String!
|
||||
$resourceType: ResourceMetaType!
|
||||
$resourceId: String!
|
||||
$projectId: String
|
||||
$metaType: String
|
||||
) {
|
||||
resourceMetaSearch(
|
||||
workspaceId: $workspaceId
|
||||
resourceType: $resourceType
|
||||
resourceId: $resourceId
|
||||
projectId: $projectId
|
||||
metaType: $metaType
|
||||
) {
|
||||
data
|
||||
}
|
||||
}
|
||||
`)
|
||||
@@ -0,0 +1,7 @@
|
||||
export type Label = {
|
||||
id: string
|
||||
name: string
|
||||
hexColor?: string
|
||||
}
|
||||
|
||||
export type LabelsValue = Label[]
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export interface CategoryOption {
|
||||
value: string // e.g. "OST_Walls"
|
||||
label: string // e.g. "Walls"
|
||||
}
|
||||
@@ -6,9 +6,16 @@ export type ModelCardNotification = {
|
||||
modelCardId: string
|
||||
text: string
|
||||
level: ModelCardNotificationLevel
|
||||
secondaryCta?: {
|
||||
name: string
|
||||
tooltipText?: string
|
||||
action: () => void
|
||||
}
|
||||
cta?: {
|
||||
name: string
|
||||
tooltipText?: string
|
||||
action: () => void
|
||||
disabled?: boolean
|
||||
}
|
||||
/**
|
||||
* If set, will display a view report button next to cta
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { graphql } from '~~/lib/common/generated/gql'
|
||||
|
||||
export const workspacePlanUsageUpdatedSubscription = graphql(`
|
||||
subscription WorkspacePlanUsageUpdated($input: WorkspacePlanUsageSubscriptionInput!) {
|
||||
workspacePlanUsageUpdated(input: $input)
|
||||
}
|
||||
`)
|
||||
+4
-5
@@ -10,16 +10,14 @@ 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
|
||||
// lodash: 'lodash-es'
|
||||
},
|
||||
|
||||
// pinia: {
|
||||
// autoImports: ['defineStore', 'storeToRefs']
|
||||
// },
|
||||
runtimeConfig: {
|
||||
public: {
|
||||
mixpanelApiHost: 'UNDEFINED',
|
||||
@@ -29,7 +27,8 @@ export default defineNuxtConfig({
|
||||
speckleUserId: process.env.SPECKLE_USER_ID,
|
||||
speckleUrl: process.env.SPECKLE_URL,
|
||||
speckleSampleProjectId: process.env.SPECKLE_SAMPLE_PROJECT_ID,
|
||||
speckleSampleModelId: process.env.SPECKLE_SAMPLE_MODEL_ID
|
||||
speckleSampleModelId: process.env.SPECKLE_SAMPLE_MODEL_ID,
|
||||
intercomAppId: ''
|
||||
}
|
||||
},
|
||||
vite: {
|
||||
|
||||
+25
-6
@@ -26,16 +26,34 @@
|
||||
"@apollo/client": "^3.7.14",
|
||||
"@headlessui/vue": "^1.7.13",
|
||||
"@heroicons/vue": "^2.0.12",
|
||||
"@intercom/messenger-js-sdk": "^0.0.14",
|
||||
"@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.24.0",
|
||||
"@speckle/objectsender": "^2.24.0",
|
||||
"@speckle/shared": "^2.24.0",
|
||||
"@speckle/tailwind-theme": "2.24.1-alpha.0",
|
||||
"@speckle/ui-components": "^2.24.0",
|
||||
"@speckle/ui-components-nuxt": "^2.24.0",
|
||||
"@speckle/objectloader": "^2.25.0",
|
||||
"@speckle/objectsender": "^2.25.0",
|
||||
"@speckle/shared": "^2.25.0",
|
||||
"@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",
|
||||
@@ -45,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",
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<div class="flex items-center justify-center"><InfiniteLoading /></div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ToastNotificationType } from '@speckle/ui-components'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAuthManager } from '~/lib/authn/useAuthManager'
|
||||
import { useTokenExchange } from '~/lib/authn/useTokenExchange'
|
||||
import { useHostAppStore } from '~/store/hostApp'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { getChallenge, getCodeVerifier, getChallengeUrl } = useAuthManager()
|
||||
const { exchangeAccessCode } = useTokenExchange()
|
||||
const hostApp = useHostAppStore()
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const origin = getChallengeUrl()
|
||||
const accessCode = route.query.access_code as string | undefined
|
||||
if (accessCode && origin) {
|
||||
const challenge = getChallenge()
|
||||
if (!challenge) {
|
||||
throw new Error('No challenge found in storage.')
|
||||
}
|
||||
const codeVerifier = getCodeVerifier() ?? undefined
|
||||
await exchangeAccessCode(origin, accessCode, challenge, codeVerifier)
|
||||
} 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 instanceof Error ? error.message : (error as string)
|
||||
})
|
||||
} finally {
|
||||
router.replace('/')
|
||||
}
|
||||
})
|
||||
</script>
|
||||
+78
-14
@@ -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>
|
||||
@@ -11,11 +11,23 @@
|
||||
class="fixed h-screen w-screen flex items-center justify-center pr-2 pointer-events-none"
|
||||
>
|
||||
<LayoutPanel fancy-glow class="transition pointer-events-auto w-[90%]">
|
||||
<h1
|
||||
class="text-heading-lg w-full bg-gradient-to-r from-blue-500 via-blue-400 to-blue-600 inline-block py-1 text-transparent bg-clip-text"
|
||||
>
|
||||
Hello!
|
||||
</h1>
|
||||
<div class="flex">
|
||||
<h1
|
||||
class="text-heading-lg w-full bg-gradient-to-r from-blue-500 via-blue-400 to-blue-600 inline-block py-1 text-transparent bg-clip-text"
|
||||
>
|
||||
Hello!
|
||||
</h1>
|
||||
<button
|
||||
v-if="accountStore.activeAccount"
|
||||
v-tippy="'Open web app'"
|
||||
class="transition mr-1 opacity-70 hover:opacity-100"
|
||||
@click.stop="
|
||||
$openUrl(accountStore.activeAccount.accountInfo.serverInfo.url)
|
||||
"
|
||||
>
|
||||
<ArrowTopRightOnSquareIcon class="w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<!-- Returning null from host app is blocked by CI for now, hence host app send here empty documentInfo, we check it's id whether null or not. -->
|
||||
<div v-if="!!store.documentInfo?.id">
|
||||
<div class="text-foreground-2 text-body-sm">
|
||||
@@ -57,7 +69,10 @@
|
||||
</div>
|
||||
</div>
|
||||
<!-- TEMPORARY MESSAGE TO USER! will be deleted -->
|
||||
<div class="mt-2 bg-highlight-1 rounded-md p-2">
|
||||
<div
|
||||
v-if="store.isDistributedBySpeckle"
|
||||
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"
|
||||
>
|
||||
@@ -74,15 +89,15 @@
|
||||
full-width
|
||||
@click="
|
||||
app.$openUrl(
|
||||
`https://speckle.systems/connectors/${store.hostAppName}`
|
||||
`https://docs.speckle.systems/connectors/${store.hostAppName}?utm=dui`
|
||||
)
|
||||
"
|
||||
>
|
||||
<span class="capitalize">{{ store.hostAppName }} </span>
|
||||
documentation
|
||||
Getting started
|
||||
</FormButton>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
<FormButton
|
||||
text
|
||||
size="sm"
|
||||
@@ -98,11 +113,34 @@
|
||||
<span class="text-foreground-2 text-body-3xs truncate line-clamp-1">
|
||||
New connectors announcement
|
||||
</span>
|
||||
</FormButton>
|
||||
</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">
|
||||
<div v-if="accounts.length !== 0 && !hasNoModelCards" class="space-y-2 pb-24">
|
||||
<div v-for="project in store.projectModelGroups" :key="project.projectId">
|
||||
<CommonProjectModelGroup :project="project" />
|
||||
</div>
|
||||
@@ -124,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">
|
||||
@@ -153,7 +212,8 @@ import { storeToRefs } from 'pinia'
|
||||
import {
|
||||
ArrowDownTrayIcon,
|
||||
ArrowUpTrayIcon,
|
||||
ArrowPathIcon
|
||||
ArrowPathIcon,
|
||||
ArrowTopRightOnSquareIcon
|
||||
} from '@heroicons/vue/24/solid'
|
||||
import { useAccountStore } from '~~/store/accounts'
|
||||
import { useHostAppStore } from '~~/store/hostApp'
|
||||
@@ -176,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
|
||||
})
|
||||
@@ -208,4 +268,8 @@ const hasNoValidProjects = computed(() => {
|
||||
const reload = () => {
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
const openSpeckleConnectors = () => {
|
||||
window.open('https://app.speckle.systems/connectors', '_blank')
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -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>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user