Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 93e4762b6a | |||
| 21a4bd4076 | |||
| 82c95aab58 | |||
| fe77ede49e | |||
| f70915f485 | |||
| f2d7493c2a |
@@ -0,0 +1,8 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
indent_size = 2
|
||||
indent_style = space
|
||||
insert_final_newline = true
|
||||
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
/.yarn/** linguist-vendored
|
||||
/.yarn/releases/* binary
|
||||
/.yarn/plugins/**/* binary
|
||||
/.pnp.* binary linguist-generated
|
||||
@@ -0,0 +1,44 @@
|
||||
name: Linting
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
lint-and-build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22.14.0'
|
||||
|
||||
- name: Enable Corepack and Install Correct Yarn Version
|
||||
run: |
|
||||
corepack enable
|
||||
corepack prepare yarn@$(jq -r .packageManager package.json | cut -d'@' -f2) --activate
|
||||
yarn --version
|
||||
|
||||
- name: Cache node_modules
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
**/node_modules
|
||||
.yarn/cache
|
||||
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-yarn-
|
||||
|
||||
- name: Install Dependencies
|
||||
run: yarn install --immutable
|
||||
|
||||
- name: Run Linter
|
||||
run: yarn lint
|
||||
|
||||
- name: Run generate
|
||||
run: yarn generate
|
||||
+18
@@ -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
|
||||
@@ -0,0 +1,35 @@
|
||||
node_modules
|
||||
build
|
||||
dist
|
||||
dist2
|
||||
dist-*
|
||||
coverage
|
||||
.nyc_output
|
||||
|
||||
.output
|
||||
.nuxt
|
||||
**/nuxt-modules/**/templates/*.js
|
||||
/lib/common/generated/**/*
|
||||
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
.yarn
|
||||
|
||||
# Profiler output
|
||||
events.json
|
||||
|
||||
# Prettier doesn't understand the syntax inside the Yaml files, because of the brackets
|
||||
utils/helm/speckle-server/templates
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
.venv
|
||||
venv
|
||||
|
||||
.*.{ts,js,vue,tsx,jsx}
|
||||
**/generated/**/*
|
||||
**/generated/graphql.ts
|
||||
|
||||
storybook-static
|
||||
.tshy
|
||||
.tshy-build
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"trailingComma": "none",
|
||||
"tabWidth": 2,
|
||||
"semi": false,
|
||||
"endOfLine": "auto",
|
||||
"bracketSpacing": true,
|
||||
"vueIndentScriptAndStyle": false,
|
||||
"htmlWhitespaceSensitivity": "ignore",
|
||||
"printWidth": 88,
|
||||
"singleQuote": true
|
||||
}
|
||||
Vendored
+18
@@ -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"]
|
||||
}
|
||||
Vendored
+61
@@ -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
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
nodeLinker: node-modules
|
||||
@@ -1,2 +1,34 @@
|
||||
# speckle-connectors-dui
|
||||
Web UI to use accross connectors (aka dui3)
|
||||
# Speckle Connectors DUI
|
||||
|
||||
DUI v3 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
|
||||
|
||||
Start the development server on `http://localhost:8082`
|
||||
|
||||
```bash
|
||||
yarn dev
|
||||
```
|
||||
|
||||
## Production
|
||||
|
||||
Build the application for production:
|
||||
|
||||
```bash
|
||||
yarn build
|
||||
```
|
||||
|
||||
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information...
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
<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>
|
||||
@@ -0,0 +1,31 @@
|
||||
/* stylelint-disable selector-id-pattern */
|
||||
@import url('@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
@@ -0,0 +1,28 @@
|
||||
import type { CodegenConfig } from '@graphql-codegen/cli'
|
||||
|
||||
const config: CodegenConfig = {
|
||||
schema: 'https://app.speckle.systems/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
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,159 @@
|
||||
<!-- NOT WILL BE USED SINCE WE ENABLE AUTOMATION CREATION FROM DUI3 -->
|
||||
<template>
|
||||
<div class="p-0">
|
||||
<slot name="activator" :toggle="toggleDialog"></slot>
|
||||
<CommonDialog
|
||||
v-model:open="showAutomateDialog"
|
||||
:title="`Settings`"
|
||||
fullscreen="none"
|
||||
>
|
||||
<div v-if="hasFunctions">
|
||||
<FormSelectBase
|
||||
key="name"
|
||||
v-model="selectedFunction"
|
||||
clearable
|
||||
label="Automate functions"
|
||||
placeholder="Nothing selected"
|
||||
name="Functions"
|
||||
show-label
|
||||
:items="functions"
|
||||
mount-menu-on-body
|
||||
>
|
||||
<template #something-selected="{ value }">
|
||||
<span>{{ isArray(value) ? value[0].name : value.name }}</span>
|
||||
</template>
|
||||
<template #option="{ item }">
|
||||
<div class="flex items-center">
|
||||
<span class="truncate">{{ item.name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</FormSelectBase>
|
||||
</div>
|
||||
<div v-if="selectedFunction && finalParams && step === 0">
|
||||
<FormJsonForm
|
||||
ref="jsonForm"
|
||||
:data="data"
|
||||
:schema="finalParams"
|
||||
class="space-y-4"
|
||||
:validate-on-mount="false"
|
||||
@change="handler"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="step === 1">
|
||||
<FormTextInput
|
||||
v-model="automationName"
|
||||
name="automationName"
|
||||
label="Automation name"
|
||||
color="foundation"
|
||||
show-label
|
||||
help="Give your automation a name"
|
||||
placeholder="Name"
|
||||
show-required
|
||||
validate-on-value-update
|
||||
/>
|
||||
</div>
|
||||
<FormButton
|
||||
v-if="selectedFunction && step === 0"
|
||||
size="sm"
|
||||
class="mt-4"
|
||||
@click="step++"
|
||||
>
|
||||
Next
|
||||
</FormButton>
|
||||
<FormButton
|
||||
v-if="selectedFunction && step === 1"
|
||||
size="sm"
|
||||
class="mt-4"
|
||||
@click="createAutomationHandler"
|
||||
>
|
||||
Create
|
||||
</FormButton>
|
||||
</CommonDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import type { AutomateFunctionItemFragment } from '~/lib/common/generated/gql/graphql'
|
||||
import {
|
||||
automateFunctionsQuery,
|
||||
createAutomationMutation
|
||||
} from '~/lib/graphql/mutationsAndQueries'
|
||||
import { provideApolloClient, useMutation, useQuery } from '@vue/apollo-composable'
|
||||
import { useAccountStore, type DUIAccount } from '~/store/accounts'
|
||||
import type { ApolloError } from '@apollo/client/errors'
|
||||
import { formatVersionParams } from '~/lib/common/helpers/jsonSchema'
|
||||
import { useJsonFormsChangeHandler } from '~/lib/core/composables/jsonSchema'
|
||||
import { isArray } from 'lodash-es'
|
||||
|
||||
const props = defineProps<{
|
||||
projectId: string
|
||||
modelId: string
|
||||
}>()
|
||||
|
||||
const step = ref<number>(0)
|
||||
|
||||
const automationName = ref<string>('')
|
||||
|
||||
const accountStore = useAccountStore()
|
||||
const { activeAccount } = storeToRefs(accountStore)
|
||||
const accountId = computed(() => activeAccount.value?.accountInfo.id) // NOTE: none of the tokens here has read, write access to automate, only frontend tokens have. Keep in mind after first pass!
|
||||
|
||||
const selectedFunction = ref<AutomateFunctionItemFragment>()
|
||||
|
||||
const showAutomateDialog = ref(false)
|
||||
|
||||
const toggleDialog = () => {
|
||||
showAutomateDialog.value = !showAutomateDialog.value
|
||||
}
|
||||
|
||||
const { mutate } = provideApolloClient((activeAccount.value as DUIAccount).client)(() =>
|
||||
useMutation(createAutomationMutation)
|
||||
)
|
||||
|
||||
const createAutomationHandler = async () => {
|
||||
const _res = await mutate({
|
||||
projectId: props.projectId,
|
||||
input: { name: automationName.value, enabled: false }
|
||||
})
|
||||
showAutomateDialog.value = false
|
||||
}
|
||||
|
||||
const { result: functionsResult, onError } = useQuery(
|
||||
automateFunctionsQuery,
|
||||
() => ({}),
|
||||
() => ({ clientId: accountId.value, debounce: 500, fetchPolicy: 'network-only' })
|
||||
)
|
||||
|
||||
onError((err: ApolloError) => {
|
||||
console.warn(err.message)
|
||||
})
|
||||
|
||||
const functions = computed(() => functionsResult.value?.automateFunctions.items)
|
||||
const hasFunctions = computed(() => functions.value?.length !== 0)
|
||||
|
||||
const release = computed(() =>
|
||||
selectedFunction.value?.releases.items.length
|
||||
? selectedFunction.value?.releases.items[0]
|
||||
: undefined
|
||||
)
|
||||
|
||||
const finalParams = computed(() => formatVersionParams(release.value?.inputSchema))
|
||||
|
||||
const { handler } = useJsonFormsChangeHandler({
|
||||
schema: finalParams
|
||||
})
|
||||
|
||||
console.log(finalParams)
|
||||
|
||||
type DataType = Record<string, unknown>
|
||||
const data = computed(() => {
|
||||
const kvp = {} as DataType
|
||||
if (finalParams.value) {
|
||||
Object.entries(finalParams.value).forEach((k, _) => {
|
||||
kvp[k as unknown as string] = undefined
|
||||
})
|
||||
}
|
||||
return kvp
|
||||
})
|
||||
</script>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,336 @@
|
||||
<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>
|
||||
@@ -0,0 +1,94 @@
|
||||
<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="notificationButtonColor(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,
|
||||
ModelCardNotificationLevel
|
||||
} 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 notificationButtonColor = (notificationLevel: ModelCardNotificationLevel) => {
|
||||
switch (notificationLevel) {
|
||||
case 'info':
|
||||
return 'outline'
|
||||
case 'danger':
|
||||
return 'danger'
|
||||
case 'success':
|
||||
return 'primary'
|
||||
case 'warning':
|
||||
return 'danger'
|
||||
default:
|
||||
return 'outline'
|
||||
}
|
||||
}
|
||||
|
||||
const textClassColor = computed(() => {
|
||||
switch (props.notification.level) {
|
||||
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>
|
||||
@@ -0,0 +1,258 @@
|
||||
<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="
|
||||
isPersonalProject ? '' : 'opacity-0 group-hover:opacity-100 transition flex'
|
||||
"
|
||||
>
|
||||
<button
|
||||
v-tippy="projectNavigatorTippy"
|
||||
class="hover:text-primary flex items-center space-x-2 p-2 relative animate-pulse"
|
||||
>
|
||||
<div class="relative w-4 h-4">
|
||||
<ArrowTopRightOnSquareIcon
|
||||
class="w-4 h-4"
|
||||
@click.stop="
|
||||
$openUrl(projectUrl),
|
||||
trackEvent('DUI3 Action', { name: 'Project View' }, project.accountId)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<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 isPersonalProject = computed(() => !projectDetails.value?.workspace)
|
||||
const projectNavigatorTippy = computed(() =>
|
||||
isPersonalProject.value
|
||||
? 'Move personal project into a workspace'
|
||||
: 'Open project in browser'
|
||||
)
|
||||
|
||||
const clientId = projectAccount.value.accountInfo.id
|
||||
|
||||
const { result: projectDetailsResult, refetch: refetchProjectDetails } = useQuery(
|
||||
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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,489 @@
|
||||
<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 as AvatarUserWithId]"
|
||||
/>
|
||||
<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'
|
||||
import type { AvatarUserWithId } from '@speckle/ui-components'
|
||||
|
||||
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 as ProjectCommentsUpdatedMessage
|
||||
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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,302 @@
|
||||
<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 || !canEdit"
|
||||
@click.stop="openVersionsDialog = true"
|
||||
>
|
||||
<span>
|
||||
Loaded
|
||||
<b>version</b>
|
||||
</span>
|
||||
from
|
||||
<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())
|
||||
})
|
||||
|
||||
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>
|
||||
@@ -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 -->
|
||||
<span class="font-bold">{{ modelCard.sendFilter?.name }}: </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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,99 @@
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
class="px-3 py-1 rounded-md shadow transition overflow-hidden bg-foundation border-foundation-2 hover:shadow-md border-1 group"
|
||||
>
|
||||
<div class="flex flex-col sm:flex-row sm:gap-2 text-foreground">
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="text-body-xs">
|
||||
<h1
|
||||
class="mb-1 text-sm font-semibold w-full inline-block py-1 bg-clip-text"
|
||||
>
|
||||
Move your projects to a workspace
|
||||
</h1>
|
||||
<p class="mb-2">
|
||||
<span class="text-sm">➊</span>
|
||||
<span class="text-xs">
|
||||
We are making workspaces the default way to work in Speckle.
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<p class="mb-1">
|
||||
<span class="text-sm">➋</span>
|
||||
<span class="text-xs">
|
||||
Introducing
|
||||
<FormButton
|
||||
text
|
||||
link
|
||||
external
|
||||
size="sm"
|
||||
class="font-semibold"
|
||||
@click="$openUrl(`https://www.speckle.systems/pricing`)"
|
||||
>
|
||||
new pricing
|
||||
</FormButton>
|
||||
including limits to the free plan.
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<FormButton
|
||||
text
|
||||
color="primary"
|
||||
size="sm"
|
||||
:class="showMore ? `mb-1` : ``"
|
||||
:icon-right="showMore ? ChevronUpIcon : ChevronDownIcon"
|
||||
@click="showMore = !showMore"
|
||||
>
|
||||
{{ showMore ? 'Show less' : 'Show timeline' }}
|
||||
</FormButton>
|
||||
|
||||
<div v-show="showMore">
|
||||
<hr />
|
||||
<h3 class="font-medium text-warning-darker my-1">By June 1st 2025</h3>
|
||||
<p class="text-xs mb-1">Move your projects to a workspace to:</p>
|
||||
<ul class="list-disc list-inside pl-2 mb-2">
|
||||
<li>
|
||||
<span class="text-xs font-medium">
|
||||
Create new projects and models
|
||||
</span>
|
||||
<span class="text-xs">
|
||||
(will be disabled for personal projects; existing projects and
|
||||
models stay editable)
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="text-xs font-medium">
|
||||
Invite new project collaborators
|
||||
</span>
|
||||
<span class="text-xs">
|
||||
(new invites will be unavailable for personal projects)
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="text-xs font-medium">
|
||||
Preserve version and comment history
|
||||
</span>
|
||||
<span class="text-xs">
|
||||
(history is reduced to 7 days for personal projects)
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
<h3 class="font-medium text-warning-darker">By Janury 1st 2026</h3>
|
||||
<span class="text-xs mb-1">
|
||||
All projects will be archived if not moved into a workspace. Don't
|
||||
worry, we'll give you plenty of reminders before then.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ChevronUpIcon, ChevronDownIcon } from '@heroicons/vue/20/solid'
|
||||
|
||||
const { $openUrl } = useNuxtApp()
|
||||
const showMore = ref(false)
|
||||
</script>
|
||||
@@ -0,0 +1,421 @@
|
||||
<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">
|
||||
<!-- NO WORKSPACE YET -->
|
||||
<div v-if="workspaces.length === 0">
|
||||
<FormButton
|
||||
full-width
|
||||
class="flex items-center"
|
||||
@click="$openUrl('https://app.speckle.systems/workspaces/actions/create')"
|
||||
>
|
||||
<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>
|
||||
<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>
|
||||
<div v-if="isPersonalProjectsAsWorkspace">
|
||||
<!-- <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> -->
|
||||
<WizardPersonalProjectsWarning />
|
||||
</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
|
||||
}
|
||||
}
|
||||
|
||||
const activeWorkspace = activeWorkspaceResult.value?.activeUser
|
||||
?.activeWorkspace as WorkspaceListWorkspaceItemFragment
|
||||
|
||||
// fallback to activeWorkspace query result
|
||||
if (activeWorkspace) {
|
||||
return activeWorkspace
|
||||
}
|
||||
|
||||
// if activeWorkspace is null will mean that it is personal projects - this fallback wont be the case soon
|
||||
return {
|
||||
id: 'personalProject',
|
||||
name: 'Personal Projects'
|
||||
} as WorkspaceListWorkspaceItemFragment
|
||||
})
|
||||
|
||||
const selectedWorkspace = ref<WorkspaceListWorkspaceItemFragment | undefined>(
|
||||
activeWorkspace.value
|
||||
)
|
||||
|
||||
const isPersonalProjectsAsWorkspace = computed(
|
||||
() => selectedWorkspace.value?.id === 'personalProject'
|
||||
)
|
||||
|
||||
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: isPersonalProjectsAsWorkspace.value
|
||||
? null
|
||||
: selectedWorkspace.value?.id,
|
||||
includeImplicitAccess: true,
|
||||
personalOnly: isPersonalProjectsAsWorkspace.value
|
||||
}
|
||||
}),
|
||||
() => ({
|
||||
enabled: filtersReady.value,
|
||||
clientId: accountId.value,
|
||||
debounce: 500,
|
||||
fetchPolicy: 'network-only'
|
||||
})
|
||||
)
|
||||
|
||||
const projects = computed(() =>
|
||||
isPersonalProjectsAsWorkspace.value // TODO: we need to replace this logic with `personalOnly` filter when it is implemented into app.speckle.systems
|
||||
? projectsResult.value?.activeUser?.projects.items.filter(
|
||||
(i) => i.workspaceId === null
|
||||
)
|
||||
: 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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="{ avatar: version.authorUser?.avatar, name: version.authorUser?.name as string }"
|
||||
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 won’t 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,171 @@
|
||||
import { omit } from 'lodash-es'
|
||||
import withNuxt from './.nuxt/eslint.config.mjs'
|
||||
import pluginVueA11y from 'eslint-plugin-vuejs-accessibility'
|
||||
import globals from 'globals'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { dirname } from 'path'
|
||||
import prettierConfig from 'eslint-config-prettier'
|
||||
import js from '@eslint/js'
|
||||
|
||||
/**
|
||||
* Feed in import.meta.url in your .mjs module to get the equivalent of __dirname
|
||||
* @param {string} importMetaUrl
|
||||
*/
|
||||
export const getESMDirname = (importMetaUrl) => {
|
||||
return dirname(fileURLToPath(importMetaUrl))
|
||||
}
|
||||
|
||||
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/no-empty-object-type': 'off', // too restrictive
|
||||
'@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/block-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/**',
|
||||
'**/dist/**',
|
||||
'**/dist-*/**',
|
||||
'**/public/**',
|
||||
'**/events.json',
|
||||
'**/generated/**/*'
|
||||
]
|
||||
},
|
||||
{
|
||||
files: ['**/*.mjs'],
|
||||
languageOptions: {
|
||||
sourceType: 'module'
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ['**/*.cjs'],
|
||||
languageOptions: {
|
||||
sourceType: 'commonjs'
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ['**/*.{js,mjs,cjs}', '**/.*.{js,mjs,cjs}'],
|
||||
...js.configs.recommended
|
||||
},
|
||||
prettierConfig,
|
||||
{
|
||||
rules: {
|
||||
camelcase: [
|
||||
1,
|
||||
{
|
||||
properties: 'always'
|
||||
}
|
||||
],
|
||||
'no-var': 'error',
|
||||
'no-alert': 'error',
|
||||
eqeqeq: 'error',
|
||||
'prefer-const': 'warn',
|
||||
'object-shorthand': 'warn'
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
export default configs
|
||||
@@ -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>
|
||||
@@ -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,33 @@
|
||||
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
|
||||
}
|
||||
@@ -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[]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user