Merge branch 'dui3'

This commit is contained in:
Dimitrie Stefanescu
2023-08-11 10:53:00 +01:00
29 changed files with 2187 additions and 562 deletions
+12 -2
View File
@@ -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'
+4 -7
View File
@@ -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>
+163
View File
@@ -0,0 +1,163 @@
<template>
<div
:class="[
'text-foreground-on-primary flex shrink-0 items-center justify-center overflow-hidden rounded-full font-semibold uppercase transition',
sizeClasses,
bgClasses,
borderClasses,
hoverClasses,
activeClasses
]"
>
<slot>
<div
v-if="user?.avatar"
class="h-full w-full bg-cover bg-center bg-no-repeat"
:style="{ backgroundImage: `url('${user.avatar}')` }"
/>
<div
v-else-if="initials"
class="flex h-full w-full select-none items-center justify-center"
>
{{ initials }}
</div>
<div v-else><UserCircleIcon :class="iconClasses" /></div>
</slot>
</div>
</template>
<script setup lang="ts">
import { UserCircleIcon } from '@heroicons/vue/20/solid'
type UserAvatar = {
name: string
avatar?: string
}
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 -1
View File
@@ -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
}
}
+24
View File
@@ -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>
}
+70
View File
@@ -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)
+161
View File
@@ -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>;
+1
View File
@@ -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
+1 -1
View File
@@ -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'
+6 -2
View File
@@ -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",
+6 -36
View File
@@ -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>
+161
View File
@@ -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>
+103
View File
@@ -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
}
-34
View File
@@ -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
}
}
})
+11
View File
@@ -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
})
})
+10
View File
@@ -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 }
})
+23
View File
@@ -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 }
})
+34
View File
@@ -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 }
})
+873 -475
View File
File diff suppressed because it is too large Load Diff