Compare commits

...

5 Commits

Author SHA1 Message Date
Kristaps Fabians Geikins d0b654a097 trigger deploy 2025-05-13 16:11:35 +03:00
Kristaps Fabians Geikins 404fb4ed33 trigger deploy 2025-05-13 16:08:05 +03:00
Kristaps Fabians Geikins ab833decdb vscode settings 2025-05-13 15:08:59 +03:00
Kristaps Fabians Geikins 694eb4056d various fixes 2025-05-13 15:00:54 +03:00
Kristaps Fabians Geikins 785d315868 stuff copied over, but aint workin 2025-05-13 14:16:10 +03:00
148 changed files with 36591 additions and 2 deletions
+8
View File
@@ -0,0 +1,8 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 2
indent_style = space
insert_final_newline = true
+12
View File
@@ -0,0 +1,12 @@
HOST=0.0.0.0
PORT=8082
NUXT_PUBLIC_MIXPANEL_TOKEN_ID=acd87c5a50b56df91a795e999812a3a4
NUXT_PUBLIC_MIXPANEL_API_HOST=https://analytics.speckle.systems
SPECKLE_ACCOUNT_ID=undefined
SPECKLE_TOKEN=undefined
SPECKLE_USER_ID=undefined
SPECKLE_URL=undefined
SPECKLE_SAMPLE_PROJECT_ID=undefined
SPECKLE_SAMPLE_MODEL_ID=undefined
+1
View File
File diff suppressed because one or more lines are too long
+4
View File
@@ -0,0 +1,4 @@
/.yarn/** linguist-vendored
/.yarn/releases/* binary
/.yarn/plugins/**/* binary
/.pnp.* binary linguist-generated
+18
View File
@@ -0,0 +1,18 @@
node_modules
*.log*
.nuxt
.nitro
.cache
.output
.env
dist
.DS_Store
.env
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
+18
View File
@@ -0,0 +1,18 @@
{
// See https://go.microsoft.com/fwlink/?LinkId=827846 to learn about workspace recommendations.
// Extension identifier format: ${publisher}.${name}. Example: vscode.csharp
// List of extensions which should be recommended for users of this workspace.
"recommendations": [
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"Vue.volar",
"bradlc.vscode-tailwindcss",
"stylelint.vscode-stylelint",
"cpylua.language-postcss",
"graphql.vscode-graphql",
"graphql.vscode-graphql-syntax"
],
// List of extensions recommended by VS Code that should not be recommended for users of this workspace.
"unwantedRecommendations": ["octref.vetur"]
}
+61
View File
@@ -0,0 +1,61 @@
{
"css.validate": false,
"less.validate": false,
"scss.validate": false,
"stylelint.validate": ["css", "scss", "vue", "postcss"],
"stylelint.enable": true,
"javascript.suggest.autoImports": true,
"typescript.suggest.autoImports": true,
"typescript.preferences.importModuleSpecifier": "non-relative",
"javascript.preferences.importModuleSpecifier": "non-relative",
"explorer.confirmDelete": false,
"files.associations": {
"*.vue": "vue"
},
"editor.formatOnPaste": true,
"editor.multiCursorModifier": "ctrlCmd",
"editor.snippetSuggestions": "top",
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
},
"editor.defaultFormatter": "esbenp.prettier-vscode",
"search.useParentIgnoreFiles": true,
"[html]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"files.eol": "\n",
"cSpell.words": [
"Automations",
"Bursty",
"discoverability",
"Encryptor",
"Gendo",
"GENDOAI",
"Insertable",
"mjml",
"multiregion",
"OIDC",
"Prorotation"
],
"editor.tabSize": 2,
"search.exclude": {
"**/node_modules": true,
"**/bower_components": true,
"**/*.code-search": true,
"**/.nuxt": true,
"**/.output": true
},
"[vue]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[dockercompose]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[json]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"vue.complete.casing.props": "kebab",
"vue.inlayHints.missingProps": true
}
+1
View File
@@ -0,0 +1 @@
nodeLinker: node-modules
+40 -2
View File
@@ -1,2 +1,40 @@
# speckle-connectors-dui
Web UI to use accross connectors (aka dui3)
# dui3
DUIv3 is a Speckle interface embedded inside the desktop connectors that allows users to interact with them - sync streams, manage servers etc. It's built in Vue 3 with Nuxt 3 and only supports client side rendering.
Look at the [Nuxt 3 documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.
## Setup
Make sure to install the dependencies:
```bash
# yarn
yarn install
```
And create an `.env` file from `.env.example`.
## Development Server
Start the development server on `http://localhost:3000`
```bash
npm run dev
```
## Production
Build the application for production:
```bash
npm run build
```
Locally preview production build:
```bash
npm run preview
```
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information...
+55
View File
@@ -0,0 +1,55 @@
<template>
<div id="speckle" class="bg-foundation-page text-foreground overflow-auto">
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
<!-- Teleport is fixing the non-clickable toast notifications if any dialog is active. It was marking div as inert and causing the issue -->
<Teleport to="body">
<SingletonToastManager />
</Teleport>
</div>
</template>
<script setup lang="ts">
import { useMixpanel } from '~/lib/core/composables/mixpanel'
import { useConfigStore } from '~/store/config'
import { useAccountStore } from '~/store/accounts'
import { storeToRefs } from 'pinia'
const uiConfigStore = useConfigStore()
const { isDarkTheme } = storeToRefs(uiConfigStore)
useHead({
// Title suffix
titleTemplate: (titleChunk) =>
titleChunk ? `${titleChunk as string} - Speckle DUIv3` : 'Speckle DUIv3',
htmlAttrs: {
lang: 'en',
class: computed(() => (isDarkTheme.value ? `dark` : ``))
},
bodyAttrs: {
class: 'simple-scrollbar bg-foundation-page text-foreground '
},
// For standalone vue devtools see: https://devtools.vuejs.org/guide/installation.html#standalone
script: import.meta.dev ? ['http://localhost:8098'] : []
})
onMounted(() => {
const { trackEvent, addConnectorToProfile, identifyProfile } = useMixpanel()
// TODO: some host apps can open DUI3 automatically, with this case we shouldn't mark track event as `"type": "action"`,
// we need to get this info from source app. (TBD which apps: Rhino opens automatically, not sure acad, sketchup and revit needs trigger button to init)
trackEvent('DUI3 Action', { name: 'Launch' })
const { accounts } = useAccountStore()
const uniqueEmails = new Set<string>()
accounts.forEach((account) => {
const email = account?.accountInfo.userInfo.email
if (email && !uniqueEmails.has(email)) {
addConnectorToProfile(email)
identifyProfile(email)
uniqueEmails.add(email)
}
})
})
</script>
<style></style>
+30
View File
@@ -0,0 +1,30 @@
/* stylelint-disable selector-id-pattern */
@import '@speckle/ui-components/style.css';
@tailwind base;
@tailwind components;
@tailwind utilities;
/**
* Don't pollute this - it's going to be bundled in all pages!
*/
/**
* Making sure page is always stretched to the bottom of the screen even if there's nothing in it
*/
html,
body,
div#__nuxt,
div#__nuxt > div {
min-height: 100%;
}
html,
body,
div#__nuxt {
height: 100%;
}
.tippy-content {
@apply text-body-3xs;
@apply !px-2 !py-1;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

+28
View File
@@ -0,0 +1,28 @@
import type { CodegenConfig } from '@graphql-codegen/cli'
const config: CodegenConfig = {
schema: 'http://127.0.0.1:3000/graphql',
documents: ['{lib,components,layouts,pages,middleware}/**/*.{vue,js,ts}'],
ignoreNoDocuments: true, // for better experience with the watcher
generates: {
'./lib/common/generated/gql/': {
preset: 'client',
config: {
useTypeImports: true,
fragmentMasking: false,
dedupeFragments: true,
scalars: {
JSONObject: '{}',
DateTime: 'string'
}
},
presetConfig: {
fragmentMasking: false,
dedupeFragments: true
},
plugins: []
}
}
}
export default config
+93
View File
@@ -0,0 +1,93 @@
<template>
<button
v-tippy="account.accountInfo.userInfo.email"
:class="`group block w-full p-1 text-left rounded-md items-center space-x-2 select-none group transition hover:bg-primary-muted hover:cursor-pointer hover:text-primary ${
!account.isValid
? 'text-danger bg-rose-500/10 cursor-not-allowed'
: 'cursor-pointer'
} ${
currentSelectedAccountId === account.accountInfo.id
? 'bg-blue-500/5 text-primary'
: ''
}`"
:disabled="!account.isValid"
@click="$emit('select', account)"
>
<div class="flex items-center space-x-2">
<UserAvatar
:user="userAvatar"
:active="account.accountInfo.isDefault"
size="sm"
/>
<div class="min-w-0 grow">
<div class="truncate overflow-hidden min-w-0 flex items-center space-x-2">
<span>{{ account.accountInfo.serverInfo.name }}</span>
<span class="text-foreground-2 truncate min-w-0 caption">
{{ account.accountInfo.serverInfo.url.split('//')[1] }}
</span>
</div>
</div>
<button
v-if="canRemoveAccount"
class="flex hidden group-hover:block px-2 py-1 text-danger"
@click.stop="showRemoveAccountDialog = true"
>
<TrashIcon class="w-4 h-4" />
</button>
</div>
</button>
<CommonDialog v-model:open="showRemoveAccountDialog" fullscreen="none">
<template #header>Remove Account</template>
<div class="text-xs mb-4">
Removing the account will remove the related model cards from your file. Do you
want to remove the account?
</div>
<div class="flex justify-between center py-2 space-x-3">
<FormButton
size="sm"
color="outline"
full-width
@click="showRemoveAccountDialog = false"
>
No
</FormButton>
<FormButton size="sm" full-width @click="handleRemove(account)">
Remove
</FormButton>
</div>
</CommonDialog>
</template>
<script setup lang="ts">
import type { DUIAccount } from '~~/store/accounts'
import { TrashIcon } from '@heroicons/vue/24/outline'
import { type BaseBridge } from '~/lib/bridge/base'
const { $accountBinding } = useNuxtApp()
const canRemoveAccount = ['RemoveAccount', 'removeAccount'].some((name) =>
($accountBinding as unknown as BaseBridge).availableMethodNames.includes(name)
)
const props = defineProps<{
account: DUIAccount
currentSelectedAccountId?: string
}>()
const emit = defineEmits<{
(e: 'select', account: DUIAccount): void
(e: 'remove', account: DUIAccount): void
}>()
const showRemoveAccountDialog = ref(false)
const handleRemove = (account: DUIAccount) => {
emit('remove', account)
}
const userAvatar = computed(() => {
return {
name: props.account.accountInfo.userInfo.name,
avatar: props.account.accountInfo.userInfo.avatar
}
})
</script>
+157
View File
@@ -0,0 +1,157 @@
<template>
<div>
<button
v-if="!justDialog"
v-tippy="`Click to change the account.`"
@click="showAccountsDialog = true"
>
<UserAvatar v-if="!showAccountsDialog" :user="user" hover-effect size="sm" />
<UserAvatar v-else hover-effect size="sm">
<XMarkIcon class="w-6 h-6" />
</UserAvatar>
</button>
<CommonDialog
v-model:open="showAccountsDialog"
:title="`${justDialog ? 'Your accounts' : 'Select account'}`"
fullscreen="none"
>
<div class="pb-2">
<CommonLoadingBar :loading="isLoading" class="my-0" />
<AccountsItem
v-for="acc in accounts"
:key="acc.accountInfo.id"
:current-selected-account-id="currentSelectedAccountId"
:account="(acc as DUIAccount)"
@select="selectAccount(acc as DUIAccount)"
@remove="removeAccount(acc as DUIAccount)"
/>
<div class="mt-4">
<FormButton
text
full-width
size="sm"
@click="showAddNewAccount = !showAddNewAccount"
>
Add a new account
</FormButton>
<CommonDialog
v-model:open="showAddNewAccount"
title="Add a new account"
fullscreen="none"
>
<div>
<div v-if="isDesktopServiceAvailable">
<AccountsSignInFlow />
</div>
<div v-else class="flex flex-wrap justify-center space-x-4 max-width">
<FormButton text @click="$openUrl(`speckle://accounts`)">
Add account via Manager
</FormButton>
<FormButton text @click="accountStore.refreshAccounts()">
Refresh accounts
</FormButton>
</div>
</div>
</CommonDialog>
</div>
</div>
</CommonDialog>
</div>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { XMarkIcon } from '@heroicons/vue/20/solid'
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'
const { trackEvent } = useMixpanel()
const app = useNuxtApp()
const { $openUrl } = useNuxtApp()
const { pingDesktopService } = useDesktopService()
const props = withDefaults(
defineProps<{
currentSelectedAccountId?: string
justDialog?: boolean
}>(),
{
justDialog: false
}
)
defineEmits<{
(e: 'select', account: DUIAccount): void
}>()
const showAddNewAccount = ref(false)
// const showAccountsDialog = ref(false)
const showAccountsDialog = defineModel<boolean>('open', {
required: false,
default: false
})
const isDesktopServiceAvailable = ref(false) // this should be false default because there is a delay if /ping is not successful.
app.$baseBinding.on('documentChanged', () => {
showAccountsDialog.value = false
})
watch(showAccountsDialog, (newVal) => {
if (newVal) {
void accountStore.refreshAccounts()
void trackEvent('DUI3 Action', { name: 'Account menu open' })
}
})
const accountStore = useAccountStore()
const { accounts, activeAccount, userSelectedAccount, isLoading } =
storeToRefs(accountStore)
watch(accounts, (newVal, oldVal) => {
if (newVal.length !== oldVal.length) {
showAddNewAccount.value = false
}
})
const selectAccount = (acc: DUIAccount) => {
if (props.justDialog) {
app.$openUrl(acc.accountInfo.serverInfo.url)
return
}
userSelectedAccount.value = acc
accountStore.setUserSelectedAccount(acc) // saves the selected account id into DUI3Config.db for later use
showAccountsDialog.value = false
void trackEvent('DUI3 Action', { name: 'Account change' })
}
const removeAccount = async (acc: DUIAccount) => {
await accountStore.removeAccount(acc)
void trackEvent('DUI3 Action', { name: 'Account removed' })
}
const user = computed(() => {
// if (!defaultAccount.value) return undefined
// let acc = defaultAccount.value
// if (props.currentSelectedAccountId) {
// const currentSelectedAccount = accounts.value.find(
// (acc) => acc.accountInfo.id === props.currentSelectedAccountId
// ) as DUIAccount
// // currentSelectedAccount could be removed by user
// if (currentSelectedAccount) {
// acc = currentSelectedAccount
// }
// }
return {
name: activeAccount.value.accountInfo.userInfo.name,
avatar: activeAccount.value.accountInfo.userInfo.avatar
}
})
onMounted(async () => {
isDesktopServiceAvailable.value = await pingDesktopService()
})
</script>
+116
View File
@@ -0,0 +1,116 @@
<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>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useIntervalFn } from '@vueuse/core'
import { useAccountStore } from '~~/store/accounts'
import { useHostAppStore } from '~/store/hostApp'
import { ToastNotificationType } from '@speckle/ui-components'
import { useMixpanel } from '~/lib/core/composables/mixpanel'
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 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`
app.$openUrl(url)
// this is a annoying timeout that we cannot detect if user added same account or not.
setTimeout(() => {
if (isAddingAccount.value) {
isAddingAccount.value = false
showCustomServerInput.value = false
accountCheckerIntervalFn.pause()
// Note to Dim: not sure about toast
hostApp.setNotification({
title: 'Sign In',
type: ToastNotificationType.Info,
description:
'Sign in timed out. This may have happened because you tried adding an existing account.'
})
// TODO: we could log it to sentry/seq later to see how likely it happens?
}
}, 30_000)
}
const restartFlow = () => {
isAddingAccount.value = false
showHelp.value = false
}
</script>
+158
View File
@@ -0,0 +1,158 @@
<!-- 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>{{ 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 } from '~/store/accounts'
import type { ApolloError } from '@apollo/client/errors'
import { formatVersionParams } from '~/lib/common/helpers/jsonSchema'
import { useJsonFormsChangeHandler } from '~/lib/core/composables/jsonSchema'
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.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>
+39
View File
@@ -0,0 +1,39 @@
<template>
<div class="p-0">
<slot name="activator" :toggle="toggleDialog"></slot>
<CommonDialog
v-model:open="showAutomateReportDialog"
:title="`Automation Report`"
fullscreen="none"
>
<div v-if="props.automationRuns" class="space-y-2">
<AutomateFunctionRunsRows
v-for="aRun in automationRuns"
:key="aRun.id"
:model-card="modelCard"
:automation-name="aRun.automation.name"
:runs="aRun.functionRuns"
:project-id="modelCard.projectId"
:model-id="modelId"
/>
</div>
</CommonDialog>
</div>
</template>
<script setup lang="ts">
import type { IModelCard } from '~/lib/models/card'
import type { AutomationRunItemFragment } from '~/lib/common/generated/gql/graphql'
const props = defineProps<{
modelCard: IModelCard
modelId: string
automationRuns: AutomationRunItemFragment[] | undefined
}>()
const showAutomateReportDialog = ref(false)
const toggleDialog = () => {
showAutomateReportDialog.value = !showAutomateReportDialog.value
}
</script>
+60
View File
@@ -0,0 +1,60 @@
<template>
<div :class="classes">
<img v-if="finalLogo" :src="finalLogo" alt="Function logo" class="h-10 w-10" />
<span v-else :class="fallbackIconClasses">λ</span>
</div>
</template>
<script setup lang="ts">
import type { MaybeNullOrUndefined, Nullable } from '@speckle/shared'
type Size = 'base' | 'xs'
const props = withDefaults(
defineProps<{
logo?: MaybeNullOrUndefined<string>
size?: Size
}>(),
{
size: 'base'
}
)
const cleanFunctionLogo = (logo: MaybeNullOrUndefined<string>): Nullable<string> => {
if (!logo?.length) return null
if (logo.startsWith('data:')) return logo
if (logo.startsWith('http:')) return logo
if (logo.startsWith('https:')) return logo
return null
}
const finalLogo = computed(() => cleanFunctionLogo(props.logo))
const classes = computed(() => {
const classParts = [
'bg-foundation-focus text-primary font-medium rounded-full shrink-0 flex justify-center text-center items-center overflow-hidden select-none'
]
switch (props.size) {
case 'xs':
classParts.push('h-4 w-4')
break
case 'base':
default:
classParts.push('h-10 w-10')
break
}
return classParts.join(' ')
})
const fallbackIconClasses = computed(() => {
const classParts: string[] = []
switch (props.size) {
case 'xs':
classParts.push('text-xs')
break
}
return classParts.join(' ')
})
</script>
+134
View File
@@ -0,0 +1,134 @@
<template>
<div
:class="`border border-blue-500/10 rounded-md space-y-2 overflow-hidden ${
expanded ? 'shadow' : ''
}`"
>
<button
class="flex space-x-1 items-center max-w-full w-full px-1 py-1 h-8 transition hover:bg-primary-muted"
@click="expanded = !expanded"
>
<div>
<Component
:is="statusMetaData.icon"
v-tippy="functionRun.status"
:class="['h-4 w-4 outline-none', statusMetaData.iconColor]"
/>
</div>
<AutomateFunctionLogo :logo="functionRun.function?.logo" size="xs" />
<div class="font-medium text-xs truncate">
{{ automationName ? automationName + ' / ' : ''
}}{{ functionRun.function?.name || 'Unknown function' }}
</div>
<div class="h-full grow flex justify-end">
<button
class="hover:bg-primary-muted hover:text-primary flex h-full items-center justify-center rounded"
>
<ChevronDownIcon
:class="`h-3 w-3 transition ${!expanded ? '-rotate-90' : 'rotate-0'}`"
/>
</button>
</div>
</button>
<div v-if="expanded" class="px-2 pb-2 space-y-4">
<!-- Status message -->
<div class="space-y-1">
<div class="text-xs font-medium text-foreground-2">Status</div>
<div
v-if="
[
AutomateRunStatus.Initializing,
AutomateRunStatus.Running,
AutomateRunStatus.Pending
].includes(functionRun.status)
"
class="text-xs text-foreground-2 italic"
>
Function is {{ functionRun.status.toLowerCase() }}.
</div>
<div v-else class="text-xs text-foreground-2 italic">
{{ functionRun.statusMessage || 'No status message' }}
</div>
</div>
<!-- Attachments -->
<!-- <div
v-if="attachments.length !== 0"
class="border-t pt-2 border-foreground-2 space-y-1"
>
<div class="text-xs font-medium text-foreground-2">Attachments</div>
<div class="ml-[2px] justify-start">
<AutomateRunsAttachmentButton
v-for="id in attachments"
:key="id"
:blob-id="id"
:project-id="projectId"
size="xs"
link
class="mr-2"
/>
</div>
</div> -->
<!-- Results -->
<div
v-if="!!results?.values.objectResults.length"
class="border-t pt-2 border-foreground-2"
>
<div class="text-xs font-medium text-foreground-2 mb-2">Results</div>
<div class="space-y-1">
<AutomateFunctionRunRowObjectResult
v-for="(result, index) in results.values.objectResults.slice(
0,
pageRunLimit
)"
:key="index"
:model-card="modelCard"
:function-id="functionRun.function?.id"
:result="result"
/>
<FormButton
v-if="pageRunLimit < results.values.objectResults.length"
size="sm"
color="outline"
class="w-full"
@click="pageRunLimit += 10"
>
Load more ({{ results.values.objectResults.length - pageRunLimit }}
hidden results)
</FormButton>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ChevronDownIcon } from '@heroicons/vue/24/outline'
import { AutomateRunStatus } from '~/lib/common/generated/gql/graphql'
import type { AutomateFunctionRunItemFragment } from '~/lib/common/generated/gql/graphql'
import {
useRunStatusMetadata,
useAutomationFunctionRunResults
} from '~/lib/automate/runStatus'
import type { IModelCard } from '~/lib/models/card'
const props = defineProps<{
modelCard: IModelCard
functionRun: AutomateFunctionRunItemFragment
automationName: string
}>()
const results = useAutomationFunctionRunResults({
results: computed(() => props.functionRun.results)
})
const { metadata: statusMetaData } = useRunStatusMetadata({
status: computed(() => props.functionRun.status)
})
const pageRunLimit = ref(5)
const expanded = ref(false)
// const attachments = computed(() =>
// (results.value?.values.blobIds || []).filter((b) => !!b)
// )
</script>
@@ -0,0 +1,98 @@
<template>
<div :class="`overflow-hidden`">
<button
:class="`block transition text-left hover:bg-primary-muted hover:shadow-md rounded-md p-1 cursor-pointer border-l-2 border-primary bg-primary-muted shadow-md`"
@click="handleClick()"
>
<div class="flex items-center space-x-1">
<div>
<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
</div>
</div>
<div v-if="result.message" class="text-xs text-foreground-2 pl-5">
{{ result.message }}
</div>
</button>
</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]
const props = defineProps<{
modelCard: IModelCard
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 handleClick = async () => {
await app.$baseBinding.highlightObjects(applicationIds.value)
}
const iconAndColor = computed(() => {
switch (props.result.level) {
case 'ERROR':
return {
icon: XMarkIcon,
color: 'text-danger font-medium'
}
case 'WARNING':
return {
icon: ExclamationTriangleIcon,
color: 'text-warning font-medium'
}
case 'INFO':
default:
return {
icon: InformationCircleIcon,
color: 'text-foreground font-medium'
}
}
})
</script>
+27
View File
@@ -0,0 +1,27 @@
<template>
<div class="space-y-2">
<AutomateFunctionRunRow
v-for="fRun in runs"
:key="fRun.id"
:model-card="modelCard"
:automation-name="automationName"
:function-run="fRun"
:project-id="projectId"
:model-id="modelId"
:version-id="versionId"
/>
</div>
</template>
<script setup lang="ts">
import type { IModelCard } from '~/lib/models/card'
import type { AutomateFunctionRunItemFragment } from '~/lib/common/generated/gql/graphql'
defineProps<{
runs: AutomateFunctionRunItemFragment[]
modelCard: IModelCard
automationName: string
projectId: string
modelId: string
versionId?: string
}>()
</script>
@@ -0,0 +1,73 @@
<template>
<svg width="120" height="120" viewBox="0 0 120 120">
<circle cx="60" cy="60" r="40" fill="none" stroke="#e6e6e6" stroke-width="12" />
<circle
class="base stroke-red-400 origin-center"
:style="`${styles.failed}`"
cx="60"
cy="60"
r="40"
fill="none"
stroke-width="25"
pathLength="100"
/>
<circle
class="base stroke-green-400 origin-center"
:style="`${styles.passed}`"
cx="60"
cy="60"
r="40"
fill="none"
stroke-width="25"
pathLength="100"
/>
<circle
class="base stroke-amber-400 origin-center"
:style="`${styles.inProgress}`"
cx="60"
cy="60"
r="40"
fill="none"
stroke-width="25"
pathLength="100"
/>
</svg>
</template>
<script setup lang="ts">
import type { RunsStatusSummary } from '~/lib/automate/runStatus'
const props = defineProps<{
summary: RunsStatusSummary
}>()
// segment: percentage + offset, where offset = prev percentage in radians
const styles = computed(() => {
const failed = (props.summary.failed / props.summary.total) * 100
const offsetFailed = 0
const passed = (props.summary.passed / props.summary.total) * 100
const offsetPassed = 360 * (failed / 100)
const inProgress = (props.summary.inProgress / props.summary.total) * 100
const offsetInProgress = offsetPassed + 360 * (passed / 100)
const stylePack = {
failed: `stroke-dashoffset: ${
100 - failed
}; transform: rotate(${offsetFailed}deg);`,
passed: `stroke-dashoffset: ${
100 - passed
}; transform: rotate(${offsetPassed}deg);`,
inProgress: `stroke-dashoffset: ${
100 - inProgress
}; transform: rotate(${offsetInProgress}deg);`
}
return stylePack
})
</script>
<style scoped>
.base {
stroke-dasharray: 100;
transform-origin: center;
}
</style>
+335
View File
@@ -0,0 +1,335 @@
<template>
<TransitionRoot as="template" :show="open">
<Dialog as="div" class="relative z-50" open @close="onClose">
<TransitionChild
as="template"
enter="ease-out duration-300"
enter-from="opacity-0"
enter-to="opacity-100"
leave="ease-in duration-400"
leave-from="opacity-100"
leave-to="opacity-0"
>
<div
class="fixed top-0 left-0 w-full h-full backdrop-blur-xs bg-black/60 dark:bg-neutral-900/60 transition-opacity"
/>
</TransitionChild>
<div class="fixed top-0 left-0 z-10 h-screen !h-[100dvh] w-screen">
<div
class="flex md:justify-center h-full w-full"
:class="[
fullscreen === 'none' || fullscreen === 'desktop'
? 'p-1 items-center'
: 'items-end md:items-center'
]"
>
<TransitionChild
as="template"
enter="ease-out duration-5000"
:enter-from="`md:opacity-0 ${
fullscreen === 'mobile' || fullscreen === 'all'
? 'translate-y-[100%]'
: 'translate-y-4'
} md:translate-y-4`"
enter-to="md:opacity-100 translate-y-0"
leave="ease-in duration-5000"
leave-from="md:opacity-100 translate-y-0"
:leave-to="`md:opacity-0 ${
fullscreen === 'mobile' || fullscreen === 'all'
? 'translate-y-[100%]'
: 'translate-y-4'
} md:translate-y-4`"
@after-leave="$emit('fully-closed')"
>
<DialogPanel
:class="dialogPanelClasses"
dialog-panel-classes
:as="isForm ? 'form' : 'div'"
@submit.prevent="onFormSubmit"
>
<div
v-if="hasTitle"
class="border-b border-outline-3"
:class="scrolledFromTop && 'relative z-20 shadow-lg'"
>
<div
class="flex items-center justify-start rounded-t-lg shrink-0 min-h-[2rem] sm:min-h-[3rem] px-2 py-2 truncate text-heading-sm"
>
<div class="flex items-center pr-12 space-x-2">
<FormButton
v-if="showBackButton"
color="subtle"
size="sm"
class="!w-6 !h-6 !p-0"
@click="$emit('back')"
>
<ChevronLeftIcon class="w-4 h-4 text-foreground-2" />
</FormButton>
<div class="w-full truncate">
{{ title }}
<slot name="header" />
</div>
</div>
</div>
</div>
<!--
Due to how forms work, if there's no other submit button, on form submission the first button
will be clicked. This is a workaround to prevent the close button from being that first button.
https://stackoverflow.com/a/4763911/3194577
-->
<button class="hidden" type="button" />
<FormButton
v-if="!hideCloser"
color="subtle"
size="sm"
class="absolute z-20 top-2 right-2 shrink-0 !w-6 !h-6 !p-0"
@click="open = false"
>
<XMarkIcon class="h-6 w-6 text-foreground-2" />
</FormButton>
<div ref="slotContainer" :class="slotContainerClasses" @scroll="onScroll">
<slot>Put your content here!</slot>
</div>
<div
v-if="hasButtons"
class="relative z-50 flex justify-end px-2 pb-6 space-x-2 shrink-0 bg-foundation-page"
:class="{
'shadow-t pt-6': !scrolledToBottom,
[buttonsWrapperClasses || '']: true
}"
>
<template v-if="buttons">
<FormButton
v-for="(button, index) in buttons"
:key="button.id || index"
v-bind="button.props || {}"
:disabled="button.props?.disabled || button.disabled"
:submit="button.props?.submit || button.submit"
@click="($event) => button.onClick?.($event)"
>
{{ button.text }}
</FormButton>
</template>
<template v-else>
<slot name="buttons" />
</template>
</div>
</DialogPanel>
</TransitionChild>
</div>
</div>
</Dialog>
</TransitionRoot>
</template>
<script setup lang="ts">
import { Dialog, DialogPanel, TransitionChild, TransitionRoot } from '@headlessui/vue'
import { FormButton, type LayoutDialogButton } from '@speckle/ui-components'
import { XMarkIcon, ChevronLeftIcon } from '@heroicons/vue/24/outline'
import { useResizeObserver, type ResizeObserverCallback } from '@vueuse/core'
import { computed, ref, useSlots, watch, onUnmounted, type SetupContext } from 'vue'
import { throttle } from 'lodash'
import { isClient } from '@vueuse/core'
type MaxWidthValue = 'xs' | 'sm' | 'md' | 'lg' | 'xl'
type FullscreenValues = 'mobile' | 'desktop' | 'all' | 'none'
const emit = defineEmits<{
(e: 'update:open', v: boolean): void
(e: 'fully-closed'): void
(e: 'back'): void
}>()
const props = withDefaults(
defineProps<{
open: boolean
maxWidth?: MaxWidthValue
fullscreen?: FullscreenValues
hideCloser?: boolean
showBackButton?: boolean
/**
* Prevent modal from closing when the user clicks outside of the modal or presses Esc
*/
preventCloseOnClickOutside?: boolean
title?: string
buttons?: Array<LayoutDialogButton>
/**
* Extra classes to apply to the button container.
*/
buttonsWrapperClasses?: string
/**
* If set, the modal will be wrapped in a form element and the `onSubmit` callback will be invoked when the user submits the form
*/
onSubmit?: (e: SubmitEvent) => void
isTransparent?: boolean
}>(),
{
fullscreen: 'mobile'
}
)
const slots: SetupContext['slots'] = useSlots()
const scrolledFromTop = ref(false)
const scrolledToBottom = ref(true)
const slotContainer = ref<HTMLElement | null>(null)
useResizeObserver(
slotContainer,
throttle<ResizeObserverCallback>(() => {
// Triggering onScroll on size change too so that we don't get stuck with shadows
// even tho the new content is not scrollable
onScroll({ target: slotContainer.value })
}, 60)
)
const isForm = computed(() => !!props.onSubmit)
const hasButtons = computed(() => props.buttons || slots.buttons)
const hasTitle = computed(() => !!props.title || !!slots.header)
const open = computed({
get: () => props.open,
set: (newVal) => emit('update:open', newVal)
})
const maxWidthWeight = computed(() => {
switch (props.maxWidth) {
case 'xs':
return 0
case 'sm':
return 1
case 'md':
return 2
case 'lg':
return 3
case 'xl':
return 4
default:
return 10000
}
})
const widthClasses = computed(() => {
const classParts: string[] = ['w-full', 'sm:w-full']
if (!isFullscreenDesktop.value) {
if (maxWidthWeight.value === 0) {
classParts.push('md:max-w-sm')
}
if (maxWidthWeight.value >= 1) {
classParts.push('md:max-w-lg')
}
if (maxWidthWeight.value >= 2) {
classParts.push('md:max-w-2xl')
}
if (maxWidthWeight.value >= 3) {
classParts.push('lg:max-w-3xl')
}
if (maxWidthWeight.value >= 4) {
classParts.push('xl:max-w-6xl')
} else {
classParts.push('md:max-w-2xl')
}
}
return classParts.join(' ')
})
const isFullscreenDesktop = computed(
() => props.fullscreen === 'desktop' || props.fullscreen === 'all'
)
const dialogPanelClasses = computed(() => {
const classParts: string[] = [
'transform md:rounded-xl text-foreground overflow-hidden transition-all text-left flex flex-col md:h-auto'
]
if (!props.isTransparent) {
classParts.push('bg-foundation-page shadow-xl border border-outline-2')
}
if (isFullscreenDesktop.value) {
classParts.push('md:h-full')
} else {
classParts.push('md:max-h-[90vh]')
}
if (props.fullscreen === 'mobile' || props.fullscreen === 'all') {
classParts.push('max-md:h-[98vh] max-md:!h-[98dvh]')
}
if (props.fullscreen === 'none' || props.fullscreen === 'desktop') {
classParts.push('rounded-lg max-h-[90vh]')
} else {
classParts.push('rounded-t-lg')
}
classParts.push(widthClasses.value)
return classParts.join(' ')
})
const slotContainerClasses = computed(() => {
const classParts: string[] = ['flex-1 simple-scrollbar overflow-y-auto text-body-xs']
if (!props.isTransparent) {
if (hasTitle.value) {
classParts.push('px-2 py-2')
if (isFullscreenDesktop.value) {
classParts.push('md:p-0')
}
} else if (!isFullscreenDesktop.value) {
classParts.push('px-2 py-2')
}
}
return classParts.join(' ')
})
const onClose = () => {
if (props.preventCloseOnClickOutside) return
open.value = false
}
const onFormSubmit = (e: SubmitEvent) => {
props.onSubmit?.(e)
}
const onScroll = throttle((e: { target: EventTarget | null }) => {
if (!e.target) return
const target = e.target as HTMLElement
const { scrollTop, offsetHeight, scrollHeight } = target
scrolledFromTop.value = scrollTop > 0
scrolledToBottom.value = scrollTop + offsetHeight >= scrollHeight
}, 60)
// Toggle 'dialog-open' class on <html> to prevent scroll jumping and disable background scroll.
// This maintains user scroll position when Headless UI dialogs are activated.
watch(open, (newValue) => {
if (isClient) {
const html = document.documentElement
if (newValue) {
html.classList.add('dialog-open')
} else {
html.classList.remove('dialog-open')
}
}
})
// Clean up when the component unmounts
onUnmounted(() => {
if (isClient) {
document.documentElement.classList.remove('dialog-open')
}
})
</script>
<style>
html.dialog-open {
overflow: visible !important;
}
html.dialog-open body {
overflow: hidden !important;
}
</style>
+76
View File
@@ -0,0 +1,76 @@
<template>
<div class="bg-highlight-1 border-t border-t-highlight-3">
<div class="flex">
<div class="flex grow justify-between items-center py-2 pl-1 pr-1 min-w-0">
<div class="grow w-full min-w-0 flex space-x-1 items-center">
<ReportBase
v-if="notification.report"
:report="notification.report"
class="mt-[3px]"
/>
<div
v-tippy="notification.text"
:class="`${textClassColor} text-body-3xs transition line-clamp-1 text-ellipsis`"
>
{{ notification.text }}
</div>
</div>
<div class="flex items-center group">
<FormButton
v-if="notification.cta"
size="sm"
:color="notification.level === 'info' ? 'outline' : notification.level"
full-width
@click.stop="notification.cta?.action"
>
{{ notification.cta.name }}
</FormButton>
</div>
</div>
<div
v-if="notification.dismissible"
class="flex items-center w-0 group-hover:w-5 transition-[width]"
>
<FormButton
v-tippy="'Dismiss'"
color="subtle"
size="sm"
:icon-left="XMarkIcon"
hide-text
:disabled="!notification.dismissible"
@click.stop="$emit('dismiss')"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useTimeoutFn } from '@vueuse/core'
import type { ModelCardNotification } from '~/lib/models/card/notification'
import { XMarkIcon } from '@heroicons/vue/24/outline'
const props = defineProps<{
notification: ModelCardNotification
}>()
const emit = defineEmits(['dismiss'])
if (props.notification.timeout) {
useTimeoutFn(() => emit('dismiss'), props.notification.timeout)
}
const textClassColor = computed(() => {
switch (props.notification.level) {
case 'danger':
return 'text-red-500'
case 'info':
return 'text-foreground-2'
case 'success':
return 'text-foreground-2'
case 'warning':
return 'text-foreground-2'
default:
return 'text-foreground-2'
}
})
</script>
+245
View File
@@ -0,0 +1,245 @@
<template>
<div v-if="projectDetails" class="px-[2px] rounded-md">
<button
:class="`flex w-full items-center text-foreground-2 justify-between hover:bg-foundation-2 ${
showModels ? 'bg-foundation-2' : 'bg-foundation-2'
} rounded-md transition group`"
@click="showModels = !showModels"
>
<div class="flex items-center transition group-hover:text-primary h-8 min-w-0">
<CommonIconsArrowFilled
:class="`w-5 ${showModels ? '' : '-rotate-90'} transition`"
/>
<div class="text-sm text-left truncate select-none flex items-center leading-1">
<div class="text-heading-sm">{{ projectDetails.name }}</div>
<div v-if="!showModels" class="text-body-3xs opacity-50 ml-2 pt-[1px]">
{{ project.senders.length + project.receivers.length }}
</div>
</div>
</div>
<div class="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"
>
<ArrowTopRightOnSquareIcon
class="w-4"
@click.stop="
$openUrl(projectUrl),
trackEvent('DUI3 Action', { name: 'Project View' }, project.accountId)
"
/>
</button>
</div>
</button>
<div v-show="showModels" class="space-y-2 mt-2 pb-1">
<CommonAlert
v-if="isWorkspaceReadOnly"
size="xs"
:color="'warning'"
:actions="[
{
title: 'Subscribe',
onClick: () => $openUrl(workspaceUrl)
}
]"
>
<template #description>
The workspace is in a read-only locked state until there's an active
subscription. Subscribe to a plan to regain full access.
</template>
</CommonAlert>
<ModelSender
v-for="model in project.senders"
:key="model.modelCardId"
:model-card="model"
:project="project"
:can-edit="canPublish"
/>
<ModelReceiver
v-for="model in project.receivers"
:key="model.modelCardId"
:model-card="model"
:project="project"
:can-edit="canLoad"
/>
</div>
</div>
<div
v-if="projectIsAccesible && !projectIsAccesible"
class="px-2 py-4 bg-foundation dark:bg-neutral-700/10 rounded-md shadow"
>
<CommonAlert
color="danger"
with-dismiss
@dismiss="askDismissProjectQuestionDialog = true"
>
<template #title>
Whoops - project
<code>{{ project.projectId }}</code>
is inaccessible.
</template>
</CommonAlert>
<CommonDialog v-model:open="askDismissProjectQuestionDialog" fullscreen="none">
<template #header>Remove Project</template>
<div class="text-xs mb-4">Do you want to remove the project from this file?</div>
<div class="flex justify-between center py-2 space-x-3">
<FormButton size="sm" full-width @click="removeProjectModels">Yes</FormButton>
<FormButton
size="sm"
full-width
@click="askDismissProjectQuestionDialog = false"
>
Hide error
</FormButton>
</div>
</CommonDialog>
</div>
</template>
<script setup lang="ts">
import { useQuery, useSubscription } from '@vue/apollo-composable'
import { ArrowTopRightOnSquareIcon } from '@heroicons/vue/20/solid'
import type { ProjectModelGroup } from '~~/store/hostApp'
import { useHostAppStore } from '~~/store/hostApp'
import { useAccountStore } from '~~/store/accounts'
import {
projectDetailsQuery,
versionCreatedSubscription,
userProjectsUpdatedSubscription,
projectUpdatedSubscription
} from '~~/lib/graphql/mutationsAndQueries'
import { useMixpanel } from '~/lib/core/composables/mixpanel'
const { trackEvent } = useMixpanel()
const accountStore = useAccountStore()
const hostAppStore = useHostAppStore()
const { $openUrl } = useNuxtApp()
const props = defineProps<{
project: ProjectModelGroup
}>()
const showModels = ref(true)
const askDismissProjectQuestionDialog = ref(false)
const writeAccessRequested = ref(false)
const projectIsAccesible = ref<boolean | undefined>(undefined)
const projectAccount = computed(() =>
accountStore.accountWithFallback(props.project.accountId, props.project.serverUrl)
)
const clientId = projectAccount.value.accountInfo.id
const { result: projectDetailsResult, refetch: refetchProjectDetails } = useQuery(
projectDetailsQuery,
() => ({ projectId: props.project.projectId }),
() => ({ clientId, debounce: 500, fetchPolicy: 'network-only' })
)
const removeProjectModels = async () => {
await hostAppStore.removeProjectModels(props.project.projectId)
askDismissProjectQuestionDialog.value = false
}
const projectDetails = computed(() => projectDetailsResult.value?.project)
watch(projectDetails, (newValue) => {
projectIsAccesible.value = newValue !== undefined
})
const canLoad = computed(() => !!projectDetails.value?.permissions.canLoad.authorized)
const canPublish = computed(
() => !!projectDetails.value?.permissions.canPublish.authorized
)
const isWorkspaceReadOnly = computed(() => {
if (!projectDetails.value?.workspace) return false // project is not even in a workspace
return projectDetails.value?.workspace?.readOnly
})
// Enable later when FE2 is ready for accepting/denying requested accesses
// const hasServerMatch = computed(() =>
// accountStore.isAccountExistsByServer(props.project.serverUrl)
// )
// const requestWriteAccess = async () => {
// if (hasServerMatch.value) {
// const { mutate } = provideApolloClient((projectAccount.value as DUIAccount).client)(
// () => useMutation(requestProjectAccess)
// )
// const res = await mutate({
// input: projectDetails.value?.id as string
// })
// writeAccessRequested.value = true
// // TODO: It throws if it has already pending request, handle it!
// console.log(res)
// }
// }
const { onResult: userProjectsUpdated } = useSubscription(
userProjectsUpdatedSubscription,
() => ({}),
() => ({ clientId })
)
const { onResult: projectUpdated } = useSubscription(
projectUpdatedSubscription,
() => ({ projectId: props.project.projectId }),
() => ({ clientId })
)
// to catch changes on visibility of project
projectUpdated((res) => {
// TODO: FIX needed: whenever project visibility changed from "discoverable" to "private", we can't get message if the `clientId` is not part of the team
// validated with Fabians this is a current behavior.
if (!res.data) return
refetchProjectDetails()
})
// to catch changes on team of the project
userProjectsUpdated((res) => {
if (!res.data) return
refetchProjectDetails()
writeAccessRequested.value = false
})
const projectUrl = computed(() => {
const acc = accountStore.accounts.find((acc) => acc.accountInfo.id === clientId)
return `${acc?.accountInfo.serverInfo.url as string}/projects/${
props.project.projectId
}`
})
const workspaceUrl = computed(() => {
const acc = accountStore.accounts.find((acc) => acc.accountInfo.id === clientId)
return `${acc?.accountInfo.serverInfo.url as string}/workspaces/${
projectDetails.value?.workspace?.slug
}`
})
// Subscribe to version created events at a project level, and filter to any receivers (if any)
const { onResult } = useSubscription(
versionCreatedSubscription,
() => ({ projectId: props.project.projectId }),
() => ({ clientId })
)
onResult((res) => {
if (!res.data) return
if (res.data?.projectVersionsUpdated?.type !== 'CREATED') return
const relevantReceiver = props.project.receivers.find(
(r) => r.modelId === res.data?.projectVersionsUpdated.version?.model.id
)
if (!relevantReceiver) return
hostAppStore.patchModel(relevantReceiver.modelCardId, {
latestVersionId: res.data.projectVersionsUpdated.version?.id,
latestVersionCreatedAt: res.data.projectVersionsUpdated.version?.createdAt,
hasDismissedUpdateWarning: false,
displayReceiveComplete: false
})
})
</script>
+40
View File
@@ -0,0 +1,40 @@
<template>
<CommonAlert
v-if="!store.isConnectorUpToDate && !hasDismissedAlert"
v-tippy="
'Version: ' + store.latestAvailableVersion?.Number + ', released ' + createdAgo
"
color="neutral"
size="xs"
hide-icon
class="mb-2 mt-1"
>
<template #description>
<div class="flex items-center">
<div class="text-body-3xs truncate line-clamp-1 min-w-0">Update available</div>
<div class="inline-flex justify-end -mr-3 grow">
<FormButton size="sm" color="outline" @click="store.downloadLatestVersion()">
Download
</FormButton>
<FormButton
size="sm"
color="subtle"
hide-text
:icon-left="XMarkIcon"
@click="hasDismissedAlert = true"
/>
</div>
</div>
</template>
</CommonAlert>
</template>
<script setup lang="ts">
import dayjs from 'dayjs'
import { XMarkIcon } from '@heroicons/vue/24/outline'
import { useHostAppStore } from '~~/store/hostApp'
const store = useHostAppStore()
const hasDismissedAlert = ref(false)
const createdAgo = computed(() => {
return dayjs(store.latestAvailableVersion?.Date).from(dayjs())
})
</script>
+14
View File
@@ -0,0 +1,14 @@
<template>
<svg
width="16"
height="32"
viewBox="0 0 16 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M7.64645 17.7498C7.84171 17.9451 8.15829 17.9451 8.35355 17.7498L11.1464 14.9569C11.4614 14.642 11.2383 14.1034 10.7929 14.1034H5.20711C4.76165 14.1034 4.53857 14.642 4.85355 14.9569L7.64645 17.7498Z"
fill="currentColor"
/>
</svg>
</template>
+56
View File
@@ -0,0 +1,56 @@
<template>
<div :class="[containerStyle, loading ? 'opacity-100' : 'opacity-0']">
<div
:class="[progress ? '' : 'swoosher top-0', barStyle]"
:style="widthStyle"
></div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{
/**
* Whether we're actively loading. If set, the progress bar will be indefinite unless a progress argument is passed in too (see below).
*/
loading: boolean
/**
* A number between 0 and 1. If set, the progress bar will no longer be indefinite and have a fixed progress.
*/
progress?: number
}>()
const widthStyle = computed(() => {
if (!props.progress) return ''
return `width: ${props.progress * 100}%;`
})
const containerStyle = computed(() => {
return 'relative w-full h-1 bg-blue-500/30 text-xs text-foreground-on-primary overflow-hidden rounded-xl'
})
const barStyle = computed(() => {
return 'h-full relative bg-blue-500/50 transition-[width]'
})
</script>
<style scoped>
.swoosher {
width: 100%;
height: 100%;
animation: swoosh 1s infinite linear;
transform-origin: 0% 30%;
}
@keyframes swoosh {
0% {
transform: translateX(0) scaleX(0);
}
40% {
transform: translateX(0) scaleX(0.4);
}
100% {
transform: translateX(100%) scaleX(0.5);
}
}
</style>
+33
View File
@@ -0,0 +1,33 @@
<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>
<script setup lang="ts">
import type { JsonFormsChangeEvent } from '@jsonforms/vue'
const jsonSchema = {
type: 'object',
properties: {
acceptTerms: {
type: 'boolean',
title: 'I accept the terms and conditions'
},
username: { type: 'string', title: 'Username', default: 'a' },
color: {
type: 'string',
title: 'Favorite Color',
enum: ['red', 'green', 'blue']
}
}
}
const paramsFormState = ref<JsonFormsChangeEvent>()
const onParamsFormChange = (e: JsonFormsChangeEvent) => {
paramsFormState.value = e
console.log(JSON.stringify(e))
}
</script>
+33
View File
@@ -0,0 +1,33 @@
<template>
<CommonDialog
v-model:open="store.showErrorDialog"
fullscreen="none"
@close="store.showErrorDialog = false"
@fully-closed="store.setHostAppError(null)"
>
<template #header>
<div class="h5 font-bold">Host App Error</div>
</template>
<div class="text-foreground-2 text-sm font-normal mx-2 -mt-2">
<div class="text-s font-bold mb-2">{{ store.hostAppError?.message }}</div>
<div class="text-xs whitespace-pre-line truncate">
{{ store.hostAppError?.error }}
</div>
<button class="text-s font-bold my-2" @click="toggleStackTrace">
{{ showStackTrace ? 'Hide' : 'Show' }} Stack Trace
</button>
<div v-if="showStackTrace" class="text-xs whitespace-pre-line truncate">
{{ store.hostAppError?.stackTrace }}
</div>
</div>
</CommonDialog>
</template>
<script setup lang="ts">
import { useHostAppStore } from '~/store/hostApp'
const store = useHostAppStore()
const showStackTrace = ref(false)
const toggleStackTrace = () => {
showStackTrace.value = !showStackTrace.value
}
</script>
+114
View File
@@ -0,0 +1,114 @@
<template>
<CommonDialog
v-model:open="isOpen"
:title="dialogTitle"
:buttons="dialogButtons"
:on-submit="onSubmit"
max-width="md"
fullscreen="none"
>
<div class="flex flex-col gap-2">
<p class="text-body-xs text-foreground font-medium">
{{ dialogIntro }}
</p>
<FormTextArea
v-model="feedback"
name="feedback"
label="Feedback"
color="foundation"
/>
<p v-if="!hideSuppport" class="text-body-xs !leading-4">
Need help? For support, head over to our
<FormButton
target="_blank"
link
text
@click="$openUrl(`https://speckle.community/`)"
>
community forum
</FormButton>
where we can chat and solve problems together.
</p>
</div>
</CommonDialog>
</template>
<script setup lang="ts">
import { ToastNotificationType, type LayoutDialogButton } from '@speckle/ui-components'
import { useForm } from 'vee-validate'
import { useZapier } from '~/lib/core/composables/zapier'
import { useMixpanel } from '~/lib/core/composables/mixpanel'
import { useAccountStore } from '~/store/accounts'
import { useHostAppStore } from '~/store/hostApp'
type FormValues = { feedback: string }
const props = defineProps<{
title?: string
intro?: string
hideSuppport?: boolean
metadata?: Record<string, unknown>
}>()
const isOpen = defineModel<boolean>('open', { required: true })
const { trackEvent } = useMixpanel()
const { sendWebhook } = useZapier()
const { handleSubmit } = useForm<FormValues>()
const accountStore = useAccountStore()
const hostApp = useHostAppStore()
const feedback = ref('')
const dialogButtons = computed((): LayoutDialogButton[] => [
{
text: 'Send',
props: { color: 'primary' },
submit: true,
id: 'sendFeedback'
}
])
const dialogTitle = computed(() => props.title || 'Give us feedback')
const dialogIntro = computed(
() =>
props.intro ||
'How can we improve Speckle? If you have a feature request, please also share how you would use it and why its important to you'
)
const onSubmit = handleSubmit(async () => {
if (!feedback.value) return
isOpen.value = false
trackEvent('Feedback Sent', {
message: feedback.value,
feedbackType: 'dui3',
...props.metadata
})
hostApp.setNotification({
type: ToastNotificationType.Success,
title: 'Thank you for your feedback!'
})
const userId = accountStore.defaultAccount.accountInfo.userInfo.id ?? ''
await sendWebhook('https://hooks.zapier.com/hooks/catch/12120532/2m4okri/', {
userId,
feedback: [
`**Action:** User Feedback`,
`**Type:** dui3`,
`**User ID:** ${userId}`,
`**Feedback:** ${feedback.value}`
].join('\n')
})
})
watch(isOpen, (newVal) => {
if (newVal) {
feedback.value = ''
}
})
</script>
+126
View File
@@ -0,0 +1,126 @@
<template>
<div class="space-y-2">
<div>
<FormSelectBase
v-model="selectedFilterName"
name="sendFilter"
label="Selected filter"
class="w-full"
fixed-height
size="sm"
:items="filterNames"
:allow-unset="false"
mount-menu-on-body
>
<template #something-selected="{ value }">
<span class="text-primary text-base text-sm">{{ value }}</span>
</template>
<template #option="{ item }">
<span class="text-base text-sm">{{ item }}</span>
</template>
</FormSelectBase>
</div>
<div v-if="selectedFilter">
<div
v-if="
selectedFilter.id === 'everything' || selectedFilter.name === 'Everything' // TODO: damn. remove name check later, if we remove now it will break production... we should differentiate its id and display name
"
>
<div class="p-4 text-primary bg-blue-500/10 rounded-md text-xs">
All supported objects will be sent. Depending on the model, this might take a
while.
</div>
</div>
<div
v-else-if="
selectedFilter.type === 'Select' &&
store.availableSelectSendFilters[selectedFilter.id]
"
>
<FilterFormSelect
:label="selectedFilter.name"
:items="(store.availableSelectSendFilters[selectedFilter.id].items as ISendFilterSelectItem[])"
:filter="(selectedFilter as SendFilterSelect)"
@update:filter="(filter : ISendFilter) => (selectedFilter = filter)"
/>
</div>
<div
v-else-if="
selectedFilter.id === 'selection' || selectedFilter.name === 'Selection' // TODO: damn. remove name check later, if we remove now it will break production... we should differentiate its id and display name
"
>
<FilterSelection
:filter="(selectedFilter as IDirectSelectionSendFilter)"
@update:filter="(filter : ISendFilter) => (selectedFilter = filter)"
/>
</div>
<div v-else-if="selectedFilter.id === 'revitViews'">
<FilterRevitViews
:filter="(selectedFilter as RevitViewsSendFilter)"
@update:filter="(filter : ISendFilter) => (selectedFilter = filter)"
/>
</div>
<div v-else-if="selectedFilter.id === 'revitCategories'">
<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
label="Saved Sets"
:items="(store.navisworksAvailableSavedSets as ISendFilterSelectItem[])"
:filter="(selectedFilter as SendFilterSelect)"
@update:filter="(filter : ISendFilter) => (selectedFilter = filter)"
/>
</div>
</div>
<div v-if="!!filter" class="text-xs caption rounded p-2 bg-orange-500/10">
This action will replace the existing
<b>{{ selectedFilterName }}</b>
filter.
</div>
</div>
</template>
<script setup lang="ts">
import type {
ISendFilter,
IDirectSelectionSendFilter,
RevitCategoriesSendFilter,
ISendFilterSelectItem,
SendFilterSelect,
RevitViewsSendFilter
} from '~/lib/models/card/send'
import { useHostAppStore } from '~~/store/hostApp'
import { storeToRefs } from 'pinia'
const store = useHostAppStore()
const { sendFilters, selectionFilter } = storeToRefs(store)
// NOTE: we're forcefully refreshing filters here because revit 2022 does not surface up views on change events, so we cannot trigger it from the host app
// on a need by basis. This way, we're forcing all host apps to give us an updated list of send filters, as it's a cheap operation (and should stay so!).
void store.refreshSendFilters()
const props = defineProps<{
filter?: ISendFilter
}>()
const emit = defineEmits<{ (e: 'update:filter', value: ISendFilter): void }>()
const selectedFilter = ref<ISendFilter>(props.filter || selectionFilter.value)
const selectedFilterName = ref(
props.filter?.name || sendFilters.value?.find((f) => f.isDefault)?.name
)
const filterNames = computed(() => sendFilters.value?.map((f) => f.name))
watch(selectedFilterName, (newValue) => {
selectedFilter.value = sendFilters.value?.find(
(f) => f.name === newValue
) as ISendFilter
})
watch(selectedFilter, (newValue) => {
emit('update:filter', newValue)
})
</script>
+43
View File
@@ -0,0 +1,43 @@
<template>
<div class="space-y-2 p-2 bg-highlight-1 rounded-md text-body-xs">
<div v-if="selectionStore.selectionInfo.selectedObjectIds?.length === 0">
No objects selected, go ahead and select some from your model!
</div>
<div v-else>{{ selectionStore.selectionInfo.summary }}</div>
</div>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import type { IDirectSelectionSendFilter, ISendFilter } from 'lib/models/card/send'
import { useHostAppStore } from '~~/store/hostApp'
import { useSelectionStore } from '~~/store/selection'
const emit = defineEmits<{
(e: 'update:filter', filter: ISendFilter): void
}>()
const store = useHostAppStore()
const { selectionFilter } = storeToRefs(store)
const selectionStore = useSelectionStore()
const { selectionInfo } = storeToRefs(selectionStore)
defineProps<{
filter: IDirectSelectionSendFilter
}>()
watch(
selectionInfo,
(newValue) => {
const filter = { ...selectionFilter.value } as IDirectSelectionSendFilter
filter.selectedObjectIds = newValue.selectedObjectIds
filter.summary = newValue.summary as string
emit('update:filter', filter)
},
{ deep: true, immediate: true }
)
onMounted(() => {
selectionStore.refreshSelectionFromHostApp()
})
</script>
+83
View File
@@ -0,0 +1,83 @@
<template>
<FormSelectBase
v-model="selectedItems"
:items="items"
:search="true"
:search-placeholder="''"
:filter-predicate="searchFilterPredicate"
:label="label"
:name="label"
placeholder="Nothing selected"
class="w-full"
fixed-height
show-label
:allow-unset="false"
mount-menu-on-body
:multiple="filter.isMultiSelectable"
by="id"
>
<template #option="{ item }">
<span class="text-base text-sm">{{ item.name }}</span>
</template>
<template #something-selected="{ value }">
<span class="text-primary text-base text-sm">
{{
filter.isMultiSelectable
? (value as ISendFilterSelectItem[]).map((v) => v.name).join(', ')
: (value as ISendFilterSelectItem).name
}}
</span>
</template>
</FormSelectBase>
</template>
<script setup lang="ts">
import type {
ISendFilter,
SendFilterSelect,
ISendFilterSelectItem
} from '~/lib/models/card/send'
const emit = defineEmits<{
(e: 'update:filter', filter: ISendFilter): void
}>()
const props = defineProps<{
label: string
filter: SendFilterSelect
items: ISendFilterSelectItem[]
}>()
const selectedItems = ref<ISendFilterSelectItem[]>(props.filter.selectedItems)
const searchFilterPredicate = (item: ISendFilterSelectItem, search: string) =>
item.name.toLocaleLowerCase().includes(search.toLocaleLowerCase())
watch(
selectedItems,
(newValue) => {
// At first it trigger undefined change
if (!newValue) {
return
}
// unless isMultiSelectable, newValue arrives as ISendFilterSelectItem
if (!Array.isArray(newValue)) {
const filter = { ...props.filter } as SendFilterSelect
filter.selectedItems = [newValue]
filter.summary = (newValue as ISendFilterSelectItem).name
emit('update:filter', filter)
return
}
// if isMultiSelectable, newValue arrives as ISendFilterSelectItem[]
const filter = { ...props.filter } as SendFilterSelect
filter.selectedItems = newValue as ISendFilterSelectItem[]
filter.summary = props.filter.isMultiSelectable
? newValue.map((v) => v.name).join(', ')
: newValue[0].name
emit('update:filter', filter)
},
{ deep: true, immediate: true }
)
</script>
+124
View File
@@ -0,0 +1,124 @@
<!-- eslint-disable vuejs-accessibility/no-static-element-interactions -->
<!-- eslint-disable vuejs-accessibility/click-events-have-key-events -->
<template>
<div class="mt-4 space-y-2">
<div class="flex items-center space-x-2 justify-between">
<FormTextInput
v-model="searchValue"
placeholder="Search"
name="search"
autocomplete="off"
:show-clear="!!searchValue"
full-width
color="foundation"
/>
</div>
<div class="flex space-y-1 flex-col">
<div
v-for="cat in selectedCategoriesObjects.sort((a, b) =>
a.name.localeCompare(b.name)
)"
:key="cat.id"
>
<!-- We were use to use FormButton for this but our lovely Revit 2022 (CEF 65) but it didn't work properly in terms of CSS. -->
<div
v-tippy="'Remove'"
:class="`block h-6 text-body-2xs px-2 py-1 rounded-md flex align-center justify-between w-full hover:cursor-pointer hover:shadow-md bg-primary text-foreground-on-primary border-outline-2 text-foreground font-medium p-1 border focus-visible:border-foundation`"
@click="selectOrUnselectCategory(cat.id)"
>
<span>{{ cat.name }}</span>
<XMarkIcon class="w-4" />
</div>
</div>
</div>
<div
class="flex space-y-1 flex-col simple-scrollbar overflow-y-auto min-h-0 max-h-48 overflow-x-hidden"
>
<!-- We were use to use FormButton for this but our lovely Revit 2022 (CEF 65) but it didn't work properly in terms of CSS. -->
<div
v-for="cat in searchResults.sort((a, b) => a.name.localeCompare(b.name))"
:key="cat.id"
v-tippy="'Add'"
:class="`block h-6 text-body-2xs ${
selectedCategories.includes(cat.id) ? 'bg-primary' : ''
} px-2 py-1 rounded-md align-center justify-between w-full hover:cursor-pointer hover:shadow-md bg-foundation border-outline-2 text-foreground font-medium p-1 hover:bg-primary-muted border disabled:hover:bg-foundation focus-visible:border-foundation`"
@click="selectOrUnselectCategory(cat.id)"
>
<span>{{ cat.name }}</span>
<!-- <PlusIcon class="w-4" /> -->
</div>
<div v-if="searchResults.length === 0" class="text-xs text-center">
Nothing found
<FormButton color="outline" size="sm" @click="searchValue = undefined">
Clear search
</FormButton>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { XMarkIcon } from '@heroicons/vue/20/solid'
import type {
CategoriesData,
ISendFilter,
RevitCategoriesSendFilter
} from '~/lib/models/card/send'
const searchValue = ref<string>()
const emit = defineEmits<{
(e: 'update:filter', filter: ISendFilter): void
}>()
const props = defineProps<{
filter: RevitCategoriesSendFilter
}>()
const availableCategories = ref<CategoriesData[]>(props.filter.availableCategories)
const searchResults = computed(() => {
const searchVal = searchValue.value
if (!searchVal?.length)
return availableCategories.value.filter(
(cat) => !selectedCategories.value.includes(cat.id)
)
return availableCategories.value.filter(
(cat) =>
cat.name.toLowerCase().includes(searchVal.toLowerCase()) &&
!selectedCategories.value.includes(cat.id)
)
})
const selectedCategories = ref<string[]>(props.filter.selectedCategories || [])
const selectOrUnselectCategory = (id: string) => {
const index = selectedCategories.value.indexOf(id)
if (index !== -1) {
selectedCategories.value.splice(index, 1)
} else {
selectedCategories.value.push(id)
}
}
const selectedCategoriesObjects = computed(() => {
return selectedCategories.value.map((id) =>
availableCategories.value.find((cat) => cat.id === id)
) as CategoriesData[]
})
watch(
selectedCategoriesObjects,
(newValue) => {
const filter = { ...props.filter } as RevitCategoriesSendFilter
const names = newValue.map((v) => v.name)
filter.selectedCategories = availableCategories.value
.filter((c) => names.includes(c.name))
.map((c) => c.id)
filter.summary = names.join(', ')
emit('update:filter', filter)
},
{ deep: true, immediate: true }
)
</script>
+58
View File
@@ -0,0 +1,58 @@
<template>
<div class="mt-4 space-y-2">
<FormSelectBase
key="name"
v-model="selectedView"
:search="true"
:search-placeholder="''"
:filter-predicate="searchFilterPredicate"
name="view"
label="View"
placeholder="Nothing selected"
class="w-full"
fixed-height
show-label
:items="store.availableViews"
:allow-unset="false"
mount-menu-on-body
>
<template #something-selected="{ value }">
<span class="text-primary text-base text-sm">{{ value }}</span>
</template>
<template #option="{ item }">
<span class="text-base text-sm">{{ item }}</span>
</template>
</FormSelectBase>
</div>
</template>
<script setup lang="ts">
import { useHostAppStore } from '~/store/hostApp'
import type { ISendFilter, RevitViewsSendFilter } from '~/lib/models/card/send'
const store = useHostAppStore()
const emit = defineEmits<{
(e: 'update:filter', filter: ISendFilter): void
}>()
const props = defineProps<{
filter: RevitViewsSendFilter
}>()
const selectedView = ref<string>(props.filter.selectedView)
const searchFilterPredicate = (item: string, search: string) =>
item.toLocaleLowerCase().includes(search.toLocaleLowerCase())
watch(
selectedView,
(newValue) => {
const filter = { ...props.filter } as RevitViewsSendFilter
filter.selectedView = newValue as string
filter.summary = newValue
emit('update:filter', filter)
},
{ deep: true, immediate: true }
)
</script>
@@ -0,0 +1,38 @@
<template>
<div>
<div class="text-foreground-2 text-body-2xs mb-1 pl-1">{{ control.label }}</div>
<FormSwitch
:name="fieldName"
:disabled="!control.enabled"
:model-value="modelValue"
:rules="validator"
:label="control.label"
:value="true"
:description="control.description"
:show-label="false"
size="xl"
:validate-on-value-update="validateOnValueUpdate"
@update:model-value="handleChange"
/>
</div>
</template>
<script setup lang="ts">
import type { ControlElement } from '@jsonforms/core'
import { rendererProps, useJsonFormsControl } from '@jsonforms/vue'
import { useJsonRendererBaseSetup } from '~/lib/form/composables/jsonRenderers'
const props = defineProps({
...rendererProps<ControlElement>()
})
const { handleChange, control, validator, fieldName, validateOnValueUpdate } =
useJsonRendererBaseSetup(useJsonFormsControl(props), {
onChangeValueConverter: (val: true | undefined) => {
return !!val
}
})
const modelValue = computed(() => {
return control.value.data ? true : undefined
})
</script>
@@ -0,0 +1,35 @@
<template>
<FormTextInput
:name="fieldName"
:disabled="!control.enabled"
:model-value="control.data"
:rules="validator"
:label="control.label"
show-label
type="date"
size="lg"
max="9999-12-31"
:placeholder="appliedOptions['placeholder']"
:help="control.description"
:validate-on-value-update="validateOnValueUpdate"
@update:model-value="handleChange"
/>
</template>
<script setup lang="ts">
import type { ControlElement } from '@jsonforms/core'
import { rendererProps, useJsonFormsControl } from '@jsonforms/vue'
import { useJsonRendererBaseSetup } from '~/lib/form/composables/jsonRenderers'
const props = defineProps({
...rendererProps<ControlElement>()
})
const {
handleChange,
control,
validator,
appliedOptions,
fieldName,
validateOnValueUpdate
} = useJsonRendererBaseSetup(useJsonFormsControl(props))
</script>
@@ -0,0 +1,49 @@
<template>
<FormTextInput
:name="fieldName"
:disabled="!control.enabled"
:model-value="modelValue"
:rules="validator"
:label="control.label"
show-label
type="datetime-local"
size="lg"
max="9999-12-31T23:59"
:placeholder="appliedOptions['placeholder']"
:help="control.description"
:validate-on-value-update="validateOnValueUpdate"
@update:model-value="handleChange"
/>
</template>
<script setup lang="ts">
import type { ControlElement } from '@jsonforms/core'
import { rendererProps, useJsonFormsControl } from '@jsonforms/vue'
import { useJsonRendererBaseSetup } from '~/lib/form/composables/jsonRenderers'
const zuluTimeSuffix = ':00.000Z'
const props = defineProps({
...rendererProps<ControlElement>()
})
const toISOString = (inputDateTime: string) => {
return inputDateTime ? inputDateTime + zuluTimeSuffix : undefined
}
const {
handleChange,
control,
validator,
appliedOptions,
fieldName,
validateOnValueUpdate
} = useJsonRendererBaseSetup(useJsonFormsControl(props), {
onChangeValueConverter: (val) => toISOString(val as string)
})
const modelValue = computed(() =>
control.value.data
? (control.value.data as string).replace(zuluTimeSuffix, '')
: undefined
)
</script>
@@ -0,0 +1,124 @@
<template>
<div>
<div class="text-foreground-2 text-body-2xs mb-1 pl-1">{{ control.label }}</div>
<FormSelectBase
:model-value="modelValue"
:name="fieldName"
:rules="validator"
:label="control.label"
:items="control.options"
:multiple="multiple"
:help="control.description"
:allow-unset="false"
by="value"
button-style="tinted"
:validate-on-value-update="validateOnValueUpdate"
mount-menu-on-body
@update:model-value="handleChange"
>
<template #nothing-selected>
{{
appliedOptions['placeholder']
? appliedOptions['placeholder']
: multiple
? 'Select values'
: 'Select a value'
}}
</template>
<template #something-selected="{ value }">
<template v-if="isMultiItemArrayValue(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 h-6"
>
<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 v-else>
<div class="flex items-center">
<span class="truncate text-foreground">
{{ (isArrayValue(value) ? value[0] : value).label }}
</span>
</div>
</template>
</template>
<template #option="{ item }">
<div class="flex items-center text-foreground-2 text-body-2xs">
<span class="truncate">{{ item.label }}</span>
</div>
</template>
</FormSelectBase>
</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 { 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: false
},
controlOverrides: {
type: Object as PropType<Nullable<ReturnType<typeof useJsonFormsEnumControl>>>,
default: null
}
})
const elementToWatchForChanges = ref(null as Nullable<HTMLElement>)
const itemContainer = ref(null as Nullable<HTMLElement>)
const { hiddenSelectedItemCount, isArrayValue, isMultiItemArrayValue } =
useFormSelectChildInternals<OptionType>({
props: toRefs(props),
emit,
dynamicVisibility: { elementToWatchForChanges, itemContainer }
})
const {
handleChange,
control,
validator,
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 string
const res = control.value.options.find((o) => o.value === val)
if (props.multiple) {
return res ? [res] : []
} else {
return res || undefined
}
})
</script>
@@ -0,0 +1,17 @@
<template>
<FormJsonEnumControlRenderer
v-bind="$props"
:multiple="false"
:control-overrides="controlOverrides"
/>
</template>
<script setup lang="ts">
import type { ControlElement } from '@jsonforms/core'
import { rendererProps, useJsonFormsOneOfEnumControl } from '@jsonforms/vue'
const props = defineProps({
...rendererProps<ControlElement>()
})
const controlOverrides = useJsonFormsOneOfEnumControl(props)
</script>
+79
View File
@@ -0,0 +1,79 @@
<template>
<form class="flex flex-col space-y-4 form-json-form">
<JsonForms
ref="internalRef"
:renderers="renderers"
:schema="finalSchema"
:uischema="finalUiSchema"
:data="data"
@change="onChange"
/>
</form>
</template>
<script setup lang="ts">
import type { JsonSchema, UISchemaElement } from '@jsonforms/core'
import type { JsonFormsChangeEvent } from '@jsonforms/vue'
import { JsonForms } from '@jsonforms/vue'
import type { Nullable, Optional } from '@speckle/shared'
import { omit } from 'lodash-es'
import { useForm } from 'vee-validate'
import { renderers } from '~/lib/form/jsonRenderers'
type DataType = Record<string, unknown>
const emit = defineEmits<(e: 'change', val: JsonFormsChangeEvent) => void>()
const props = defineProps<{
schema: JsonSchema
uiSchema?: UISchemaElement
data?: DataType
}>()
const { validate } = useForm()
const internalRef = ref<Nullable<{ jsonforms: { core: JsonFormsChangeEvent } }>>(null)
// const data = ref({})
const finalSchema = computed(() => {
const base = props.schema
return omit(base, ['$schema', '$id'])
})
const autoGeneratedUiSchema = computed(() => {
const properties = Object.keys(props.schema.properties || {})
return {
type: 'VerticalLayout',
elements: properties.map((p) => ({
type: 'Control',
scope: `#/properties/${p}`
}))
}
})
const finalUiSchema = computed(() => props.uiSchema || autoGeneratedUiSchema.value)
const onChange = async (e: JsonFormsChangeEvent) => {
// console.log(JSON.parse(JSON.stringify(e)))
// NOTE: setting data.value causes trigger again
// data.value = e.data as DataType
await validate({ mode: 'force' })
emit('change', e)
}
const getFormState = (): Optional<JsonFormsChangeEvent> =>
internalRef.value?.jsonforms.core
? ({
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
data: internalRef.value.jsonforms.core.data,
errors: internalRef.value.jsonforms.core.errors
} as JsonFormsChangeEvent)
: undefined
defineExpose({ getFormState })
</script>
<style lang="postcss">
.form-json-form {
.vertical-layout {
@apply space-y-4;
}
}
</style>
@@ -0,0 +1,37 @@
<template>
<FormTextInput
:name="fieldName"
:disabled="!control.enabled"
:model-value="control.data + ''"
:rules="validator"
:label="control.label"
:placeholder="appliedOptions['placeholder']"
:help="control.description"
type="number"
step="1"
size="lg"
show-label
:validate-on-value-update="validateOnValueUpdate"
@update:model-value="handleChange"
/>
</template>
<script setup lang="ts">
import type { ControlElement } from '@jsonforms/core'
import { rendererProps, useJsonFormsControl } from '@jsonforms/vue'
import { useJsonRendererBaseSetup } from '~/lib/form/composables/jsonRenderers'
const props = defineProps({
...rendererProps<ControlElement>()
})
const {
handleChange,
control,
validator,
appliedOptions,
fieldName,
validateOnValueUpdate
} = useJsonRendererBaseSetup(useJsonFormsControl(props), {
onChangeValueConverter: (val: string) => (val ? parseInt(val) : undefined)
})
</script>
@@ -0,0 +1,32 @@
<template>
<FormTextArea
:name="fieldName"
:disabled="!control.enabled"
:model-value="control.data"
:rules="validator"
:placeholder="appliedOptions['placeholder']"
:help="control.description"
:label="control.label"
show-label
:validate-on-value-update="validateOnValueUpdate"
@update:model-value="handleChange"
/>
</template>
<script setup lang="ts">
import type { ControlElement } from '@jsonforms/core'
import { rendererProps, useJsonFormsControl } from '@jsonforms/vue'
import { useJsonRendererBaseSetup } from '~/lib/form/composables/jsonRenderers'
const props = defineProps({
...rendererProps<ControlElement>()
})
const {
handleChange,
control,
validator,
appliedOptions,
fieldName,
validateOnValueUpdate
} = useJsonRendererBaseSetup(useJsonFormsControl(props))
</script>
@@ -0,0 +1,36 @@
<template>
<FormTextInput
:name="fieldName"
:disabled="!control.enabled"
:model-value="control.data + ''"
:rules="validator"
:label="control.label"
type="number"
size="lg"
show-label
:placeholder="appliedOptions['placeholder']"
:help="control.description"
:validate-on-value-update="validateOnValueUpdate"
@update:model-value="handleChange"
/>
</template>
<script setup lang="ts">
import type { ControlElement } from '@jsonforms/core'
import { rendererProps, useJsonFormsControl } from '@jsonforms/vue'
import { useJsonRendererBaseSetup } from '~/lib/form/composables/jsonRenderers'
const props = defineProps({
...rendererProps<ControlElement>()
})
const {
handleChange,
control,
validator,
appliedOptions,
fieldName,
validateOnValueUpdate
} = useJsonRendererBaseSetup(useJsonFormsControl(props), {
onChangeValueConverter: (val: string) => (val ? Number(val) : undefined)
})
</script>
@@ -0,0 +1,33 @@
<template>
<FormTextInput
:name="fieldName"
:disabled="!control.enabled"
:model-value="control.data"
:rules="validator"
:label="control.label"
:placeholder="appliedOptions['placeholder']"
:help="control.description"
show-label
size="lg"
:validate-on-value-update="validateOnValueUpdate"
@update:model-value="handleChange"
/>
</template>
<script setup lang="ts">
import type { ControlElement } from '@jsonforms/core'
import { rendererProps, useJsonFormsControl } from '@jsonforms/vue'
import { useJsonRendererBaseSetup } from '~/lib/form/composables/jsonRenderers'
const props = defineProps({
...rendererProps<ControlElement>()
})
const {
handleChange,
control,
validator,
appliedOptions,
fieldName,
validateOnValueUpdate
} = useJsonRendererBaseSetup(useJsonFormsControl(props))
</script>
@@ -0,0 +1,35 @@
<template>
<FormTextInput
:name="fieldName"
:disabled="!control.enabled"
:model-value="control.data"
:rules="validator"
:label="control.label"
show-label
type="time"
step="1"
size="lg"
:placeholder="appliedOptions['placeholder']"
:help="control.description"
:validate-on-value-update="validateOnValueUpdate"
@update:model-value="handleChange"
/>
</template>
<script setup lang="ts">
import type { ControlElement } from '@jsonforms/core'
import { rendererProps, useJsonFormsControl } from '@jsonforms/vue'
import { useJsonRendererBaseSetup } from '~/lib/form/composables/jsonRenderers'
const props = defineProps({
...rendererProps<ControlElement>()
})
const {
handleChange,
control,
validator,
appliedOptions,
fieldName,
validateOnValueUpdate
} = useJsonRendererBaseSetup(useJsonFormsControl(props))
</script>
+7
View File
@@ -0,0 +1,7 @@
<template>
<button
class="relative group p-1 rounded-md text-foreground-2 hover:text-primary hover:bg-highlight-1 transition"
>
<slot default></slot>
</button>
</template>
+25
View File
@@ -0,0 +1,25 @@
<template>
<!-- <NuxtLink class="flex items-center" to="/"> -->
<img
class="block h-5 w-5"
:class="{ 'mr-2': !minimal, grayscale: active }"
src="~~/assets/images/speckle_logo_big.png"
alt="Speckle"
/>
<!-- <div v-if="!minimal" class="text-primary h6 mt-0 font-bold leading-7 md:flex">
Speckle
</div> -->
<!-- </NuxtLink> -->
</template>
<script setup lang="ts">
defineProps({
minimal: {
type: Boolean,
default: false
},
active: {
type: Boolean,
default: true
}
})
</script>
+102
View File
@@ -0,0 +1,102 @@
<template>
<nav
v-if="!hasNoModelCards"
class="fixed top-0 h-9 flex items-center bg-foundation border-b border-outline-2 w-full transition z-20"
>
<div class="flex items-center transition-all justify-between w-full">
<div class="flex items-center space-x-2">
<div class="max-[200px]:hidden block ml-2">
<img
class="block h-6 w-6"
src="~~/assets/images/speckle_logo_big.png"
alt="Speckle"
/>
</div>
<div class="relative group flex items-center">
<FormButton
v-tippy="'Publish objects from this file to a new Speckle model'"
color="outline"
size="sm"
class="relative group px-0"
:icon-left="ArrowUpTrayIcon"
hide-text
@click="showSendDialog = true"
></FormButton>
</div>
<div class="relative group flex items-center">
<FormButton
v-tippy="'Load a model from Speckle into this file'"
color="outline"
size="sm"
class="relative group px-0"
:icon-left="ArrowDownTrayIcon"
hide-text
@click="showReceiveDialog = true"
></FormButton>
</div>
</div>
<div class="flex justify-between items-center pr-1">
<!-- <FormButton
v-if="!hostAppStore.isConnectorUpToDate"
v-tippy="hostAppStore.latestAvailableVersion?.Number.replace('+0', '')"
:icon-right="ArrowUpCircleIcon"
size="sm"
color="subtle"
class="flex min-w-0 transition text-primary py-1 mr-1"
@click.stop="hostAppStore.downloadLatestVersion()"
>
<span class="">Update</span>
</FormButton> -->
<div class="text-[8px] text-foreground-disabled max-[150px]:hidden">
{{ hostAppStore.connectorVersion }}
</div>
<HeaderButton
v-tippy="'Documentation and help'"
@click="
app.$openUrl(
`https://www.speckle.systems/connectors/${hostAppStore.hostAppName}?utm=dui`
)
"
>
<QuestionMarkCircleIcon
class="w-4 text-foreground-disabled group-hover:text-foreground-2"
/>
</HeaderButton>
<HeaderButton v-tippy="'Send us feedback'" @click="showFeedbackDialog = true">
<ChatBubbleLeftIcon
class="w-4 text-foreground-disabled group-hover:text-foreground-2"
/>
</HeaderButton>
<HeaderUserMenu />
</div>
</div>
<FeedbackDialog v-model:open="showFeedbackDialog" />
<SendWizard v-model:open="showSendDialog" @close="showSendDialog = false" />
<ReceiveWizard
v-model:open="showReceiveDialog"
@close="showReceiveDialog = false"
/>
</nav>
</template>
<script setup lang="ts">
import {
ArrowUpTrayIcon,
ArrowDownTrayIcon,
QuestionMarkCircleIcon,
ChatBubbleLeftIcon
} from '@heroicons/vue/24/solid'
import { useHostAppStore } from '~/store/hostApp'
const app = useNuxtApp()
const hostAppStore = useHostAppStore()
const hasNoModelCards = computed(() => hostAppStore.projectModelGroups.length === 0)
const showFeedbackDialog = ref<boolean>(false)
const showSendDialog = ref<boolean>(false)
const showReceiveDialog = ref<boolean>(false)
app.$baseBinding.on('documentChanged', () => {
showSendDialog.value = false
showReceiveDialog.value = false
})
</script>
+46
View File
@@ -0,0 +1,46 @@
<template>
<nav
v-if="!hasNoModelCards"
class="fixed top-0 h-10 bg-foundation max-w-full w-full shadow hover:shadow-md transition z-20"
>
<div class="px-2 select-none">
<div class="flex items-center h-10 transition-all justify-between">
<div class="flex items-center">
<HeaderLogoBlock :active="false" minimal class="mr-0" />
<!-- <div class="ml-2">Speckle</div> -->
<!-- <div
title="3.0 is coming!"
class="ml-1 text-tiny bg-primary rounded-full px-2 py-[2px] text-foreground-on-primary transition hover:scale-110"
>
beta
</div> -->
<div class="flex flex-shrink-0 items-center -ml-2 md:ml-0">
<PortalTarget name="navigation"></PortalTarget>
</div>
</div>
<div class="flex justify-between items-center">
<FormButton
v-if="!hostAppStore.isConnectorUpToDate"
v-tippy="hostAppStore.latestAvailableVersion?.Number.replace('+0', '')"
:icon-right="ArrowUpCircleIcon"
size="sm"
color="subtle"
class="flex min-w-0 transition text-primary py-1 mr-1"
@click.stop="hostAppStore.downloadLatestVersion()"
>
<span class="">Update</span>
</FormButton>
<HeaderUserMenu />
</div>
</div>
</div>
</nav>
</template>
<script setup lang="ts">
import { ArrowUpCircleIcon } from '@heroicons/vue/24/outline'
import { useHostAppStore } from '~/store/hostApp'
const hostAppStore = useHostAppStore()
const hasNoModelCards = computed(() => hostAppStore.projectModelGroups.length === 0)
</script>
+33
View File
@@ -0,0 +1,33 @@
<template>
<div class="transition text-foreground hover:text-primary-focus">
<NuxtLink
:to="to"
class="flex items-center text-sm"
active-class="text-primary font-bold"
>
<div v-if="separator">
<ChevronRightIcon class="flex w-4 h-4 mt-[3px] mx-0 md:mx-1" />
</div>
<div class="max-w-[120px] md:max-w-[200px] lg:max-w-[300px] truncate">
{{ name || to }}
</div>
</NuxtLink>
</div>
</template>
<script setup lang="ts">
import { ChevronRightIcon } from '@heroicons/vue/20/solid'
defineProps({
separator: {
type: Boolean,
default: true
},
to: {
type: String,
default: '/'
},
name: {
type: String,
default: null
}
})
</script>
+118
View File
@@ -0,0 +1,118 @@
<template>
<div>
<AccountsMenu v-model:open="showAccountsDialog" just-dialog />
<Menu as="div" class="flex items-center z-100">
<MenuButton v-slot="{ open }">
<span class="sr-only">Open user menu</span>
<FormButton
color="subtle"
size="sm"
:icon-left="!open ? Bars3Icon : XMarkIcon"
hide-text
/>
<!-- <HeaderButton>
<Bars3Icon v-if="!open" class="w-4" />
<XMarkIcon v-else class="w-4" />
</HeaderButton> -->
</MenuButton>
<Transition
enter-active-class="transition ease-out duration-200"
enter-from-class="transform opacity-0 scale-95"
enter-to-class="transform opacity-100 scale-100"
leave-active-class="transition ease-in duration-75"
leave-from-class="transform opacity-100 scale-100"
leave-to-class="transform opacity-0 scale-95"
>
<MenuItems
class="absolute right-1 top-8 origin-top-right bg-foundation outline outline-1 outline-outline-5 rounded-md shadow-lg overflow-hidden"
>
<MenuItem v-slot="{ active }" @click="toggleTheme">
<div
:class="[
active ? 'bg-highlight-1' : '',
'my-1 text-body-2xs flex px-2 py-1 text-foreground cursor-pointer transition mx-1 rounded'
]"
>
{{ isDarkTheme ? 'Light theme' : 'Dark theme' }}
</div>
</MenuItem>
<div class="border-t border-outline-3 mt-1">
<MenuItem
v-slot="{ active }"
@click="
(e) => {
showAccountsDialog = true
e.preventDefault()
}
"
>
<div
:class="[
active ? 'bg-highlight-1' : '',
'my-1 text-body-2xs flex px-2 py-1 text-foreground cursor-pointer transition mx-1 rounded'
]"
>
Manage accounts
</div>
</MenuItem>
</div>
<div class="border-t border-outline-3 mt-1">
<MenuItem
v-slot="{ active }"
@click="$openUrl(`https://www.speckle.systems?utm=dui`)"
>
<div
:class="[
active ? 'bg-highlight-1' : '',
'my-1 text-body-2xs flex px-2 py-1 text-foreground cursor-pointer transition mx-1 rounded'
]"
>
About Speckle
</div>
</MenuItem>
</div>
<div
v-if="hasConfigBindings && isDevMode"
class="mb-2 border-t border-outline-3"
>
<MenuItem v-slot="{ active }" @click="$showDevTools">
<div
:class="[
active ? 'bg-highlight-1' : '',
'my-1 text-body-3xs flex px-2 py-1 text-foreground-2 cursor-pointer transition mx-1 rounded'
]"
>
Open Dev Tools
</div>
</MenuItem>
<MenuItem v-slot="{ active }">
<NuxtLink
to="/test"
:class="[
active ? 'bg-highlight-1' : '',
'text-body-3xs flex px-2 py-1 text-foreground-2 cursor-pointer transition mx-1 rounded'
]"
>
Test Page
</NuxtLink>
</MenuItem>
</div>
</MenuItems>
</Transition>
</Menu>
</div>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { XMarkIcon, Bars3Icon } from '@heroicons/vue/20/solid'
import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/vue'
import { useConfigStore } from '~/store/config'
const uiConfigStore = useConfigStore()
const { isDarkTheme, hasConfigBindings, isDevMode } = storeToRefs(uiConfigStore)
const { toggleTheme } = uiConfigStore
const { $showDevTools, $openUrl } = useNuxtApp()
const showAccountsDialog = ref(false)
</script>
+97
View File
@@ -0,0 +1,97 @@
<template>
<div>
<Menu as="div" class="ml-1 flex items-center z-100">
<MenuButton v-slot="{ open }">
<span class="sr-only">Open user menu</span>
<button
class="rounded-full transition hover:bg-primary hover:text-foreground-on-primary p-1"
>
<Bars3Icon v-if="!open" class="w-4" />
<XMarkIcon v-else class="w-4" />
</button>
</MenuButton>
<Transition
enter-active-class="transition ease-out duration-200"
enter-from-class="transform opacity-0 scale-95"
enter-to-class="transform opacity-100 scale-100"
leave-active-class="transition ease-in duration-75"
leave-from-class="transform opacity-100 scale-100"
leave-to-class="transform opacity-0 scale-95"
>
<MenuItems
class="absolute right-1 top-11 origin-top-right bg-foundation outline outline-1 outline-primary-muted rounded-md shadow-lg overflow-hidden"
>
<MenuItem v-slot="{ active }" @click="showFeedbackDialog = true">
<div
:class="[
active ? 'bg-highlight-1' : '',
'my-1 text-body-xs flex px-2 py-1 text-foreground cursor-pointer transition mx-1 rounded'
]"
>
Feedback
</div>
</MenuItem>
<MenuItem v-slot="{ active }" @click="toggleTheme">
<div
:class="[
active ? 'bg-highlight-1' : '',
'my-1 text-body-xs flex px-2 py-1 text-foreground cursor-pointer transition mx-1 rounded'
]"
>
{{ isDarkTheme ? 'Light mode' : 'Dark mode' }}
</div>
</MenuItem>
<div v-if="hasConfigBindings && isDevMode">
<div class="border-t border-outline-3 py-1 mt-1">
<MenuItem v-slot="{ active }" @click="$showDevTools">
<div
:class="[
active ? 'bg-highlight-1' : '',
'my-1 text-body-xs flex px-2 py-1 text-foreground cursor-pointer transition mx-1 rounded'
]"
>
Open Dev Tools
</div>
</MenuItem>
</div>
<MenuItem v-slot="{ active }">
<NuxtLink
to="/test"
:class="[
active ? 'bg-highlight-1' : '',
'text-body-xs flex px-2 py-1 text-foreground cursor-pointer transition mx-1 rounded'
]"
>
Test Page
</NuxtLink>
</MenuItem>
</div>
<div class="border-t border-outline-3 py-1 mt-1">
<MenuItem>
<div class="px-3 pt-1 text-tiny text-foreground-2">
Version {{ hostApp.connectorVersion }}
</div>
</MenuItem>
</div>
</MenuItems>
</Transition>
</Menu>
<FeedbackDialog v-model:open="showFeedbackDialog" />
</div>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { XMarkIcon, Bars3Icon } from '@heroicons/vue/20/solid'
import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/vue'
import { useConfigStore } from '~/store/config'
import { useHostAppStore } from '~/store/hostApp'
const uiConfigStore = useConfigStore()
const { isDarkTheme, hasConfigBindings, isDevMode } = storeToRefs(uiConfigStore)
const { toggleTheme } = uiConfigStore
const hostApp = useHostAppStore()
const { $showDevTools } = useNuxtApp()
const showFeedbackDialog = ref<boolean>(false)
</script>
+49
View File
@@ -0,0 +1,49 @@
<template>
<LayoutPanel fancy-glow class="transition pointer-events-auto w-full">
<h1
class="h4 w-full bg-red-400 text-center font-bold bg-gradient-to-r from-blue-500 via-blue-400 to-blue-600 inline-block py-1 text-transparent bg-clip-text"
>
Welcome to Speckle
</h1>
<div v-if="isDesktopServiceAvailable">
<AccountsSignInFlow />
</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.
</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
</FormButton>
<div>
<div class="text-xs">Already done?</div>
<FormButton
size="sm"
full-width
text
link
@click="accountStore.refreshAccounts()"
>
Click to refresh
</FormButton>
</div>
</div>
</div>
</LayoutPanel>
</template>
<script setup lang="ts">
import { useAccountStore } from '~~/store/accounts'
import { useDesktopService } from '~/lib/core/composables/desktopService'
const accountStore = useAccountStore()
const { pingDesktopService } = useDesktopService()
const isDesktopServiceAvailable = ref(false) // this should be false default because there is a delay if /ping is not successful.
onMounted(async () => {
isDesktopServiceAvailable.value = await pingDesktopService()
})
</script>
+129
View File
@@ -0,0 +1,129 @@
<template>
<div>
<FormButton
color="subtle"
:icon-left="Bars3Icon"
hide-text
size="sm"
@click.stop="openModelCardActionsDialog = true"
/>
<CommonDialog
v-model:open="openModelCardActionsDialog"
:title="`${modelName} actions`"
fullscreen="none"
>
<SendSettingsDialog
v-if="hasSettings"
:model-card-id="props.modelCard.modelCardId"
:settings="props.modelCard.settings"
>
<template #activator="{ toggle }">
<button class="action action-normal" @click="toggle()">
<div class="truncate max-[275px]:text-xs">Settings</div>
<div><Cog6ToothIcon class="w-5 h-5" /></div>
</button>
</template>
</SendSettingsDialog>
<ReportBase v-if="modelCard.report" :report="modelCard.report">
<template #activator="{ toggle }">
<button class="action action-normal" @click="toggle()">
<div class="truncate max-[275px]:text-xs">View Report</div>
<div><InformationCircleIcon class="w-5 h-5" /></div>
</button>
</template>
</ReportBase>
<button
v-for="item in items"
:key="item.name"
:class="`action ${item.danger ? 'action-danger' : 'action-normal'}`"
@click="item.action"
>
<div class="truncate max-[275px]:text-xs">{{ item.name }}</div>
<div>
<Component :is="item.icon" class="w-5 h-5" />
</div>
</button>
</CommonDialog>
</div>
</template>
<script setup lang="ts">
import {
InformationCircleIcon,
Cog6ToothIcon,
ArrowTopRightOnSquareIcon,
ClockIcon,
ArchiveBoxXMarkIcon,
Bars3Icon
} from '@heroicons/vue/24/outline'
import type { IModelCard } from '~/lib/models/card'
import { useMixpanel } from '~/lib/core/composables/mixpanel'
const { trackEvent } = useMixpanel()
const openModelCardActionsDialog = ref(false)
const emit = defineEmits(['view', 'view-versions', 'copy-model-link', 'remove'])
const props = defineProps<{
modelName: string
modelCard: IModelCard
}>()
const hasSettings = computed(() => {
return !!props.modelCard.settings
})
const app = useNuxtApp()
app.$baseBinding.on('documentChanged', () => {
openModelCardActionsDialog.value = false
})
const items = [
{
name: 'View 3D model in browser',
icon: ArrowTopRightOnSquareIcon,
action: () => {
void trackEvent('DUI3 Action', {
name: 'Version View',
source: 'model actions dialog'
})
emit('view')
openModelCardActionsDialog.value = false
}
},
{
name: 'View model versions',
icon: ClockIcon,
action: () => {
void trackEvent('DUI3 Action', {
name: 'Model History View',
source: 'model actions dialog'
})
emit('view-versions')
openModelCardActionsDialog.value = false
}
},
{
name: 'Remove from file',
danger: true,
icon: ArchiveBoxXMarkIcon,
action: () => {
// NOTE: Mixpanel event tracking is in host app store
emit('remove')
openModelCardActionsDialog.value = false
}
}
]
</script>
<style scoped lang="postcss">
.action {
@apply text-body-sm flex items-center justify-between w-full rounded-lg text-left space-x-2 transition p-2 select-none hover:cursor-pointer min-w-0;
}
.action-normal {
@apply hover:text-primary;
}
.action-danger {
@apply text-danger hover:bg-rose-500/10;
}
</style>
+487
View File
@@ -0,0 +1,487 @@
<template>
<!-- eslint-disable-next-line vuejs-accessibility/click-events-have-key-events vuejs-accessibility/no-static-element-interactions -->
<div
:class="`rounded-md hover:shadow-md shadow transition overflow-hidden ${cardBgColor} border-foundation hover:border-outline-2 border-2 group`"
>
<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">
<!-- CTA button -->
<FormButton
v-tippy="buttonTooltip"
color="outline"
:icon-left="
modelCard.progress
? XCircleIcon
: isSender
? ArrowUpTrayIcon
: ArrowDownTrayIcon
"
hide-text
class=""
:disabled="!canEdit"
@click.stop="$emit('manual-publish-or-load')"
></FormButton>
</div>
<div class="grow min-w-0 max-[160px]:hidden">
<div class="text-body-3xs text-foreground-2 truncate">
{{ folderPath }}
</div>
<div
class="text-heading-sm truncate text-foreground dark:text-foreground-2 select-none leading-4"
>
{{ modelData.displayName }}
</div>
</div>
<!-- TODO: uncomment if needed, this is a hack to hide this from two apps where we don't support it -->
<div class="flex items-center justify-end grow">
<AutomateResultDialog
v-if="isSender && summary"
:model-card="modelCard"
:automation-runs="automationRuns"
:project-id="modelCard.projectId"
:model-id="modelCard.modelId"
>
<template #activator="{ toggle }">
<button
v-tippy="summary.summary.value.longSummary"
class="action action-normal p-1 hover:bg-highlight-2 rounded-md transition"
@click.stop="toggle()"
>
<AutomateRunsTriggerStatusIcon
:summary="summary.summary.value"
class="h-4 w-4"
/>
</button>
</template>
</AutomateResultDialog>
<FormButton
v-if="store.hostAppName !== 'navisworks' && store.hostAppName !== 'etabs'"
v-tippy="'Highlight'"
color="subtle"
:icon-left="CursorArrowRaysIcon"
hide-text
size="sm"
@click="highlightModel"
/>
<ModelActionsDialog
:model-card="modelCard"
:model-name="modelData.displayName"
@view="viewModel"
@view-versions="viewModelVersions"
@remove="removeModel"
/>
</div>
</div>
<div class="max-[160px]:flex w-full hidden px-1 mt-2 h-[40px] items-center">
<div class="grow min-w-0">
<div class="text-body-3xs text-foreground-2 truncate">
{{ folderPath }}
</div>
<div
class="text-heading-sm truncate text-foreground dark:text-foreground-2 select-none leading-4"
>
{{ modelData.displayName }}
</div>
</div>
</div>
</div>
<div v-else-if="loading" class="px-1 py-1">
Fetching model data...
<CommonLoadingBar loading />
</div>
<div v-else class="px-1 py-1">Error loading data.</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" -->
<div v-if="canEdit" class="px-1">
<slot></slot>
</div>
<!-- Progress state -->
<div
v-if="modelCard.progress"
:class="`${
modelCard.progress ? 'h-10 opacity-100' : 'h-0 opacity-0 py-0'
} overflow-hidden bg-highlight-2`"
>
<CommonLoadingProgressBar
:loading="!!modelCard.progress"
:progress="modelCard.progress ? modelCard.progress.progress : undefined"
/>
<div class="text-body-3xs px-2 h-full flex items-center text-foreground">
{{ modelCard.progress?.status || '...' }}
{{
modelCard.progress?.progress
? ((props.modelCard.progress?.progress as number) * 100).toFixed() + '%'
: ''
}}
</div>
</div>
<div v-if="canEdit">
<!-- Card States: Expiry, errors, new version created, etc. -->
<slot name="states"></slot>
<div class="relative">
<!-- Swanky web app integration: show users who is viewing the model -->
<Transition name="bounce">
<div
v-if="currentlyViewingUsers.length !== 0"
class="text-body-3xs text-foreground-2 py-1 px-1 bg-highlight-1 border-t border-t-highlight-3 flex space-x-1 items-center justify-between"
>
<div class="flex items-center space-x-1">
<UserAvatarGroup size="xs" :users="currentlyViewingUsers" />
<span class="line-clamp-1">
{{ currentlyViewingUsers.length === 1 ? 'is' : 'are' }} now viewing
</span>
</div>
<div>
<FormButton size="sm" color="outline" full-width @click="viewModel()">
Join
</FormButton>
</div>
</div>
</Transition>
<!-- Swanky web app integration: show comment created notification -->
<Transition name="bounce">
<div v-if="latestCommentNotification">
<div class="h-[1px] bg-blue-500/20 disappearing-bar"></div>
<div
class="text-body-3xs text-foreground-2 py-1 px-1 bg-highlight-1 flex space-x-1 items-center justify-between"
>
<div
v-tippy="
`${latestCommentNotification.comment?.author.name} just left a
comment.`
"
class="flex items-center space-x-1"
>
<UserAvatarGroup
size="xs"
:users="[latestCommentNotification.comment?.author]"
/>
<span class="line-clamp-1">
{{ latestCommentNotification.comment?.author.name }} just left a
comment.
</span>
</div>
<div>
<FormButton size="sm" color="outline" full-width @click="viewComment()">
Reply
</FormButton>
</div>
</div>
</div>
</Transition>
</div>
</div>
<div v-else>
<CommonModelNotification
:notification="{
modelCardId: modelCard.modelCardId,
dismissible: false,
level: 'danger',
text: disabledMessage
}"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { useQuery, useSubscription } from '@vue/apollo-composable'
import {
automateRunsSubscription,
automateStatusQuery,
modelCommentCreatedSubscription,
modelDetailsQuery,
modelViewingSubscription
} from '~/lib/graphql/mutationsAndQueries'
import { ArrowUpTrayIcon, ArrowDownTrayIcon } from '@heroicons/vue/24/solid'
import type { ProjectModelGroup } from '~~/store/hostApp'
import { useHostAppStore } from '~~/store/hostApp'
import type { IModelCard } from '~~/lib/models/card'
import { useAccountStore } from '~/store/accounts'
import type { IReceiverModelCard } from '~/lib/models/card/receiver'
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'
const app = useNuxtApp()
const store = useHostAppStore()
const accStore = useAccountStore()
const { trackEvent } = useMixpanel()
const props = defineProps<{
modelCard: IModelCard
project: ProjectModelGroup
canEdit: boolean
}>()
defineEmits<{
(e: 'manual-publish-or-load'): void
}>()
const isSender = computed(() => {
return props.modelCard.typeDiscriminator.includes('SenderModelCard')
})
const buttonTooltip = computed(() => {
return props.modelCard.progress
? 'Cancel'
: isSender.value
? 'Publish model'
: 'Load selected version'
})
const projectAccount = computed(() =>
accStore.accountWithFallback(props.project.accountId, props.project.serverUrl)
)
const disabledMessage = computed(() =>
isSender.value
? 'Publish is not permitted by your role on this project.'
: 'Load is not permitted by your role on this project.'
)
const clientId = projectAccount.value.accountInfo.id
const { result: modelResult, loading } = useQuery(
modelDetailsQuery,
() => ({
projectId: props.project.projectId,
modelId: props.modelCard.modelId
}),
() => ({ clientId })
)
const modelData = computed(() => modelResult.value?.project.model)
const queryData = computed(() => modelResult.value?.project)
const folderPath = computed(() => {
const splitName = modelData.value?.name.split('/')
if (!splitName || splitName.length === 1) return ' '
const withoutLast = splitName.slice(0, -1)
return withoutLast.join('/')
})
const { result: automateResult, refetch } = useQuery(
automateStatusQuery,
() => ({
projectId: props.project.projectId,
modelId: props.modelCard.modelId
}),
() => ({ clientId })
)
const automationRuns = computed(
() => automateResult.value?.project.model.automationsStatus?.automationRuns
)
const { onResult: onAutomateRunResult } = useSubscription(
automateRunsSubscription,
() => ({ projectId: props.project.projectId }),
() => ({ clientId })
)
onAutomateRunResult(() => {
refetch()
})
const summary = computed(() => {
if (!automationRuns.value) {
return undefined
}
return useFunctionRunsStatusSummary({
runs: automationRuns.value
})
})
provide<IModelCard>('cardBase', props.modelCard)
const highlightModel = () => {
if (!modelData.value) return
// Some host apps aren't friendly enough to handle highlighting models when some other ops are running.
if (props.modelCard.progress) return
// Do not highlight if baked object ids not set yet. Otherwise we rely on connector to handle it, don't if possible to handle here!
if (!isSender.value && !(props.modelCard as IReceiverModelCard).bakedObjectIds) {
store.setModelError({
modelCardId: props.modelCard.modelCardId,
error: 'No objects found to highlight.'
})
return
}
app.$baseBinding.highlightModel(props.modelCard.modelCardId)
trackEvent('DUI3 Action', { name: 'Highlight Model' }, props.modelCard.accountId)
}
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)
app.$baseBinding.openUrl(
`${projectAccount.value.accountInfo.serverInfo.url}/projects/${props.modelCard?.projectId}/models/${props.modelCard.modelId}`
)
}
const viewModelVersions = () => {
app.$baseBinding.openUrl(
`${projectAccount.value.accountInfo.serverInfo.url}/projects/${props.modelCard?.projectId}/models/${props.modelCard.modelId}/versions`
)
}
const removeModel = () => {
store.removeModel(props.modelCard)
}
defineExpose({
viewModel,
modelData,
queryData
})
const cardBgColor = computed(() => {
// if (props.modelCard.error || !props.canEdit)
// return 'bg-red-500/10 hover:bg-red-500/20'
// if (props.modelCard.expired) return 'bg-blue-500/10 hover:bg-blue-500/20'
// if (
// (props.modelCard as ISenderModelCard).latestCreatedVersionId ||
// (props.modelCard as IReceiverModelCard).displayReceiveComplete === true
// ) {
// if (failRate.value > 80) {
// return 'bg-orange-500/10'
// }
// return 'bg-blue-500/10 hover:bg-blue-500/20'
// }
// if (
// (props.modelCard as IReceiverModelCard).selectedVersionId !==
// (props.modelCard as IReceiverModelCard).latestVersionId &&
// !(props.modelCard as IReceiverModelCard).hasDismissedUpdateWarning
// )
// return 'bg-orange-500/10'
return 'bg-foundation xxxhover:bg-highlight-1'
})
const { onResult: onModelViewingResult } = useSubscription(
modelViewingSubscription,
() => ({
target: {
projectId: props.modelCard.projectId,
resourceIdString: props.modelCard.modelId
}
}),
() => ({ clientId })
)
const currentlyViewingUsersMap = ref<
Record<string, { name: string; id: string; avatar?: string | null; lastSeen: number }>
>({})
const currentlyViewingUsers = computed(() =>
Object.values(currentlyViewingUsersMap.value)
)
onModelViewingResult((res) => {
const user = res.data?.viewerUserActivityBroadcasted.user
if (res.data?.viewerUserActivityBroadcasted.status === 'VIEWING' && user) {
// add user to currently viewing people
currentlyViewingUsersMap.value[user.id] = { ...user, lastSeen: Date.now() }
} else if (
res.data?.viewerUserActivityBroadcasted.status === 'DISCONNECTED' &&
user
) {
// remove user from currently viewing people
delete currentlyViewingUsersMap.value[user.id]
}
})
// NOTE: FE does not send a disconnect event on page unload, so we need to do our own cleanup
useIntervalFn(() => {
const now = Date.now()
for (const key in currentlyViewingUsersMap.value) {
const { lastSeen } = currentlyViewingUsersMap.value[key]
if (now - lastSeen > 5_000) delete currentlyViewingUsersMap.value[key]
}
}, 1000)
const { onResult: onCommentResult } = useSubscription(
modelCommentCreatedSubscription,
() => ({
target: {
projectId: props.modelCard.projectId,
resourceIdString: props.modelCard.modelId
}
}),
() => ({ clientId })
)
const latestCommentNotification = ref<ProjectCommentsUpdatedMessage>()
const { start: startCommentClearTimeout, stop: stopCommentClearTimeout } = useTimeoutFn(
() => {
latestCommentNotification.value = undefined
stopCommentClearTimeout()
},
30_000
)
onCommentResult((res) => {
latestCommentNotification.value = res.data?.projectCommentsUpdated
startCommentClearTimeout()
})
const viewComment = () => {
trackEvent('DUI3 Action', { name: 'Comment View' }, props.modelCard.accountId)
if (!latestCommentNotification.value?.comment) return
const commentId =
latestCommentNotification.value?.comment?.parent?.id ||
latestCommentNotification.value?.comment.id
app.$baseBinding.openUrl(
`${projectAccount.value.accountInfo.serverInfo.url}/projects/${props.modelCard?.projectId}/models/${props.modelCard.modelId}#threadId=${commentId}`
)
}
</script>
<style scoped lang="css">
@keyframes disappear-width {
0% {
width: 100%;
}
100% {
display: none;
width: 0%;
}
}
.disappearing-bar {
animation: disappear-width 30s;
}
.bounce-enter-active {
animation: bounce-in 0.5s;
}
.bounce-leave-active {
animation: bounce-in 0.5s reverse;
}
@keyframes bounce-in {
0% {
transform: scale(0);
}
50% {
transform: scale(1.05);
}
100% {
transform: scale(1);
}
}
</style>
+186
View File
@@ -0,0 +1,186 @@
<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>
+306
View File
@@ -0,0 +1,306 @@
<template>
<ModelCardBase
:model-card="modelCard"
:project="project"
:can-edit="canEdit"
@manual-publish-or-load="handleMainButtonClick"
>
<div class="flex max-[275px]:w-full items-center space-x-2 my-2">
<FormButton
v-tippy="
isExpired
? 'A new version was pushed ' +
latestVersionCreatedAt +
'. Click to load a different version.'
: 'Load a different version'
"
:icon-left="ClockIcon"
size="sm"
color="subtle"
class="block text-foreground-2 hover:text-foreground overflow-hidden max-w-full !justify-start"
full-width
:disabled="!!modelCard.progress || noReadAccess"
@click.stop="openVersionsDialog = true"
>
<span>
Loaded
<b>version</b>
</span>
&nbsp;from&nbsp;
<span class="truncate">{{ createdAgo }}</span>
</FormButton>
</div>
<!-- <div
class="min-w-0 truncate text-foreground-2 -mt-1"
:title="
versionDetailsResult?.project.model.version.message || 'No message provided'
"
>
<span class="truncate max-[275px]:truncate-no select-none text-xs">
{{ createdAgo }}
</span>
</div> -->
<CommonDialog
v-model:open="openVersionsDialog"
fullscreen="none"
title="Change loaded version"
>
<WizardVersionSelector
:account-id="modelCard.accountId"
:project-id="modelCard.projectId"
:model-id="modelCard.modelId"
:workspace-slug="modelCard.workspaceSlug"
:selected-version-id="modelCard.selectedVersionId"
@next="handleVersionSelection"
/>
</CommonDialog>
<template #states>
<CommonModelNotification
v-if="expiredNotification"
:notification="expiredNotification"
@dismiss="
store.patchModel(modelCard.modelCardId, {
hasDismissedUpdateWarning: true
})
"
/>
<CommonModelNotification
v-if="errorNotification"
:notification="errorNotification"
@dismiss="store.patchModel(modelCard.modelCardId, { error: undefined })"
/>
<CommonModelNotification
v-if="receiveResultNotification"
:notification="receiveResultNotification"
@dismiss="
store.patchModel(modelCard.modelCardId, {
displayReceiveComplete: false
})
"
/>
</template>
</ModelCardBase>
</template>
<script setup lang="ts">
import dayjs from 'dayjs'
import { useQuery } from '@vue/apollo-composable'
import { ClockIcon } from '@heroicons/vue/24/solid'
import type { ModelCardNotification } from '~/lib/models/card/notification'
import type { ProjectModelGroup } from '~/store/hostApp'
import { useHostAppStore } from '~/store/hostApp'
import type { IReceiverModelCard } from '~/lib/models/card/receiver'
import { versionDetailsQuery } from '~/lib/graphql/mutationsAndQueries'
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'
const { trackEvent } = useMixpanel()
const app = useNuxtApp()
const accountStore = useAccountStore()
const props = defineProps<{
modelCard: IReceiverModelCard
project: ProjectModelGroup
canEdit: boolean
}>()
const store = useHostAppStore()
const openVersionsDialog = ref(false)
const projectAccount = computed(() =>
accountStore.accountWithFallback(props.project.accountId, props.project.serverUrl)
)
app.$baseBinding.on('documentChanged', () => {
openVersionsDialog.value = false
})
const isExpired = computed(() => {
return props.modelCard.latestVersionId !== props.modelCard.selectedVersionId
})
// Cancels any in progress receive AND load selected version
const handleVersionSelection = async (
selectedVersion: VersionListItemFragment,
latestVersion: VersionListItemFragment
) => {
openVersionsDialog.value = false
void trackEvent('DUI3 Action', {
name: 'Load Card Version Change',
isLatestVersion: selectedVersion === latestVersion
})
if (props.modelCard.progress) {
await store.receiveModelCancel(props.modelCard.modelCardId)
}
await store.patchModel(props.modelCard.modelCardId, {
selectedVersionId: selectedVersion.id,
selectedVersionSourceApp: selectedVersion.sourceApplication,
selectedVersionUserId: selectedVersion.authorUser?.id,
latestVersionId: latestVersion.id, // patch this dude as well, to make sure
latestVersionSourceApp: latestVersion.sourceApplication,
latestVersionUserId: latestVersion.authorUser?.id,
hasSelectedOldVersion: selectedVersion.id === latestVersion.id
})
await store.receiveModel(props.modelCard.modelCardId, 'VersionSelector')
}
// Cancels any in progress receive OR receives latest version
const handleMainButtonClick = async () => {
if (props.modelCard.progress)
return await store.receiveModelCancel(props.modelCard.modelCardId)
await receiveCurrentVersion()
}
const receiveCurrentVersion = async () => {
await store.receiveModel(props.modelCard.modelCardId, 'ModelCardButton')
}
// Cancels any in progress receive AND receives latest version
const receiveLatestVersion = async () => {
// Note: here we're updating the model card info, and afterwards we're hitting the receive action
await store.patchModel(props.modelCard.modelCardId, {
selectedVersionId: props.modelCard.latestVersionId,
selectedVersionSourceApp: props.modelCard.latestVersionSourceApp,
selectedVersionUserId: props.modelCard.latestVersionUserId
})
if (props.modelCard.progress)
await store.receiveModelCancel(props.modelCard.modelCardId)
await store.receiveModel(props.modelCard.modelCardId, 'UpdateNotification')
}
const expiredNotification = computed(() => {
if (!props.modelCard.latestVersionId || props.modelCard.hasDismissedUpdateWarning)
return
if (props.modelCard.latestVersionId === props.modelCard.selectedVersionId) return
const notification = {} as ModelCardNotification
notification.dismissible = true
notification.level = 'info'
notification.text = 'Newer version available!'
notification.cta = {
name: 'Update',
action: receiveLatestVersion
}
return notification
})
const failRate = computed(() => {
if (!props.modelCard.report) return 0
return (
(props.modelCard.report.filter((r) => r.status === 4).length /
props.modelCard.report.length) *
100
)
})
const receiveResultNotificationText = computed(() => {
if (failRate.value > 80) {
return 'Model loaded. Some objects have failed to convert!'
}
return 'Model loaded!'
})
const receiveResultNotificationLevel = computed(() => {
if (failRate.value > 80) {
return 'warning'
}
return 'info'
})
const receiveResultNotification = computed(() => {
if (
!props.modelCard.bakedObjectIds ||
props.modelCard.displayReceiveComplete !== true
)
return
const notification = {} as ModelCardNotification
notification.dismissible = true
notification.level = receiveResultNotificationLevel.value
notification.text = receiveResultNotificationText.value
notification.report = props.modelCard.report
notification.cta = {
name: 'Highlight',
action: () => {
app.$baseBinding.highlightModel(props.modelCard.modelCardId)
}
}
return notification
})
const errorNotification = computed(() => {
if (!props.modelCard.error) return
const notification = {} as ModelCardNotification
notification.dismissible = true
notification.level = 'danger'
notification.text = props.modelCard.error.errorMessage
notification.report = props.modelCard.report
return notification
})
const { result: versionDetailsResult, refetch } = useQuery(
versionDetailsQuery,
() => ({
projectId: props.modelCard.projectId,
modelId: props.modelCard.modelId,
versionId: props.modelCard.selectedVersionId
}),
() => ({
clientId: projectAccount.value.accountInfo.id
})
)
const createdAgoUpdater = useInterval(10_000) // refresh the created ago, and latestversion etc. every 10s
const createdAgo = computed(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
createdAgoUpdater.value
return dayjs(versionDetailsResult.value?.project.model.version.createdAt).from(
dayjs()
)
})
const latestVersionCreatedAt = computed(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
createdAgoUpdater.value
return dayjs(props.modelCard.latestVersionCreatedAt).from(dayjs())
})
const noReadAccess = computed(() => {
return props.canEdit
})
onMounted(() => {
refetch()
})
// On initialisation, we check whether there was a never version created while we were offline. If so, flagging this dude as expired.
watchOnce(versionDetailsResult, async (newVal) => {
if (!newVal) return
let patchObject = {}
if (
newVal?.project.model.versions.items &&
newVal?.project.model.versions.items.length !== 0 &&
newVal?.project.model.versions.items[0].id !== props.modelCard.selectedVersionId
) {
patchObject = {
latestVersionId: newVal?.project.model.versions.items[0].id,
latestVersionCreatedAt: newVal?.project.model.versions.items[0].createdAt,
latestVersionSourceApp: newVal?.project.model.versions.items[0].sourceApplication,
latestVersionUserId: newVal?.project.model.versions.items[0].authorUser?.id,
hasDismissedUpdateWarning: props.modelCard.hasSelectedOldVersion ? true : false
}
}
// Always update the card's project name and model name, if needed. Note, this is not needed for senders (senders do not need to create layers).
await store.patchModel(props.modelCard.modelCardId, {
...patchObject,
projectName: newVal?.project.name as string,
modelName: newVal?.project.model.name as string
})
})
</script>
+198
View File
@@ -0,0 +1,198 @@
<template>
<ModelCardBase
ref="cardBase"
:model-card="modelCard"
:project="project"
:can-edit="canEdit"
@manual-publish-or-load="sendOrCancel"
>
<div class="flex max-[275px]:w-full overflow-hidden my-2">
<FormButton
v-tippy="'Edit what gets published'"
:icon-left="Square3Stack3DIcon"
size="sm"
color="subtle"
class="block text-foreground-2 hover:text-foreground overflow-hidden max-w-full !justify-start"
:disabled="!!modelCard.progress || !props.canEdit"
full-width
@click.stop="openFilterDialog = true"
>
<!-- Sending&nbsp; -->
<span class="font-bold">{{ modelCard.sendFilter?.name }}:&nbsp;</span>
<span class="truncate">{{ modelCard.sendFilter?.summary }}</span>
</FormButton>
</div>
<CommonDialog
v-model:open="openFilterDialog"
:title="`Change filter`"
fullscreen="none"
>
<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()">
Save
</FormButton>
<FormButton size="sm" @click.stop="saveFilterAndSend()">
Save & Publish
</FormButton>
</div>
</CommonDialog>
<template #states>
<CommonModelNotification
v-if="expiredNotification"
:notification="expiredNotification"
/>
<CommonModelNotification
v-if="errorNotification"
:notification="errorNotification"
:report="modelCard.report"
@dismiss="store.patchModel(modelCard.modelCardId, { error: undefined })"
/>
<CommonModelNotification
v-if="latestVersionNotification"
:notification="latestVersionNotification"
:report="modelCard.report"
@dismiss="
store.patchModel(modelCard.modelCardId, {
latestCreatedVersionId: undefined
})
"
/>
</template>
</ModelCardBase>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import ModelCardBase from '~/components/model/CardBase.vue'
import { Square3Stack3DIcon } from '@heroicons/vue/20/solid'
import type { ModelCardNotification } from '~/lib/models/card/notification'
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'
const { trackEvent } = useMixpanel()
const app = useNuxtApp()
const cardBase = ref<InstanceType<typeof ModelCardBase>>()
const props = defineProps<{
modelCard: ISenderModelCard
project: ProjectModelGroup
canEdit: boolean
}>()
const store = useHostAppStore()
const openFilterDialog = ref(false)
app.$baseBinding.on('documentChanged', () => {
openFilterDialog.value = false
})
const sendOrCancel = () => {
if (!props.canEdit) {
return
}
if (props.modelCard.progress) store.sendModelCancel(props.modelCard.modelCardId)
else store.sendModel(props.modelCard.modelCardId, 'ModelCardButton')
}
let newFilter: ISendFilter
const updateFilter = (filter: ISendFilter) => {
newFilter = filter
}
const saveFilter = async () => {
void trackEvent('DUI3 Action', {
name: 'Publish Card Filter Change',
filter: newFilter.typeDiscriminator
})
// do not reset idmap while creating a new one because it is managed by host app
newFilter.idMap = props.modelCard.sendFilter?.idMap
await store.patchModel(props.modelCard.modelCardId, {
sendFilter: newFilter,
expired: true
})
openFilterDialog.value = false
}
const saveFilterAndSend = async () => {
await saveFilter()
store.sendModel(props.modelCard.modelCardId, 'Filter')
}
const expiredNotification = computed(() => {
if (!props.modelCard.expired) return
const notification = {} as ModelCardNotification
notification.dismissible = false
notification.level = props.modelCard.progress ? 'info' : 'info'
notification.text = props.modelCard.progress
? 'Model changed while publishing'
: 'Out of sync with application'
const ctaType = props.modelCard.progress ? 'Restart' : 'Update'
notification.cta = {
name: ctaType,
action: async () => {
if (props.modelCard.progress) {
await store.sendModelCancel(props.modelCard.modelCardId)
}
store.sendModel(props.modelCard.modelCardId, ctaType)
}
}
return notification
})
const errorNotification = computed(() => {
if (!props.modelCard.error) return
const notification = {} as ModelCardNotification
notification.dismissible = props.modelCard.error.dismissible
notification.level = 'danger'
notification.text = props.modelCard.error.errorMessage
notification.report = props.modelCard.report
return notification
})
const failRate = computed(() => {
if (!props.modelCard.report) return 0
return (
(props.modelCard.report.filter((r) => r.status === 4).length /
props.modelCard.report.length) *
100
)
})
const sendResultNotificationText = computed(() => {
if (failRate.value > 80) {
return 'Version created. Some objects have failed to convert!'
}
return 'Version created!'
})
const sendResultNotificationLevel = computed(() => {
if (failRate.value > 80) {
return 'warning'
}
return 'info'
})
const latestVersionNotification = computed(() => {
if (!props.modelCard.latestCreatedVersionId) return
const notification = {} as ModelCardNotification
notification.dismissible = true
notification.level = sendResultNotificationLevel.value
notification.text = sendResultNotificationText.value
notification.report = props.modelCard.report
notification.cta = {
name: 'View version',
// 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
})
</script>
+220
View File
@@ -0,0 +1,220 @@
<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>
@@ -0,0 +1,141 @@
<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>
@@ -0,0 +1,198 @@
<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>
+203
View File
@@ -0,0 +1,203 @@
<template>
<CommonDialog
v-model:open="showReceiveDialog"
fullscreen="none"
:title="title"
:show-back-button="step !== 1"
@back="step--"
@fully-closed="step = 1"
>
<div>
<div v-if="step === 1">
<WizardProjectSelector
:is-sender="false"
:show-new-project="false"
@next="selectProject"
@search-text-update="updateSearchText"
/>
</div>
<div v-if="step === 2 && selectedProject && selectedAccountId">
<div>
<WizardModelSelector
:project="selectedProject"
:account-id="selectedAccountId"
:show-new-model="false"
@next="selectModel"
/>
</div>
</div>
<div v-if="step === 3">
<WizardVersionSelector
v-if="selectedProject && selectedModel"
:account-id="selectedAccountId"
:project-id="selectedProject.id"
:model-id="selectedModel.id"
:selected-version-id="urlParsedVersionId"
:workspace-slug="selectedWorkspace?.slug"
:from-wizard="true"
@next="selectVersionAndAddModel"
/>
</div>
</div>
<div v-if="urlParseError" class="p-2 text-xs text-danger">{{ urlParseError }}</div>
</CommonDialog>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import type {
ModelListModelItemFragment,
ProjectListProjectItemFragment,
VersionListItemFragment,
WorkspaceListWorkspaceItemFragment
} from '~/lib/common/generated/gql/graphql'
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 { useAddByUrl } from '~/lib/core/composables/addByUrl'
import { getSlugFromHostAppNameAndVersion } from '~/lib/common/helpers/hostAppSlug'
const { trackEvent } = useMixpanel()
const showReceiveDialog = defineModel<boolean>('open', { default: false })
const emit = defineEmits(['close'])
const step = ref(1)
// 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 accountStore = useAccountStore()
const { activeAccount } = storeToRefs(accountStore)
const selectedAccountId = ref<string>(activeAccount.value?.accountInfo.id as string)
const selectedWorkspace = ref<WorkspaceListWorkspaceItemFragment>()
const selectedProject = ref<ProjectListProjectItemFragment>()
const selectedModel = ref<ModelListModelItemFragment>()
const { tryParseUrl, urlParsedData, urlParseError } = useAddByUrl()
const updateSearchText = (text: string | undefined) => {
urlParseError.value = undefined
if (!text) return
tryParseUrl(text, 'receiver')
}
const urlParsedVersionId = ref<string>()
watch(urlParsedData, (newVal) => {
if (!newVal) return
selectProject(newVal.account?.accountInfo.id, newVal.project)
selectModel(newVal.model)
if (newVal.version) urlParsedVersionId.value = newVal.version.id
})
watch(showReceiveDialog, (newVal) => {
if (newVal) {
urlParseError.value = undefined
}
})
const selectProject = (
accountId: string,
project: ProjectListProjectItemFragment,
workspace?: WorkspaceListWorkspaceItemFragment
) => {
step.value++
selectedAccountId.value = accountId
selectedProject.value = project
selectedWorkspace.value = workspace
void trackEvent('DUI3 Action', { name: 'Load Wizard', step: 'project selected' })
}
const selectModel = (model: ModelListModelItemFragment) => {
step.value++
selectedModel.value = model
void trackEvent('DUI3 Action', { name: 'Load Wizard', step: 'model selected' })
}
const title = computed(() => {
if (step.value === 1) return 'Select project'
if (step.value === 2) return 'Select model'
if (step.value === 3) return 'Select version'
return ''
})
// accountId, serverUrl, ModelListModelItemFragment, VersionListItemFragment
const selectVersionAndAddModel = async (
version: VersionListItemFragment,
latestVersion: VersionListItemFragment
) => {
void trackEvent('DUI3 Action', {
name: 'Load Wizard',
step: 'version selected',
hasSelectedLatestVersion: version.id === latestVersion.id
})
const existingModel = hostAppStore.models.find(
(m) =>
m.modelId === selectedModel.value?.id &&
m.typeDiscriminator === 'ReceiverModelCard'
) as ReceiverModelCard
if (existingModel) {
emit('close')
// Patch the existing model card with new versions!
await hostAppStore.patchModel(existingModel.modelCardId, {
selectedVersionId: version.id,
selectedVersionSourceApp: version.sourceApplication,
selectedVersionUserId: version.authorUser?.id,
latestVersionId: latestVersion.id,
latestVersionSourceApp: latestVersion.sourceApplication,
latestVersionUserId: latestVersion.authorUser?.id
})
await hostAppStore.receiveModel(existingModel.modelCardId, 'Wizard')
return
}
// We were tracking the source host app wrong before `getHostAppFromString`
// i.e. we were having `Revit 2023` instead of `revit`
const selectedVersionSourceApp = getSlugFromHostAppNameAndVersion(
version.sourceApplication as string
)
const latestVersionSourceApp = getSlugFromHostAppNameAndVersion(
latestVersion.sourceApplication as string
)
const modelCard = new ReceiverModelCard()
modelCard.accountId = selectedAccountId.value
modelCard.serverUrl = activeAccount.value.accountInfo.serverInfo.url
modelCard.projectId = selectedProject.value?.id as string
modelCard.modelId = selectedModel.value?.id as string
modelCard.workspaceId = selectedProject.value?.workspace?.id as string
modelCard.workspaceSlug = selectedProject?.value?.workspace?.slug as string
modelCard.projectName = selectedProject.value?.name as string
modelCard.modelName = selectedModel.value?.name as string
modelCard.selectedVersionId = version.id
modelCard.selectedVersionSourceApp = selectedVersionSourceApp
modelCard.selectedVersionUserId = version.authorUser?.id as string
modelCard.latestVersionId = latestVersion.id
modelCard.latestVersionSourceApp = latestVersionSourceApp
modelCard.latestVersionUserId = latestVersion.authorUser?.id as string
modelCard.hasDismissedUpdateWarning = true
modelCard.hasSelectedOldVersion = version.id !== latestVersion.id
emit('close')
await hostAppStore.addModel(modelCard)
await hostAppStore.receiveModel(modelCard.modelCardId, 'Wizard')
}
const hostAppStore = useHostAppStore()
</script>
+159
View File
@@ -0,0 +1,159 @@
<template>
<div>
<slot name="activator" :toggle="toggleDialog">
<FormButton
v-tippy="'View report'"
color="outline"
:icon-left="
summary.failedCount === 0 && summary.warningCount === 0
? CheckCircleIcon
: ExclamationCircleIcon
"
hide-text
size="sm"
@click.stop="toggleDialog()"
/>
</slot>
<CommonDialog v-model:open="showReportDialog" :title="`Report`" fullscreen="none">
<div class="text-body-2xs">
{{ numberOfSuccess }} objects converted ok, {{ numberOfWarning }} warnings and
{{ numberOfFailed }} errors.
</div>
<div class="flex mt-2 space-x-2 text-body-2xs">
<span>Filter:</span>
<button
v-if="numberOfSuccess !== 0"
class="flex items-center justify-center border-success px-1 pb-1 text-success leading-none"
:class="successToggle ? 'border-b-2' : ''"
@click="successToggle = !successToggle"
>
<CheckCircleIcon
class="w-4 mr-1 stroke-green-500 text-green-500"
></CheckCircleIcon>
{{ numberOfSuccess }}
</button>
<button
v-if="numberOfWarning !== 0"
class="flex items-center justify-center border-warning px-1 pb-1 text-warning leading-none"
:class="warningToggle ? 'border-b-2' : ''"
@click="warningToggle = !warningToggle"
>
<ExclamationTriangleIcon
class="w-4 mr-1 stroke-warning-500 text-warning-500"
></ExclamationTriangleIcon>
{{ numberOfWarning }}
</button>
<button
v-if="numberOfFailed !== 0"
class="flex items-center justify-center border-danger px-1 pb-1 text-danger leading-none"
:class="failedToggle ? 'border-b-2' : ''"
@click="failedToggle = !failedToggle"
>
<ExclamationCircleIcon
class="w-4 mr-1 stroke-red-500 text-red-500"
></ExclamationCircleIcon>
{{ numberOfFailed }}
</button>
</div>
<div class="flex flex-col space-y-1 py-2">
<ReportItem
v-for="(item, index) in reportLimited"
:key="index"
:report-item="item"
/>
<div v-if="reportLimited.length === 0" class="text-body-xs text-foreground-2">
No items found.
</div>
</div>
<div v-if="report.length > reportSlice">
<FormButton size="sm" full-width color="outline" @click="reportSlice += 20">
Show more
</FormButton>
</div>
</CommonDialog>
</div>
</template>
<script setup lang="ts">
import {
ExclamationCircleIcon,
ExclamationTriangleIcon,
CheckCircleIcon
} from '@heroicons/vue/20/solid'
import type { ConversionResult } from '~~/lib/conversions/conversionResult'
const props = defineProps<{
report: ConversionResult[]
}>()
const showReportDialog = ref(false)
const successToggle = ref(true) // Status 1
const warningToggle = ref(true) // Status 3
const failedToggle = ref(true) // Status 4
const toggleDialog = () => {
showReportDialog.value = !showReportDialog.value
}
const reportSlice = ref(10)
// Limit so we don't display 100k items at once and burn
const reportLimited = computed(() => reportSorted.value.slice(0, reportSlice.value))
// Sort to errors first
const reportSorted = computed(() =>
[...filteredReports.value].sort((a, b) => b.status - a.status)
)
// Filter according to toggles
const filteredReports = computed(() => {
return props.report.filter((report) => {
if (successToggle.value && report.status === 1) {
return true
}
if (failedToggle.value && report.status === 4) {
return true
}
if (warningToggle.value && report.status === 3) {
return true
}
// TODO: do more later!
return false
})
})
const numberOfSuccess = computed(
() => props.report.filter((r) => r.status === 1).length
)
const numberOfWarning = computed(
() => props.report.filter((r) => r.status === 3).length
)
const numberOfFailed = computed(() => props.report.filter((r) => r.status === 4).length)
const summary = computed(() => {
const failed = props.report.filter((item) => item.status === 4)
const warning = props.report.filter((item) => item.status === 3)
const ok = props.report.filter((item) => item.status === 1)
let hint = 'All objects converted ok'
const isSuccess = failed.length === 0 && warning.length === 0
if (!isSuccess) {
if (failed.length !== 0 && warning.length !== 0) {
// both fail and warning
hint = `${failed.length} object(s) failed to convert, ${warning.length} object(s) converted with warning`
} else if (failed.length !== 0 && warning.length === 0) {
// only fail
hint = `${failed.length} object(s) failed to convert`
} else if (warning.length !== 0 && failed.length === 0) {
// only warning
hint = `${warning.length} object(s) converted with warning`
}
}
return {
failedCount: failed.length,
warningCount: warning.length,
okCount: ok.length,
hint
}
})
</script>
+135
View File
@@ -0,0 +1,135 @@
<template>
<button
class="block rounded-lg p-1 transition hover:bg-primary-muted"
@click="highlightObject"
>
<div class="text-foreground-2 flex items-center relative">
<div class="mr-1 hover:cursor-pointer">
<div v-if="reportItem.status === 1">
<CheckCircleIcon class="w-4 stroke-green-500 text-green-500" />
</div>
<div v-else-if="reportItem.status === 3">
<ExclamationTriangleIcon class="w-4 text-warning"></ExclamationTriangleIcon>
</div>
<div v-else>
<ExclamationCircleIcon class="w-4 text-danger"></ExclamationCircleIcon>
</div>
</div>
<div class="text-xs transition truncate">
<span v-if="reportItem.status === 1">
{{ reportItem.sourceType?.split('.').reverse()[0] }} >
</span>
<span>
{{
reportItem.resultType
? reportItem.resultType?.split('.').reverse()[0]
: reportItem.error?.message
}}
</span>
</div>
<button
v-tippy="'Details'"
class="block rounded-lg transition hover:bg-primary-muted ml-auto"
@click.stop="toggleDetails"
>
<div v-if="!showDetails">
<ChevronDownIcon class="w-4" />
</div>
<div v-else>
<ChevronUpIcon class="w-4" />
</div>
</button>
<button
v-if="reportItem.status !== 1 && !isSender"
v-tippy="'See object on Web'"
class="block rounded-lg transition hover:bg-primary-muted ml-1"
@click.stop="openObjectOnWeb"
>
<ArrowTopRightOnSquareIcon class="w-4" />
</button>
</div>
</button>
<div
v-if="showDetails"
class="text-xs text-foreground-2 ml-3 rounded-lg p-1 hover:bg-primary-muted hover:cursor-pointer"
>
<button
v-tippy="'Copy to clipboard'"
class="text-left w-full whitespace-pre-wrap break-all overflow-hidden"
@click="copyToClipboard(details)"
>
{{ details }}
</button>
</div>
</template>
<script setup lang="ts">
import {
ExclamationCircleIcon,
ExclamationTriangleIcon,
CheckCircleIcon,
ChevronUpIcon,
ChevronDownIcon,
ArrowTopRightOnSquareIcon
} from '@heroicons/vue/24/solid'
import type { ConversionResult } from '~/lib/conversions/conversionResult'
import { useAccountStore } from '~/store/accounts'
import type { IModelCard } from 'lib/models/card'
import { useHostAppStore } from '~/store/hostApp'
const app = useNuxtApp()
const hostAppStore = useHostAppStore()
const accStore = useAccountStore()
const showDetails = ref<boolean>(false)
const props = defineProps<{
reportItem: ConversionResult
}>()
const cardBase = inject('cardBase') as IModelCard
const isSender = computed(() =>
hostAppStore.models
.find((m) => m.modelCardId === cardBase.modelCardId)
?.typeDiscriminator.toLowerCase()
.includes('sender')
)
const acc = accStore.accounts.find((acc) => acc.accountInfo.id === cardBase.accountId)
const details = computed(() =>
props.reportItem.error
? props.reportItem.error.stackTrace
: `${props.reportItem.sourceType} > ${props.reportItem.resultType}`
)
const openObjectOnWeb = () => {
// This is a POC implementation. Later we will highlight object(s) within the model. Currently it is done by 'Isolate' filter on viewer but there is no direct URL to achieve this.
const url = `${acc?.accountInfo.serverInfo.url}/projects/${cardBase?.projectId}/models/${props.reportItem.sourceId}`
app.$openUrl(url)
}
const highlightObject = () => {
// sender reports highlight in source app
if (cardBase.typeDiscriminator.toLowerCase().includes('send')) {
app.$baseBinding.highlightObjects([props.reportItem.sourceId])
return
}
// receive reports that are ok highliht in source app
if (props.reportItem.status === 1 && props.reportItem.resultId) {
app.$baseBinding.highlightObjects([props.reportItem.resultId])
return
}
}
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text)
}
const toggleDetails = () => {
showDetails.value = !showDetails.value
}
</script>
+35
View File
@@ -0,0 +1,35 @@
<template>
<div class="space-y-4">
<FilterListSelect @update:filter="updateFilter" />
<SendSettings
v-if="hasSendSettings"
expandable
@update:settings="updateSettings"
></SendSettings>
</div>
</template>
<script setup lang="ts">
import type { ISendFilter } from '~/lib/models/card/send'
import { useHostAppStore } from '~/store/hostApp'
import type { CardSetting } from '~/lib/models/card/setting'
const emit = defineEmits<{
(e: 'update:filter', filter: ISendFilter): void
(e: 'update:settings', settings: CardSetting[]): void
}>()
const updateFilter = (filter: ISendFilter) => {
// TODO: something like hostApp.validateSendFilter()
// which should return a bool and a reason if invalid
emit('update:filter', filter)
}
const updateSettings = (settings: CardSetting[]) => {
emit('update:settings', settings)
}
const store = useHostAppStore()
const hasSendSettings = computed(
() => store.sendSettings && store.sendSettings?.length > 0
)
</script>
+82
View File
@@ -0,0 +1,82 @@
<template>
<div class="p-0">
<button
v-if="expandable"
class="flex w-full items-center text-foreground-2 justify-between hover:foundation-3 rounded-md transition group mb-2"
@click="showSettings = !showSettings"
>
<div class="flex items-center transition group-hover:text-primary h-8 min-w-0">
<CommonIconsArrowFilled
:class="`w-5 ${showSettings ? '' : '-rotate-90'} transition`"
/>
<div class="text-body-sm text-left select-none">Settings</div>
</div>
</button>
<div v-show="showSettings" class="px-1">
<FormJsonForm
:schema="settingsJsonForms"
:data="data"
@change="onParamsFormChange"
></FormJsonForm>
</div>
</div>
</template>
<script setup lang="ts">
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'
const props = defineProps<{
settings?: CardSetting[]
expandable: boolean
}>()
const emit = defineEmits<{ (e: 'update:settings', value: CardSetting[]): void }>()
const store = useHostAppStore()
const defaultSendSettings = computed(() => store.sendSettings)
const sendSettings = ref<CardSetting[] | undefined>(
cloneDeep(props.settings ?? defaultSendSettings.value) // need to prevent mutation!
)
const showSettings = ref(!props.expandable)
const settingsJsonForms = computed(() => {
if (sendSettings.value === undefined) return {}
const obj: JsonSchema = { type: 'object', properties: {} }
sendSettings.value.forEach((setting: CardSetting) => {
const mappedSetting = omit({ ...setting, $id: setting.id }, ['id'])
if (obj && obj.properties) {
obj.properties[setting.id] = mappedSetting
}
})
return obj
})
type DataType = Record<string, unknown>
const data = computed(() => {
const settingValues = {} as DataType
if (sendSettings.value) {
sendSettings.value.forEach((setting) => {
settingValues[setting.id as string] = setting.value
})
}
return settingValues
})
const onParamsFormChange = (e: JsonFormsChangeEvent) => {
if (sendSettings.value === undefined) return
sendSettings.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)
}
</script>
+59
View File
@@ -0,0 +1,59 @@
<template>
<div class="p-0">
<slot name="activator" :toggle="toggleDialog"></slot>
<CommonDialog
v-model:open="showSettingsDialog"
:title="`Settings`"
fullscreen="none"
>
<SendSettings
:expandable="false"
:settings="props.settings"
@update:settings="updateSettings"
></SendSettings>
<div class="mt-4 flex justify-end items-center space-x-2">
<FormButton size="sm" color="outline" @click="showSettingsDialog = false">
Cancel
</FormButton>
<FormButton size="sm" @click="saveSettings()">Save</FormButton>
</div>
</CommonDialog>
</div>
</template>
<script setup lang="ts">
import { useMixpanel } from '~/lib/core/composables/mixpanel'
import { useHostAppStore } from '~/store/hostApp'
import type { CardSetting } from '~/lib/models/card/setting'
const { trackEvent } = useMixpanel()
const props = defineProps<{
settings?: CardSetting[]
modelCardId: string
}>()
const store = useHostAppStore()
const showSettingsDialog = ref(false)
const toggleDialog = () => {
showSettingsDialog.value = !showSettingsDialog.value
}
let newSettings: CardSetting[]
const updateSettings = (settings: CardSetting[]) => {
newSettings = settings
}
const saveSettings = async () => {
void trackEvent('DUI3 Action', {
name: 'Send Settings Updated'
})
await store.patchModel(props.modelCardId, {
settings: newSettings,
expired: true
})
showSettingsDialog.value = false
}
</script>
+168
View File
@@ -0,0 +1,168 @@
<template>
<CommonDialog
v-model:open="showSendDialog"
fullscreen="none"
:title="title"
:show-back-button="step !== 1"
@back="step--"
@fully-closed="step = 1"
>
<div v-if="step === 1">
<WizardProjectSelector
is-sender
disable-no-write-access-projects
@next="selectProject"
@search-text-update="updateSearchText"
/>
</div>
<!-- Model selector wizard -->
<div v-if="step === 2 && selectedProject && selectedAccountId">
<WizardModelSelector
:project="selectedProject"
:workspace-id="selectedProject.workspace?.id"
:workspace-slug="selectedProject.workspace?.slug"
:account-id="selectedAccountId"
is-sender
@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)"
/>
<div class="mt-2">
<FormButton full-width @click="addModel">Publish</FormButton>
</div>
</div>
<div v-if="urlParseError" class="p-2 text-xs text-danger">
{{ urlParseError }}
</div>
</CommonDialog>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import type {
ModelListModelItemFragment,
ProjectListProjectItemFragment
} from '~/lib/common/generated/gql/graphql'
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 { useMixpanel } from '~/lib/core/composables/mixpanel'
import type { CardSetting } from '~/lib/models/card/setting'
import { useAddByUrl } from '~/lib/core/composables/addByUrl'
const { trackEvent } = useMixpanel()
const showSendDialog = defineModel<boolean>('open', { default: false })
const emit = defineEmits(['close'])
const step = ref(1)
const accountStore = useAccountStore()
const { activeAccount } = storeToRefs(accountStore)
const selectedAccountId = ref<string>(activeAccount.value?.accountInfo.id as string)
const selectedProject = ref<ProjectListProjectItemFragment>()
const selectedModel = ref<ModelListModelItemFragment>()
const filter = ref<ISendFilter | undefined>(undefined)
const settings = ref<CardSetting[] | undefined>(undefined)
const { tryParseUrl, urlParsedData, urlParseError } = useAddByUrl()
const updateSearchText = (text: string | undefined) => {
urlParseError.value = undefined
if (!text) return
tryParseUrl(text, 'sender')
}
watch(urlParsedData, (newVal) => {
if (!newVal) return
selectProject(newVal.account?.accountInfo.id, newVal.project)
selectModel(newVal.model)
})
watch(showSendDialog, (newVal) => {
if (newVal) {
urlParseError.value = undefined
}
})
const selectProject = (accountId: string, project: ProjectListProjectItemFragment) => {
step.value++
selectedAccountId.value = accountId
selectedProject.value = project
void trackEvent('DUI3 Action', { name: 'Publish Wizard', step: 'project selected' })
}
const title = computed(() => {
if (step.value === 1) return 'Select project'
if (step.value === 2) return 'Select model'
if (step.value === 3) return 'Select objects'
return ''
})
const selectModel = (model: ModelListModelItemFragment) => {
step.value++
selectedModel.value = model
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', {
name: 'Publish Wizard',
step: 'objects selected',
filter: filter.value?.typeDiscriminator
})
const existingModel = hostAppStore.models.find(
(m) =>
m.modelId === selectedModel.value?.id &&
m.typeDiscriminator.includes('SenderModelCard')
) as SenderModelCard
if (existingModel) {
emit('close')
// Patch the existing model card with new send filter and non-expired state!
await hostAppStore.patchModel(existingModel.modelCardId, {
sendFilter: filter.value as ISendFilter,
expired: false
})
void hostAppStore.sendModel(existingModel.modelCardId, 'Wizard')
return
}
const model = new SenderModelCard()
model.accountId = selectedAccountId.value
model.serverUrl = activeAccount.value?.accountInfo.serverInfo.url as string
model.projectId = selectedProject.value?.id as string
model.modelId = selectedModel.value?.id as string
model.workspaceId = selectedProject.value?.workspace?.id as string
model.workspaceSlug = selectedProject?.value?.workspace?.slug as string
model.sendFilter = filter.value as ISendFilter
model.sendFilter.idMap = {} // do not let it null from the beginning otherwise we will end up with null state on Revit...
model.settings = settings.value
model.expired = false
emit('close')
await hostAppStore.addModel(model)
void hostAppStore.sendModel(model.modelCardId, 'Wizard')
}
</script>
+9
View File
@@ -0,0 +1,9 @@
<template>
<GlobalToastRenderer v-model:notification="hostAppStore.currentNotification" />
</template>
<script setup lang="ts">
import { GlobalToastRenderer } from '@speckle/ui-components'
import { useHostAppStore } from '~/store/hostApp'
const hostAppStore = useHostAppStore()
</script>
+163
View File
@@ -0,0 +1,163 @@
<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>
+300
View File
@@ -0,0 +1,300 @@
<template>
<div class="space-y-2">
<div class="space-y-2">
<div class="flex items-center space-x-2 justify-between">
<FormTextInput
v-model="searchText"
:placeholder="
totalCount === 0 ? 'New model name' : 'Search in ' + project.name
"
name="search"
autocomplete="off"
:show-clear="!!searchText"
full-width
color="foundation"
/>
<ModelCreateDialog
:project-id="project.id"
:workspace-id="workspaceId"
:workspace-slug="workspaceSlug"
@model:created="(result: ModelListModelItemFragment) => handleModelCreated(result)"
>
<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>
</div>
<div class="relative grid grid-cols-1 gap-2">
<CommonLoadingBar v-if="loading" loading />
<WizardListModelCard
v-for="model in models"
:key="model.id"
:model="model"
@click="handleModelSelect(model)"
/>
<CommonDialog
v-model:open="showSelectionHasProblemsDialog"
title="Warning"
fullscreen="none"
>
<div class="mx-1">
<p class="text-body-xs mb-2">You are about to overwrite this model.</p>
<p
v-if="hasNonZeroVersionsProblem"
class="mb-2 text-body-3xs text-foreground-2"
>
The model you selected contains versions coming from
<b>other files/apps</b>
.
</p>
<p v-if="existingModelProblem" class="mb-2 text-body-3xs text-foreground-2">
<b>{{ ` ${existingModelName}` }}</b>
is already being used to
<b>{{ isSender ? 'publish,' : 'load,' }}</b>
you could consider using the existing one.
</p>
</div>
<template #buttons>
<FormButton
full-width
size="sm"
text
@click="showSelectionHasProblemsDialog = false"
>
Cancel
</FormButton>
<FormButton full-width size="sm" @click="confirmModelSelection()">
Proceed
</FormButton>
</template>
</CommonDialog>
<FormButton
color="outline"
full-width
:disabled="hasReachedEnd"
@click="loadMore"
>
{{ hasReachedEnd ? 'No more models found' : 'Load older models' }}
</FormButton>
</div>
</div>
<CommonDialog
v-model:open="showNewModelDialog"
title="Create new model"
fullscreen="none"
>
<form @submit="onSubmit">
<FormTextInput
v-model="newModelName"
:rules="rules"
:placeholder="hostAppStore.documentInfo?.name"
name="name"
color="foundation"
:show-clear="!!newModelName"
full-width
autocomplete="off"
size="lg"
/>
<div class="mt-4 flex justify-end items-center space-x-2 w-full">
<FormButton size="sm" text @click="showNewModelDialog = false">
Cancel
</FormButton>
<FormButton size="sm" submit :disabled="isCreatingModel">Create</FormButton>
</div>
</form>
</CommonDialog>
</div>
</template>
<script setup lang="ts">
import { PlusIcon } from '@heroicons/vue/20/solid'
import { provideApolloClient, useMutation, useQuery } from '@vue/apollo-composable'
import type {
ProjectListProjectItemFragment,
ModelListModelItemFragment
} from '~/lib/common/generated/gql/graphql'
import { useModelNameValidationRules } from '~/lib/validation'
import {
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'
import { useMixpanel } from '~/lib/core/composables/mixpanel'
const { trackEvent } = useMixpanel()
const hostAppStore = useHostAppStore()
const emit = defineEmits<{
(e: 'next', model: ModelListModelItemFragment): void
}>()
const props = withDefaults(
defineProps<{
project: ProjectListProjectItemFragment
workspaceId?: string
workspaceSlug?: string
accountId: string
showNewModel?: boolean
isSender?: boolean
}>(),
{ showNewModel: true, isSender: false }
)
const accountStore = useAccountStore()
const showNewModelDialog = ref(false)
const showSelectionHasProblemsDialog = ref(false)
const searchText = ref<string>()
const newModelName = ref<string>()
watch(searchText, () => (newModelName.value = searchText.value))
let selectedModel: ModelListModelItemFragment | undefined = undefined
const existingModelProblem = ref(false)
const existingModelName = ref<string | undefined>(undefined)
const hasNonZeroVersionsProblem = ref(false)
const handleModelSelect = (model: ModelListModelItemFragment) => {
const existingModel = hostAppStore.models.find((m) => m.modelId === model.id)
existingModelProblem.value = !!existingModel
if (existingModelProblem.value) {
existingModelName.value = model.name
}
hasNonZeroVersionsProblem.value =
model.versions.totalCount !== 0 && props.showNewModel // NOTE: we're using the showNewModel prop as a giveaway of whether we're in the send wizard - we do not need this extra check in the receive wizard
if (!existingModelProblem.value && !hasNonZeroVersionsProblem.value) {
return emit('next', model)
}
selectedModel = model
showSelectionHasProblemsDialog.value = true
}
const confirmModelSelection = () => {
existingModelProblem.value = false
hasNonZeroVersionsProblem.value = false
emit('next', selectedModel as ModelListModelItemFragment)
}
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.
emit('next', result)
}
const isCreatingModel = ref(false)
const createNewModel = async (name: string) => {
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)
const { mutate } = provideApolloClient(account.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)
} 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 {
result: projectModelsResult,
loading,
fetchMore,
refetch
} = useQuery(
projectModelsQuery,
() => ({
projectId: props.project.id,
limit: 10,
filter: {
search: (searchText.value || '').trim() || null
}
}),
() => ({ clientId: props.accountId, debounce: 500, fetchPolicy: 'cache-and-network' })
)
const models = computed(() => projectModelsResult.value?.project.models.items)
const totalCount = computed(() => projectModelsResult.value?.project.models.totalCount)
const hasReachedEnd = ref(false)
watch(projectModelsResult, (newVal) => {
if (
newVal &&
newVal?.project.models.items.length >= newVal?.project.models.totalCount
) {
hasReachedEnd.value = true
} else {
hasReachedEnd.value = false
}
})
const loadMore = () => {
fetchMore({
variables: { cursor: projectModelsResult.value?.project.models.cursor },
updateQuery: (previousResult, { fetchMoreResult }) => {
if (!fetchMoreResult || fetchMoreResult.project.models.items.length === 0) {
hasReachedEnd.value = true
return previousResult
}
if (
previousResult.project.models.items.length +
fetchMoreResult.project.models.items.length >=
fetchMoreResult.project.models.totalCount
) {
hasReachedEnd.value = true
}
return {
project: {
id: previousResult.project.id,
__typename: previousResult.project.__typename,
models: {
__typename: previousResult.project.models.__typename,
totalCount: previousResult.project.models.totalCount,
cursor: fetchMoreResult.project.models.cursor,
items: [
...previousResult.project.models.items,
...fetchMoreResult.project.models.items
]
}
}
}
}
})
}
</script>
+394
View File
@@ -0,0 +1,394 @@
<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 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')"
>
<div class="min-w-0 truncate flex-grow">
<span>{{ 'Create a workspace' }}</span>
</div>
<ArrowTopRightOnSquareIcon class="w-4" />
</FormButton>
</div>
<WorkspaceMenu
v-else-if="selectedWorkspace"
:workspaces="workspaces"
:current-selected-workspace-id="selectedWorkspace.id"
@workspace:selected="(workspace: WorkspaceListWorkspaceItemFragment) => handleWorkspaceSelected(workspace)"
>
<template #activator="{ toggle }">
<button
v-tippy="'Click to change the workspace'"
class="flex items-center w-full p-1 space-x-2 bg-foundation hover:bg-primary-muted rounded text-foreground border"
@click="toggle()"
>
<WorkspaceAvatar
:size="'xs'"
:name="selectedWorkspace.name || ''"
:logo="selectedWorkspace.logo"
/>
<div class="min-w-0 truncate flex-grow text-left">
<span>{{ selectedWorkspace.name }}</span>
</div>
<ChevronDownIcon class="h-3 w-3 shrink-0" />
</button>
</template>
</WorkspaceMenu>
</div>
<div class="px-0.5 shrink-0">
<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
v-model="searchText"
placeholder="Search your projects"
name="search"
autocomplete="off"
:show-clear="!!searchText"
full-width
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
v-else
@project:created="(result : ProjectListProjectItemFragment) => handleProjectCreated(result)"
>
<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>
<div v-if="!workspacesEnabled || !workspaces" class="mt-1">
<AccountsMenu
:current-selected-account-id="accountId"
@select="(e) => selectAccount(e)"
/>
</div>
</div>
</div>
<CommonLoadingBar v-if="loading" loading />
</div>
<div class="grid grid-cols-1 gap-2 relative z-0">
<WizardListProjectCard
v-for="project in projects"
:key="project.id"
:project="project"
:is-sender="isSender"
@click="handleProjectCardClick(project)"
/>
<FormButton
full-width
:disabled="hasReachedEnd"
color="outline"
@click="loadMore"
>
{{ hasReachedEnd ? 'No more projects found' : 'Load older projects' }}
</FormButton>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ChevronDownIcon, ArrowTopRightOnSquareIcon } from '@heroicons/vue/24/outline'
import { storeToRefs } from 'pinia'
import { PlusIcon } from '@heroicons/vue/20/solid'
import type { DUIAccount } from '~/store/accounts'
import { useAccountStore } from '~/store/accounts'
import {
activeWorkspaceQuery,
projectsListQuery,
serverInfoQuery,
setActiveWorkspaceMutation,
workspacesListQuery
} from '~/lib/graphql/mutationsAndQueries'
import { useMutation, provideApolloClient, useQuery } from '@vue/apollo-composable'
import type {
ProjectListProjectItemFragment,
WorkspaceListWorkspaceItemFragment
} from 'lib/common/generated/gql/graphql'
import { useMixpanel } from '~/lib/core/composables/mixpanel'
import { useConfigStore } from '~/store/config'
const { trackEvent } = useMixpanel()
const { $openUrl } = useNuxtApp()
const emit = defineEmits<{
(
e: 'next',
accountId: string,
project: ProjectListProjectItemFragment,
workspace?: WorkspaceListWorkspaceItemFragment // NOTE: this nullabilities will disappear whenever we are workspace only
): void
(e: 'search-text-update', text: string | undefined): void
}>()
const props = withDefaults(
defineProps<{
isSender: boolean
showNewProject?: boolean
/**
* For the send wizard - not allowing selecting projects we can't write to.
*/
disableNoWriteAccessProjects?: boolean
}>(),
{
showNewProject: true,
disableNoWriteAccessProjects: false
}
)
const searchText = ref<string>()
const newProjectName = ref<string>()
const accountStore = useAccountStore()
const configStore = useConfigStore()
const { activeAccount } = storeToRefs(accountStore)
const accountId = computed(() => activeAccount.value.accountInfo.id)
watch(searchText, () => {
newProjectName.value = searchText.value
emit('search-text-update', searchText.value)
})
// TODO: this function is never triggered!! remove or evaluate
const selectAccount = (account: DUIAccount) => {
refetchServerInfo() // to be able to understand workspaces enabled or not
refetchActiveWorkspace()
refetchWorkspaces()
void trackEvent('DUI3 Action', { name: 'Account Select' }, account.accountInfo.id)
}
const handleProjectCreated = (result: ProjectListProjectItemFragment) => {
refetch() // Sorts the list with newly created project otherwise it will put the project at the bottom.
emit('next', accountId.value, result)
}
const { result: serverInfoResult, refetch: refetchServerInfo } = useQuery(
serverInfoQuery,
() => ({}),
() => ({ clientId: accountId.value, debounce: 500, fetchPolicy: 'network-only' })
)
const workspacesEnabled = computed(
() => serverInfoResult.value?.serverInfo.workspaces.workspacesEnabled
)
const { result: workspacesResult, refetch: refetchWorkspaces } = useQuery(
workspacesListQuery,
() => ({
limit: 100
}),
() => ({ clientId: accountId.value, debounce: 500, fetchPolicy: 'network-only' })
)
const workspaces = computed(() => workspacesResult.value?.activeUser?.workspaces.items)
const { result: activeWorkspaceResult, refetch: refetchActiveWorkspace } = useQuery(
activeWorkspaceQuery,
() => ({}),
() => ({ clientId: accountId.value, debounce: 500, fetchPolicy: 'network-only' })
)
const activeWorkspace = computed(() => {
const userSelectedWorkspaceId = configStore.userSelectedWorkspaceId
if (userSelectedWorkspaceId) {
const previouslySelectedWorkspace = workspaces.value?.find(
(w) => w.id === userSelectedWorkspaceId
)
if (previouslySelectedWorkspace) {
return previouslySelectedWorkspace
}
}
// fallback to activeWorkspace query result
return activeWorkspaceResult.value?.activeUser
?.activeWorkspace as WorkspaceListWorkspaceItemFragment
})
const selectedWorkspace = ref<WorkspaceListWorkspaceItemFragment | undefined>(
activeWorkspace.value
)
watch(
workspaces,
(newItems) => {
if (newItems && newItems.length > 0) {
selectedWorkspace.value = activeWorkspace.value ?? newItems[0]
} else {
selectedWorkspace.value = undefined
}
},
{ immediate: true }
)
const handleProjectCardClick = (project: ProjectListProjectItemFragment) => {
if (
props.isSender
? project.permissions.canPublish.authorized
: project.permissions.canLoad.authorized
) {
emit('next', accountId.value, project, selectedWorkspace.value)
}
}
const handleWorkspaceSelected = async (
newSelectedWorkspace: WorkspaceListWorkspaceItemFragment
) => {
selectedWorkspace.value = newSelectedWorkspace
const account = computed(() => {
return accountStore.accounts.find(
(acc) => acc.accountInfo.id === accountId.value
) as DUIAccount
})
const { mutate } = provideApolloClient(account.value.client)(() =>
useMutation(setActiveWorkspaceMutation)
)
try {
await mutate({ slug: newSelectedWorkspace.slug })
} catch (error) {
// I dont believe we should throw toast for this, but good to be critical on console
console.error(error)
}
configStore.setUserSelectedWorkspace(newSelectedWorkspace.id)
}
// This is a hack for people who don't have a workspace and have personal projects only.
const timeoutWait = ref(false)
const filtersReady = computed(
() => selectedWorkspace.value !== undefined || timeoutWait.value
)
onMounted(() => {
setTimeout(() => {
timeoutWait.value = true
}, 1000)
})
const {
result: projectsResult,
loading,
fetchMore,
refetch
} = useQuery(
projectsListQuery,
() => ({
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,
includeImplicitAccess: true,
personalOnly: selectedWorkspace.value?.id === 'personalProject'
}
}),
() => ({
enabled: filtersReady.value,
clientId: accountId.value,
debounce: 500,
fetchPolicy: 'network-only'
})
)
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
? projectsResult.value?.activeUser?.projects.items.filter(
(i) => i.workspaceId === null
)
: projectsResult.value?.activeUser?.projects.items
)
const hasReachedEnd = ref(false)
watch(searchText, () => {
hasReachedEnd.value = false
})
watch(projectsResult, (newVal) => {
if (
newVal &&
newVal.activeUser &&
newVal?.activeUser?.projects.items.length >= newVal?.activeUser?.projects.totalCount
) {
hasReachedEnd.value = true
} else {
hasReachedEnd.value = false
}
})
const loadMore = () => {
fetchMore({
variables: { cursor: projectsResult.value?.activeUser?.projects.cursor },
updateQuery: (previousResult, { fetchMoreResult }) => {
if (!fetchMoreResult || fetchMoreResult.activeUser?.projects.items.length === 0) {
hasReachedEnd.value = true
return previousResult
}
if (!previousResult.activeUser || !fetchMoreResult.activeUser)
return previousResult
return {
activeUser: {
id: previousResult.activeUser?.id,
__typename: previousResult.activeUser?.__typename,
projects: {
__typename: previousResult.activeUser?.projects.__typename,
cursor: fetchMoreResult?.activeUser?.projects.cursor,
totalCount: fetchMoreResult?.activeUser?.projects.totalCount,
items: [
...previousResult.activeUser.projects.items,
...fetchMoreResult.activeUser.projects.items
]
}
}
}
}
})
}
</script>
+143
View File
@@ -0,0 +1,143 @@
<template>
<div>
<div class="space-y-2">
<div
v-if="isLimited && workspaceSlug"
class="flex items-center justify-between bg-foundation rounded-md border border-outline-3 p-1 space-x-2 text-xs"
>
<div class="ml-1">Upgrade to load older versions.</div>
<FormButton
size="sm"
@click="$openUrl(`${serverUrl}/settings/workspaces/${workspaceSlug}/billing`)"
>
Upgrade
</FormButton>
</div>
<div v-if="latestVersion" class="grid grid-cols-2 gap-3 max-[275px]:grid-cols-1">
<WizardListVersionCard
v-for="(version, index) in versions"
:key="version.id"
:version="version"
:index="index"
:latest-version-id="latestVersion.id"
:selected-version-id="selectedVersionId"
:project-id="projectId"
:from-wizard="fromWizard"
:account-id="accountId"
@click="$emit('next', version, latestVersion)"
/>
</div>
<CommonLoadingBar v-if="loading" loading />
<FormButton
color="outline"
full-width
:disabled="hasReachedEnd"
@click="loadMore"
>
{{ hasReachedEnd ? 'No older versions' : 'Show older versions' }}
</FormButton>
</div>
</div>
</template>
<script setup lang="ts">
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'
defineEmits<{
(
e: 'next',
version: VersionListItemFragment,
latestVersion: VersionListItemFragment
): void
}>()
const props = defineProps<{
accountId: string
projectId: string
modelId: string
selectedVersionId?: string
workspaceSlug?: string
fromWizard?: boolean
}>()
const accountStore = useAccountStore()
const serverUrl = computed(() => accountStore.activeAccount.accountInfo.serverInfo.url)
const {
result: modelVersionResults,
loading,
fetchMore,
refetch
} = useQuery(
modelVersionsQuery,
() => {
const payload = {
projectId: props.projectId,
modelId: props.modelId,
limit: 6,
filter: props.selectedVersionId
? { priorityIds: [props.selectedVersionId] }
: undefined
}
return payload
},
() => ({ clientId: props.accountId, fetchPolicy: 'cache-and-network' })
)
const versions = computed(() => modelVersionResults.value?.project.model.versions.items)
const isLimited = computed(
() => versions.value?.filter((v) => v.referencedObject === null).length !== 0
)
const hasReachedEnd = ref(false)
const latestVersion = computed(() => {
if (!versions.value) return
const sorted = [...versions.value].sort(
(a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt)
)
return sorted[0]
})
const loadMore = () => {
fetchMore({
variables: { cursor: modelVersionResults.value?.project.model.versions.cursor },
updateQuery: (previousResult, { fetchMoreResult }) => {
if (
!fetchMoreResult ||
fetchMoreResult.project.model.versions.items.length === 0
) {
hasReachedEnd.value = true
return previousResult
}
return {
project: {
id: previousResult.project.id,
__typename: previousResult.project.__typename,
model: {
id: previousResult.project.model.id,
__typename: previousResult.project.model.__typename,
versions: {
__typename: previousResult.project.model.versions.__typename,
totalCount: previousResult.project.model.versions.totalCount,
cursor: fetchMoreResult?.project.model.versions.cursor,
items: [
...previousResult.project.model.versions.items,
...fetchMoreResult.project.model.versions.items
]
}
}
}
}
}
})
}
onMounted(() => {
refetch()
})
</script>
+85
View File
@@ -0,0 +1,85 @@
<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>{{ 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'
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>
+86
View File
@@ -0,0 +1,86 @@
<template>
<button
class="group text-left relative bg-foundation-2 rounded p-1 hover:text-primary hover:bg-primary-muted transition cursor-pointer hover:shadow-md"
>
<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">
<img
:src="model.previewUrl"
alt="preview image for model"
class="h-12 w-12 object-cover"
/>
</div>
<div
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" />
</div>
</div>
<div class="min-w-0 w-full">
<div class="text-body-3xs text-foreground-2 truncate" :title="model.name">
{{ folderPath }}
</div>
<div class="flex items-center justify-around space-x-2">
<div class="text-heading-sm grow truncate text-ellipsis">
{{ model.displayName }}
</div>
</div>
<div class="text-body-3xs text-foreground-2 truncate flex space-x-2">
<div>updated {{ updatedAgo }}</div>
</div>
</div>
<div class="space-y-2 max-[275px]:hidden">
<div class="px-1 text-xs flex items-center">
<div>{{ model.versions.totalCount }}</div>
<ClockIcon class="ml-1 h-3" />
</div>
<div class="text-right">
<SourceAppBadge v-if="sourceApp" :source-app="sourceApp" />
</div>
</div>
</div>
</button>
</template>
<script setup lang="ts">
import dayjs from 'dayjs'
import { CubeTransparentIcon } from '@heroicons/vue/20/solid'
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'
const props = defineProps<{
model: ModelListModelItemFragment
}>()
const folderPath = computed(() => {
const splitName = props.model.name.split('/')
if (splitName.length === 1) return ' '
const withoutLast = splitName.slice(0, -1)
return withoutLast.join('/')
})
const updatedAgo = computed(() => {
return dayjs(props.model.updatedAt).from(dayjs())
})
const sourceApp = computed(() => {
if (props.model.versions.items.length === 0) return
const version = props.model.versions.items[0]
return (
SourceApps.find((sapp) =>
version.sourceApplication?.toLowerCase()?.includes(sapp.searchKey.toLowerCase())
) || {
searchKey: '',
name: version.sourceApplication as SourceAppName,
short: version.sourceApplication?.substring(0, 3) as string,
bgColor: '#000'
}
)
})
</script>
+55
View File
@@ -0,0 +1,55 @@
<template>
<div
v-tippy="cardTippy"
:class="`group relative bg-foundation-2 rounded px-2 py-1 transition ${
hasAccess
? 'cursor-pointer hover:text-primary hover:bg-primary-muted hover:shadow-md'
: 'cursor-not-allowed italic bg-neutral-500/5'
} `"
>
<div
:class="`text-heading-sm text-ellipsis truncate ${
hasAccess ? '' : 'text-foreground-2'
}`"
>
{{ project.name }}
</div>
<div class="text-body-3xs text-foreground-2">
{{ projectRole }}, updated {{ updatedAgo }}
</div>
</div>
</template>
<script setup lang="ts">
import dayjs from 'dayjs'
import type { ProjectListProjectItemFragment } from '~/lib/common/generated/gql/graphql'
const props = defineProps<{
project: ProjectListProjectItemFragment
isSender: boolean
}>()
const updatedAgo = computed(() => {
return dayjs(props.project.updatedAt).from(dayjs())
})
const cardTippy = computed(() => (!hasAccess.value ? disabledMessage.value : ''))
// Previously we were having hard coded messaging, web team will provide better messaging per permission here instaed common message
const disabledMessage = computed(() =>
props.isSender
? props.project.permissions.canPublish.message
: props.project.permissions.canLoad.message
)
const hasAccess = computed(() =>
props.isSender
? props.project.permissions.canPublish.authorized
: props.project.permissions.canLoad.authorized
)
const projectRole = computed(() => {
if (hasAccess.value) {
return 'Can edit'
}
return 'Can view'
})
</script>
+139
View File
@@ -0,0 +1,139 @@
<template>
<button
:class="`relative block text-left shadow rounded-md bg-foundation-2 hover:bg-primary-muted overflow-hidden transition `"
:disabled="(selectedVersionId === version.id && !fromWizard) || isLimited"
>
<UserAvatar
v-tippy="`Authored by ${version.authorUser?.name}`"
:user="version.authorUser"
size="sm"
class="absolute inset-1"
/>
<div v-if="isLimited">
<div
class="bg-foundation h-24 w-full flex-shrink-0 rounded-md border border-outline-3"
:class="isLimited ? 'diagonal-stripes' : ''"
>
<div class="flex flex-col items-center justify-center space-y-2 w-full h-full">
<div
class="flex h-10 w-10 items-center justify-center rounded-md bg-foundation border border-outline-3"
>
<LockClosedIcon class="h-5 w-5 text-foreground-3 z-20" />
</div>
</div>
</div>
</div>
<div v-else class="flex items-center justify-center w-full h-24">
<img :src="version.previewUrl" alt="version preview" />
</div>
<div class="p-1.5 border-t dark:border-gray-700">
<div class="flex space-x-2 items-center min-w-0">
<SourceAppBadge
:source-app="
SourceApps.find((sapp) =>
version.sourceApplication?.toLowerCase()?.includes(sapp.searchKey.toLowerCase())
) || {
searchKey: '',
name: version.sourceApplication as SourceAppName,
short: version.sourceApplication?.substring(0, 3) as string,
bgColor: '#000'
}
"
/>
<span class="text-body-2xs text-foreground-2 truncate">{{ createdAgo }}</span>
</div>
</div>
<CommonBadge
v-if="latestVersionId === version.id && selectedVersionId !== latestVersionId"
dot
dot-icon-color-classes="animate-ping"
class="absolute top-1 right-1 shadow"
>
Latest
</CommonBadge>
<CommonBadge
v-if="selectedVersionId === version.id"
dot
color-classes="bg-foundation"
class="absolute top-1 right-1 shadow"
>
Current
</CommonBadge>
<!-- Warning if obj is coming from the v2 side -->
<!-- <div v-if="!objectVersion" class="bottom-0 left-0">
<div
class="text-body-2xs px-2 bg-blue-500/5 py-2 text-foreground-2 flex items-center space-x-1 justify-center"
>
<div>Compatibility warning:</div>
<FormButton size="sm" text @click.stop="showCompatWarning = true">
read more
</FormButton>
<CommonDialog
v-model:open="showCompatWarning"
title="Compatibility warning"
fullscreen="none"
>
This version might not receive as expected.
<br />
<br />
As we progress with the new Speckle, there are a few things that wont work as
expected. We recommend you send this model again using next connectors if
available.
<br />
<br />
We will do our best to convert, but, for example, Instances (Blocks), Render
Materials, Parameters and others will not work from the previous version of
the connectors.
<div class="mt-4 flex justify-end items-center space-x-2">
<FormButton size="sm" @click="showCompatWarning = false">
Understood
</FormButton>
</div>
</CommonDialog>
</div>
</div> -->
</button>
</template>
<script setup lang="ts">
import { LockClosedIcon } from '@heroicons/vue/24/solid'
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 { objectQuery } from '~/lib/graphql/mutationsAndQueries'
// import { useQuery } from '@vue/apollo-composable'
const props = defineProps<{
version: VersionListItemFragment
index: number
latestVersionId: string
accountId: string
projectId: string
workspaceSlug?: string
selectedVersionId?: string
fromWizard?: boolean
}>()
const createdAgo = computed(() => {
return dayjs(props.version.createdAt).from(dayjs())
})
const isLimited = computed(() => props.version.referencedObject === null)
// NOTE!!!: This logic somehow caused regression on versionList fetchMore, but we do not know exactly why yet.
// const { result: objectQueryResult } = useQuery(
// objectQuery,
// () => ({ projectId: props.projectId, objectId: props.referencedObjectId }),
// () => ({ clientId: props.accountId })
// )
// type Data = {
// version?: number
// }
// const objectVersion = computed(() => {
// const data = objectQueryResult.value?.project?.object?.data as Data | undefined
// return data?.version
// })
// const showCompatWarning = ref(false)
</script>
+35
View File
@@ -0,0 +1,35 @@
<template>
<div
:class="[
'flex shrink-0 overflow-hidden rounded-md border border-outline-2 bg-foundation-2',
sizeClasses
]"
>
<div
class="h-full w-full bg-cover bg-center bg-no-repeat flex items-center justify-center"
:style="logo ? { backgroundImage: `url('${logo}')` } : {}"
>
<span v-if="!logo" class="text-foreground-3 uppercase leading-none">
{{ name[0] }}
</span>
</div>
</div>
</template>
<script setup lang="ts">
import type { MaybeNullOrUndefined } from '@speckle/shared'
import { type UserAvatarSize, useAvatarSizeClasses } from '@speckle/ui-components'
const props = withDefaults(
defineProps<{
size?: UserAvatarSize
logo: MaybeNullOrUndefined<string>
name: string
}>(),
{
size: 'base'
}
)
const { sizeClasses } = useAvatarSizeClasses({ props: toRefs(props) })
</script>
+38
View File
@@ -0,0 +1,38 @@
<template>
<button
:class="`group block w-full p-1 text-left rounded-md items-center space-x-2 select-none group transition hover:bg-primary-muted hover:text-primary ${
workspace.readOnly
? 'text-danger bg-rose-500/10 cursor-not-allowed'
: 'cursor-pointer'
} ${
currentSelectedWorkspaceId === workspace.id ? 'bg-blue-500/5 text-primary' : ''
}`"
:disabled="workspace.readOnly"
@click="$emit('select', workspace)"
>
<div class="flex items-center space-x-2">
<WorkspaceAvatar
:size="'sm'"
:name="workspace.name || ''"
:logo="workspace.logo"
/>
<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>
</div>
</button>
</template>
<script setup lang="ts">
import type { WorkspaceListWorkspaceItemFragment } from '~/lib/common/generated/gql/graphql'
defineProps<{
workspace: WorkspaceListWorkspaceItemFragment
currentSelectedWorkspaceId: string
}>()
defineEmits<{
(e: 'select', workspace: WorkspaceListWorkspaceItemFragment): void
}>()
</script>
+47
View File
@@ -0,0 +1,47 @@
<template>
<div class="flex-grow">
<slot name="activator" :toggle="toggleDialog"></slot>
<CommonDialog
v-model:open="showWorkspaceSelectorDialog"
:title="`Select workspace`"
fullscreen="none"
>
<WorkspaceListItem
v-for="workspace in workspacesWithPersonalProjects"
:key="workspace.id"
:current-selected-workspace-id="currentSelectedWorkspaceId"
:workspace="workspace"
@select="
$emit('workspace:selected', workspace), (showWorkspaceSelectorDialog = false)
"
></WorkspaceListItem>
</CommonDialog>
</div>
</template>
<script setup lang="ts">
import type { WorkspaceListWorkspaceItemFragment } from '~/lib/common/generated/gql/graphql'
const showWorkspaceSelectorDialog = ref(false)
const props = defineProps<{
workspaces: WorkspaceListWorkspaceItemFragment[]
currentSelectedWorkspaceId: string
}>()
defineEmits<{
(e: 'workspace:selected', result: WorkspaceListWorkspaceItemFragment): void
}>()
const workspacesWithPersonalProjects = computed(() => [
...props.workspaces,
{
id: 'personalProject',
name: 'Personal Projects'
} as WorkspaceListWorkspaceItemFragment
])
const toggleDialog = () => {
showWorkspaceSelectorDialog.value = !showWorkspaceSelectorDialog.value
}
</script>
+125
View File
@@ -0,0 +1,125 @@
import { omit } from 'lodash-es'
import { baseConfigs, globals, getESMDirname } from '../../eslint.config.mjs'
import withNuxt from './.nuxt/eslint.config.mjs'
import pluginVueA11y from 'eslint-plugin-vuejs-accessibility'
const configs = await withNuxt([
{
rules: {
camelcase: [
'error',
{
properties: 'always',
allow: ['^[\\w]+_[\\w]+Fragment$']
}
],
'no-alert': 'error',
eqeqeq: ['error', 'always', { null: 'always' }],
'no-console': 'off',
'no-var': 'error'
}
},
{
files: ['**/*.{ts,vue,tsx,mts,cts}'],
languageOptions: {
parserOptions: {
project: ['./tsconfig.eslint.json'],
extraFileExtensions: ['.vue'],
tsconfigRootDir: getESMDirname(import.meta.url)
}
}
},
{
files: ['**/*.test.{ts,js}'],
languageOptions: {
globals: {
...globals.jest
}
}
},
{
files: ['./{components|pages|store|lib}/*.{js,ts,vue}'],
languageOptions: {
globals: {
...globals.browser
}
}
},
{
files: ['**/*.{ts,tsx,vue}'],
rules: {
'@typescript-eslint/no-explicit-any': ['error'],
'@typescript-eslint/no-unsafe-argument': ['error'],
'@typescript-eslint/no-unsafe-assignment': 'error',
'@typescript-eslint/no-unsafe-call': 'error',
'@typescript-eslint/no-unsafe-member-access': 'error',
'@typescript-eslint/no-unsafe-return': 'error',
'@typescript-eslint/no-for-in-array': ['error'],
'@typescript-eslint/restrict-plus-operands': ['error'],
'@typescript-eslint/await-thenable': ['warn'],
'@typescript-eslint/no-restricted-types': ['warn'],
'require-await': 'off',
'@typescript-eslint/require-await': 'error',
'no-undef': 'off',
'@typescript-eslint/unified-signatures': 'off', // DX sucks in vue event definitions
'@typescript-eslint/no-dynamic-delete': 'off', // too restrictive
'@typescript-eslint/restrict-template-expressions': 'off', // too restrictive
'@typescript-eslint/no-invalid-void-type': 'off' // too restrictive
}
},
...pluginVueA11y.configs['flat/recommended'].map((c) => ({
...c,
files: [...(c.files || []), '**/*.vue'],
languageOptions: c.languageOptions
? omit(c.languageOptions, ['parserOptions', 'parser']) // Prevent overriding parser
: undefined
})),
{
files: ['**/*.vue'],
rules: {
'vue/component-tags-order': [
'error',
{ order: ['docs', 'template', 'script', 'style'] }
],
'vue/require-default-prop': 'off',
'vue/multi-word-component-names': 'off',
'vue/component-name-in-template-casing': [
'error',
'PascalCase',
{ registeredComponentsOnly: false }
],
'vuejs-accessibility/label-has-for': [
'error',
{
required: {
some: ['nesting', 'id']
}
}
],
'vue/html-self-closing': 'off' // messes with prettier
}
},
{
files: ['**/*.d.ts'],
rules: {
'no-var': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-restricted-types': 'off'
}
}
]).prepend([
{
ignores: [
'**/node_modules/**',
'**/templates/*',
'./lib/common/generated/**/*',
'storybook-static',
'.nuxt/**',
'.output/**'
]
},
...baseConfigs
])
export default configs
+38
View File
@@ -0,0 +1,38 @@
<template>
<div class="relative min-h-screen flex flex-col">
<HeaderNavBar />
<main class="flex-1 px-1 max-[275px]:px-0" :class="hasNoModelCards ? '' : 'mt-10'">
<slot />
</main>
<div
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>
<FormButton
size="sm"
text
color="subtle"
:icon-right="isDarkTheme ? SunIcon : MoonIcon"
hide-text
@click="toggleTheme()"
>
Toggle theme
</FormButton>
</div>
</div>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { useHostAppStore } from '~/store/hostApp'
import { useConfigStore } from '~/store/config'
import { MoonIcon, SunIcon } from '@heroicons/vue/24/outline'
const uiConfigStore = useConfigStore()
const { isDarkTheme } = storeToRefs(uiConfigStore)
const { toggleTheme } = uiConfigStore
const hostApp = useHostAppStore()
const hasNoModelCards = computed(() => hostApp.projectModelGroups.length === 0)
</script>
+136
View File
@@ -0,0 +1,136 @@
import { ApolloClient, gql } from '@apollo/client/core'
import { ApolloClients } from '@vue/apollo-composable'
import type { ComputedRef, Ref } from 'vue'
import type { Account } from '~/lib/bindings/definitions/IBasicConnectorBinding'
import { resolveClientConfig } from '~/lib/core/configs/apollo'
export type DUIAccount = {
/** account info coming from the host app */
accountInfo: Account
/** the graphql client; a bit superflous */
client?: ApolloClient<unknown>
/** whether an intial serverinfo query succeeded. */
isValid: boolean
}
export type DUIAccountsState = {
accounts: Ref<DUIAccount[]>
validAccounts: ComputedRef<DUIAccount[]>
refreshAccounts: () => Promise<void>
defaultAccount: ComputedRef<DUIAccount | undefined>
loading: Ref<boolean>
}
const AccountsInjectionKey = 'DUI_ACCOUNTS_STATE'
/**
* Use this composable to set up the account bindings and graphql clients at the top of the app.
* TODO: Properly handle cases when user was not connected to the internet,
* and then actually got connected.
*/
export function useAccountsSetup(): DUIAccountsState {
const app = useNuxtApp()
const $baseBinding = app.$baseBinding
const accounts = ref<DUIAccount[]>([])
const apolloClients = {} as Record<string, ApolloClient<unknown>>
// Tries to connect to the accounts and sets their is valid prop to false if fails.
const testAccounts = async (accs: DUIAccount[]) => {
const accountTestQuery = gql`
query AcccountTestQuery {
serverInfo {
version
name
company
}
}
`
for (const acc of accs) {
if (!acc.client) continue
try {
await acc.client.query({ query: accountTestQuery })
acc.isValid = true
} catch {
// TODO: properly dispose and kill this client. It's unclear how to do it.
acc.isValid = false
// NOTE: we do not want to delete the client, as we might want to "refresh" in
// case the user was not connected to the interweb.
// acc.client.disableNetworkFetches = true
// acc.client.stop()
// delete acc.client
}
}
}
const loading = ref(false)
// Matches local accounts coming from the host app to app state.
const refreshAccounts = async () => {
loading.value = true
const accs = await $baseBinding.getAccounts()
// We create a whole new list of accounts that will replace the old list. This way we ensure we drop
// out of scope old accounts that not exist anymore (TODO: test), and we don't need to do complex diffing.
const newAccs = [] as DUIAccount[]
for (const acc of accs) {
const existing = accounts.value.find((a) => a.accountInfo.id === acc.id)
if (existing) {
newAccs.push(existing as DUIAccount)
continue
}
const client = new ApolloClient(
resolveClientConfig({
httpEndpoint: new URL('/graphql', acc.serverInfo.url).href,
authToken: () => acc.token
})
)
apolloClients[acc.id] = client
newAccs.push({
accountInfo: acc,
client,
isValid: true
})
}
// We test accounts here so we try to prevent the app from querying/using invalid accounts.
await testAccounts(newAccs)
// Once we have tested the new accounts, finally set them.
accounts.value = newAccs
loading.value = false
}
void refreshAccounts() // Promise that we do not want to await (convention with void)
const defaultAccount = computed(() =>
accounts.value.find((acc) => acc.accountInfo.isDefault)
)
const validAccounts = computed(() => {
return accounts.value.filter((a) => a.isValid)
})
const accState = {
accounts,
defaultAccount,
validAccounts,
refreshAccounts,
loading
}
app.vueApp.provide(ApolloClients, apolloClients)
provide(AccountsInjectionKey, accState)
return accState // as DUIAccountsState
}
/**
* Use this composable to access the users' local accounts and their corresponding graphql client.
*/
export function useInjectedAccounts(): DUIAccountsState {
const state = inject(AccountsInjectionKey) as DUIAccountsState
return state
}
+186
View File
@@ -0,0 +1,186 @@
import type { AutomationRunItemFragment } from '~/lib/common/generated/gql/graphql'
import type { PropAnyComponent } from '@speckle/ui-components'
import { AutomateRunStatus } from '~/lib/common/generated/gql/graphql'
import {
CheckCircleIcon,
EllipsisHorizontalCircleIcon,
EllipsisHorizontalIcon,
ExclamationCircleIcon,
ArrowPathIcon,
ClockIcon,
XCircleIcon
} from '@heroicons/vue/24/outline'
import { Automate, type MaybeNullOrUndefined } from '@speckle/shared'
export type RunsStatusSummary = {
failed: number
passed: number
inProgress: number
total: number
title: string
titleColor: string
longSummary: string
}
export const useFunctionRunsStatusSummary = (params: {
runs: MaybeRef<AutomationRunItemFragment[]>
}) => {
const { runs } = params
const summary = computed((): RunsStatusSummary => {
const allFunctionRuns = unref(runs)
const result: RunsStatusSummary = {
failed: 0,
passed: 0,
inProgress: 0,
total: allFunctionRuns.length,
title: 'All runs passed.',
titleColor: 'text-success',
longSummary: ''
}
for (const run of allFunctionRuns) {
switch (run.status) {
case AutomateRunStatus.Succeeded:
result.passed++
break
case AutomateRunStatus.Failed:
case AutomateRunStatus.Exception:
case AutomateRunStatus.Timeout:
case AutomateRunStatus.Canceled:
result.title = 'Some runs failed.'
result.titleColor = 'text-danger'
result.failed++
break
default:
if (result.failed === 0) {
result.title = 'Some runs are still in progress.'
result.titleColor = 'text-warning'
}
result.inProgress++
break
}
}
// format:
// 2 failed, 1 passed runs
// 1 passed, 2 in progress, 1 failed runs
// 1 passed run
const longSummarySegments = []
if (result.passed > 0) longSummarySegments.push(`${result.passed} passed`)
if (result.inProgress > 0)
longSummarySegments.push(`${result.inProgress} in progress`)
if (result.failed > 0) longSummarySegments.push(`${result.failed} failed`)
result.longSummary = (
longSummarySegments.join(', ') + ` run${result.total > 1 ? 's' : ''}.`
).replace(/,(?=[^,]+$)/, ', and')
return result
})
return { summary }
}
export type AutomateRunStatusMetadata = {
icon: PropAnyComponent
xsIcon: PropAnyComponent
iconColor: string
badgeColor: string
disclosureColor: 'success' | 'warning' | 'danger' | 'default'
}
export const useRunStatusMetadata = (params: {
status: MaybeRef<AutomateRunStatus>
}) => {
const { status } = params
const metadata = computed((): AutomateRunStatusMetadata => {
switch (unref(status)) {
case AutomateRunStatus.Canceled:
return {
icon: XCircleIcon,
xsIcon: XCircleIcon,
iconColor: 'text-warning',
badgeColor: 'bg-warning',
disclosureColor: 'warning'
}
case AutomateRunStatus.Exception:
return {
icon: ExclamationCircleIcon,
xsIcon: ExclamationCircleIcon,
iconColor: 'text-danger',
badgeColor: 'bg-danger',
disclosureColor: 'danger'
}
case AutomateRunStatus.Failed:
return {
icon: ExclamationCircleIcon,
xsIcon: ExclamationCircleIcon,
iconColor: 'text-danger',
badgeColor: 'bg-danger',
disclosureColor: 'danger'
}
case AutomateRunStatus.Initializing:
return {
icon: EllipsisHorizontalCircleIcon,
xsIcon: EllipsisHorizontalIcon,
iconColor: 'text-warning',
badgeColor: 'bg-warning',
disclosureColor: 'warning'
}
case AutomateRunStatus.Pending:
return {
icon: EllipsisHorizontalCircleIcon,
xsIcon: EllipsisHorizontalIcon,
iconColor: 'text-primary',
badgeColor: 'bg-primary',
disclosureColor: 'default'
}
case AutomateRunStatus.Running:
return {
icon: ArrowPathIcon,
xsIcon: ArrowPathIcon,
iconColor: 'text-primary animate-spin',
badgeColor: 'bg-primary',
disclosureColor: 'default'
}
case AutomateRunStatus.Succeeded:
return {
icon: CheckCircleIcon,
xsIcon: CheckCircleIcon,
iconColor: 'text-success',
badgeColor: 'bg-success',
disclosureColor: 'success'
}
case AutomateRunStatus.Timeout:
return {
icon: ClockIcon,
xsIcon: ClockIcon,
iconColor: 'text-danger',
badgeColor: 'bg-danger',
disclosureColor: 'danger'
}
}
})
return { metadata }
}
export const useAutomationFunctionRunResults = (params: {
results: MaybeRef<MaybeNullOrUndefined<Record<string, unknown>>>
}) => {
const { results } = params
const ret = computed(
(): MaybeNullOrUndefined<Automate.AutomateTypes.ResultsSchema> => {
const res = unref(results)
if (!res) return res
if (!Automate.AutomateTypes.isResultsSchema(res)) return null
return Automate.AutomateTypes.formatResultsSchema(res)
}
)
return ret
}
@@ -0,0 +1,30 @@
import type { IBinding, IBindingSharedEvents } from 'lib/bindings/definitions/IBinding'
export const IAccountBindingKey = 'accountsBinding'
export interface IAccountBinding extends IBinding<IAccountBindingEvents> {
getAccounts: () => Promise<Account[]>
removeAccount: (accountId: string) => Promise<void>
}
// An almost 1-1 mapping of what we need from the Core accounts class.
export type Account = {
id: string
isDefault: boolean
token: string
serverInfo: {
name: string
url: string
frontend2: boolean
}
userInfo: {
id: string
avatar: string
email: string
name: string
commits: { totalCount: number }
streams: { totalCount: number }
}
}
export interface IAccountBindingEvents extends IBindingSharedEvents {}
@@ -0,0 +1,59 @@
import type {
IBinding,
IBindingSharedEvents
} from '~~/lib/bindings/definitions/IBinding'
import type { IModelCard, IModelCardSharedEvents } from '~~/lib/models/card'
export const IBasicConnectorBindingKey = 'baseBinding'
// Needs to be agreed between Frontend and Core
export interface IBasicConnectorBinding
extends IBinding<IBasicConnectorBindingHostEvents> {
// Various
/**
* return `slug` from connectors, we should have name it better at the beginning
*/
getSourceApplicationName: () => Promise<string>
getSourceApplicationVersion: () => Promise<string>
getConnectorVersion: () => Promise<string>
getDocumentInfo: () => Promise<DocumentInfo>
// Document state calls
getDocumentState: () => Promise<DocumentModelStore>
addModel: (model: IModelCard) => Promise<void>
updateModel: (model: IModelCard) => Promise<void>
highlightModel: (modelCardId: string) => Promise<void>
highlightObjects: (objectIds: string[]) => Promise<void>
removeModel: (model: IModelCard) => Promise<void>
removeModels: (models: IModelCard[]) => Promise<void>
}
export interface IBasicConnectorBindingHostEvents
extends IBindingSharedEvents,
IModelCardSharedEvents {
documentChanged: () => void
}
export type DocumentModelStore = {
models: IModelCard[]
}
export type DocumentInfo = {
location: string
name: string
id: string
message?: string
}
export type ToastInfo = {
modelCardId: string
text: string
level: 'info' | 'danger' | 'warning' | 'success'
action?: ToastAction
timeout?: number
}
export type ToastAction = {
url: string
name: string
}
+24
View File
@@ -0,0 +1,24 @@
import type { ToastNotification } from '@speckle/ui-components'
/**
* Basic interface scaffolding two standard method.
*/
export interface IBinding<T> {
/**
* Events sent over from the host application.
*/
on: <E extends keyof T>(event: E, callback: T[E]) => void
/**
* If possible, opens up dev tools from the embedded browser window.
* Currently needed for CefSharp, as right click inspect doesn't exist.
*/
showDevTools: () => Promise<void>
/**
* Opens an url in the OS's default browser.
*/
openUrl: (url: string) => Promise<void>
}
export interface IBindingSharedEvents {
setGlobalNotification: (toastNotification: ToastNotification) => void
}
@@ -0,0 +1,41 @@
import { BaseBridge } from '~/lib/bridge/base'
import type {
IBinding,
IBindingSharedEvents
} from '~/lib/bindings/definitions/IBinding'
/**
* The name under which this binding will be registered.
*/
export const IConfigBindingKey = 'configBinding'
/**
* A test binding interface to ensure compatbility. Ideally all host environments would implement and register it.
*/
export interface IConfigBinding extends IBinding<IConfigBindingEvents> {
getIsDevMode: () => Promise<boolean>
getConfig: () => Promise<ConnectorConfig>
updateConfig: (config: ConnectorConfig) => void
setUserSelectedAccountId: (accountId: string) => void
getUserSelectedAccountId: () => Promise<AccountsConfig>
getAccountsConfig: () => Promise<AccountsConfig> // should have been named like this from day 0. we should get rid of `getUserSelectedAccountId` function after some amount of time to not confuse ourselves.
setUserSelectedWorkspaceId: (workspaceId: string) => void
getWorkspacesConfig: () => Promise<WorkspacesConfig>
}
export interface IConfigBindingEvents extends IBindingSharedEvents {}
export type ConnectorConfig = {
darkTheme: boolean
}
export type AccountsConfig = {
userSelectedAccountId: string
}
export type WorkspacesConfig = {
userSelectedWorkspaceId: string
}
// Useless, but will do for now :)
export class MockedConfigBinding extends BaseBridge {}
@@ -0,0 +1,26 @@
import type { ConversionResult } from 'lib/conversions/conversionResult'
import type { IModelCardSharedEvents } from '~/lib/models/card'
import type { CardSetting } from '~/lib/models/card/setting'
import type {
IBinding,
IBindingSharedEvents
} from '~~/lib/bindings/definitions/IBinding'
export const IReceiveBindingKey = 'receiveBinding'
export interface IReceiveBinding extends IBinding<IReceiveBindingEvents> {
receive: (modelCardId: string) => Promise<void>
getReceiveSettings: () => Promise<CardSetting[]>
cancelReceive: (modelId: string) => Promise<void>
}
export interface IReceiveBindingEvents
extends IBindingSharedEvents,
IModelCardSharedEvents {
// See note oon timeout in bridge v2; we might not need this
setModelReceiveResult: (args: {
modelCardId: string
bakedObjectIds: string[]
conversionResults: ConversionResult[]
}) => void
}
@@ -0,0 +1,19 @@
import type {
IBinding,
IBindingSharedEvents
} from '~~/lib/bindings/definitions/IBinding'
export const ISelectionBindingKey = 'selectionBinding'
export interface ISelectionBinding extends IBinding<ISelectionBindingHostEvents> {
getSelection: () => Promise<SelectionInfo>
}
export interface ISelectionBindingHostEvents extends IBindingSharedEvents {
setSelection: (args: SelectionInfo) => void
}
export type SelectionInfo = {
summary?: string
selectedObjectIds: string[]
}
+40
View File
@@ -0,0 +1,40 @@
import type { ISendFilter } from '~~/lib/models/card/send'
import type {
IBinding,
IBindingSharedEvents
} from '~~/lib/bindings/definitions/IBinding'
import type { CardSetting } from '~/lib/models/card/setting'
import type { IModelCardSharedEvents } from '~/lib/models/card'
import type { ConversionResult } from 'lib/conversions/conversionResult'
import type { CreateVersionArgs } from '~/lib/bridge/server'
export const ISendBindingKey = 'sendBinding'
export interface ISendBinding extends IBinding<ISendBindingEvents> {
getSendFilters: () => Promise<ISendFilter[]>
getSendSettings: () => Promise<CardSetting[]>
send: (modelId: string) => Promise<void>
cancelSend: (modelId: string) => Promise<void>
}
export interface ISendBindingEvents
extends IBindingSharedEvents,
IModelCardSharedEvents {
refreshSendFilters: () => void
setModelsExpired: (modelCardIds: string[]) => void
setModelSendResult: (args: {
modelCardId: string
versionId: string
sendConversionResults: ConversionResult[]
}) => void
setIdMap: (args: {
modelCardId: string
idMap: Record<string, string>
newSelectedObjectIds: string[]
}) => void
/**
* Use whenever want to cancel model card progress, it is used on Archicad so far since send operation blocks the UI thread.
*/
triggerCancel: (modelCardId: string) => void
triggerCreateVersion: (args: CreateVersionArgs) => void
}
+61
View File
@@ -0,0 +1,61 @@
/* eslint-disable @typescript-eslint/require-await */
import { BaseBridge } from '~~/lib/bridge/base'
import type {
IBinding,
IBindingSharedEvents
} from '~~/lib/bindings/definitions/IBinding'
/**
* The name under which this binding will be registered.
*/
export const ITestBindingKey = 'testBinding'
/**
* A test binding interface to ensure compatbility. Ideally all host environments would implement and register it.
*/
export interface ITestBinding extends IBinding<ITestBindingEvents> {
sayHi: (name: string, count: number, sayHelloNotHi: boolean) => Promise<string[]>
goAway: () => Promise<void>
getComplexType: () => Promise<ComplexType>
shouldThrow: () => Promise<void>
triggerEvent: (eventName: string) => Promise<void>
}
export interface ITestBindingEvents extends IBindingSharedEvents {
emptyTestEvent: () => void
testEvent: (args: TestEventArgs) => void
}
export type TestEventArgs = {
name: string
isOk: boolean
count: number
}
export type ComplexType = {
id: string
count: number
}
export class MockedTestBinding extends BaseBridge {
public async sayHi(name: string, count: number, sayHelloNotHi: boolean) {
return `Hello from mocked bindings. Args: name = ${name}, count = ${count}, sayHelloNotHi = ${sayHelloNotHi.toString()}.`
}
public async goAway() {
return
}
public async getComplexType() {
return { id: 'wow', count: 42 }
}
public async shouldThrow() {
return
}
public async triggerEvent(eventName: string) {
return eventName
}
}
@@ -0,0 +1,11 @@
import type {
IBinding,
IBindingSharedEvents
} from '~/lib/bindings/definitions/IBinding'
export const ITopLevelExpectionHandlerBindingKey = 'topLevelExceptionHandlerBinding'
export interface ITopLevelExpectionHandlerBinding
extends IBinding<ITopLevelExpectionHandlerBindingEvents> {}
export interface ITopLevelExpectionHandlerBindingEvents extends IBindingSharedEvents {}
+36
View File
@@ -0,0 +1,36 @@
export interface IDiscriminatedObject {
typeDiscriminator: string
}
export class DiscriminatedObject implements IDiscriminatedObject {
typeDiscriminator: string
constructor(typeDiscriminator: string) {
this.typeDiscriminator = typeDiscriminator
}
}
export interface FormInputBase extends IDiscriminatedObject {
label?: string
showLabel?: boolean
}
export interface FormTextInput extends FormInputBase {
value?: string
placeholder?: string
}
export interface BooleanValueInput extends FormInputBase {
value: boolean
}
export interface ListValueInput extends FormInputBase {
options: ListValueItem[]
selectedOptions: ListValueInput[]
multiSelect: boolean
}
export interface ListValueItem extends IDiscriminatedObject {
id: string
name: string
color?: string
}
+31
View File
@@ -0,0 +1,31 @@
import { BaseBridgeErrorHandler } from '~/lib/bridge/errorHandler'
import type { Emitter } from 'nanoevents'
import { createNanoEvents } from 'nanoevents'
/**
* A simple (typed) event emitter base class that host applications can use to send messages (and data) to the web ui,
* e.g. via `browser.executeScriptAsync("myBindings.on('eventName', serializedData)")`.
*/
export class BaseBridge {
public emitter: Emitter
/**
* Holds a list of connector implemented or available methods in this binding.
*/
public availableMethodNames: string[] = []
constructor() {
this.emitter = createNanoEvents()
this.availableMethodNames = []
new BaseBridgeErrorHandler(this.emitter) // Where we set error to hostApp store
}
// NOTE: these do not need to be typed extra in here, as they will be properly typed on the specific binding's interface.
on(event: string | number, callback: (...args: unknown[]) => void) {
return this.emitter.on(event, callback)
}
// NOTE: this could be private - as it should be only used by the host application.
emit(eventName: string, payload: string) {
const parsedPayload = payload ? (JSON.parse(payload) as unknown) : null
this.emitter.emit(eventName, parsedPayload)
}
}
+10
View File
@@ -0,0 +1,10 @@
/**
* Defines the expected contract of the host application bound object.
*/
export type IRawBridge = {
GetBindingsMethodNames: () => Promise<string[]>
RunMethod: (methodName: string, requestId: string, args: string) => Promise<string>
ShowDevTools: () => Promise<void>
OpenUrl: (url: string) => Promise<void>
GetCallResult: (requestId: string) => Promise<string>
}
+34
View File
@@ -0,0 +1,34 @@
import type { ToastNotification } from '@speckle/ui-components'
import { ToastNotificationType } from '@speckle/ui-components'
import type { Emitter } from 'nanoevents'
import { useHostAppStore } from '~/store/hostApp'
export type HostAppError = {
message: string
error: string
stackTrace: string
}
export class BaseBridgeErrorHandler {
constructor(emitter: Emitter) {
emitter.on('errorOnResponse', (data: string) => {
this.handleError(data)
})
}
private handleError(data: string) {
const store = useHostAppStore()
const parsedData = JSON.parse(data) as Record<string, unknown> as HostAppError
store.setHostAppError(parsedData)
const notification: ToastNotification = {
type: ToastNotificationType.Danger,
title: 'Host App Error',
description: parsedData.message,
cta: {
title: 'Show details',
onClick: () => (store.showErrorDialog = true)
}
}
store.setNotification(notification)
}
}

Some files were not shown because too many files have changed in this diff Show More