Merge branch 'dui3'
This commit is contained in:
+12
-2
@@ -6,12 +6,22 @@
|
||||
</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} - Speckle DUIv3` : 'Speckle DUIv3',
|
||||
titleChunk ? `${titleChunk as string} - Speckle DUIv3` : 'Speckle DUIv3',
|
||||
htmlAttrs: {
|
||||
lang: 'en'
|
||||
lang: 'en',
|
||||
class: computed(() => (isDarkTheme.value ? `dark` : ``))
|
||||
},
|
||||
bodyAttrs: {
|
||||
class: 'simple-scrollbar bg-foundation-page text-foreground'
|
||||
|
||||
@@ -2,20 +2,17 @@
|
||||
<nav
|
||||
class="fixed top-0 h-14 bg-foundation max-w-full w-full shadow hover:shadow-md transition z-20"
|
||||
>
|
||||
<div class="px-4">
|
||||
<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">
|
||||
<HeaderNavLink
|
||||
to="/"
|
||||
name="Dashboard"
|
||||
:separator="true"
|
||||
class="hidden md:inline-block"
|
||||
/>
|
||||
<PortalTarget name="navigation"></PortalTarget>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<HeaderUserMenu />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<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 { 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>
|
||||
@@ -0,0 +1,105 @@
|
||||
<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 { 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>
|
||||
@@ -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
|
||||
}
|
||||
|
||||
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>
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="min-h-full">
|
||||
<HeaderNavBar />
|
||||
<main class="my-4 layout-container pb-20 mt-20">
|
||||
<main class="px-1 pb-4 mt-16">
|
||||
<slot />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { ApolloClient, gql } from '@apollo/client/core'
|
||||
import { ApolloClients } from '@vue/apollo-composable'
|
||||
import { ComputedRef, Ref } from 'vue'
|
||||
import { 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 (error) {
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/* eslint-disable @typescript-eslint/require-await */
|
||||
|
||||
import { BaseBridge } from '~~/lib/bridge/base'
|
||||
import { 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 {
|
||||
constructor() {
|
||||
super()
|
||||
}
|
||||
|
||||
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')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* 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>
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/* eslint-disable @typescript-eslint/require-await */
|
||||
|
||||
import { BaseBridge } from '~~/lib/bridge/base'
|
||||
import { 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 {
|
||||
constructor() {
|
||||
super()
|
||||
}
|
||||
|
||||
getConfig() {
|
||||
return {
|
||||
darkTheme: false
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
updateConfig(config: Config) {
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/* eslint-disable @typescript-eslint/require-await */
|
||||
|
||||
import { BaseBridge } from '~~/lib/bridge/base'
|
||||
import { 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 {
|
||||
constructor() {
|
||||
super()
|
||||
}
|
||||
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,24 @@
|
||||
import { createNanoEvents, Emitter } 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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* 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>
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { BaseBridge } from '~/lib/bridge/base'
|
||||
import { 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 (e) {
|
||||
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)
|
||||
@@ -0,0 +1,161 @@
|
||||
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.'
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import type { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-
|
||||
* Therefore it is highly recommended to use the babel-plugin for production.
|
||||
*/
|
||||
const documents = {
|
||||
"\n query AcccountTestQuery {\n serverInfo {\n version\n name\n company\n }\n }\n ": types.AcccountTestQueryDocument,
|
||||
"\n query ServerInfoTest {\n serverInfo {\n version\n }\n }\n": types.ServerInfoTestDocument,
|
||||
};
|
||||
|
||||
@@ -30,6 +31,10 @@ const documents = {
|
||||
**/
|
||||
export function graphql(source: string): unknown;
|
||||
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n query AcccountTestQuery {\n serverInfo {\n version\n name\n company\n }\n }\n "): (typeof documents)["\n query AcccountTestQuery {\n serverInfo {\n version\n name\n company\n }\n }\n "];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
|
||||
@@ -209,7 +209,7 @@ export type Comment = {
|
||||
* Legacy comment viewer data field
|
||||
* @deprecated Use the new viewerState field instead
|
||||
*/
|
||||
data?: Maybe<LegacyCommentViewerData>;
|
||||
data?: Maybe<Scalars['JSONObject']>;
|
||||
/** Whether or not comment is a reply to another comment */
|
||||
hasParent: Scalars['Boolean'];
|
||||
id: Scalars['String'];
|
||||
@@ -369,6 +369,7 @@ export type Commit = {
|
||||
authorAvatar?: Maybe<Scalars['String']>;
|
||||
authorId?: Maybe<Scalars['String']>;
|
||||
authorName?: Maybe<Scalars['String']>;
|
||||
branch?: Maybe<Branch>;
|
||||
branchName?: Maybe<Scalars['String']>;
|
||||
/**
|
||||
* The total number of comments for this commit. To actually get the comments, use the comments query and pass in a resource array consisting of of this commit's id.
|
||||
@@ -635,7 +636,7 @@ export type Model = {
|
||||
pendingImportedVersions: Array<FileUpload>;
|
||||
previewUrl?: Maybe<Scalars['String']>;
|
||||
updatedAt: Scalars['DateTime'];
|
||||
version?: Maybe<Version>;
|
||||
version: Version;
|
||||
versions: VersionCollection;
|
||||
};
|
||||
|
||||
@@ -694,6 +695,8 @@ export type ModelMutationsUpdateArgs = {
|
||||
export type ModelVersionsFilter = {
|
||||
/** Make sure these specified versions are always loaded first */
|
||||
priorityIds?: InputMaybe<Array<Scalars['String']>>;
|
||||
/** Only return versions specified in `priorityIds` */
|
||||
priorityIdsOnly?: InputMaybe<Scalars['Boolean']>;
|
||||
};
|
||||
|
||||
export type ModelsTreeItem = {
|
||||
@@ -1221,7 +1224,7 @@ export type Project = {
|
||||
/** Collaborators who have been invited, but not yet accepted. */
|
||||
invitedTeam?: Maybe<Array<PendingStreamCollaborator>>;
|
||||
/** Returns a specific model by its ID */
|
||||
model?: Maybe<Model>;
|
||||
model: Model;
|
||||
/** Return a model tree of children for the specified model name */
|
||||
modelChildrenTree: Array<ModelsTreeItem>;
|
||||
/** Returns a flat list of all models */
|
||||
@@ -1605,7 +1608,7 @@ export type Query = {
|
||||
* Find a specific project. Will throw an authorization error if active user isn't authorized
|
||||
* to see it, for example, if a project isn't public and the user doesn't have the appropriate rights.
|
||||
*/
|
||||
project?: Maybe<Project>;
|
||||
project: Project;
|
||||
/**
|
||||
* Look for an invitation to a project, for the current user (authed or not). If token
|
||||
* isn't specified, the server will look for any valid invite.
|
||||
@@ -2266,6 +2269,7 @@ export type SubscriptionUserViewerActivityArgs = {
|
||||
|
||||
|
||||
export type SubscriptionViewerUserActivityBroadcastedArgs = {
|
||||
sessionId?: InputMaybe<Scalars['String']>;
|
||||
target: ViewerUpdateTrackingTarget;
|
||||
};
|
||||
|
||||
@@ -2624,10 +2628,16 @@ export type WebhookUpdateInput = {
|
||||
url?: InputMaybe<Scalars['String']>;
|
||||
};
|
||||
|
||||
export type AcccountTestQueryQueryVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
|
||||
export type AcccountTestQueryQuery = { __typename?: 'Query', serverInfo: { __typename?: 'ServerInfo', version?: string | null, name: string, company?: string | null } };
|
||||
|
||||
export type ServerInfoTestQueryVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
|
||||
export type ServerInfoTestQuery = { __typename?: 'Query', serverInfo: { __typename?: 'ServerInfo', version?: string | null } };
|
||||
|
||||
|
||||
export const AcccountTestQueryDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"AcccountTestQuery"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"serverInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"version"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"company"}}]}}]}}]} as unknown as DocumentNode<AcccountTestQueryQuery, AcccountTestQueryQueryVariables>;
|
||||
export const ServerInfoTestDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ServerInfoTest"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"serverInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"version"}}]}}]}}]} as unknown as DocumentNode<ServerInfoTestQuery, ServerInfoTestQueryVariables>;
|
||||
@@ -250,6 +250,7 @@ function createWsClient(params: {
|
||||
|
||||
return new SubscriptionClient(wsEndpoint, {
|
||||
reconnect: true,
|
||||
reconnectionAttempts: 3,
|
||||
connectionParams: () => {
|
||||
const token = authToken()
|
||||
const Authorization = token?.length ? `Bearer ${token}` : null
|
||||
|
||||
@@ -6,7 +6,7 @@ export default defineNuxtConfig({
|
||||
shim: false,
|
||||
strict: true
|
||||
},
|
||||
modules: ['@nuxtjs/tailwindcss'],
|
||||
modules: ['@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'
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"@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:^",
|
||||
@@ -34,8 +35,11 @@
|
||||
"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"
|
||||
"subscriptions-transport-ws": "^0.11.0",
|
||||
"vue-tippy": "^6.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@graphql-codegen/cli": "^2.13.6",
|
||||
@@ -52,7 +56,7 @@
|
||||
"eslint-plugin-nuxt": "^4.0.0",
|
||||
"eslint-plugin-vue": "^9.5.1",
|
||||
"eslint-plugin-vuejs-accessibility": "^1.2.0",
|
||||
"nuxt": "^3.5.0",
|
||||
"nuxt": "^3.6.3",
|
||||
"postcss": "^8.4.18",
|
||||
"postcss-custom-properties": "^12.1.9",
|
||||
"postcss-html": "^1.5.0",
|
||||
|
||||
@@ -1,40 +1,10 @@
|
||||
<template>
|
||||
<div>
|
||||
Hello world! Query results:
|
||||
<div>
|
||||
<div v-for="(res, clientId) in queries" :key="clientId">
|
||||
<strong>{{ clientId }}:</strong>
|
||||
{{ res.result.value?.serverInfo.version || '' }}
|
||||
</div>
|
||||
<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>
|
||||
<Portal to="navigation">
|
||||
<HeaderNavLink :to="'/'" :name="'Home'"></HeaderNavLink>
|
||||
</Portal>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { UseQueryReturn, useQuery } from '@vue/apollo-composable'
|
||||
import { graphql } from '~/lib/common/generated/gql'
|
||||
import { ServerInfoTestQuery } from '~/lib/common/generated/gql/graphql'
|
||||
|
||||
const versionQuery = graphql(`
|
||||
query ServerInfoTest {
|
||||
serverInfo {
|
||||
version
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
/**
|
||||
* Imagine these come from window or something
|
||||
*/
|
||||
const clients = ['latest', 'xyz']
|
||||
|
||||
const queries: Record<
|
||||
string,
|
||||
UseQueryReturn<ServerInfoTestQuery, Record<string, never>>
|
||||
> = {}
|
||||
for (const clientId of clients) {
|
||||
queries[clientId] = useQuery(versionQuery, undefined, { clientId })
|
||||
}
|
||||
</script>
|
||||
<script setup lang="ts"></script>
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
<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 } from '@heroicons/vue/20/solid'
|
||||
import { TestEventArgs } from '~/lib/bindings/definitions/ITestBinding'
|
||||
import { CheckIcon, MinusIcon, XMarkIcon } from '@heroicons/vue/20/solid'
|
||||
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 (e) {
|
||||
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>
|
||||
@@ -0,0 +1,103 @@
|
||||
import { IRawBridge } from '~/lib/bridge/definitions'
|
||||
|
||||
import { GenericBridge } from '~/lib/bridge/generic'
|
||||
import { SketchupBridge } from '~/lib/bridge/sketchup'
|
||||
|
||||
import {
|
||||
IBasicConnectorBinding,
|
||||
IBasicConnectorBindingKey,
|
||||
MockedBaseBinding
|
||||
} from '~/lib/bindings/definitions/IBasicConnectorBinding'
|
||||
|
||||
import {
|
||||
ITestBinding,
|
||||
ITestBindingKey,
|
||||
MockedTestBinding
|
||||
} from '~/lib/bindings/definitions/ITestBinding'
|
||||
|
||||
import {
|
||||
IConfigBinding,
|
||||
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
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import { ApolloClient } from '@apollo/client/core'
|
||||
import { ApolloClients } from '@vue/apollo-composable'
|
||||
import { resolveClientConfig } from '~/lib/core/configs/apollo'
|
||||
|
||||
export default defineNuxtPlugin((nuxtApp) => {
|
||||
/**
|
||||
* TODO: You can use `window` here to get credentials for all of the clients
|
||||
* we need from the parent connectors. The following is just an example
|
||||
*/
|
||||
|
||||
const apolloClients = {
|
||||
latest: new ApolloClient(
|
||||
// Imagine endpoint & token is resolved from window or something
|
||||
resolveClientConfig({
|
||||
httpEndpoint: 'https://latest.speckle.systems/graphql',
|
||||
authToken: () => null
|
||||
})
|
||||
),
|
||||
xyz: new ApolloClient(
|
||||
// Imagine endpoint & token is resolved from window or something
|
||||
resolveClientConfig({
|
||||
httpEndpoint: 'https://speckle.xyz/graphql',
|
||||
authToken: () => null
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
nuxtApp.vueApp.provide(ApolloClients, apolloClients)
|
||||
return {
|
||||
provide: {
|
||||
apolloClients
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,11 @@
|
||||
import VueTippy from 'vue-tippy'
|
||||
import 'tippy.js/dist/tippy.css'
|
||||
|
||||
export default defineNuxtPlugin((nuxtApp) => {
|
||||
nuxtApp.vueApp.use(VueTippy, {
|
||||
defaultProps: {
|
||||
arrow: true
|
||||
},
|
||||
flipDuration: 0
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,10 @@
|
||||
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 }
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { 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 }
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { 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 }
|
||||
})
|
||||
Reference in New Issue
Block a user