chore: finish removing dui3 (#4738)

This commit is contained in:
Kristaps Fabians Geikins
2025-05-14 10:48:34 +03:00
committed by GitHub
parent 380e55b7aa
commit a1f8f79b7a
51 changed files with 106 additions and 11448 deletions
@@ -13,10 +13,6 @@ services:
source: node_modules # top-level volume source: node_modules # top-level volume
target: /workspaces/speckle-server/node_modules target: /workspaces/speckle-server/node_modules
read_only: false read_only: false
- type: volume
source: dui3-node_modules
target: /workspaces/speckle-server/packages/dui3/node_modules
read_only: false
- type: volume - type: volume
source: fileimport-service-node_modules source: fileimport-service-node_modules
target: /workspaces/speckle-server/packages/fileimport-service/node_modules target: /workspaces/speckle-server/packages/fileimport-service/node_modules
@@ -104,10 +100,6 @@ services:
source: node_modules source: node_modules
target: /workspaces/speckle-server/node_modules target: /workspaces/speckle-server/node_modules
read_only: false read_only: false
- type: volume
source: dui3-node_modules
target: /workspaces/speckle-server/packages/dui3/node_modules
read_only: false
- type: volume - type: volume
source: fileimport-service-node_modules source: fileimport-service-node_modules
target: /workspaces/speckle-server/packages/fileimport-service/node_modules target: /workspaces/speckle-server/packages/fileimport-service/node_modules
@@ -176,7 +168,6 @@ volumes:
# (this allows the devcontainer to be based on linux yet work on Apple Silicon etc..) # (this allows the devcontainer to be based on linux yet work on Apple Silicon etc..)
# If you add a new package with a new `node_modules`, it needs to be added here # If you add a new package with a new `node_modules`, it needs to be added here
node_modules: node_modules:
dui3-node_modules:
fileimport-service-node_modules: fileimport-service-node_modules:
frontend-2-node_modules: frontend-2-node_modules:
monitor-deployment-node_modules: monitor-deployment-node_modules:
+1 -1
View File
@@ -31,7 +31,7 @@
"dev": "yarn workspaces foreach -pivW -j unlimited run dev", "dev": "yarn workspaces foreach -pivW -j unlimited run dev",
"dev:no-server": "yarn workspaces foreach --exclude @speckle/server -pivW -j unlimited run dev", "dev:no-server": "yarn workspaces foreach --exclude @speckle/server -pivW -j unlimited run dev",
"dev:minimal": "yarn workspaces foreach -pivW -j unlimited --include '{@speckle/server,@speckle/frontend-2}' run dev", "dev:minimal": "yarn workspaces foreach -pivW -j unlimited --include '{@speckle/server,@speckle/frontend-2}' run dev",
"gqlgen": "yarn workspaces foreach -pivW -j unlimited --include '{@speckle/server,@speckle/frontend,@speckle/frontend-2,@speckle/dui3}' run gqlgen", "gqlgen": "yarn workspaces foreach -pivW -j unlimited --include '{@speckle/server,@speckle/frontend,@speckle/frontend-2}' run gqlgen",
"dev:server": "yarn workspace @speckle/server dev", "dev:server": "yarn workspace @speckle/server dev",
"dev:frontend-2": "yarn workspace @speckle/frontend-2 dev", "dev:frontend-2": "yarn workspace @speckle/frontend-2 dev",
"dev:shared": "yarn workspace @speckle/shared dev", "dev:shared": "yarn workspace @speckle/shared dev",
-11
View File
@@ -1,11 +0,0 @@
HOST=0.0.0.0
PORT=8082
NUXT_PUBLIC_MIXPANEL_TOKEN_ID=acd87c5a50b56df91a795e999812a3a4
NUXT_PUBLIC_MIXPANEL_API_HOST=https://analytics.speckle.systems
##########################################################
# Local dev settings
##########################################################
# Uncomment to enable pino-pretty log formatting in debug mode (disabled cause of node22 issues)
# ALLOW_PRETTY_DEBUGGER=true
-10
View File
@@ -1,10 +0,0 @@
node_modules
*.log*
.nuxt
.nitro
.cache
.output
.env
dist
.DS_Store
.env
-13
View File
@@ -1,13 +0,0 @@
{
"css.validate": false,
"less.validate": false,
"scss.validate": false,
"stylelint.validate": ["css", "scss", "vue", "postcss"],
"stylelint.enable": true,
"stylelint.configFile": "${workspaceFolder}/stylelint.config.js",
"volar.completion.preferredTagNameCase": "pascal",
"javascript.suggest.autoImports": true,
"typescript.suggest.autoImports": true,
"typescript.preferences.importModuleSpecifier": "non-relative",
"javascript.preferences.importModuleSpecifier": "non-relative"
}
-40
View File
@@ -1,40 +0,0 @@
# dui3
DUIv3 is a Speckle interface embedded inside the desktop connectors that allows users to interact with them - sync streams, manage servers etc. It's built in Vue 3 with Nuxt 3 and only supports client side rendering.
Look at the [Nuxt 3 documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.
## Setup
Make sure to install the dependencies:
```bash
# yarn
yarn install
```
And create an `.env` file from `.env.example`.
## Development Server
Start the development server on `http://localhost:3000`
```bash
npm run dev
```
## Production
Build the application for production:
```bash
npm run build
```
Locally preview production build:
```bash
npm run preview
```
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.
-30
View File
@@ -1,30 +0,0 @@
<template>
<div id="speckle" class="bg-foundation-page text-foreground">
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</div>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { useAccountsSetup } from '~/lib/accounts/composables/setup'
import { useDocumentInfoStore } from '~/store/uiConfig'
const uiConfigStore = useDocumentInfoStore()
const { isDarkTheme } = storeToRefs(uiConfigStore)
useAccountsSetup()
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'
}
})
</script>
-25
View File
@@ -1,25 +0,0 @@
/* stylelint-disable selector-id-pattern */
@import '@speckle/ui-components/style.css';
@tailwind base;
@tailwind components;
@tailwind utilities;
/**
* Don't pollute this - it's going to be bundled in all pages!
*/
/**
* Making sure page is always stretched to the bottom of the screen even if there's nothing in it
*/
html,
body,
div#__nuxt,
div#__nuxt > div {
min-height: 100%;
}
html,
body,
div#__nuxt {
height: 100%;
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

-28
View File
@@ -1,28 +0,0 @@
import type { CodegenConfig } from '@graphql-codegen/cli'
const config: CodegenConfig = {
schema: 'http://127.0.0.1:3000/graphql',
documents: ['{lib,components,layouts,pages,middleware}/**/*.{vue,js,ts}'],
ignoreNoDocuments: true, // for better experience with the watcher
generates: {
'./lib/common/generated/gql/': {
preset: 'client',
config: {
useTypeImports: true,
fragmentMasking: false,
dedupeFragments: true,
scalars: {
JSONObject: '{}',
DateTime: 'string'
}
},
presetConfig: {
fragmentMasking: false,
dedupeFragments: true
},
plugins: []
}
}
}
export default config
@@ -1,28 +0,0 @@
<template>
<NuxtLink class="flex items-center" to="/">
<img
class="block h-6 w-6"
: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 hidden 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>
@@ -1,20 +0,0 @@
<template>
<nav
class="fixed top-0 h-14 bg-foundation max-w-full w-full shadow hover:shadow-md transition z-20"
>
<div class="px-2">
<div class="flex items-center h-14 transition-all justify-between">
<div class="flex items-center">
<HeaderLogoBlock :active="false" class="mr-0" />
<div class="flex flex-shrink-0 items-center -ml-2 md:ml-0">
<PortalTarget name="navigation"></PortalTarget>
</div>
</div>
<div>
<HeaderUserMenu />
</div>
</div>
</div>
</nav>
</template>
<script setup lang="ts"></script>
@@ -1,33 +0,0 @@
<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>
@@ -1,51 +0,0 @@
<template>
<button
v-tippy="tip"
:class="`block w-full text-left items-center space-x-2 hover:bg-primary-muted transition p-2 select-none group hover:cursor-pointer hover:text-primary ${
!account.isValid ? 'text-danger bg-rose-500/10' : ''
} ${account.accountInfo.isDefault ? 'bg-blue-500/5' : ''}`"
@click="$openUrl(account.accountInfo.serverInfo.url)"
>
<div class="flex items-center space-x-2">
<UserAvatar :user="userAvatar" :active="account.accountInfo.isDefault" />
<div class="min-w-0 grow">
<div class="truncate overflow-hidden min-w-0">
{{ account.accountInfo.serverInfo.name }}
<span class="text-foreground-2 truncate min-w-0">
{{ account.accountInfo.serverInfo.url.split('//')[1] }}
</span>
</div>
<div class="truncate text-xs text-foreground-2">
{{ account.accountInfo.userInfo.email }}
</div>
</div>
<div class="transition opacity-0 group-hover:opacity-100">
<ArrowTopRightOnSquareIcon class="w-4 h-4" />
</div>
</div>
</button>
</template>
<script setup lang="ts">
import type { DUIAccount } from 'lib/accounts/composables/setup'
import { ArrowTopRightOnSquareIcon } from '@heroicons/vue/20/solid'
const props = defineProps<{
account: DUIAccount
}>()
const userAvatar = computed(() => {
return {
name: props.account.accountInfo.userInfo.name,
avatar: props.account.accountInfo.userInfo.avatar
}
})
const tip = computed(() => {
let value = ''
if (props.account.accountInfo.isDefault) value += 'This is your default account. '
if (!props.account.isValid) value += 'This account is not reachable.'
return value === '' ? null : value
})
const { $openUrl } = useNuxtApp()
</script>
@@ -1,105 +0,0 @@
<template>
<div>
<Menu as="div" class="ml-2 flex items-center">
<MenuButton v-slot="{ open }">
<span class="sr-only">Open user menu</span>
<UserAvatar v-if="!open" size="lg" :user="user" hover-effect />
<UserAvatar v-else size="lg" hover-effect>
<XMarkIcon class="w-5 h-5" />
</UserAvatar>
</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-0 md:right-4 top-14 md:top-16 w-full md:w-64 origin-top-right bg-foundation sm:rounded-t-md rounded-b-md shadow-lg overflow-hidden"
>
<MenuItem>
<div class="border border-t-1 border-primary-muted">
<div v-if="loading" class="p-2">Loading accounts...</div>
<div v-else class="p-2 flex items-center justify-between">
<div class="text-xs text-foreground-2">Your accounts</div>
<div>
<FormButton
text
size="xs"
@click.stop="accountStore.refreshAccounts()"
>
Refresh
</FormButton>
</div>
</div>
<div class="space-y-0">
<HeaderUserAccount
v-for="acc in accounts"
:key="acc.accountInfo.id"
:account="(acc as DUIAccount)"
/>
</div>
</div>
</MenuItem>
<MenuItem v-slot="{ close }" as="div">
<div class="px-2 py-3 flex space-x-2 border-t-1 justify-between">
<div class="">
<button
class="text-xs text-foreground-2 hover:text-primary transition"
@click="$showDevTools"
>
Open Dev Tools
</button>
<NuxtLink
class="text-xs text-foreground-2 hover:text-primary transition"
to="/test"
@click="close()"
>
Test Page
</NuxtLink>
</div>
<!--
NOTE: Here's an example of customising the frontend app based on what bindings we
have loaded. E.g., if config bindings are not present, we do not show any button
regarding switching themes.
-->
<div v-if="hasConfigBindings">
<FormButton size="xs" text @click.stop="toggleTheme()">
{{ isDarkTheme ? 'Switch To Light Theme' : 'Switch To Dark Theme' }}
</FormButton>
</div>
</div>
</MenuItem>
</MenuItems>
</Transition>
</Menu>
</div>
</template>
<script setup lang="ts">
import { XMarkIcon } from '@heroicons/vue/20/solid'
import { storeToRefs } from 'pinia'
import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/vue'
import { useAccountStore } from '~/store/accounts'
import type { DUIAccount } from '~/lib/accounts/composables/setup'
import { useDocumentInfoStore } from '~/store/uiConfig'
const accountStore = useAccountStore()
const { accounts, defaultAccount, loading } = storeToRefs(accountStore)
const uiConfigStore = useDocumentInfoStore()
const { isDarkTheme, hasConfigBindings } = storeToRefs(uiConfigStore)
const { toggleTheme } = uiConfigStore
const { $showDevTools } = useNuxtApp()
const user = computed(() => {
if (!defaultAccount.value) return undefined
return {
name: defaultAccount.value?.accountInfo.userInfo.name,
avatar: defaultAccount.value?.accountInfo.userInfo.avatar
}
})
</script>
-163
View File
@@ -1,163 +0,0 @@
<template>
<div
:class="[
'text-foreground-on-primary flex shrink-0 items-center justify-center overflow-hidden rounded-full font-semibold uppercase transition',
sizeClasses,
bgClasses,
borderClasses,
hoverClasses,
activeClasses
]"
>
<slot>
<div
v-if="user?.avatar"
class="h-full w-full bg-cover bg-center bg-no-repeat"
:style="{ backgroundImage: `url('${user.avatar}')` }"
/>
<div
v-else-if="initials"
class="flex h-full w-full select-none items-center justify-center"
>
{{ initials }}
</div>
<div v-else><UserCircleIcon :class="iconClasses" /></div>
</slot>
</div>
</template>
<script setup lang="ts">
import { UserCircleIcon } from '@heroicons/vue/20/solid'
type UserAvatar = {
name: string
avatar?: string
}
export 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>
-125
View File
@@ -1,125 +0,0 @@
import { omit } from 'lodash-es'
import { baseConfigs, globals, getESMDirname } from '../../eslint.config.mjs'
import withNuxt from './.nuxt/eslint.config.mjs'
import pluginVueA11y from 'eslint-plugin-vuejs-accessibility'
const configs = await withNuxt([
{
rules: {
camelcase: [
'error',
{
properties: 'always',
allow: ['^[\\w]+_[\\w]+Fragment$']
}
],
'no-alert': 'error',
eqeqeq: ['error', 'always', { null: 'always' }],
'no-console': 'off',
'no-var': 'error'
}
},
{
files: ['**/*.{ts,vue,tsx,mts,cts}'],
languageOptions: {
parserOptions: {
project: ['./tsconfig.eslint.json'],
extraFileExtensions: ['.vue'],
tsconfigRootDir: getESMDirname(import.meta.url)
}
}
},
{
files: ['**/*.test.{ts,js}'],
languageOptions: {
globals: {
...globals.jest
}
}
},
{
files: ['./{components|pages|store|lib}/*.{js,ts,vue}'],
languageOptions: {
globals: {
...globals.browser
}
}
},
{
files: ['**/*.{ts,tsx,vue}'],
rules: {
'@typescript-eslint/no-explicit-any': ['error'],
'@typescript-eslint/no-unsafe-argument': ['error'],
'@typescript-eslint/no-unsafe-assignment': 'error',
'@typescript-eslint/no-unsafe-call': 'error',
'@typescript-eslint/no-unsafe-member-access': 'error',
'@typescript-eslint/no-unsafe-return': 'error',
'@typescript-eslint/no-for-in-array': ['error'],
'@typescript-eslint/restrict-plus-operands': ['error'],
'@typescript-eslint/await-thenable': ['warn'],
'@typescript-eslint/no-restricted-types': ['warn'],
'require-await': 'off',
'@typescript-eslint/require-await': 'error',
'no-undef': 'off',
'@typescript-eslint/unified-signatures': 'off', // DX sucks in vue event definitions
'@typescript-eslint/no-dynamic-delete': 'off', // too restrictive
'@typescript-eslint/restrict-template-expressions': 'off', // too restrictive
'@typescript-eslint/no-invalid-void-type': 'off' // too restrictive
}
},
...pluginVueA11y.configs['flat/recommended'].map((c) => ({
...c,
files: [...(c.files || []), '**/*.vue'],
languageOptions: c.languageOptions
? omit(c.languageOptions, ['parserOptions', 'parser']) // Prevent overriding parser
: undefined
})),
{
files: ['**/*.vue'],
rules: {
'vue/component-tags-order': [
'error',
{ order: ['docs', 'template', 'script', 'style'] }
],
'vue/require-default-prop': 'off',
'vue/multi-word-component-names': 'off',
'vue/component-name-in-template-casing': [
'error',
'PascalCase',
{ registeredComponentsOnly: false }
],
'vuejs-accessibility/label-has-for': [
'error',
{
required: {
some: ['nesting', 'id']
}
}
],
'vue/html-self-closing': 'off' // messes with prettier
}
},
{
files: ['**/*.d.ts'],
rules: {
'no-var': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-restricted-types': 'off'
}
}
]).prepend([
{
ignores: [
'**/node_modules/**',
'**/templates/*',
'./lib/common/generated/**/*',
'storybook-static',
'.nuxt/**',
'.output/**'
]
},
...baseConfigs
])
export default configs
-8
View File
@@ -1,8 +0,0 @@
<template>
<div class="min-h-full">
<HeaderNavBar />
<main class="px-1 pb-4 mt-16">
<slot />
</main>
</div>
</template>
@@ -1,136 +0,0 @@
import { ApolloClient, gql } from '@apollo/client/core'
import { ApolloClients } from '@vue/apollo-composable'
import type { ComputedRef, Ref } from 'vue'
import type { Account } from '~/lib/bindings/definitions/IBasicConnectorBinding'
import { resolveClientConfig } from '~/lib/core/configs/apollo'
export type DUIAccount = {
/** account info coming from the host app */
accountInfo: Account
/** the graphql client; a bit superflous */
client?: ApolloClient<unknown>
/** whether an intial serverinfo query succeeded. */
isValid: boolean
}
export type DUIAccountsState = {
accounts: Ref<DUIAccount[]>
validAccounts: ComputedRef<DUIAccount[]>
refreshAccounts: () => Promise<void>
defaultAccount: ComputedRef<DUIAccount | undefined>
loading: Ref<boolean>
}
const AccountsInjectionKey = 'DUI_ACCOUNTS_STATE'
/**
* Use this composable to set up the account bindings and graphql clients at the top of the app.
* TODO: Properly handle cases when user was not connected to the internet,
* and then actually got connected.
*/
export function useAccountsSetup(): DUIAccountsState {
const app = useNuxtApp()
const $baseBinding = app.$baseBinding
const accounts = ref<DUIAccount[]>([])
const apolloClients = {} as Record<string, ApolloClient<unknown>>
// Tries to connect to the accounts and sets their is valid prop to false if fails.
const testAccounts = async (accs: DUIAccount[]) => {
const accountTestQuery = gql`
query AcccountTestQuery {
serverInfo {
version
name
company
}
}
`
for (const acc of accs) {
if (!acc.client) continue
try {
await acc.client.query({ query: accountTestQuery })
acc.isValid = true
} catch {
// TODO: properly dispose and kill this client. It's unclear how to do it.
acc.isValid = false
// NOTE: we do not want to delete the client, as we might want to "refresh" in
// case the user was not connected to the interweb.
// acc.client.disableNetworkFetches = true
// acc.client.stop()
// delete acc.client
}
}
}
const loading = ref(false)
// Matches local accounts coming from the host app to app state.
const refreshAccounts = async () => {
loading.value = true
const accs = await $baseBinding.getAccounts()
// We create a whole new list of accounts that will replace the old list. This way we ensure we drop
// out of scope old accounts that not exist anymore (TODO: test), and we don't need to do complex diffing.
const newAccs = [] as DUIAccount[]
for (const acc of accs) {
const existing = accounts.value.find((a) => a.accountInfo.id === acc.id)
if (existing) {
newAccs.push(existing as DUIAccount)
continue
}
const client = new ApolloClient(
resolveClientConfig({
httpEndpoint: new URL('/graphql', acc.serverInfo.url).href,
authToken: () => acc.token
})
)
apolloClients[acc.id] = client
newAccs.push({
accountInfo: acc,
client,
isValid: true
})
}
// We test accounts here so we try to prevent the app from querying/using invalid accounts.
await testAccounts(newAccs)
// Once we have tested the new accounts, finally set them.
accounts.value = newAccs
loading.value = false
}
void refreshAccounts() // Promise that we do not want to await (convention with void)
const defaultAccount = computed(() =>
accounts.value.find((acc) => acc.accountInfo.isDefault)
)
const validAccounts = computed(() => {
return accounts.value.filter((a) => a.isValid)
})
const accState = {
accounts,
defaultAccount,
validAccounts,
refreshAccounts,
loading
}
app.vueApp.provide(ApolloClients, apolloClients)
provide(AccountsInjectionKey, accState)
return accState // as DUIAccountsState
}
/**
* Use this composable to access the users' local accounts and their corresponding graphql client.
*/
export function useInjectedAccounts(): DUIAccountsState {
const state = inject(AccountsInjectionKey) as DUIAccountsState
return state
}
@@ -1,78 +0,0 @@
/* eslint-disable @typescript-eslint/require-await */
import { BaseBridge } from '~~/lib/bridge/base'
import type { IBinding } from '~~/lib/bindings/definitions/IBinding'
export const IBasicConnectorBindingKey = 'baseBinding'
// Needs to be agreed between Frontend and Core
export interface IBasicConnectorBinding
extends IBinding<IBasicConnectorBindingHostEvents> {
getAccounts: () => Promise<Account[]>
getSourceApplicationName: () => Promise<string>
getSourceApplicationVersion: () => Promise<string>
getDocumentInfo: () => Promise<DocumentInfo>
}
export interface IBasicConnectorBindingHostEvents {
displayToastNotification: (args: ToastInfo) => void
documentChanged: () => 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
}
userInfo: {
id: string
avatar: string
email: string
name: string
commits: { totalCount: number }
streams: { totalCount: number }
}
}
export type DocumentInfo = {
location: string
name: string
id: string
}
// NOTE: just a reminder for now
export type ToastInfo = {
text: string
details?: string
type: 'info' | 'error' | 'warning'
}
export class MockedBaseBinding extends BaseBridge {
public async getAccounts() {
return []
}
public async getSourceApplicationName() {
return 'Mocks'
}
public async getSourceApplicationVersion() {
return Math.random().toString()
}
public async getDocumentInfo() {
return {
name: 'Mocked File',
location: 'www',
id: Math.random().toString()
}
}
public async showDevTools() {
console.log('Mocked bindings cannot do this')
}
}
@@ -1,18 +0,0 @@
/**
* 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>
}
@@ -1,36 +0,0 @@
import { BaseBridge } from '~~/lib/bridge/base'
import type { IBinding } 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> {
getConfig: () => Promise<Config>
updateConfig: (config: Config) => Promise<void>
}
export interface IConfigBindingEvents {
void: () => void
}
export type Config = {
darkTheme: boolean
}
export class MockedConfigBinding extends BaseBridge {
getConfig() {
return {
darkTheme: false
}
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
updateConfig(config: Config) {
// do nothing
}
}
@@ -1,58 +0,0 @@
/* eslint-disable @typescript-eslint/require-await */
import { BaseBridge } from '~~/lib/bridge/base'
import type { IBinding } 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 {
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
}
}
-25
View File
@@ -1,25 +0,0 @@
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
constructor() {
this.emitter = createNanoEvents()
}
// 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)
}
}
@@ -1,9 +0,0 @@
/**
* Defines the expected contract of the host application bound object.
*/
export type IRawBridge = {
GetBindingsMethodNames: () => Promise<string[]>
RunMethod: (methodName: string, args: string) => Promise<string>
ShowDevTools: () => Promise<void>
OpenUrl: (url: string) => Promise<void>
}
-70
View File
@@ -1,70 +0,0 @@
import { BaseBridge } from '~/lib/bridge/base'
import type { IRawBridge } from '~/lib/bridge/definitions'
/**
* A generic bridge class for Webivew2 or CefSharp.
*/
export class GenericBridge extends BaseBridge {
private bridge: IRawBridge
constructor(object: IRawBridge) {
super()
this.bridge = object
}
public async create(): Promise<boolean> {
// NOTE: GetMethods is a call to the .NET side.
let availableMethodNames = [] as string[]
try {
availableMethodNames = await this.bridge.GetBindingsMethodNames()
} catch {
console.warn(`Failed to get method names.`)
return false
}
// NOTE: hoisting original calls as lowerCasedMethodNames, but using the UpperCasedName for the .NET call
// This allows us to follow js convetions and keep .NET ones too (eg. bindings.sayHi('') => public string SayHi(string name) {}
for (const methodName of availableMethodNames) {
const lowercasedMethodName = lowercaseMethodName(methodName)
const hoistTarget = this as unknown as Record<string, object>
hoistTarget[lowercasedMethodName] = (...args: unknown[]) =>
this.runMethod(methodName, args)
}
return true
}
private async runMethod(methodName: string, args: unknown[]): Promise<unknown> {
const preserializedArgs = args.map((a) => JSON.stringify(a))
// NOTE: RunMethod is a call to the .NET side.
const result = await this.bridge.RunMethod(
methodName,
JSON.stringify(preserializedArgs)
)
const parsed = result ? (JSON.parse(result) as Record<string, unknown>) : null
if (parsed && parsed['error']) {
console.error(parsed)
throw new Error(
`Failed to run ${methodName} with args ${JSON.stringify(
args
)}. The host app error is logged above.`
)
}
return parsed
}
public showDevTools() {
this.bridge.ShowDevTools()
}
public openUrl(url: string) {
this.bridge.OpenUrl(url)
}
}
const lowercaseMethodName = (name: string) =>
name.charAt(0).toLowerCase() + name.slice(1)
-161
View File
@@ -1,161 +0,0 @@
import { uniqueId } from 'lodash-es'
import { BaseBridge } from './base'
declare let sketchup: {
exec: (data: Record<string, unknown>) => void
getCommands: (viewId: string) => void
}
/**
* This class operates in different way than the others, because calls into Sketchup are one way only.
* E.g., we cannot return values from internal calls to it (e.g., const test = sketchup.rubyCall() does not work ).
* This class basically makes the sketchup bindings work in the same way as cef/webview by returning a promise
* on each method call. That promise is either resolved once sketchup sends back (via receiveResponse) a corresponding
* reply, or it's rejected after a given TIMEOUT_MS (currently 2s).
* TODO: implement the event dispatcher side as well.
*/
export class SketchupBridge extends BaseBridge {
private requests = {} as Record<
string,
{
resolve: (value: unknown) => void
reject: (reason: string | Error) => void
rejectTimerId: number
}
>
private bindingName: string
private TIMEOUT_MS = 2000 // 2s
public isInitalized: Promise<boolean>
private resolveIsInitializedPromise!: (v: boolean) => unknown
private rejectIsInitializedPromise!: (message: string) => unknown
constructor(bindingName: string) {
super()
this.bindingName = bindingName || 'default_bindings'
this.isInitalized = new Promise((resolve, reject) => {
this.resolveIsInitializedPromise = resolve
this.rejectIsInitializedPromise = reject
setTimeout(
() =>
reject(
`Failed to get command names from Sketchup; timed out after ${this.TIMEOUT_MS}ms.`
),
this.TIMEOUT_MS
)
})
// NOTE: we need to hoist the bindings in global scope BEFORE we call sketchup exec get comands below.
;(globalThis as Record<string, unknown>).bindings = this
}
// NOTE: Overriden emit as we do not need to parse the data back - the Sketchup bridge already parses it for us.
emit(eventName: string, payload: string): void {
this.emitter.emit(eventName, payload as unknown as Record<string, unknown>)
}
public async create(): Promise<boolean> {
// Initialization continues in the receiveCommandsAndInitializeBridge function,
// where we expect sketchup to return to us the command names for related bindings/views.
sketchup.getCommands(this.bindingName)
//
try {
await this.isInitalized
return true
} catch {
return false
}
}
/**
* Will be called by `executeScript('bindings.receiveCommandsAndInitializeBridge()')` from sketchup. This is where the hoisting happens.
* NOTE: Oguhzan, we can defintively have commandNames be a string, and not a string[]
* And do JSON.parse() here to get them out properly.
* @param commandNames
*/
private receiveCommandsAndInitializeBridge(commandNamesString: string) {
const commandNames = JSON.parse(commandNamesString) as string[]
const hoistTarget = this as unknown as Record<string, unknown>
for (const commandName of commandNames) {
hoistTarget[commandName] = (...args: unknown[]) =>
this.runMethod(commandName, args)
}
this.resolveIsInitializedPromise(true)
}
/**
* Will be called by `executeScript('bindings.rejectBindings()')` from sketchup.
* @param message
*/
private rejectBindings(message: string) {
this.rejectIsInitializedPromise(message)
}
/**
* Internal calls to Sketchup.
* @param methodName
* @param args
*/
private async runMethod(methodName: string, args: unknown[]): Promise<unknown> {
const requestId = uniqueId(this.bindingName)
// TODO: more on the ruby end, but for now Oguzhan seems happy with this.
// Changes might be needed in the future.
sketchup.exec({
name: methodName,
// eslint-disable-next-line camelcase
request_id: requestId,
// eslint-disable-next-line camelcase
binding_name: this.bindingName,
data: { args }
})
return new Promise((resolve, reject) => {
this.requests[requestId] = {
resolve,
reject,
rejectTimerId: window.setTimeout(() => {
reject(
`Sketchup response timed out - did not receive anything back in good time (${this.TIMEOUT_MS}ms).`
)
delete this.requests[requestId]
}, this.TIMEOUT_MS)
}
})
}
private receiveResponse(requestId: string, data: object) {
if (!this.requests[requestId])
throw new Error(
`Sketchup Bridge found no request to resolve with the id of ${requestId}. Something is weird!`
)
const request = this.requests[requestId]
try {
// eslint-disable-next-line no-prototype-builtins
if (data && data.hasOwnProperty('error')) {
console.error(data)
throw new Error(
`Failed to run ${requestId}. The host app error is logged above.`
)
}
// NOTE/TODO: does not need parsing
// const parsedData = JSON.parse(data) as Record<string, unknown> // TODO: check if data is undefined
request.resolve(data)
} catch (e) {
request.reject(e as Error)
} finally {
window.clearTimeout(request.rejectTimerId)
delete this.requests[requestId]
}
}
public showDevTools() {
// eslint-disable-next-line no-alert
window.alert(
'Sketchup cannot do this. The dev tools menu is accessible via a right click.'
)
}
}
@@ -1,42 +0,0 @@
/* eslint-disable */
import * as types from './graphql';
import type { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core';
/**
* Map of all GraphQL operations in the project.
*
* This map has several performance disadvantages:
* 1. It is not tree-shakeable, so it will include all operations in the project.
* 2. It is not minifiable, so the string of a GraphQL query will be multiple times inside the bundle.
* 3. It does not support dead code elimination, so it will add unused operations.
*
* Therefore it is highly recommended to use the babel or swc plugin for production.
*/
const documents = {
"\n query AcccountTestQuery {\n serverInfo {\n version\n name\n company\n }\n }\n ": types.AcccountTestQueryDocument,
};
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*
*
* @example
* ```ts
* const query = graphql(`query GetUser($id: ID!) { user(id: $id) { name } }`);
* ```
*
* The query argument is unknown!
* Please regenerate the types.
*/
export function graphql(source: string): unknown;
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n query AcccountTestQuery {\n serverInfo {\n version\n name\n company\n }\n }\n "): (typeof documents)["\n query AcccountTestQuery {\n serverInfo {\n version\n name\n company\n }\n }\n "];
export function graphql(source: string) {
return (documents as any)[source] ?? {};
}
export type DocumentType<TDocumentNode extends DocumentNode<any, any>> = TDocumentNode extends DocumentNode< infer TType, any> ? TType : never;
File diff suppressed because it is too large Load Diff
@@ -1 +0,0 @@
export * from "./gql";
-348
View File
@@ -1,348 +0,0 @@
/* eslint-disable @typescript-eslint/no-unsafe-return */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import type { ApolloLink, ApolloClientOptions } from '@apollo/client/core'
import { InMemoryCache, split, from } from '@apollo/client/core'
import { setContext } from '@apollo/client/link/context'
import { SubscriptionClient } from 'subscriptions-transport-ws'
import { createUploadLink } from 'apollo-upload-client'
import { WebSocketLink } from '@apollo/client/link/ws'
import { getMainDefinition } from '@apollo/client/utilities'
import type { OperationDefinitionNode } from 'graphql'
import { Kind } from 'graphql'
import type { Nullable } from '@speckle/shared'
import {
buildAbstractCollectionMergeFunction,
buildArrayMergeFunction,
incomingOverwritesExistingMergeFunction
} from '~~/lib/core/helpers/apolloSetup'
import { onError } from '@apollo/client/link/error'
import * as Observability from '@speckle/shared/dist/esm/observability/index.js'
let subscriptionsStopped = false
const errorRpm = Observability.simpleRpmCounter()
const STOP_SUBSCRIPTIONS_AT_ERRORS_PER_MIN = 100
const appVersion = (import.meta.env.SPECKLE_SERVER_VERSION as string) || 'unknown'
const appName = 'dui-3'
function createCache(): InMemoryCache {
return new InMemoryCache({
/**
* This is where you configure how various GQL fields should be read, written to or merged when new data comes in.
* If you define a merge function here, you don't need to duplicate the merge logic inside an `update()` callback
* of a fetchMore call, for example.
*
* Feel free to re-use utilities in the `apolloSetup` helper for defining merge functions or even use the ones that come from `@apollo/client/utilities`.
*
* Read more: https://www.apollographql.com/docs/react/caching/cache-field-behavior
*/
typePolicies: {
Query: {
fields: {
otherUser: {
read(original, { args, toReference }) {
if (args?.id) {
return toReference({ __typename: 'LimitedUser', id: args.id })
}
return original
}
},
activeUser: {
merge(existing, incoming, { mergeObjects }) {
return mergeObjects(existing, incoming)
},
read(original, { args, toReference }) {
if (args?.id) {
return toReference({ __typename: 'User', id: args.id })
}
return original
}
},
user: {
read(original, { args, toReference }) {
if (args?.id) {
return toReference({ __typename: 'User', id: args.id })
}
return original
}
},
stream: {
read(original, { args, toReference }) {
if (args?.id) {
return toReference({ __typename: 'Stream', id: args.id })
}
return original
}
},
streams: {
keyArgs: ['query'],
merge: buildAbstractCollectionMergeFunction('UserStreamCollection', {
checkIdentity: true
})
},
project: {
read(original, { args, toReference }) {
if (args?.id) {
return toReference({ __typename: 'Project', id: args.id })
}
return original
}
},
projects: {
merge: buildArrayMergeFunction()
}
}
},
LimitedUser: {
fields: {
commits: {
keyArgs: false,
merge: buildAbstractCollectionMergeFunction('CommitCollection')
}
}
},
User: {
fields: {
timeline: {
keyArgs: ['after', 'before'],
merge: buildAbstractCollectionMergeFunction('ActivityCollection')
},
commits: {
keyArgs: false,
merge: buildAbstractCollectionMergeFunction('CommitCollection')
},
favoriteStreams: {
keyArgs: false,
merge: buildAbstractCollectionMergeFunction('StreamCollection')
},
projects: {
keyArgs: ['filter', 'limit'],
merge: buildAbstractCollectionMergeFunction('ProjectCollection')
}
}
},
Project: {
fields: {
models: {
keyArgs: ['filter', 'limit'],
merge: buildAbstractCollectionMergeFunction('ModelCollection')
},
versions: {
keyArgs: ['filter', 'limit'],
merge: buildAbstractCollectionMergeFunction('VersionCollection')
},
commentThreads: {
keyArgs: ['filter', 'limit'],
merge: buildAbstractCollectionMergeFunction('CommentCollection')
},
modelsTree: {
keyArgs: ['filter', 'limit'],
merge: buildAbstractCollectionMergeFunction('ModelsTreeItemCollection')
},
replyAuthors: {
keyArgs: false,
merge: buildAbstractCollectionMergeFunction('CommentReplyAuthorCollection')
},
viewerResources: {
merge: (_existing, incoming) => [...incoming]
},
model: {
read(original, { args, toReference }) {
if (args?.id) {
return toReference({ __typename: 'Model', id: args.id })
}
return original
}
},
team: {
merge: (_existing, incoming) => incoming
},
invitedTeam: {
merge: (_existing, incoming) => incoming
},
pendingImportedModels: {
merge: (_existing, incoming) => incoming
}
}
},
Model: {
fields: {
versions: {
keyArgs: ['filter', 'limit'],
merge: buildAbstractCollectionMergeFunction('VersionCollection')
},
pendingImportedVersions: {
merge: (_existing, incoming) => incoming
}
}
},
Comment: {
fields: {
replies: {
keyArgs: ['limit']
}
}
},
Stream: {
fields: {
activity: {
keyArgs: ['after', 'before', 'actionType'],
merge: buildAbstractCollectionMergeFunction('ActivityCollection')
},
commits: {
keyArgs: false,
merge: buildAbstractCollectionMergeFunction('CommitCollection', {
checkIdentity: true
})
},
pendingCollaborators: {
merge: incomingOverwritesExistingMergeFunction
},
pendingAccessRequests: {
merge: incomingOverwritesExistingMergeFunction
}
}
},
Branch: {
fields: {
commits: {
keyArgs: false,
merge: buildAbstractCollectionMergeFunction('CommitCollection', {
checkIdentity: true
})
}
}
},
BranchCollection: {
merge: true
},
ServerStats: {
merge: true
},
WebhookEventCollection: {
merge: true
},
ServerInfo: {
merge: true
},
CommentThreadActivityMessage: {
merge: true
}
}
})
}
function createWsClient(params: {
wsEndpoint: string
authToken: () => Nullable<string>
}): SubscriptionClient {
const { wsEndpoint, authToken } = params
return new SubscriptionClient(wsEndpoint, {
reconnect: true,
reconnectionAttempts: 3,
connectionParams: () => {
const token = authToken()
const Authorization = token?.length ? `Bearer ${token}` : null
return Authorization ? { Authorization, headers: { Authorization } } : {}
}
})
}
function createLink(params: {
httpEndpoint: string
wsClient?: SubscriptionClient
authToken: () => Nullable<string>
}): ApolloLink {
const { httpEndpoint, wsClient, authToken } = params
// Prepare links
const httpLink = createUploadLink({
uri: httpEndpoint
})
const authLink = setContext((_, { headers }) => {
const token = authToken()
const authHeader = token?.length ? { Authorization: `Bearer ${token}` } : {}
return {
headers: {
...headers,
...authHeader
}
}
})
let link = authLink.concat(httpLink as unknown as ApolloLink)
if (wsClient) {
const wsLink = new WebSocketLink(wsClient)
link = split(
({ query }) => {
const definition = getMainDefinition(query) as OperationDefinitionNode
const { kind, operation } = definition
return kind === Kind.OPERATION_DEFINITION && operation === 'subscription'
},
wsLink,
link
)
}
const errorLink = onError((res) => {
console.error('Apollo Client error', res)
// Disable subscriptions if too many errors per minute
const rpm = errorRpm.hit()
if (
import.meta.client &&
wsClient &&
!subscriptionsStopped &&
rpm > STOP_SUBSCRIPTIONS_AT_ERRORS_PER_MIN
) {
subscriptionsStopped = true
console.error(
`Too many errors (${rpm} errors per minute), stopping subscriptions!`
)
wsClient.use([
{
applyMiddleware: () => {
// never invokes next() - essentially stuck
}
}
])
}
})
return from([errorLink, link])
}
type ResolveClientConfigParams = {
httpEndpoint: string
authToken: () => Nullable<string>
}
export const resolveClientConfig = (
params: ResolveClientConfigParams
): Pick<ApolloClientOptions<unknown>, 'cache' | 'link' | 'name' | 'version'> => {
const { httpEndpoint, authToken } = params
const wsEndpoint = httpEndpoint.replace('http', 'ws')
const wsClient = import.meta.client
? createWsClient({ wsEndpoint, authToken })
: undefined
const link = createLink({ httpEndpoint, wsClient, authToken })
return {
// If we don't markRaw the cache, sometimes we get cryptic internal Apollo Client errors that essentially
// result from parts of its internals being made reactive, even tho they shouldn't be
cache: markRaw(createCache()),
link,
name: appName,
version: appVersion
}
}
@@ -1,110 +0,0 @@
import type { Optional } from '@speckle/shared'
import type { FieldMergeFunction } from '@apollo/client/core'
interface AbstractCollection<T extends string> {
__typename: T
totalCount: number
cursor: string | null
items: Record<string, unknown>[]
}
interface MergeSettings {
/**
* Set to false if you want to merge incoming items without checking
* for duplicates. Usually you don't want to do this as you can introduce duplicates this way.
* Defaults to true
*/
checkIdentity: boolean
/**
* Optionally change the prop that should be used to compare
* equality between items
* Defaults to '__ref', which is the prop added by Apollo that contains the globally unique ID of the object
*/
identityProp: string
}
const prepareMergeSettings = (
settings: Optional<Partial<MergeSettings>>
): MergeSettings => ({
checkIdentity: true,
identityProp: '__ref',
...(settings || {})
})
/**
* Build an Apollo merge function for a field that returns an array of identifiable objects
*/
export function buildArrayMergeFunction(
settings?: Partial<MergeSettings>
): FieldMergeFunction<Record<string, unknown>[], Record<string, unknown>[]> {
const { checkIdentity, identityProp } = prepareMergeSettings(settings)
return (existing, incoming) => {
let finalItems: Record<string, unknown>[]
if (checkIdentity) {
finalItems = [...(existing || [])]
for (const newItem of incoming || []) {
if (
finalItems.findIndex(
(item) => item[identityProp] === newItem[identityProp]
) === -1
) {
finalItems.push(newItem)
}
}
} else {
finalItems = [...(existing || []), ...(incoming || [])]
}
return finalItems
}
}
/**
* Build an Apollo merge function for a field that returns a collection like AbstractCollection
*/
export function buildAbstractCollectionMergeFunction<T extends string>(
typeName: T,
settings?: Partial<MergeSettings>
): FieldMergeFunction<Optional<AbstractCollection<T>>, AbstractCollection<T>> {
const { checkIdentity, identityProp } = prepareMergeSettings(settings)
return (
existing: Optional<AbstractCollection<T>>,
incoming: AbstractCollection<T>
) => {
const existingItems = existing?.items || []
const incomingItems = incoming?.items || []
let finalItems: Record<string, unknown>[]
if (checkIdentity) {
finalItems = [...existingItems]
for (const newItem of incomingItems) {
if (
finalItems.findIndex(
(item) => item[identityProp] === newItem[identityProp]
) === -1
) {
finalItems.push(newItem)
}
}
} else {
finalItems = [...existingItems, ...incomingItems]
}
return {
__typename: incoming?.__typename || existing?.__typename || typeName,
totalCount: incoming.totalCount || 0,
cursor: incoming.cursor || null,
items: finalItems
}
}
}
/**
* Merge function that just takes incoming data and overrides all of old data with it
* Useful for array fields w/o pagination, where a new array response is supposed to replace
* the entire old one
*/
export const incomingOverwritesExistingMergeFunction: FieldMergeFunction = (
_existing: unknown,
incoming: unknown
) => incoming
-76
View File
@@ -1,76 +0,0 @@
import legacy from '@vitejs/plugin-legacy'
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
typescript: {
shim: false,
strict: true
},
modules: [
'@nuxt/eslint',
'@nuxtjs/tailwindcss',
'@speckle/ui-components-nuxt',
'@pinia/nuxt'
],
alias: {
// Rewriting all lodash calls to lodash-es for proper tree-shaking & chunk splitting
lodash: 'lodash-es'
},
vite: {
resolve: {
alias: [{ find: /^lodash$/, replacement: 'lodash-es' }]
},
build: {
// older chrome version for CEF 65 support. all identifiers except the chrome one are default ones.
target: ['es2020', 'edge88', 'firefox78', 'chrome65', 'safari14'],
// optionally disable minification for debugging
minify: false
},
plugins: [
// again - only for CEF 65
legacy({
renderLegacyChunks: false,
// only adding the specific polyfills we need to reduce bundle size
modernPolyfills: ['es.global-this', 'es/object', 'es/array']
})
]
},
ssr: false,
build: {
transpile: [
/^@apollo\/client/,
'ts-invariant/process',
'@vue/apollo-composable',
'@headlessui/vue',
/^@heroicons\/vue/,
'@vueuse/core',
'@vueuse/shared',
'@speckle/ui-components'
]
},
hooks: {
'build:manifest': (manifest) => {
// kinda hacky, vite polyfills are incorrectly being loaded last so we have to move them to appear first in the object.
// we can't replace `manifest` entirely, cause then we're only mutating a local variable, not the actual manifest
// which is why we have to mutate the reference.
// since ES2015 object string property order is more or less guaranteed - the order is chronological
const polyfillKey = 'vite/legacy-polyfills'
const polyfillEntry = manifest[polyfillKey]
if (!polyfillEntry) return
const oldManifest = { ...manifest }
delete oldManifest[polyfillKey]
for (const key in manifest) {
delete manifest[key]
}
manifest[polyfillKey] = polyfillEntry
for (const key in oldManifest) {
manifest[key] = oldManifest[key]
}
}
}
})
-81
View File
@@ -1,81 +0,0 @@
{
"name": "@speckle/dui3",
"description": "Speckle desktop UI embedded in connectors. Built w/ Vue 3 & Nuxt 3",
"version": "0.0.1",
"private": true,
"engines": {
"node": "^22.6.0"
},
"scripts": {
"build": "nuxt build",
"dev:nuxt": "nuxt dev",
"dev": "concurrently \"nuxt dev\" \"yarn gqlgen:watch\"",
"generate": "nuxt generate",
"preview": "nuxt preview",
"postinstall": "yarn ensure:tailwind-deps && nuxt prepare",
"lint:js": "eslint .",
"lint:tsc": "vue-tsc --noEmit",
"lint:prettier": "prettier --config ../../.prettierrc --ignore-path ../../.prettierignore --check .",
"lint:css": "stylelint \"**/*.{css,vue}\"",
"lint": "yarn lint:js && yarn lint:tsc && yarn lint:prettier && yarn lint:css",
"lint:ci": "yarn lint:tsc && yarn lint:css",
"gqlgen": "graphql-codegen",
"gqlgen:watch": "graphql-codegen --watch"
},
"dependencies": {
"@apollo/client": "^3.7.14",
"@headlessui/vue": "^1.7.13",
"@heroicons/vue": "^2.0.12",
"@pinia/nuxt": "^0.4.11",
"@speckle/shared": "workspace:^",
"@speckle/ui-components": "workspace:^",
"@speckle/ui-components-nuxt": "workspace:^",
"@vue/apollo-composable": "^4.0.0-beta.5",
"@vueuse/core": "^9.13.0",
"apollo-upload-client": "^17.0.0",
"graphql": "^16.6.0",
"graphql-tag": "^2.12.6",
"lodash-es": "^4.17.21",
"nanoevents": "^8.0.0",
"pinia": "^2.1.4",
"portal-vue": "^3.0.0",
"subscriptions-transport-ws": "^0.11.0",
"vue-tippy": "^6.2.0"
},
"devDependencies": {
"@graphql-codegen/cli": "^5.0.5",
"@graphql-codegen/client-preset": "^4.3.0",
"@nuxt/eslint": "^0.3.13",
"@nuxtjs/tailwindcss": "^6.7.0",
"@parcel/watcher": "^2.5.1",
"@types/apollo-upload-client": "^17.0.1",
"@types/eslint": "^8.56.10",
"@types/lodash-es": "^4.17.6",
"@types/node": "^18",
"@typescript-eslint/eslint-plugin": "^7.12.0",
"@typescript-eslint/parser": "^7.12.0",
"@vitejs/plugin-legacy": "^4.0.3",
"autoprefixer": "^10.4.14",
"concurrently": "^7.5.0",
"eslint": "^9.4.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-vuejs-accessibility": "^2.3.0",
"nuxt": "^3.6.3",
"postcss": "^8.4.31",
"postcss-custom-properties": "^12.1.9",
"postcss-html": "^1.5.0",
"postcss-nesting": "^10.2.0",
"prettier": "^2.7.1",
"stylelint": "^15.10.1",
"stylelint-config-prettier": "^9.0.3",
"stylelint-config-recommended-vue": "^1.4.0",
"stylelint-config-standard": "^26.0.0",
"tailwindcss": "^3.3.2",
"type-fest": "^3.5.1",
"typescript": "^4.8.3",
"vue-tsc": "^2.2.2"
},
"installConfig": {
"hoistingLimits": "workspaces"
}
}
-10
View File
@@ -1,10 +0,0 @@
<template>
<div class="flex items-center justify-center h-[calc(100vh-14rem)]">
<div
class="p-2 bg-primary text-foreground-on-primary shadow-md rounded-md font-bold"
>
TODO: Everything
</div>
</div>
</template>
<script setup lang="ts"></script>
-160
View File
@@ -1,160 +0,0 @@
<template>
<div class="flex flex-col space-y-2">
<Portal to="navigation">
<FormButton to="/" size="sm" :icon-left="ArrowLeftIcon" class="ml-2">
Back home
</FormButton>
</Portal>
<div>
<p class="text-sm text-foreground-2 py-2 px-2">
Do not expect these to save the day. They are just some
<b class="text-foreground-primary">minor sanity checks</b>
.
</p>
</div>
<FormButton
size="xl"
color="card"
full-width
class="sticky top-10 top-16"
@click="runTests()"
>
Run Tests
</FormButton>
<div
v-for="test in tests"
:key="test.name"
class="py-2 px-2 bg-foundation shadow hover:shadow-lg transition rounded-lg text-xs"
>
<div class="flex space-x-2">
<div>
<MinusIcon v-if="test.status === 0" class="w-4 h-4 text-primary" />
<CheckIcon v-if="test.status === 1" class="w-4 h-4 text-success" />
<XMarkIcon v-if="test.status === 2" class="w-4 h-4 text-danger" />
</div>
<div>{{ test.name }}</div>
</div>
<div class="text-xs max-w-full overflow-x-scroll simple-scrollbar py-2">
<pre>{{ test }}</pre>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ArrowLeftIcon, CheckIcon, MinusIcon, XMarkIcon } from '@heroicons/vue/20/solid'
import type { TestEventArgs } from '~/lib/bindings/definitions/ITestBinding'
const { $testBindings } = useNuxtApp()
const tests = ref([
{
name: 'Simple call with parameters',
test: async (): Promise<unknown> => {
await $testBindings.sayHi('Speckle', 3, false)
return 'ok'
},
status: 0,
result: {} as unknown
},
{
name: 'Simple call with invalid parameters',
test: async (): Promise<unknown> => {
try {
await (
$testBindings as unknown as {
sayHi: (name: string, count: number) => Promise<string>
}
).sayHi('Speckle', 0) // note, invalid on purpose, it looks long because ts needs to be happy
return 'not ok'
} catch {
return 'ok'
}
},
status: 0,
result: {} as unknown
},
{
name: 'Simple function call with no args and no result',
test: async (): Promise<unknown> => {
const res = await $testBindings.goAway()
return res === null || res === undefined ? 'ok' : 'not ok'
},
status: 0,
result: {} as unknown
},
{
name: 'Get a more complicated object from a method call',
test: async (): Promise<unknown> => {
const res = await $testBindings.getComplexType()
const key = Object.keys(res)[0]
return key.toLowerCase()[0] === key[0] ? 'ok' : 'serialization gone wrong'
},
status: 0,
result: {} as unknown
},
{
name: 'Simple event capture',
test: async () => {
await $testBindings.triggerEvent('emptyTestEvent')
return 'not ok'
},
status: 0,
result: 'not run yet' as unknown
},
{
name: 'Event capture with args',
test: async () => {
await $testBindings.triggerEvent('testEvent')
return 'not ok'
},
status: 0,
result: 'not run yet' as unknown
}
])
const runTests = async () => {
for (const test of tests.value) {
test.result = null
test.status = 0
}
for (const test of tests.value) {
try {
const res = await test.test()
if (res === 'ok') {
test.status = 1
} else {
test.status = 2
}
test.result = res
} catch (e) {
test.status = 2
test.result = e
}
}
}
$testBindings.on('emptyTestEvent', () => {
setTimeout(() => {
console.log('sketchup sent event back', 'emptyTestEvent')
const myTest = tests.value.find((t) => t.name === 'Simple event capture')
console.log(myTest, 'myTest')
if (!myTest) return
myTest.status = 1
myTest.result = 'got an event back, we are okay'
}, 300)
})
$testBindings.on('testEvent', (args: TestEventArgs) => {
setTimeout(() => {
console.log(args, 'testEvent')
const myTest = tests.value.find((t) => t.name === 'Event capture with args')
console.log(myTest, 'myTest')
if (!myTest) return
myTest.status = 1
myTest.result = args
}, 300)
})
</script>
-103
View File
@@ -1,103 +0,0 @@
import type { IRawBridge } from '~/lib/bridge/definitions'
import { GenericBridge } from '~/lib/bridge/generic'
import { SketchupBridge } from '~/lib/bridge/sketchup'
import type { IBasicConnectorBinding } from '~/lib/bindings/definitions/IBasicConnectorBinding'
import {
IBasicConnectorBindingKey,
MockedBaseBinding
} from '~/lib/bindings/definitions/IBasicConnectorBinding'
import type { ITestBinding } from '~/lib/bindings/definitions/ITestBinding'
import {
ITestBindingKey,
MockedTestBinding
} from '~/lib/bindings/definitions/ITestBinding'
import type { IConfigBinding } from '~/lib/bindings/definitions/IConfigBinding'
import {
IConfigBindingKey,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
MockedConfigBinding
} from '~/lib/bindings/definitions/IConfigBinding'
// Makes TS happy
declare let globalThis: Record<string, unknown> & {
CefSharp?: { BindObjectAsync: (name: string) => Promise<void> }
chrome?: { webview: { hostObjects: Record<string, IRawBridge> } }
sketchup?: Record<string, unknown>
}
/**
* Here we are loading any bindings that we expect to have from all
* connectors. If some are not present, that's okay - we're going to
* strip or customize functionality from the ui itself.
*/
export default defineNuxtPlugin(async () => {
// Registers some default test bindings.
const testBindings =
(await tryHoistBinding<ITestBinding>(ITestBindingKey)) || new MockedTestBinding()
// Tries to register some non-existant bindings.
const nonExistantBindings = await tryHoistBinding('nonExistantBindings')
// Registers a set of default bindings.
const baseBinding =
(await tryHoistBinding<IBasicConnectorBinding>(IBasicConnectorBindingKey)) ||
new MockedBaseBinding()
// UI configuration bindings.
const configBinding = await tryHoistBinding<IConfigBinding>(IConfigBindingKey)
const showDevTools = () => {
baseBinding.showDevTools()
}
const openUrl = (url: string) => {
baseBinding.openUrl(url)
}
return {
provide: {
testBindings,
nonExistantBindings,
baseBinding,
configBinding,
showDevTools,
openUrl
}
}
})
/**
* Checks possible browser window targets for a given binding, and, if it finds it,
* creates a bridge for it and registers it in the global scope.
* @param name binding name
* @returns null if the binding was not found, or the binding.
*/
const tryHoistBinding = async <T>(name: string) => {
let bridge: GenericBridge | SketchupBridge | null = null
let tempBridge: GenericBridge | SketchupBridge | null = null
if (globalThis.CefSharp) {
await globalThis.CefSharp.BindObjectAsync(name)
tempBridge = new GenericBridge(globalThis[name] as unknown as IRawBridge)
}
if (globalThis.chrome && globalThis.chrome.webview && !tempBridge) {
tempBridge = new GenericBridge(globalThis.chrome.webview.hostObjects[name])
}
if (globalThis.sketchup && !tempBridge) {
tempBridge = new SketchupBridge(name)
}
const res = await tempBridge?.create()
if (res) bridge = tempBridge
if (!bridge) console.warn(`Failed to bind ${name} binding.`)
globalThis[name] = bridge
return bridge as unknown as T
}
-5
View File
@@ -1,5 +0,0 @@
import PortalVue from 'portal-vue'
export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.vueApp.use(PortalVue)
})
-11
View File
@@ -1,11 +0,0 @@
import VueTippy from 'vue-tippy'
import 'tippy.js/dist/tippy.css'
export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.vueApp.use(VueTippy, {
defaultProps: {
arrow: true
},
flipDuration: 0
})
})
-7
View File
@@ -1,7 +0,0 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
'postcss-nesting': {}
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

-3
View File
@@ -1,3 +0,0 @@
{
"extends": "../.nuxt/tsconfig.server.json"
}
-10
View File
@@ -1,10 +0,0 @@
import { defineStore } from 'pinia'
import { useInjectedAccounts } from '~/lib/accounts/composables/setup'
// NOTE: this store simply wraps around the injected accounts composable (for now)
export const useAccountStore = defineStore('accountStore', () => {
const { accounts, refreshAccounts, defaultAccount, validAccounts, loading } =
useInjectedAccounts()
return { accounts, refreshAccounts, defaultAccount, validAccounts, loading }
})
-23
View File
@@ -1,23 +0,0 @@
import { defineStore } from 'pinia'
import type { DocumentInfo } from 'lib/bindings/definitions/IBasicConnectorBinding'
export const useDocumentInfoStore = defineStore('documentInfoStore', () => {
const app = useNuxtApp()
const documentInfo = ref<DocumentInfo>()
app.$baseBinding.on('documentChanged', () => {
console.log('doc changed')
setTimeout(async () => {
const docInfo = await app.$baseBinding.getDocumentInfo()
documentInfo.value = docInfo
}, 500) // Rhino needs some time.
})
const initDocInfo = async () => {
documentInfo.value = await app.$baseBinding.getDocumentInfo()
}
void initDocInfo()
return { documentInfo }
})
-34
View File
@@ -1,34 +0,0 @@
import { defineStore } from 'pinia'
import type { Config } from 'lib/bindings/definitions/IConfigBinding'
export const useDocumentInfoStore = defineStore('documentInfoStore', () => {
const { $configBinding } = useNuxtApp()
const hasConfigBindings = ref(!!$configBinding)
const uiConfig = ref<Config>({ darkTheme: false })
watch(
uiConfig,
async (newValue) => {
if (!newValue || !$configBinding) return
await $configBinding.updateConfig(newValue)
},
{ deep: true }
)
const isDarkTheme = computed(() => {
return uiConfig.value?.darkTheme
})
const toggleTheme = () => {
uiConfig.value.darkTheme = !uiConfig.value.darkTheme
}
const init = async () => {
if (!$configBinding) return
uiConfig.value = await $configBinding.getConfig()
}
void init()
return { hasConfigBindings, isDarkTheme, toggleTheme }
})
-28
View File
@@ -1,28 +0,0 @@
module.exports = {
extends: [
'stylelint-config-standard',
'stylelint-config-recommended-vue',
'stylelint-config-prettier'
],
// add your custom config here
// https://stylelint.io/user-guide/configuration
rules: {
// Rules to make stylelint happy with tailwind syntax
'at-rule-no-unknown': [
true,
{
ignoreAtRules: ['tailwind', 'apply', 'variants', 'responsive', 'screen']
}
],
'declaration-block-trailing-semicolon': null,
'no-descending-specificity': null
},
overrides: [
{
files: '**/*.vue',
rules: {
'value-keyword-case': null
}
}
]
}
-26
View File
@@ -1,26 +0,0 @@
import { plugin as speckleTheme } from '@speckle/tailwind-theme'
import { tailwindContentEntries as themeEntries } from '@speckle/tailwind-theme/tailwind-configure'
import { tailwindContentEntries as uiLibEntries } from '@speckle/ui-components/tailwind-configure'
import formsPlugin from '@tailwindcss/forms'
/** @type {import('tailwindcss').Config} */
const config = {
darkMode: 'class',
content: [
`./components/**/*.{vue,js,ts}`,
`./layouts/**/*.vue`,
`./pages/**/*.vue`,
`./composables/**/*.{js,ts}`,
`./plugins/**/*.{js,ts}`,
'./stories/**/*.{js,ts,vue,mdx}',
'./app.vue',
'./.storybook/**/*.{js,ts,vue}',
'./lib/**/composables/*.{js,ts}',
...themeEntries(),
...uiLibEntries()
// `./lib/**/*.{js,ts,vue}`, // TODO: Wait for fix https://github.com/nuxt/framework/issues/2886#issuecomment-1108312903
],
plugins: [speckleTheme, formsPlugin]
}
export default config
-5
View File
@@ -1,5 +0,0 @@
{
// https://v3.nuxtjs.org/concepts/typescript
"extends": "./tsconfig.json",
"include": ["./.nuxt/nuxt.d.ts", "**/*", ".*.js"]
}
-4
View File
@@ -1,4 +0,0 @@
{
// https://nuxt.com/docs/guide/concepts/typescript
"extends": "./.nuxt/tsconfig.json"
}
-4
View File
@@ -8,10 +8,6 @@
"path": "packages/frontend-2", "path": "packages/frontend-2",
"name": "🏬 frontend" "name": "🏬 frontend"
}, },
{
"path": "packages/dui3",
"name": "🥉 dui 3.0"
},
{ {
"path": "packages/tailwind-theme", "path": "packages/tailwind-theme",
"name": "🎨 tailwind-theme" "name": "🎨 tailwind-theme"
+105 -3982
View File
File diff suppressed because it is too large Load Diff