Compare commits
145 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ac09115aed | |||
| 22bca347f1 | |||
| 35ee546fc5 | |||
| 2d086a4ab6 | |||
| 9c73769ec6 | |||
| 59b0c690f0 | |||
| 6eaf5e2135 | |||
| 43fe995bd8 | |||
| 941b3442fa | |||
| 4fabaa4fde | |||
| 30303e6783 | |||
| ba08c93c51 | |||
| ed9f2ba40d | |||
| ded9f7d041 | |||
| 3a2475b375 | |||
| baaf73d438 | |||
| b4794f1fd3 | |||
| 2ca577fe60 | |||
| c37235381f | |||
| 8e2f507286 | |||
| 9d3a623fe6 | |||
| 8fc81b0b4e | |||
| 6f2f599b1b | |||
| a69de13f16 | |||
| d2b0d35119 | |||
| b026659460 | |||
| 009cc77bab | |||
| a8b802b7e3 | |||
| 6fc3df4a0d | |||
| f47f19c02d | |||
| 85f806368a | |||
| 35ddce1f90 | |||
| a37b3389d6 | |||
| ed4aa92ce1 | |||
| 60f3bed254 | |||
| 2f412df64a | |||
| c7e0929eca | |||
| eef0a59719 | |||
| 19f306756c | |||
| 305b100d34 | |||
| f2cc0d55e3 | |||
| fdfef1d496 | |||
| 5174af78cc | |||
| ede6e99440 | |||
| 9c708c64a0 | |||
| 41e635c8ef | |||
| 095ccf114d | |||
| a95fd9bdfe | |||
| bc665a008c | |||
| 00a6a66ee0 | |||
| b0157af3c8 | |||
| 9b065bf921 | |||
| 99ebd403c7 | |||
| a166b86657 | |||
| 185ba0f50a | |||
| 49cabaa1bc | |||
| 11b6d5254e | |||
| aa5d59ba5b | |||
| 1cdd5c89a4 | |||
| 047dbff259 | |||
| ffff7366c3 | |||
| 4ecd6fbee9 | |||
| 54039daa32 | |||
| b7e347f3f0 | |||
| c8f85c3874 | |||
| 85405d10dd | |||
| d8fdc2c3c5 | |||
| 034d8645c6 | |||
| d797a65fab | |||
| 028c9d2ac1 | |||
| b2695e77f5 | |||
| 669afe81cf | |||
| 48bb180899 | |||
| 3b4aa93858 | |||
| 4ebf702ab2 | |||
| 6e6bd423a0 | |||
| 57ef9685b6 | |||
| 2ff5849739 | |||
| e55c0ca7dd | |||
| 8ef79f7a7c | |||
| 79951f7cf7 | |||
| cf70ddc79b | |||
| 4e16813c75 | |||
| ee4e7576ad | |||
| 3ba11c983b | |||
| d92dcf5342 | |||
| 55afedab68 | |||
| a8d948ec71 | |||
| 65a9d3e485 | |||
| be631746b9 | |||
| 20c43f2108 | |||
| af0de85ef7 | |||
| 7c54845a05 | |||
| cbec244443 | |||
| a2a9ab1f4b | |||
| 2f87c34272 | |||
| 7fde35e639 | |||
| 97765d84ca | |||
| 9d15be73ad | |||
| 45f763a2ce | |||
| 3c6bda7af9 | |||
| 007794dae2 | |||
| f8912338cb | |||
| f932fa46a3 | |||
| 292d2bf0bb | |||
| 43340d9b52 | |||
| 4898a9e2e9 | |||
| 19b982e2e3 | |||
| e3cf896c14 | |||
| 34d855212f | |||
| 8d159547d4 | |||
| 0c3ee8b38f | |||
| c51f282644 | |||
| 1faea0aec2 | |||
| 5e97acf4c0 | |||
| 9c7413d630 | |||
| 89acd7ca80 | |||
| 2e51a2fb3c | |||
| 644754262c | |||
| b28129c30e | |||
| 305ad36cac | |||
| 554d0ee478 | |||
| 33fd9c65e3 | |||
| ebaee49fe8 | |||
| b877c1d321 | |||
| be4dc87b3e | |||
| 94ddc486aa | |||
| ac5984d184 | |||
| 8dae170592 | |||
| d6f371c7d2 | |||
| cbf9e8d578 | |||
| 6f10519e6f | |||
| 590b8b8872 | |||
| 2514b6c606 | |||
| af37112a5f | |||
| d224b33bc8 | |||
| 377cfc2f65 | |||
| fdd13170f5 | |||
| e70310654b | |||
| 8bb14d3389 | |||
| 93e4762b6a | |||
| 21a4bd4076 | |||
| 82c95aab58 | |||
| fe77ede49e | |||
| f70915f485 |
@@ -0,0 +1,41 @@
|
||||
# Irrelevant source files
|
||||
deployment/
|
||||
|
||||
# Build output and other temporary files
|
||||
.husky/_/
|
||||
.netlify/
|
||||
.nuxt/
|
||||
dist/
|
||||
node_modules/
|
||||
|
||||
# Version control
|
||||
.git/
|
||||
.gitignore
|
||||
|
||||
# GitHub / CI metadata
|
||||
.github/
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
*.env
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# IDE / editor settings
|
||||
.vscode/
|
||||
.idea/
|
||||
.zed/
|
||||
*.iml
|
||||
|
||||
# OS / editor junk
|
||||
.DS_Store
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# AI
|
||||
.claude/
|
||||
.cursor/
|
||||
|
||||
# testing
|
||||
tests/
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,48 @@
|
||||
name: Build Docker Container
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
PUBLISH:
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
IMAGE_VERSION_TAG:
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions: {} # purposefully empty by default at workflow level, explicitly overridden for specific jobs below
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-build-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
docker-build:
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
name: Build Docker image
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write # to be able to push images to ghcr.io. As permissions is static, it has to be granted even if PUBLISH is false
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- name: Login to Helm Chart & Container Image Registry
|
||||
if: ${{ inputs.PUBLISH == true }}
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ github.token }}
|
||||
- name: Setup Docker Builder
|
||||
uses: useblacksmith/setup-docker-builder@affa10db466676f3dfb3e54caeb228ee0691510f
|
||||
- name: Build and push
|
||||
uses: useblacksmith/build-push-action@30c71162f16ea2c27c3e21523255d209b8b538c1
|
||||
with:
|
||||
push: ${{ inputs.PUBLISH }}
|
||||
tags: ghcr.io/specklesystems/speckle-dui:${{ inputs.IMAGE_VERSION_TAG }}
|
||||
file: ./deployment/docker/Dockerfile
|
||||
network: host # to be able to connect to Tailscale and pull private base image during build
|
||||
allow: network.host # to be able to connect to Tailscale and pull private base image during build
|
||||
@@ -0,0 +1,63 @@
|
||||
name: Get Version
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
outputs:
|
||||
IMAGE_VERSION_TAG:
|
||||
description: 'The image version tag under which the Helm chart and docker image should be published'
|
||||
value: ${{ jobs.get-version.outputs.VERSION }}
|
||||
|
||||
permissions: {} # purposefully empty by default at workflow level, explicitly overridden for specific jobs below
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-get-version-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
get-version:
|
||||
outputs:
|
||||
VERSION: ${{ steps.get-version.outputs.VERSION }}
|
||||
name: Get Version
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||
with:
|
||||
sparse-checkout: ''
|
||||
fetch-depth: 1
|
||||
fetch-tags: 1
|
||||
persist-credentials: true # zizmor: ignore[artipacked] need to fetch tags in the next step and this ensures that git is configured & authenticated
|
||||
- run: git fetch origin 'refs/tags/*:refs/tags/*'
|
||||
- name: Get version tag
|
||||
id: get-version
|
||||
run: |
|
||||
VERSION=""
|
||||
if [[ "${GITHUB_REF_NAME}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
VERSION="${GITHUB_REF_NAME}"
|
||||
echo "VERSION=${VERSION}" >> ${GITHUB_OUTPUT}
|
||||
echo "${VERSION} is a valid semver, we shall use it. Exiting"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
LAST_RELEASE="$(git describe --always --tags $(git rev-list --tags --max-count=1) | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' | head -n 1 || true)" # get the last release tag. FIXME: Fails if a commit is tagged with more than one tag: https://stackoverflow.com/questions/8089002/git-describe-with-two-tags-on-the-same-commit/56039163#56039163
|
||||
LAST_RELEASE="${LAST_RELEASE:-0.0.0}"
|
||||
NEXT_RELEASE="$(echo "${LAST_RELEASE}" | awk -F. -v OFS=. '{$NF += 1 ; print}')"
|
||||
|
||||
if [[ "${GITHUB_REF_NAME}" == "main" ]]; then
|
||||
VERSION="${NEXT_RELEASE}-alpha.${GITHUB_RUN_NUMBER}"
|
||||
echo "VERSION=${VERSION}" >> ${GITHUB_OUTPUT}
|
||||
echo "${VERSION} will be an alpha version. Exiting"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
BRANCH_NAME_TRUNCATED="$(echo "${GITHUB_REF_NAME}" | cut -c -28 | sed 's/[^a-zA-Z0-9.-]/-/g')" # docker has a 128 character tag limit, so ensuring the branch name will be short enough
|
||||
|
||||
PADDED_RUN_NUMBER="$(printf "%06d" "${GITHUB_RUN_NUMBER}")"
|
||||
COMMIT_SHA1_TRUNCATED="$(echo "${GITHUB_SHA}" | cut -c -7)"
|
||||
|
||||
VERSION="${NEXT_RELEASE}-branch.${BRANCH_NAME_TRUNCATED}.${PADDED_RUN_NUMBER}-${COMMIT_SHA1_TRUNCATED}"
|
||||
echo "VERSION=${VERSION}" >> ${GITHUB_OUTPUT}
|
||||
echo "${VERSION} will be a branch build version. Exiting"
|
||||
exit 0
|
||||
@@ -0,0 +1,35 @@
|
||||
name: Lint
|
||||
|
||||
on:
|
||||
workflow_call: {}
|
||||
|
||||
permissions: {} # purposefully empty by default at workflow level, explicitly overridden for specific jobs below
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-lint-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||
with:
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f
|
||||
with:
|
||||
node-version: '22.14.0'
|
||||
cache: 'yarn'
|
||||
|
||||
- name: Install Dependencies
|
||||
run: yarn install --immutable
|
||||
|
||||
- name: Run Linter
|
||||
run: yarn lint
|
||||
@@ -0,0 +1,39 @@
|
||||
name: Pull Request
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true # other running workflows get cancelled on the same branch
|
||||
|
||||
permissions: {} # purposefully empty by default at workflow level, explicitly overridden for specific jobs below
|
||||
|
||||
jobs:
|
||||
get-version:
|
||||
uses: ./.github/workflows/get-version.yml
|
||||
with: {}
|
||||
secrets: {}
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
lint:
|
||||
uses: ./.github/workflows/lint.yml
|
||||
with: {}
|
||||
secrets: {}
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
build:
|
||||
needs:
|
||||
- get-version
|
||||
uses: ./.github/workflows/build.yml
|
||||
with:
|
||||
PUBLISH: false
|
||||
IMAGE_VERSION_TAG: ${{ needs.get-version.outputs.IMAGE_VERSION_TAG }}
|
||||
secrets: {}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write # to be able to push images to ghcr.io, even if PUBLISH is false, as permissions is static at workflow level
|
||||
@@ -0,0 +1,41 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- '[0-9]+.[0-9]+.[0-9]+'
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true # other running workflows get cancelled on the same branch
|
||||
|
||||
permissions: {} # purposefully empty by default at workflow level, explicitly overridden for specific jobs below
|
||||
|
||||
jobs:
|
||||
get-version:
|
||||
uses: ./.github/workflows/get-version.yml
|
||||
with: {}
|
||||
secrets: {}
|
||||
permissions:
|
||||
contents: read
|
||||
lint:
|
||||
uses: ./.github/workflows/lint.yml
|
||||
with: {}
|
||||
secrets: {}
|
||||
permissions:
|
||||
contents: read
|
||||
build:
|
||||
uses: ./.github/workflows/build.yml
|
||||
needs:
|
||||
- get-version
|
||||
- lint
|
||||
with:
|
||||
PUBLISH: true
|
||||
IMAGE_VERSION_TAG: ${{ needs.get-version.outputs.IMAGE_VERSION_TAG }}
|
||||
secrets: {}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write # to be able to push images to ghcr.io
|
||||
+3
-1
@@ -15,4 +15,6 @@ dist
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/sdks
|
||||
!.yarn/versions
|
||||
!.yarn/versions
|
||||
|
||||
.claude
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
node_modules
|
||||
build
|
||||
dist
|
||||
dist2
|
||||
dist-*
|
||||
coverage
|
||||
.nyc_output
|
||||
|
||||
.output
|
||||
.nuxt
|
||||
**/nuxt-modules/**/templates/*.js
|
||||
/lib/common/generated/**/*
|
||||
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
.yarn
|
||||
|
||||
# Profiler output
|
||||
events.json
|
||||
|
||||
# Prettier doesn't understand the syntax inside the Yaml files, because of the brackets
|
||||
utils/helm/speckle-server/templates
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
.venv
|
||||
venv
|
||||
|
||||
.*.{ts,js,vue,tsx,jsx}
|
||||
**/generated/**/*
|
||||
**/generated/graphql.ts
|
||||
|
||||
storybook-static
|
||||
.tshy
|
||||
.tshy-build
|
||||
|
||||
# Helm
|
||||
deployment/helm
|
||||
tests/deployment
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"trailingComma": "none",
|
||||
"tabWidth": 2,
|
||||
"semi": false,
|
||||
"endOfLine": "auto",
|
||||
"bracketSpacing": true,
|
||||
"vueIndentScriptAndStyle": false,
|
||||
"htmlWhitespaceSensitivity": "ignore",
|
||||
"printWidth": 88,
|
||||
"singleQuote": true
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
dist/
|
||||
Vendored
+16
-16
@@ -1,18 +1,18 @@
|
||||
{
|
||||
// See https://go.microsoft.com/fwlink/?LinkId=827846 to learn about workspace recommendations.
|
||||
// Extension identifier format: ${publisher}.${name}. Example: vscode.csharp
|
||||
// See https://go.microsoft.com/fwlink/?LinkId=827846 to learn about workspace recommendations.
|
||||
// Extension identifier format: ${publisher}.${name}. Example: vscode.csharp
|
||||
|
||||
// List of extensions which should be recommended for users of this workspace.
|
||||
"recommendations": [
|
||||
"dbaeumer.vscode-eslint",
|
||||
"esbenp.prettier-vscode",
|
||||
"Vue.volar",
|
||||
"bradlc.vscode-tailwindcss",
|
||||
"stylelint.vscode-stylelint",
|
||||
"cpylua.language-postcss",
|
||||
"graphql.vscode-graphql",
|
||||
"graphql.vscode-graphql-syntax"
|
||||
],
|
||||
// List of extensions recommended by VS Code that should not be recommended for users of this workspace.
|
||||
"unwantedRecommendations": ["octref.vetur"]
|
||||
}
|
||||
// List of extensions which should be recommended for users of this workspace.
|
||||
"recommendations": [
|
||||
"dbaeumer.vscode-eslint",
|
||||
"esbenp.prettier-vscode",
|
||||
"Vue.volar",
|
||||
"bradlc.vscode-tailwindcss",
|
||||
"stylelint.vscode-stylelint",
|
||||
"cpylua.language-postcss",
|
||||
"graphql.vscode-graphql",
|
||||
"graphql.vscode-graphql-syntax"
|
||||
],
|
||||
// List of extensions recommended by VS Code that should not be recommended for users of this workspace.
|
||||
"unwantedRecommendations": ["octref.vetur"]
|
||||
}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
nodeLinker: node-modules
|
||||
nodeLinker: node-modules
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# dui3
|
||||
# Speckle Connectors DUI
|
||||
|
||||
DUIv3 is a Speckle interface embedded inside the desktop connectors that allows users to interact with them - sync streams, manage servers etc. It's built in Vue 3 with Nuxt 3 and only supports client side rendering.
|
||||
DUI v3 is a Speckle interface embedded inside the desktop connectors that allows users to interact with them - sync streams, manage servers etc. It's built in Vue 3 with Nuxt 3 and only supports client side rendering.
|
||||
|
||||
Look at the [Nuxt 3 documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.
|
||||
|
||||
@@ -15,12 +15,12 @@ yarn install
|
||||
|
||||
And create an `.env` file from `.env.example`.
|
||||
|
||||
## Development Server
|
||||
## Development
|
||||
|
||||
Start the development server on `http://localhost:3000`
|
||||
Start the development server on `http://localhost:8082`
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
yarn dev
|
||||
```
|
||||
|
||||
## Production
|
||||
@@ -28,13 +28,7 @@ npm run dev
|
||||
Build the application for production:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
Locally preview production build:
|
||||
|
||||
```bash
|
||||
npm run preview
|
||||
yarn build
|
||||
```
|
||||
|
||||
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information...
|
||||
|
||||
@@ -13,15 +13,20 @@
|
||||
import { useMixpanel } from '~/lib/core/composables/mixpanel'
|
||||
import { useConfigStore } from '~/store/config'
|
||||
import { useAccountStore } from '~/store/accounts'
|
||||
import { useHostAppStore } from '~/store/hostApp'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { logToSeq } from '~/lib/logger/composables/useLogger'
|
||||
|
||||
const uiConfigStore = useConfigStore()
|
||||
const { isDarkTheme } = storeToRefs(uiConfigStore)
|
||||
const hostAppStore = useHostAppStore()
|
||||
const { connectorVersion, hostAppName, hostAppVersion } = storeToRefs(hostAppStore)
|
||||
|
||||
useHead({
|
||||
// Title suffix
|
||||
titleTemplate: (titleChunk) =>
|
||||
titleChunk ? `${titleChunk as string} - Speckle DUIv3` : 'Speckle DUIv3',
|
||||
title: computed(
|
||||
() =>
|
||||
`CNX: (hostApp: ${hostAppName.value}:v${hostAppVersion.value}),(version: ${connectorVersion.value})`
|
||||
),
|
||||
htmlAttrs: {
|
||||
lang: 'en',
|
||||
class: computed(() => (isDarkTheme.value ? `dark` : ``))
|
||||
@@ -50,6 +55,10 @@ onMounted(() => {
|
||||
uniqueEmails.add(email)
|
||||
}
|
||||
})
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { $intercom } = useNuxtApp() // needed her for initialisation
|
||||
|
||||
logToSeq('Information', 'DUI3 initialized')
|
||||
})
|
||||
</script>
|
||||
<style></style>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/* stylelint-disable selector-id-pattern */
|
||||
@import '@speckle/ui-components/style.css';
|
||||
@import url('@speckle/ui-components/style.css');
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import type { CodegenConfig } from '@graphql-codegen/cli'
|
||||
|
||||
const config: CodegenConfig = {
|
||||
schema: 'http://127.0.0.1:3000/graphql',
|
||||
schema: 'https://app.speckle.systems/graphql',
|
||||
documents: ['{lib,components,layouts,pages,middleware}/**/*.{vue,js,ts}'],
|
||||
ignoreNoDocuments: true, // for better experience with the watcher
|
||||
generates: {
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
<template>
|
||||
<div v-if="!hidden" class="flex flex-col space-y-2">
|
||||
<!-- idle: server URL + sign in button -->
|
||||
<template v-if="state === 'idle'">
|
||||
<div class="flex space-x-2">
|
||||
<FormButton
|
||||
v-if="canAddAccount"
|
||||
full-width
|
||||
color="outline"
|
||||
@click="openBrowserAuth()"
|
||||
>
|
||||
Log in with OAuth token
|
||||
</FormButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- waiting: instructions + code input -->
|
||||
<template v-if="state === 'waiting' || state === 'submitting'">
|
||||
<div class="text-foreground-2 space-y-2 border rounded-lg p-2">
|
||||
<div class="text-sm text-center">
|
||||
Check your browser: authorize the app, then copy the exchange code and paste
|
||||
it below.
|
||||
</div>
|
||||
<div class="py-2"><CommonLoadingBar :loading="state === 'waiting'" /></div>
|
||||
<FormTextInput
|
||||
v-model="exchangeCode"
|
||||
name="exchangeCode"
|
||||
:show-label="false"
|
||||
placeholder="Paste exchange code here"
|
||||
color="foundation"
|
||||
autocomplete="off"
|
||||
:disabled="state === 'submitting'"
|
||||
/>
|
||||
<FormButton
|
||||
full-width
|
||||
:disabled="!exchangeCode?.trim() || state === 'submitting'"
|
||||
@click="submitCode()"
|
||||
>
|
||||
{{ state === 'submitting' ? 'Signing in...' : 'Submit' }}
|
||||
</FormButton>
|
||||
|
||||
<div v-if="showHelp" class="p-2 rounded-md space-y-1">
|
||||
<div class="text-sm text-center">Having trouble?</div>
|
||||
<div class="flex justify-center">
|
||||
<span>
|
||||
<FormButton size="sm" text @click="retryFlow()">Retry</FormButton>
|
||||
or
|
||||
<FormButton text size="sm" @click="$openUrl('https://speckle.community')">
|
||||
Get in touch with us
|
||||
</FormButton>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- error -->
|
||||
<template v-if="state === 'error'">
|
||||
<div class="text-foreground-2 space-y-2">
|
||||
<div class="text-sm text-center text-red-500">
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
<FormButton full-width @click="retryFlow()">Try again</FormButton>
|
||||
<FormButton text size="sm" full-width @click="emit('backToSignIn')">
|
||||
Back
|
||||
</FormButton>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useAuthManager } from '~/lib/authn/useAuthManager'
|
||||
import { useTokenExchange, supportsOAuthToken } from '~/lib/authn/useTokenExchange'
|
||||
import { useMixpanel } from '~/lib/core/composables/mixpanel'
|
||||
import { useAccountStore } from '~/store/accounts'
|
||||
import type { BaseBridge } from '~/lib/bridge/base'
|
||||
|
||||
const props = defineProps<{
|
||||
serverUrl: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'backToSignIn'): void
|
||||
}>()
|
||||
|
||||
const app = useNuxtApp()
|
||||
const { generateLocalChallenge } = useAuthManager()
|
||||
const { exchangeAccessCode } = useTokenExchange()
|
||||
const { trackEvent } = useMixpanel()
|
||||
const accountStore = useAccountStore()
|
||||
|
||||
const { $accountBinding } = useNuxtApp()
|
||||
const canAddAccount = ['AddAccount', 'addAccount'].some((name) =>
|
||||
($accountBinding as unknown as BaseBridge).availableMethodNames.includes(name)
|
||||
)
|
||||
|
||||
const state = ref<'idle' | 'waiting' | 'submitting' | 'error'>('idle')
|
||||
const exchangeCode = ref<string | undefined>()
|
||||
const errorMessage = ref('')
|
||||
const showHelp = ref(false)
|
||||
const hidden = ref(false)
|
||||
|
||||
const checkServerSupport = async (url: string) => {
|
||||
const serverUrl = url ? new URL(url).origin : 'https://app.speckle.systems'
|
||||
hidden.value = !(await supportsOAuthToken(serverUrl))
|
||||
}
|
||||
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
onMounted(() => checkServerSupport(props.serverUrl))
|
||||
watch(
|
||||
() => props.serverUrl,
|
||||
(url) => {
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
debounceTimer = setTimeout(() => checkServerSupport(url), 500)
|
||||
}
|
||||
)
|
||||
|
||||
let currentCodeVerifier = ''
|
||||
let currentCodeChallenge = ''
|
||||
let currentServerUrl = ''
|
||||
|
||||
const openBrowserAuth = async () => {
|
||||
currentServerUrl = props.serverUrl
|
||||
? new URL(props.serverUrl).origin
|
||||
: 'https://app.speckle.systems'
|
||||
|
||||
const { codeVerifier, codeChallenge } = await generateLocalChallenge()
|
||||
currentCodeVerifier = codeVerifier
|
||||
currentCodeChallenge = codeChallenge
|
||||
const authUrl = `${currentServerUrl}/authn/verify/sdui/${codeChallenge}?returnExchangeToken=true&code_challenge_method=S256`
|
||||
app.$openUrl(authUrl)
|
||||
|
||||
state.value = 'waiting'
|
||||
exchangeCode.value = undefined
|
||||
showHelp.value = false
|
||||
|
||||
setTimeout(() => {
|
||||
if (state.value === 'waiting') {
|
||||
showHelp.value = true
|
||||
}
|
||||
}, 10_000)
|
||||
}
|
||||
|
||||
const submitCode = async () => {
|
||||
const code = exchangeCode.value?.trim()
|
||||
if (!code || !currentCodeChallenge || !currentServerUrl) return
|
||||
|
||||
state.value = 'submitting'
|
||||
try {
|
||||
await exchangeAccessCode(
|
||||
currentServerUrl,
|
||||
code,
|
||||
currentCodeChallenge,
|
||||
currentCodeVerifier
|
||||
)
|
||||
void trackEvent('DUI Account Added')
|
||||
// Refresh accounts so the watcher in Menu.vue detects the new account and closes the dialog
|
||||
await accountStore.refreshAccounts()
|
||||
} catch (error) {
|
||||
errorMessage.value =
|
||||
error instanceof Error ? error.message : 'Failed to sign in. Please try again.'
|
||||
state.value = 'error'
|
||||
}
|
||||
}
|
||||
|
||||
const retryFlow = () => {
|
||||
state.value = 'idle'
|
||||
exchangeCode.value = undefined
|
||||
errorMessage.value = ''
|
||||
showHelp.value = false
|
||||
}
|
||||
</script>
|
||||
@@ -60,7 +60,7 @@
|
||||
<script setup lang="ts">
|
||||
import type { DUIAccount } from '~~/store/accounts'
|
||||
import { TrashIcon } from '@heroicons/vue/24/outline'
|
||||
import { type BaseBridge } from '~/lib/bridge/base'
|
||||
import type { BaseBridge } from '~/lib/bridge/base'
|
||||
|
||||
const { $accountBinding } = useNuxtApp()
|
||||
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
<template>
|
||||
<div class="flex flex-col space-y-2">
|
||||
<div v-if="isDesktopServiceAvailable">
|
||||
<div v-show="!isAddingAccount" class="text-foreground-2 space-y-2">
|
||||
<div class="flex space-x-2">
|
||||
<FormButton full-width color="outline" @click="startAccountAddFlow()">
|
||||
Log in (Legacy)
|
||||
</FormButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-show="isAddingAccount"
|
||||
class="text-foreground-2 mt-2 mb-4 space-y-2 border rounded-lg p-2"
|
||||
>
|
||||
<div class="text-sm text-center">
|
||||
Please check your browser: waiting for authorization to complete.
|
||||
</div>
|
||||
<div class="py-2"><CommonLoadingBar :loading="isAddingAccount" /></div>
|
||||
<div v-if="showHelp" class="p-2 rounded-md space-y-1">
|
||||
<div class="text-sm text-center">Having trouble?</div>
|
||||
<div class="flex justify-center">
|
||||
<span>
|
||||
<FormButton text size="sm" @click="$openUrl('https://speckle.community')">
|
||||
Get in touch with us
|
||||
</FormButton>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="space-y-3">
|
||||
<div class="text-foreground-2 text-sm">
|
||||
The Speckle Desktop Service is required to add accounts as legacy way. This
|
||||
background service handles authentication securely.
|
||||
</div>
|
||||
<div class="flex space-x-2">
|
||||
<FormButton
|
||||
color="outline"
|
||||
class="px-1"
|
||||
:icon-left="ArrowLeftIcon"
|
||||
hide-text
|
||||
@click="emit('backToSignIn')"
|
||||
/>
|
||||
<FormButton full-width @click="$openUrl('https://releases.speckle.systems')">
|
||||
Download Desktop Service
|
||||
</FormButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useIntervalFn } from '@vueuse/core'
|
||||
import { useHostAppStore } from '~/store/hostApp'
|
||||
import { ToastNotificationType } from '@speckle/ui-components'
|
||||
import { useMixpanel } from '~/lib/core/composables/mixpanel'
|
||||
import { useAccountStore } from '~~/store/accounts'
|
||||
import { useDesktopService } from '~/lib/core/composables/desktopService'
|
||||
import { ArrowLeftIcon } from '@heroicons/vue/24/solid'
|
||||
|
||||
const accountStore = useAccountStore()
|
||||
const { pingDesktopService } = useDesktopService()
|
||||
const hostApp = useHostAppStore()
|
||||
const app = useNuxtApp()
|
||||
const { trackEvent } = useMixpanel()
|
||||
|
||||
const props = defineProps<{
|
||||
serverUrl: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'backToSignIn'): void
|
||||
}>()
|
||||
|
||||
const showCustomServerInput = ref(false)
|
||||
const isAddingAccount = ref(false)
|
||||
const isDesktopServiceAvailable = ref(false) // this should be false default because there is a delay if /ping is not successful.
|
||||
const showHelp = ref(false)
|
||||
|
||||
const accountCheckerIntervalFn = useIntervalFn(
|
||||
async () => {
|
||||
const previousAccountCount = accountStore.accounts.length
|
||||
await accountStore.refreshAccounts()
|
||||
const currentAccountCount = accountStore.accounts.length
|
||||
if (previousAccountCount !== currentAccountCount) {
|
||||
isAddingAccount.value = false
|
||||
showCustomServerInput.value = false
|
||||
accountCheckerIntervalFn.pause()
|
||||
trackEvent('DUI Account Added')
|
||||
}
|
||||
},
|
||||
1000,
|
||||
{ immediate: false }
|
||||
)
|
||||
|
||||
const startAccountAddFlow = () => {
|
||||
isAddingAccount.value = true
|
||||
accountCheckerIntervalFn.resume()
|
||||
setTimeout(() => {
|
||||
showHelp.value = true
|
||||
}, 10_000)
|
||||
const url = props.serverUrl
|
||||
? `http://localhost:29364/auth/add-account?serverUrl=${
|
||||
new URL(props.serverUrl).origin
|
||||
}`
|
||||
: `http://localhost:29364/auth/add-account`
|
||||
|
||||
app.$openUrl(url)
|
||||
|
||||
// this is a annoying timeout that we cannot detect if user added same account or not.
|
||||
setTimeout(() => {
|
||||
if (isAddingAccount.value) {
|
||||
isAddingAccount.value = false
|
||||
showCustomServerInput.value = false
|
||||
accountCheckerIntervalFn.pause()
|
||||
// Note to Dim: not sure about toast
|
||||
hostApp.setNotification({
|
||||
title: 'Sign In',
|
||||
type: ToastNotificationType.Info,
|
||||
description:
|
||||
'Sign in timed out. This may have happened because you tried adding an existing account.'
|
||||
})
|
||||
// TODO: we could log it to sentry/seq later to see how likely it happens?
|
||||
}
|
||||
}, 30_000)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
isDesktopServiceAvailable.value = await pingDesktopService()
|
||||
})
|
||||
</script>
|
||||
@@ -39,17 +39,23 @@
|
||||
title="Add a new account"
|
||||
fullscreen="none"
|
||||
>
|
||||
<div>
|
||||
<div v-if="isDesktopServiceAvailable">
|
||||
<AccountsSignInFlow />
|
||||
</div>
|
||||
<div v-else class="flex flex-wrap justify-center space-x-4 max-width">
|
||||
<FormButton text @click="$openUrl(`speckle://accounts`)">
|
||||
Add account via Manager
|
||||
</FormButton>
|
||||
<FormButton text @click="accountStore.refreshAccounts()">
|
||||
Refresh accounts
|
||||
</FormButton>
|
||||
<div class="flex flex-col space-y-4 p-2">
|
||||
<FormTextInput
|
||||
v-model="customServerUrl"
|
||||
name="Server to sign in"
|
||||
show-label
|
||||
placeholder="https://app.speckle.systems"
|
||||
color="foundation"
|
||||
autocomplete="off"
|
||||
show-clear
|
||||
/>
|
||||
<div class="space-y-2">
|
||||
<AccountsSignInFlow :server-url="customServerUrl" />
|
||||
<AccountsExchangeTokenSignInFlow :server-url="customServerUrl" />
|
||||
<AccountsLegacySignInFlow
|
||||
v-if="!canStartAuthAccount"
|
||||
:server-url="customServerUrl"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CommonDialog>
|
||||
@@ -58,6 +64,7 @@
|
||||
</CommonDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { XMarkIcon } from '@heroicons/vue/20/solid'
|
||||
@@ -65,11 +72,18 @@ import type { DUIAccount } from '~/store/accounts'
|
||||
import { useAccountStore } from '~/store/accounts'
|
||||
import { useMixpanel } from '~/lib/core/composables/mixpanel'
|
||||
import { useDesktopService } from '~/lib/core/composables/desktopService'
|
||||
import type { BaseBridge } from '~/lib/bridge/base'
|
||||
|
||||
const { trackEvent } = useMixpanel()
|
||||
const app = useNuxtApp()
|
||||
const { $openUrl } = useNuxtApp()
|
||||
const { pingDesktopService } = useDesktopService()
|
||||
const { $accountBinding } = useNuxtApp()
|
||||
const canStartAuthAccount = ['AuthenticateAccount', 'authenticateAccount'].some(
|
||||
(name) =>
|
||||
($accountBinding as unknown as BaseBridge).availableMethodNames.includes(name)
|
||||
)
|
||||
|
||||
const customServerUrl = ref<string>('https://app.speckle.systems')
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -86,7 +100,7 @@ defineEmits<{
|
||||
}>()
|
||||
|
||||
const showAddNewAccount = ref(false)
|
||||
// const showAccountsDialog = ref(false)
|
||||
const signInMode = ref<'default' | 'exchange' | 'legacy'>('default')
|
||||
|
||||
const showAccountsDialog = defineModel<boolean>('open', {
|
||||
required: false,
|
||||
@@ -95,7 +109,7 @@ const showAccountsDialog = defineModel<boolean>('open', {
|
||||
|
||||
const isDesktopServiceAvailable = ref(false) // this should be false default because there is a delay if /ping is not successful.
|
||||
|
||||
app.$baseBinding.on('documentChanged', () => {
|
||||
app.$baseBinding?.on('documentChanged', () => {
|
||||
showAccountsDialog.value = false
|
||||
})
|
||||
|
||||
@@ -106,6 +120,13 @@ watch(showAccountsDialog, (newVal) => {
|
||||
}
|
||||
})
|
||||
|
||||
watch(showAddNewAccount, (newVal) => {
|
||||
if (newVal) {
|
||||
// reset the sign-in mode on every add account sub-dialog
|
||||
signInMode.value = 'default'
|
||||
}
|
||||
})
|
||||
|
||||
const accountStore = useAccountStore()
|
||||
const { accounts, activeAccount, userSelectedAccount, isLoading } =
|
||||
storeToRefs(accountStore)
|
||||
@@ -144,7 +165,6 @@ const user = computed(() => {
|
||||
// acc = currentSelectedAccount
|
||||
// }
|
||||
// }
|
||||
|
||||
return {
|
||||
name: activeAccount.value.accountInfo.userInfo.name,
|
||||
avatar: activeAccount.value.accountInfo.userInfo.avatar
|
||||
|
||||
@@ -1,116 +1,53 @@
|
||||
<template>
|
||||
<div>
|
||||
<div v-show="!isAddingAccount" class="text-foreground-2 my-2 space-y-2">
|
||||
<div v-if="showCustomServerInput">
|
||||
<FormTextInput
|
||||
v-model="customServerUrl"
|
||||
name="name"
|
||||
:show-label="false"
|
||||
placeholder="https://app.speckle.systems"
|
||||
color="foundation"
|
||||
autocomplete="off"
|
||||
show-clear
|
||||
@clear="showCustomServerInput = false"
|
||||
/>
|
||||
</div>
|
||||
<FormButton full-width @click="startAccountAddFlow()">Sign In</FormButton>
|
||||
<FormButton
|
||||
text
|
||||
size="sm"
|
||||
full-width
|
||||
@click="showCustomServerInput = !showCustomServerInput"
|
||||
>
|
||||
{{ showCustomServerInput ? 'Use default server' : 'Set custom server url' }}
|
||||
</FormButton>
|
||||
</div>
|
||||
|
||||
<div v-show="isAddingAccount" class="text-foreground-2 mt-2 mb-4 space-y-2">
|
||||
<div class="text-sm text-center">
|
||||
Please check your browser: waiting for authorization to complete.
|
||||
</div>
|
||||
<div class="py-2"><CommonLoadingBar :loading="isAddingAccount" /></div>
|
||||
<div v-if="showHelp" class="bg-blue-500/10 p-2 rounded-md space-y-2">
|
||||
<div class="text-sm text-center">Having trouble?</div>
|
||||
<FormButton size="sm" full-width @click="restartFlow()">Retry</FormButton>
|
||||
<FormButton
|
||||
text
|
||||
size="sm"
|
||||
full-width
|
||||
@click="$openUrl('https://speckle.community')"
|
||||
>
|
||||
Get in touch with us
|
||||
</FormButton>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col space-y-2">
|
||||
<FormButton v-if="canAddAccount" full-width @click="logIn()">Log in</FormButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useIntervalFn } from '@vueuse/core'
|
||||
import { useAccountStore } from '~~/store/accounts'
|
||||
import { useAuthManager } from '~/lib/authn/useAuthManager'
|
||||
import type { BaseBridge } from '~/lib/bridge/base'
|
||||
import { useAccountStore } from '~/store/accounts'
|
||||
import { useHostAppStore } from '~/store/hostApp'
|
||||
import { ToastNotificationType } from '@speckle/ui-components'
|
||||
import { useMixpanel } from '~/lib/core/composables/mixpanel'
|
||||
|
||||
const props = defineProps<{
|
||||
serverUrl: string
|
||||
}>()
|
||||
|
||||
const accountStore = useAccountStore()
|
||||
const hostApp = useHostAppStore()
|
||||
const app = useNuxtApp()
|
||||
const { trackEvent } = useMixpanel()
|
||||
|
||||
const customServerUrl = ref<string | undefined>(undefined)
|
||||
const isAddingAccount = ref(false)
|
||||
const showHelp = ref(false)
|
||||
const showCustomServerInput = ref(false)
|
||||
|
||||
const accountCheckerIntervalFn = useIntervalFn(
|
||||
async () => {
|
||||
const previousAccountCount = accountStore.accounts.length
|
||||
await accountStore.refreshAccounts()
|
||||
const currentAccountCount = accountStore.accounts.length
|
||||
if (previousAccountCount !== currentAccountCount) {
|
||||
isAddingAccount.value = false
|
||||
showCustomServerInput.value = false
|
||||
accountCheckerIntervalFn.pause()
|
||||
trackEvent('DUI Account Added')
|
||||
}
|
||||
},
|
||||
1000,
|
||||
{ immediate: false }
|
||||
const hostAppStore = useHostAppStore()
|
||||
const { $accountBinding } = useNuxtApp()
|
||||
const canAddAccount = ['AddAccount', 'addAccount'].some((name) =>
|
||||
($accountBinding as unknown as BaseBridge).availableMethodNames.includes(name)
|
||||
)
|
||||
const canStartAuthAccount = ['AuthenticateAccount', 'authenticateAccount'].some(
|
||||
(name) =>
|
||||
($accountBinding as unknown as BaseBridge).availableMethodNames.includes(name)
|
||||
)
|
||||
|
||||
const startAccountAddFlow = () => {
|
||||
isAddingAccount.value = true
|
||||
accountCheckerIntervalFn.resume()
|
||||
setTimeout(() => {
|
||||
showHelp.value = true
|
||||
}, 10_000)
|
||||
const url = customServerUrl.value
|
||||
? `http://localhost:29364/auth/add-account?serverUrl=${customServerUrl.value}`
|
||||
: `http://localhost:29364/auth/add-account`
|
||||
const { generateChallenge } = useAuthManager()
|
||||
|
||||
app.$openUrl(url)
|
||||
|
||||
// this is a annoying timeout that we cannot detect if user added same account or not.
|
||||
setTimeout(() => {
|
||||
if (isAddingAccount.value) {
|
||||
isAddingAccount.value = false
|
||||
showCustomServerInput.value = false
|
||||
accountCheckerIntervalFn.pause()
|
||||
// Note to Dim: not sure about toast
|
||||
hostApp.setNotification({
|
||||
title: 'Sign In',
|
||||
const logIn = async () => {
|
||||
const serverUrl = props.serverUrl
|
||||
? new URL(props.serverUrl).origin
|
||||
: 'https://app.speckle.systems'
|
||||
if (canStartAuthAccount) {
|
||||
const acc = await $accountBinding.authenticateAccount(serverUrl)
|
||||
if (acc.token) {
|
||||
await accountStore.refreshAccounts()
|
||||
} else {
|
||||
hostAppStore.setNotification({
|
||||
title: 'Log In',
|
||||
type: ToastNotificationType.Info,
|
||||
description:
|
||||
'Sign in timed out. This may have happened because you tried adding an existing account.'
|
||||
"Log in could not completed. Make sure you have logged in successfully, otherwise try 'Log in with OAuth token'"
|
||||
})
|
||||
// TODO: we could log it to sentry/seq later to see how likely it happens?
|
||||
}
|
||||
}, 30_000)
|
||||
}
|
||||
|
||||
const restartFlow = () => {
|
||||
isAddingAccount.value = false
|
||||
showHelp.value = false
|
||||
} else {
|
||||
const { codeChallenge } = await generateChallenge(serverUrl)
|
||||
const authUrl = `${serverUrl}/authn/verify/sdui/${codeChallenge}?code_challenge_method=S256`
|
||||
window.location.href = authUrl
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
<!-- NOT WILL BE USED SINCE WE ENABLE AUTOMATION CREATION FROM DUI3 -->
|
||||
<template>
|
||||
<div class="p-0">
|
||||
<slot name="activator" :toggle="toggleDialog"></slot>
|
||||
<CommonDialog
|
||||
v-model:open="showAutomateDialog"
|
||||
:title="`Settings`"
|
||||
fullscreen="none"
|
||||
>
|
||||
<div v-if="hasFunctions">
|
||||
<FormSelectBase
|
||||
key="name"
|
||||
v-model="selectedFunction"
|
||||
clearable
|
||||
label="Automate functions"
|
||||
placeholder="Nothing selected"
|
||||
name="Functions"
|
||||
show-label
|
||||
:items="functions"
|
||||
mount-menu-on-body
|
||||
>
|
||||
<template #something-selected="{ value }">
|
||||
<span>{{ value.name }}</span>
|
||||
</template>
|
||||
<template #option="{ item }">
|
||||
<div class="flex items-center">
|
||||
<span class="truncate">{{ item.name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</FormSelectBase>
|
||||
</div>
|
||||
<div v-if="selectedFunction && finalParams && step === 0">
|
||||
<FormJsonForm
|
||||
ref="jsonForm"
|
||||
:data="data"
|
||||
:schema="finalParams"
|
||||
class="space-y-4"
|
||||
:validate-on-mount="false"
|
||||
@change="handler"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="step === 1">
|
||||
<FormTextInput
|
||||
v-model="automationName"
|
||||
name="automationName"
|
||||
label="Automation name"
|
||||
color="foundation"
|
||||
show-label
|
||||
help="Give your automation a name"
|
||||
placeholder="Name"
|
||||
show-required
|
||||
validate-on-value-update
|
||||
/>
|
||||
</div>
|
||||
<FormButton
|
||||
v-if="selectedFunction && step === 0"
|
||||
size="sm"
|
||||
class="mt-4"
|
||||
@click="step++"
|
||||
>
|
||||
Next
|
||||
</FormButton>
|
||||
<FormButton
|
||||
v-if="selectedFunction && step === 1"
|
||||
size="sm"
|
||||
class="mt-4"
|
||||
@click="createAutomationHandler"
|
||||
>
|
||||
Create
|
||||
</FormButton>
|
||||
</CommonDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import type { AutomateFunctionItemFragment } from '~/lib/common/generated/gql/graphql'
|
||||
import {
|
||||
automateFunctionsQuery,
|
||||
createAutomationMutation
|
||||
} from '~/lib/graphql/mutationsAndQueries'
|
||||
import { provideApolloClient, useMutation, useQuery } from '@vue/apollo-composable'
|
||||
import { useAccountStore } from '~/store/accounts'
|
||||
import type { ApolloError } from '@apollo/client/errors'
|
||||
import { formatVersionParams } from '~/lib/common/helpers/jsonSchema'
|
||||
import { useJsonFormsChangeHandler } from '~/lib/core/composables/jsonSchema'
|
||||
|
||||
const props = defineProps<{
|
||||
projectId: string
|
||||
modelId: string
|
||||
}>()
|
||||
|
||||
const step = ref<number>(0)
|
||||
|
||||
const automationName = ref<string>('')
|
||||
|
||||
const accountStore = useAccountStore()
|
||||
const { activeAccount } = storeToRefs(accountStore)
|
||||
const accountId = computed(() => activeAccount.value?.accountInfo.id) // NOTE: none of the tokens here has read, write access to automate, only frontend tokens have. Keep in mind after first pass!
|
||||
|
||||
const selectedFunction = ref<AutomateFunctionItemFragment>()
|
||||
|
||||
const showAutomateDialog = ref(false)
|
||||
|
||||
const toggleDialog = () => {
|
||||
showAutomateDialog.value = !showAutomateDialog.value
|
||||
}
|
||||
|
||||
const { mutate } = provideApolloClient(activeAccount.value.client)(() =>
|
||||
useMutation(createAutomationMutation)
|
||||
)
|
||||
|
||||
const createAutomationHandler = async () => {
|
||||
const _res = await mutate({
|
||||
projectId: props.projectId,
|
||||
input: { name: automationName.value, enabled: false }
|
||||
})
|
||||
showAutomateDialog.value = false
|
||||
}
|
||||
|
||||
const { result: functionsResult, onError } = useQuery(
|
||||
automateFunctionsQuery,
|
||||
() => ({}),
|
||||
() => ({ clientId: accountId.value, debounce: 500, fetchPolicy: 'network-only' })
|
||||
)
|
||||
|
||||
onError((err: ApolloError) => {
|
||||
console.warn(err.message)
|
||||
})
|
||||
|
||||
const functions = computed(() => functionsResult.value?.automateFunctions.items)
|
||||
const hasFunctions = computed(() => functions.value?.length !== 0)
|
||||
|
||||
const release = computed(() =>
|
||||
selectedFunction.value?.releases.items.length
|
||||
? selectedFunction.value?.releases.items[0]
|
||||
: undefined
|
||||
)
|
||||
|
||||
const finalParams = computed(() => formatVersionParams(release.value?.inputSchema))
|
||||
|
||||
const { handler } = useJsonFormsChangeHandler({
|
||||
schema: finalParams
|
||||
})
|
||||
|
||||
console.log(finalParams)
|
||||
|
||||
type DataType = Record<string, unknown>
|
||||
const data = computed(() => {
|
||||
const kvp = {} as DataType
|
||||
if (finalParams.value) {
|
||||
Object.entries(finalParams.value).forEach((k, _) => {
|
||||
kvp[k as unknown as string] = undefined
|
||||
})
|
||||
}
|
||||
return kvp
|
||||
})
|
||||
</script>
|
||||
@@ -9,7 +9,13 @@
|
||||
<Component :is="iconAndColor.icon" :class="`w-4 h-4 ${iconAndColor.color}`" />
|
||||
</div>
|
||||
<div :class="`text-xs ${iconAndColor.color}`">
|
||||
{{ result.category }}: {{ result.objectIds.length }} affected elements
|
||||
{{ result.category }}:
|
||||
{{
|
||||
'objectIds' in props.result
|
||||
? props.result.objectIds.length
|
||||
: props.result.objectAppIds.length
|
||||
}}
|
||||
affected elements
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="result.message" class="text-xs text-foreground-2 pl-5">
|
||||
@@ -19,16 +25,13 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { useQuery } from '@vue/apollo-composable'
|
||||
import {
|
||||
XMarkIcon,
|
||||
InformationCircleIcon,
|
||||
ExclamationTriangleIcon
|
||||
} from '@heroicons/vue/24/outline'
|
||||
import type { Automate } from '@speckle/shared'
|
||||
import { objectQuery } from '~/lib/graphql/mutationsAndQueries'
|
||||
import type { IModelCard } from '~/lib/models/card'
|
||||
import { useAccountStore } from '~/store/accounts'
|
||||
|
||||
type ObjectResult = Automate.AutomateTypes.ResultsSchema['values']['objectResults'][0]
|
||||
|
||||
@@ -37,41 +40,16 @@ const props = defineProps<{
|
||||
result: ObjectResult
|
||||
functionId?: string
|
||||
}>()
|
||||
|
||||
const accStore = useAccountStore()
|
||||
const app = useNuxtApp()
|
||||
const projectAccount = computed(() =>
|
||||
accStore.accountWithFallback(props.modelCard.accountId, props.modelCard.serverUrl)
|
||||
)
|
||||
const clientId = projectAccount.value.accountInfo.id
|
||||
|
||||
const applicationIds = ref<string[]>([])
|
||||
|
||||
type Data = {
|
||||
applicationId?: string
|
||||
}
|
||||
|
||||
// Loop over each objectId to run the query and collect application IDs
|
||||
props.result.objectIds.forEach((objectId) => {
|
||||
const { result: objectResult } = useQuery(
|
||||
objectQuery,
|
||||
() => ({
|
||||
projectId: props.modelCard.projectId,
|
||||
objectId
|
||||
}),
|
||||
() => ({ clientId })
|
||||
)
|
||||
|
||||
watch(objectResult, (newValue) => {
|
||||
const data = newValue?.project.object?.data as Data | undefined
|
||||
const applicationId = data?.applicationId
|
||||
if (applicationId && !applicationIds.value.includes(applicationId)) {
|
||||
applicationIds.value.push(applicationId)
|
||||
}
|
||||
})
|
||||
const applicationIds = computed(() => {
|
||||
// Old schema ignore
|
||||
if ('objectIds' in props.result) return []
|
||||
return Object.values(props.result.objectAppIds).filter((id) => id !== null)
|
||||
})
|
||||
|
||||
const handleClick = async () => {
|
||||
if (applicationIds.value.length === 0) return
|
||||
await app.$baseBinding.highlightObjects(applicationIds.value)
|
||||
}
|
||||
|
||||
|
||||
@@ -329,6 +329,7 @@ onUnmounted(() => {
|
||||
html.dialog-open {
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
html.dialog-open body {
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
@@ -17,14 +17,27 @@
|
||||
</div>
|
||||
<div class="flex items-center group">
|
||||
<FormButton
|
||||
v-if="notification.cta"
|
||||
v-if="notification.secondaryCta"
|
||||
v-tippy="notification.secondaryCta.tooltipText"
|
||||
size="sm"
|
||||
:color="notification.level === 'info' ? 'outline' : notification.level"
|
||||
color="outline"
|
||||
full-width
|
||||
@click.stop="notification.cta?.action"
|
||||
class="mr-1"
|
||||
@click.stop="notification.secondaryCta.action"
|
||||
>
|
||||
{{ notification.cta.name }}
|
||||
{{ notification.secondaryCta.name }}
|
||||
</FormButton>
|
||||
<div v-if="notification.cta" v-tippy="notification.cta.tooltipText">
|
||||
<FormButton
|
||||
:disabled="notification.cta.disabled"
|
||||
size="sm"
|
||||
color="primary"
|
||||
full-width
|
||||
@click.stop="notification.cta?.action"
|
||||
>
|
||||
{{ notification.cta.name }}
|
||||
</FormButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
@@ -59,6 +72,21 @@ if (props.notification.timeout) {
|
||||
useTimeoutFn(() => emit('dismiss'), props.notification.timeout)
|
||||
}
|
||||
|
||||
// const notificationButtonColor = (notificationLevel: ModelCardNotificationLevel) => {
|
||||
// switch (notificationLevel) {
|
||||
// case 'info':
|
||||
// return 'outline'
|
||||
// case 'danger':
|
||||
// return 'danger'
|
||||
// case 'success':
|
||||
// return 'primary'
|
||||
// case 'warning':
|
||||
// return 'danger'
|
||||
// default:
|
||||
// return 'outline'
|
||||
// }
|
||||
// }
|
||||
|
||||
const textClassColor = computed(() => {
|
||||
switch (props.notification.level) {
|
||||
case 'danger':
|
||||
|
||||
@@ -18,18 +18,24 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="opacity-0 group-hover:opacity-100 transition flex">
|
||||
<div
|
||||
:class="
|
||||
isPersonalProject ? '' : 'opacity-0 group-hover:opacity-100 transition flex'
|
||||
"
|
||||
>
|
||||
<button
|
||||
v-tippy="'Open project in browser'"
|
||||
class="hover:text-primary flex items-center space-x-2 p-2"
|
||||
v-tippy="projectNavigatorTippy"
|
||||
class="hover:text-primary flex items-center space-x-2 p-2 relative animate-pulse"
|
||||
>
|
||||
<ArrowTopRightOnSquareIcon
|
||||
class="w-4"
|
||||
@click.stop="
|
||||
$openUrl(projectUrl),
|
||||
trackEvent('DUI3 Action', { name: 'Project View' }, project.accountId)
|
||||
"
|
||||
/>
|
||||
<div class="relative w-4 h-4">
|
||||
<ArrowTopRightOnSquareIcon
|
||||
class="w-4 h-4"
|
||||
@click.stop="
|
||||
$openUrl(projectUrl),
|
||||
trackEvent('DUI3 Action', { name: 'Project View' }, project.accountId)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</button>
|
||||
@@ -68,7 +74,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="projectIsAccesible && !projectIsAccesible"
|
||||
v-if="projectIsAccesible === false"
|
||||
class="px-2 py-4 bg-foundation dark:bg-neutral-700/10 rounded-md shadow"
|
||||
>
|
||||
<CommonAlert
|
||||
@@ -77,22 +83,35 @@
|
||||
@dismiss="askDismissProjectQuestionDialog = true"
|
||||
>
|
||||
<template #title>
|
||||
Whoops - project
|
||||
<code>{{ project.projectId }}</code>
|
||||
is inaccessible.
|
||||
<span v-if="inaccessibleReason === 'no-account'">
|
||||
<!-- no local account matches this project's server — query was never attempted -->
|
||||
No account found for
|
||||
<code>{{ project.serverUrl }}</code>
|
||||
</span>
|
||||
<span v-else>
|
||||
<!-- project query threw — server will throw for any reason the account can't see
|
||||
the project (deleted, private, auth failure, stale token). model card level
|
||||
permission errors e.g. role changes surface separately on the card itself -->
|
||||
Project
|
||||
<code>{{ project.projectId }}</code>
|
||||
is not accessible on
|
||||
<code>{{ project.serverUrl }}</code>
|
||||
</span>
|
||||
</template>
|
||||
</CommonAlert>
|
||||
<CommonDialog v-model:open="askDismissProjectQuestionDialog" fullscreen="none">
|
||||
<template #header>Remove Project</template>
|
||||
<div class="text-xs mb-4">Do you want to remove the project from this file?</div>
|
||||
<div class="flex justify-between center py-2 space-x-3">
|
||||
<FormButton size="sm" full-width @click="removeProjectModels">Yes</FormButton>
|
||||
<FormButton size="sm" full-width @click="removeProjectModels">
|
||||
Remove
|
||||
</FormButton>
|
||||
<FormButton
|
||||
size="sm"
|
||||
full-width
|
||||
@click="askDismissProjectQuestionDialog = false"
|
||||
>
|
||||
Hide error
|
||||
Cancel
|
||||
</FormButton>
|
||||
</div>
|
||||
</CommonDialog>
|
||||
@@ -125,19 +144,64 @@ const showModels = ref(true)
|
||||
const askDismissProjectQuestionDialog = ref(false)
|
||||
const writeAccessRequested = ref(false)
|
||||
const projectIsAccesible = ref<boolean | undefined>(undefined)
|
||||
const inaccessibleReason = ref<'no-account' | 'error' | undefined>(undefined)
|
||||
|
||||
const projectAccount = computed(() =>
|
||||
accountStore.accountWithFallback(props.project.accountId, props.project.serverUrl)
|
||||
)
|
||||
|
||||
const clientId = projectAccount.value.accountInfo.id
|
||||
const isPersonalProject = computed(() => !projectDetails.value?.workspace)
|
||||
const projectNavigatorTippy = computed(() =>
|
||||
isPersonalProject.value
|
||||
? 'Move personal project into a workspace'
|
||||
: 'Open project in browser'
|
||||
)
|
||||
|
||||
const { result: projectDetailsResult, refetch: refetchProjectDetails } = useQuery(
|
||||
const normalizeUrl = (url: string) => url.replace(/\/$/, '').toLowerCase()
|
||||
|
||||
// match by account ID first, then fall back to server URL
|
||||
// normalized to avoid trailing-slash / casing mismatches (can that even be a thing??)
|
||||
const accountExists = computed(() => {
|
||||
const byId = accountStore.isAccountExistsById(props.project.accountId)
|
||||
const byServer = accountStore.accounts.some(
|
||||
(acc) =>
|
||||
normalizeUrl(acc.accountInfo.serverInfo.url) ===
|
||||
normalizeUrl(props.project.serverUrl)
|
||||
)
|
||||
return byId || byServer
|
||||
})
|
||||
|
||||
// reactive so it re-derives if accounts change after mount (e.g. user adds the missing account)
|
||||
const clientId = computed(() => projectAccount.value.accountInfo.id)
|
||||
|
||||
const {
|
||||
result: projectDetailsResult,
|
||||
refetch: refetchProjectDetails,
|
||||
onError: onProjectDetailsError
|
||||
} = useQuery(
|
||||
projectDetailsQuery,
|
||||
() => ({ projectId: props.project.projectId }),
|
||||
() => ({ clientId, debounce: 500, fetchPolicy: 'network-only' })
|
||||
() => ({
|
||||
clientId: clientId.value,
|
||||
debounce: 500,
|
||||
fetchPolicy: 'network-only',
|
||||
enabled: accountExists.value
|
||||
})
|
||||
)
|
||||
|
||||
// re-run the query when the resolved account changes (account added) or
|
||||
// when accountExists flips back to true (account re-added after removal)
|
||||
watch([clientId, accountExists], ([, exists], [, prevExists]) => {
|
||||
if (exists) {
|
||||
if (!prevExists) {
|
||||
// accountExists just recovered — reset accessible state so we don't
|
||||
// show stale inaccessible UI while the query is in flight
|
||||
projectIsAccesible.value = undefined
|
||||
}
|
||||
void refetchProjectDetails()
|
||||
}
|
||||
})
|
||||
|
||||
const removeProjectModels = async () => {
|
||||
await hostAppStore.removeProjectModels(props.project.projectId)
|
||||
askDismissProjectQuestionDialog.value = false
|
||||
@@ -145,8 +209,54 @@ const removeProjectModels = async () => {
|
||||
|
||||
const projectDetails = computed(() => projectDetailsResult.value?.project)
|
||||
|
||||
watch(projectDetails, (newValue) => {
|
||||
projectIsAccesible.value = newValue !== undefined
|
||||
watch(
|
||||
[projectDetails, accountExists],
|
||||
([details, exists]) => {
|
||||
if (!exists) {
|
||||
// account not present on this machine at all
|
||||
console.warn('[ProjectModelGroup] inaccessible: no matching account', {
|
||||
projectId: props.project.projectId,
|
||||
storedAccountId: props.project.accountId,
|
||||
storedServerUrl: props.project.serverUrl,
|
||||
localAccounts: accountStore.accounts.map((a) => ({
|
||||
id: a.accountInfo.id,
|
||||
serverUrl: a.accountInfo.serverInfo.url
|
||||
}))
|
||||
})
|
||||
inaccessibleReason.value = 'no-account'
|
||||
projectIsAccesible.value = false
|
||||
} else if (details !== undefined) {
|
||||
// query returned real data — project is accessible
|
||||
inaccessibleReason.value = undefined
|
||||
projectIsAccesible.value = true
|
||||
}
|
||||
// undefined means the query is still loading; don't update state yet
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
onProjectDetailsError((error) => {
|
||||
// the project query throws for any reason the account can't see the project —
|
||||
// deleted, private, auth failure, network error. all cases show the same message.
|
||||
console.warn('[ProjectModelGroup] inaccessible: project query errored', {
|
||||
projectId: props.project.projectId,
|
||||
accountId: props.project.accountId,
|
||||
error: error.message
|
||||
})
|
||||
inaccessibleReason.value = 'error'
|
||||
projectIsAccesible.value = false
|
||||
})
|
||||
|
||||
// when the account's validity changes (e.g. SSO session deleted externally), refetch
|
||||
// so the query hits the server and picks up the new auth state
|
||||
const accountIsValid = computed(
|
||||
() => accountStore.accounts.find((a) => a.accountInfo.id === clientId.value)?.isValid
|
||||
)
|
||||
|
||||
watch(accountIsValid, (isValid, wasValid) => {
|
||||
if (wasValid !== undefined && isValid !== wasValid) {
|
||||
void refetchProjectDetails()
|
||||
}
|
||||
})
|
||||
|
||||
const canLoad = computed(() => !!projectDetails.value?.permissions.canLoad.authorized)
|
||||
@@ -181,13 +291,13 @@ const isWorkspaceReadOnly = computed(() => {
|
||||
const { onResult: userProjectsUpdated } = useSubscription(
|
||||
userProjectsUpdatedSubscription,
|
||||
() => ({}),
|
||||
() => ({ clientId })
|
||||
() => ({ clientId: clientId.value, enabled: accountExists.value })
|
||||
)
|
||||
|
||||
const { onResult: projectUpdated } = useSubscription(
|
||||
projectUpdatedSubscription,
|
||||
() => ({ projectId: props.project.projectId }),
|
||||
() => ({ clientId })
|
||||
() => ({ clientId: clientId.value, enabled: accountExists.value })
|
||||
)
|
||||
|
||||
// to catch changes on visibility of project
|
||||
@@ -206,14 +316,14 @@ userProjectsUpdated((res) => {
|
||||
})
|
||||
|
||||
const projectUrl = computed(() => {
|
||||
const acc = accountStore.accounts.find((acc) => acc.accountInfo.id === clientId)
|
||||
const acc = accountStore.accounts.find((acc) => acc.accountInfo.id === clientId.value)
|
||||
return `${acc?.accountInfo.serverInfo.url as string}/projects/${
|
||||
props.project.projectId
|
||||
}`
|
||||
})
|
||||
|
||||
const workspaceUrl = computed(() => {
|
||||
const acc = accountStore.accounts.find((acc) => acc.accountInfo.id === clientId)
|
||||
const acc = accountStore.accounts.find((acc) => acc.accountInfo.id === clientId.value)
|
||||
return `${acc?.accountInfo.serverInfo.url as string}/workspaces/${
|
||||
projectDetails.value?.workspace?.slug
|
||||
}`
|
||||
@@ -223,7 +333,7 @@ const workspaceUrl = computed(() => {
|
||||
const { onResult } = useSubscription(
|
||||
versionCreatedSubscription,
|
||||
() => ({ projectId: props.project.projectId }),
|
||||
() => ({ clientId })
|
||||
() => ({ clientId: clientId.value, enabled: accountExists.value })
|
||||
)
|
||||
|
||||
onResult((res) => {
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
<template>
|
||||
<CommonAlert
|
||||
v-if="!store.isConnectorUpToDate && !hasDismissedAlert"
|
||||
v-if="
|
||||
store.isDistributedBySpeckle &&
|
||||
store.latestAvailableVersion &&
|
||||
!store.isConnectorUpToDate &&
|
||||
!hasDismissedAlert &&
|
||||
!store.isUpdateNotificationDisabled
|
||||
"
|
||||
v-tippy="
|
||||
'Version: ' + store.latestAvailableVersion?.Number + ', released ' + createdAgo
|
||||
"
|
||||
color="neutral"
|
||||
size="xs"
|
||||
hide-icon
|
||||
class="mb-2 mt-1"
|
||||
class="mt-1"
|
||||
>
|
||||
<template #description>
|
||||
<div class="flex items-center">
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<template>
|
||||
<!-- ONLY FOR TEST FOR NOW-->
|
||||
<form class="flex flex-col space-y-4 form-json-form">
|
||||
<span>Settings</span>
|
||||
<FormJsonForm :schema="jsonSchema" @change="onParamsFormChange"></FormJsonForm>
|
||||
</form>
|
||||
</template>
|
||||
@@ -21,6 +20,11 @@ const jsonSchema = {
|
||||
type: 'string',
|
||||
title: 'Favorite Color',
|
||||
enum: ['red', 'green', 'blue']
|
||||
},
|
||||
multiSelect: {
|
||||
type: 'array',
|
||||
title: 'Multi Favorite Chars',
|
||||
enum: ['a', 'b', 'c', 'd']
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,6 +32,6 @@ const jsonSchema = {
|
||||
const paramsFormState = ref<JsonFormsChangeEvent>()
|
||||
const onParamsFormChange = (e: JsonFormsChangeEvent) => {
|
||||
paramsFormState.value = e
|
||||
console.log(JSON.stringify(e))
|
||||
console.log(e)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -66,6 +66,13 @@
|
||||
@update:filter="(filter : ISendFilter) => (selectedFilter = filter)"
|
||||
/>
|
||||
</div>
|
||||
<!-- I dont like the way we use revit categories filter for archicad layers, this component need to be generalized if we have one more -->
|
||||
<div v-else-if="selectedFilter.id === 'archicadLayers'">
|
||||
<FilterRevitCategories
|
||||
:filter="(selectedFilter as RevitCategoriesSendFilter)"
|
||||
@update:filter="(filter : ISendFilter) => (selectedFilter = filter)"
|
||||
/>
|
||||
</div>
|
||||
<!-- Below should have been implemented as sendFilterSelect as above, we can delete it later -->
|
||||
<div v-else-if="selectedFilter.id === 'navisworksSavedSets'">
|
||||
<FilterFormSelect
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import type { IDirectSelectionSendFilter, ISendFilter } from 'lib/models/card/send'
|
||||
import type { IDirectSelectionSendFilter, ISendFilter } from '~/lib/models/card/send'
|
||||
import { useHostAppStore } from '~~/store/hostApp'
|
||||
import { useSelectionStore } from '~~/store/selection'
|
||||
|
||||
|
||||
@@ -12,8 +12,11 @@
|
||||
full-width
|
||||
color="foundation"
|
||||
/>
|
||||
<FormButton color="outline" size="sm" @click="selectAllCategories">
|
||||
{{ allSelected ? 'Deselect all' : 'Select all' }}
|
||||
</FormButton>
|
||||
</div>
|
||||
<div class="flex space-y-1 flex-col">
|
||||
<div class="flex space-y-1 flex-col max-h-48 simple-scrollbar overflow-auto">
|
||||
<div
|
||||
v-for="cat in selectedCategoriesObjects.sort((a, b) =>
|
||||
a.name.localeCompare(b.name)
|
||||
@@ -93,6 +96,25 @@ const searchResults = computed(() => {
|
||||
|
||||
const selectedCategories = ref<string[]>(props.filter.selectedCategories || [])
|
||||
|
||||
const selectAllCategories = () => {
|
||||
if (allSelected.value) {
|
||||
selectedCategories.value = []
|
||||
return
|
||||
}
|
||||
availableCategories.value.forEach((cat) => {
|
||||
const index = selectedCategories.value.indexOf(cat.id)
|
||||
if (index !== -1) {
|
||||
return
|
||||
} else {
|
||||
selectedCategories.value.push(cat.id)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const allSelected = computed(() => {
|
||||
return availableCategories.value.length === selectedCategories.value.length
|
||||
})
|
||||
|
||||
const selectOrUnselectCategory = (id: string) => {
|
||||
const index = selectedCategories.value.indexOf(id)
|
||||
if (index !== -1) {
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="text-foreground-2 text-body-2xs mb-1 pl-1">
|
||||
{{ control.label }}
|
||||
</div>
|
||||
<!-- button next to component (like revit send categories) -->
|
||||
<!-- min width to keep components "in-sync" at narrow sizes -->
|
||||
<!-- size "sm" matches height of select all toggle -->
|
||||
<div class="flex items-center space-x-2 min-w-72">
|
||||
<FormSelectMulti
|
||||
:model-value="modelValue"
|
||||
:name="fieldName"
|
||||
:rules="multiValidator"
|
||||
:label="control.label"
|
||||
:items="control.options"
|
||||
class="flex-1 min-w-0"
|
||||
clearable
|
||||
:search="true"
|
||||
:search-placeholder="'Search'"
|
||||
:filter-predicate="searchFilterPredicate"
|
||||
:help="control.description"
|
||||
:allow-unset="false"
|
||||
by="value"
|
||||
button-style="tinted"
|
||||
:validate-on-value-update="validateOnValueUpdate"
|
||||
mount-menu-on-body
|
||||
fixed-height
|
||||
@update:model-value="handleChange"
|
||||
>
|
||||
<template #nothing-selected>
|
||||
{{
|
||||
appliedOptions['placeholder']
|
||||
? appliedOptions['placeholder']
|
||||
: 'Select values'
|
||||
}}
|
||||
</template>
|
||||
<template #something-selected="{ value }">
|
||||
<div ref="elementToWatchForChanges" class="flex items-center space-x-0.5">
|
||||
<div ref="itemContainer" class="flex flex-wrap overflow-hidden space-x-0.5">
|
||||
<div v-for="(item, i) in value" :key="item.value" class="text-foreground">
|
||||
{{ item.label + (i < value.length - 1 ? ', ' : '') }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="hiddenSelectedItemCount > 0" class="text-foreground-2 normal">
|
||||
+{{ hiddenSelectedItemCount }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #option="{ item }">
|
||||
<div class="flex items-center text-foreground-2 text-body-2xs">
|
||||
<span class="truncate">{{ item.label }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</FormSelectMulti>
|
||||
|
||||
<!-- Select All / Deselect All button - positioned next to dropdown like Revit -->
|
||||
<FormButton color="outline" class="min-w-28" size="base" @click="toggleSelectAll">
|
||||
{{ allSelected ? 'Deselect all' : 'Select all' }}
|
||||
</FormButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import type { ControlElement } from '@jsonforms/core'
|
||||
import { rendererProps, useJsonFormsEnumControl } from '@jsonforms/vue'
|
||||
import type { Nullable } from '@speckle/shared'
|
||||
import { useFormSelectChildInternals } from '@speckle/ui-components'
|
||||
import type { GenericValidateFunction } from 'vee-validate'
|
||||
import { useJsonRendererBaseSetup } from '~/lib/form/composables/jsonRenderers'
|
||||
|
||||
type OptionType = { value: string; label: string }
|
||||
type ValueType = OptionType | OptionType[] | undefined
|
||||
|
||||
const emit = defineEmits<(e: 'update:modelValue', v: ValueType) => void>()
|
||||
|
||||
const props = defineProps({
|
||||
...rendererProps<ControlElement>(),
|
||||
// TODO: Doesn't appear that jsonforms properly supports multiple selection
|
||||
multiple: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
controlOverrides: {
|
||||
type: Object as PropType<Nullable<ReturnType<typeof useJsonFormsEnumControl>>>,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
|
||||
const searchFilterPredicate = (item: OptionType, search: string) =>
|
||||
item.label.toLocaleLowerCase().includes(search.toLocaleLowerCase())
|
||||
|
||||
const elementToWatchForChanges = ref(null as Nullable<HTMLElement>)
|
||||
const itemContainer = ref(null as Nullable<HTMLElement>)
|
||||
|
||||
const { hiddenSelectedItemCount, isArrayValue } =
|
||||
useFormSelectChildInternals<OptionType>({
|
||||
props: toRefs(props),
|
||||
emit,
|
||||
dynamicVisibility: { elementToWatchForChanges, itemContainer }
|
||||
})
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
const multiValidator: GenericValidateFunction<any> = () => true // ignoring validation for multi enum since it is custom and jsonforms does not support it properly
|
||||
|
||||
const { handleChange, control, appliedOptions, fieldName, validateOnValueUpdate } =
|
||||
useJsonRendererBaseSetup(props.controlOverrides || useJsonFormsEnumControl(props), {
|
||||
onChangeValueConverter: (newVal: ValueType) => {
|
||||
if (props.multiple && isArrayValue(newVal)) {
|
||||
return newVal.map((v) => v.value)
|
||||
} else if (newVal && !props.multiple && !isArrayValue(newVal)) {
|
||||
return newVal.value
|
||||
} else {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const modelValue = computed(() => {
|
||||
const val = control.value.data as OptionType[]
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
return control.value.options.filter((o) => val?.includes(o.value))
|
||||
})
|
||||
|
||||
/**
|
||||
* Computed property to check if all available options are selected.
|
||||
*/
|
||||
const allSelected = computed(() => {
|
||||
const currentSelection = modelValue.value || []
|
||||
const allOptions = control.value.options || []
|
||||
return currentSelection.length === allOptions.length && allOptions.length > 0
|
||||
})
|
||||
|
||||
/**
|
||||
* Toggle between selecting all categories and clearing all selections.
|
||||
*/
|
||||
const toggleSelectAll = () => {
|
||||
if (allSelected.value) {
|
||||
// deselect all -> pass empty array
|
||||
handleChange([])
|
||||
} else {
|
||||
// select all available options
|
||||
const allOptions = control.value.options || []
|
||||
handleChange(allOptions)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -7,8 +7,8 @@
|
||||
:label="control.label"
|
||||
:placeholder="appliedOptions['placeholder']"
|
||||
:help="control.description"
|
||||
color="foundation"
|
||||
show-label
|
||||
size="lg"
|
||||
:validate-on-value-update="validateOnValueUpdate"
|
||||
@update:model-value="handleChange"
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M6.73336 1.45469C7.57004 1.29277 8.43001 1.29277 9.26669 1.45469M9.26669 14.5454C8.43001 14.7073 7.57004 14.7073 6.73336 14.5454M11.7394 2.48069C12.447 2.96017 13.0558 3.57127 13.5327 4.28069M1.45469 9.26669C1.29277 8.43001 1.29277 7.57004 1.45469 6.73336M13.5194 11.7394C13.0399 12.447 12.4288 13.0558 11.7194 13.5327M14.5454 6.73336C14.7073 7.57004 14.7073 8.43001 14.5454 9.26669M2.48069 4.26069C2.96017 3.55304 3.57127 2.94421 4.28069 2.46736M4.26069 13.5194C3.55304 13.0399 2.94421 12.4288 2.46736 11.7194"
|
||||
stroke="#707070"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,18 @@
|
||||
<template>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M8 1.33301C11.6819 1.33301 14.667 4.3181 14.667 8C14.6669 11.6819 11.6819 14.667 8 14.667C4.3182 14.6669 1.33305 11.6818 1.33301 8C1.33301 4.31816 4.31818 1.3331 8 1.33301ZM10.5303 6.13672C10.2374 5.84383 9.76262 5.84383 9.46973 6.13672L7.33301 8.27246L6.53027 7.46973C6.23742 7.17705 5.76258 7.17705 5.46973 7.46973C5.17713 7.76259 5.17708 8.23745 5.46973 8.53027L6.80273 9.86426C6.94329 10.0047 7.13433 10.0839 7.33301 10.084C7.53165 10.084 7.72268 10.0046 7.86328 9.86426L10.5303 7.19727C10.8231 6.90445 10.8229 6.42963 10.5303 6.13672Z"
|
||||
fill="#15803D"
|
||||
/>
|
||||
<path
|
||||
d="M8 1.33301C11.6819 1.33301 14.667 4.3181 14.667 8C14.6669 11.6819 11.6819 14.667 8 14.667C4.3182 14.6669 1.33305 11.6818 1.33301 8C1.33301 4.31816 4.31818 1.3331 8 1.33301ZM10.5303 6.13672C10.2374 5.84383 9.76262 5.84383 9.46973 6.13672L7.33301 8.27246L6.53027 7.46973C6.23742 7.17705 5.76258 7.17705 5.46973 7.46973C5.17713 7.76259 5.17708 8.23745 5.46973 8.53027L6.80273 9.86426C6.94329 10.0047 7.13433 10.0839 7.33301 10.084C7.53165 10.084 7.72268 10.0046 7.86328 9.86426L10.5303 7.19727C10.8231 6.90445 10.8229 6.42963 10.5303 6.13672Z"
|
||||
fill="#16A34A"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,35 @@
|
||||
<template>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M8.00024 2.08337C11.2678 2.08355 13.9163 4.73279 13.9163 8.00037C13.9161 11.2678 11.2677 13.9162 8.00024 13.9164C4.73267 13.9164 2.08343 11.2679 2.08325 8.00037C2.08325 4.73268 4.73256 2.08337 8.00024 2.08337Z"
|
||||
stroke="#EAB308"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M8.75 4.83789C10.1832 5.17655 11.25 6.46328 11.25 8C11.25 9.53664 10.1831 10.8224 8.75 11.1611V4.83789Z"
|
||||
fill="#EAB308"
|
||||
/>
|
||||
<path
|
||||
d="M8.75 4.83789C10.1832 5.17655 11.25 6.46328 11.25 8C11.25 9.53664 10.1831 10.8224 8.75 11.1611V4.83789Z"
|
||||
stroke="#7C7C7D"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M8.75 4.83789C10.1832 5.17655 11.25 6.46328 11.25 8C11.25 9.53664 10.1831 10.8224 8.75 11.1611V4.83789Z"
|
||||
stroke="#EAB308"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -25,6 +25,7 @@
|
||||
</div>
|
||||
<div class="relative group flex items-center">
|
||||
<FormButton
|
||||
v-if="app.$receiveBinding"
|
||||
v-tippy="'Load a model from Speckle into this file'"
|
||||
color="outline"
|
||||
size="sm"
|
||||
@@ -48,14 +49,32 @@
|
||||
>
|
||||
<span class="">Update</span>
|
||||
</FormButton> -->
|
||||
<div class="text-[8px] text-foreground-disabled max-[150px]:hidden">
|
||||
|
||||
<div
|
||||
class="text-[8px] text-foreground-disabled max-[150px]:hidden"
|
||||
:class="{ 'mr-2': !hostAppStore.isDistributedBySpeckle }"
|
||||
>
|
||||
{{ hostAppStore.connectorVersion }}
|
||||
</div>
|
||||
<div
|
||||
v-if="!hostAppStore.isDistributedBySpeckle && hostAppStore.hostAppName"
|
||||
v-tippy="
|
||||
`${hostAppStore.hostAppName
|
||||
.charAt(0)
|
||||
.toUpperCase()}${hostAppStore.hostAppName.slice(
|
||||
1
|
||||
)} connector is not distributed by Speckle.`
|
||||
"
|
||||
class="text-xs text-foreground-disabled max-[150px]:hidden mr-1"
|
||||
>
|
||||
<CommonBadge color="secondary">Partner</CommonBadge>
|
||||
</div>
|
||||
<HeaderButton
|
||||
v-if="hostAppStore.isDistributedBySpeckle"
|
||||
v-tippy="'Documentation and help'"
|
||||
@click="
|
||||
app.$openUrl(
|
||||
`https://www.speckle.systems/connectors/${hostAppStore.hostAppName}?utm=dui`
|
||||
`https://docs.speckle.systems/connectors/${hostAppStore.hostAppName}?utm=dui`
|
||||
)
|
||||
"
|
||||
>
|
||||
@@ -63,7 +82,11 @@
|
||||
class="w-4 text-foreground-disabled group-hover:text-foreground-2"
|
||||
/>
|
||||
</HeaderButton>
|
||||
<HeaderButton v-tippy="'Send us feedback'" @click="showFeedbackDialog = true">
|
||||
<HeaderButton
|
||||
v-if="hostAppStore.isDistributedBySpeckle"
|
||||
v-tippy="'Send us feedback'"
|
||||
@click="openFeedbackDialog()"
|
||||
>
|
||||
<ChatBubbleLeftIcon
|
||||
class="w-4 text-foreground-disabled group-hover:text-foreground-2"
|
||||
/>
|
||||
@@ -95,8 +118,22 @@ const showFeedbackDialog = ref<boolean>(false)
|
||||
const showSendDialog = ref<boolean>(false)
|
||||
const showReceiveDialog = ref<boolean>(false)
|
||||
|
||||
app.$baseBinding.on('documentChanged', () => {
|
||||
app.$baseBinding?.on('documentChanged', () => {
|
||||
showSendDialog.value = false
|
||||
showReceiveDialog.value = false
|
||||
})
|
||||
|
||||
const { $intercom } = useNuxtApp()
|
||||
|
||||
const openFeedbackDialog = () => {
|
||||
if (
|
||||
(hostAppStore.hostAppName?.toLowerCase() === 'revit' &&
|
||||
hostAppStore.hostAppVersion?.includes('2022')) ||
|
||||
!hostAppStore.isDistributedBySpeckle
|
||||
) {
|
||||
showFeedbackDialog.value = true
|
||||
} else {
|
||||
$intercom.show()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -36,7 +36,36 @@
|
||||
{{ isDarkTheme ? 'Light theme' : 'Dark theme' }}
|
||||
</div>
|
||||
</MenuItem>
|
||||
|
||||
<MenuItem
|
||||
v-if="isDisableCacheSupported"
|
||||
v-slot="{ active }"
|
||||
@click="toggleCache"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
active ? 'bg-highlight-1' : '',
|
||||
'my-1 text-body-2xs flex justify-between px-2 py-1 text-foreground cursor-pointer transition mx-1 rounded'
|
||||
]"
|
||||
>
|
||||
<span>Disable Cache</span>
|
||||
<span v-if="isCacheDisabled" class="text-primary font-bold ml-2">✓</span>
|
||||
</div>
|
||||
</MenuItem>
|
||||
|
||||
<div class="border-t border-outline-3 mt-1">
|
||||
<MenuItem v-if="app.$revitMapperBinding" v-slot="{ active }">
|
||||
<button
|
||||
type="button"
|
||||
:class="[
|
||||
active ? 'bg-highlight-1' : '',
|
||||
'my-1 text-body-2xs flex px-2 py-1 text-foreground cursor-pointer transition mx-1 rounded'
|
||||
]"
|
||||
@click="$router.push('/revit-mapper')"
|
||||
>
|
||||
Assign Revit Categories
|
||||
</button>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
v-slot="{ active }"
|
||||
@click="
|
||||
@@ -108,10 +137,41 @@ import { storeToRefs } from 'pinia'
|
||||
import { XMarkIcon, Bars3Icon } from '@heroicons/vue/20/solid'
|
||||
import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/vue'
|
||||
import { useConfigStore } from '~/store/config'
|
||||
import { useHostAppStore } from '~/store/hostApp'
|
||||
|
||||
const app = useNuxtApp()
|
||||
|
||||
const uiConfigStore = useConfigStore()
|
||||
const { isDarkTheme, hasConfigBindings, isDevMode } = storeToRefs(uiConfigStore)
|
||||
const { toggleTheme } = uiConfigStore
|
||||
const { isDarkTheme, hasConfigBindings, isDevMode, isCacheDisabled } =
|
||||
storeToRefs(uiConfigStore)
|
||||
const { toggleTheme, toggleCache } = uiConfigStore
|
||||
|
||||
const hostAppStore = useHostAppStore()
|
||||
const { hostAppName, connectorVersion } = storeToRefs(hostAppStore)
|
||||
|
||||
const isDisableCacheSupported = computed(() => {
|
||||
const appName = hostAppName.value
|
||||
const version = connectorVersion.value
|
||||
|
||||
if (!appName || !version) return false
|
||||
|
||||
// excludes non-sharp connectors (assuming they don't have backend cache bypass)
|
||||
const nonSharpApps = ['sketchup', 'archicad', 'vectorworks']
|
||||
if (nonSharpApps.includes(appName.toLowerCase())) return false
|
||||
|
||||
// always show in dev environments
|
||||
if (version.includes('dev') || version.includes('local') || version.includes('1.0.0'))
|
||||
return true
|
||||
|
||||
// for sharp connectors, check if version is >= 3.18.0
|
||||
const targetVersion = '3.19.0'
|
||||
return (
|
||||
version.localeCompare(targetVersion, undefined, {
|
||||
numeric: true,
|
||||
sensitivity: 'base'
|
||||
}) >= 0
|
||||
)
|
||||
})
|
||||
|
||||
const { $showDevTools, $openUrl } = useNuxtApp()
|
||||
const showAccountsDialog = ref(false)
|
||||
|
||||
@@ -5,21 +5,35 @@
|
||||
>
|
||||
Welcome to Speckle
|
||||
</h1>
|
||||
<div v-if="isDesktopServiceAvailable">
|
||||
<AccountsSignInFlow />
|
||||
<div v-if="isDesktopServiceAvailable || canAddAccount">
|
||||
<div class="flex flex-col space-y-4 p-2">
|
||||
<FormTextInput
|
||||
v-model="customServerUrl"
|
||||
name="Server to sign in"
|
||||
:show-label="false"
|
||||
placeholder="https://app.speckle.systems"
|
||||
color="foundation"
|
||||
autocomplete="off"
|
||||
show-clear
|
||||
/>
|
||||
<div class="space-y-2">
|
||||
<AccountsSignInFlow :server-url="customServerUrl" />
|
||||
<AccountsExchangeTokenSignInFlow :server-url="customServerUrl" />
|
||||
<AccountsLegacySignInFlow :server-url="customServerUrl" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="text-foreground-2 mt-2 mb-4">
|
||||
Click the button below to sign into Speckle via Manager. This will allow you to
|
||||
publish or load data.
|
||||
To sign in and start using Speckle, you'll need the Desktop Service running.
|
||||
This lightweight background service handles secure authentication.
|
||||
</div>
|
||||
<div class="text-foreground-2 text-sm mt-2 mb-4"></div>
|
||||
<div class="flex flex-wrap justify-center space-y-2 max-width">
|
||||
<FormButton full-width @click="$openUrl(`speckle://accounts`)">
|
||||
Sign In
|
||||
<div class="space-y-3">
|
||||
<FormButton full-width @click="$openUrl('https://releases.speckle.systems')">
|
||||
Download Desktop Service
|
||||
</FormButton>
|
||||
<div>
|
||||
<div class="text-xs">Already done?</div>
|
||||
<div class="text-center">
|
||||
<div class="text-foreground-2 text-xs mb-2">Already installed?</div>
|
||||
<FormButton
|
||||
size="sm"
|
||||
full-width
|
||||
@@ -27,20 +41,29 @@
|
||||
link
|
||||
@click="accountStore.refreshAccounts()"
|
||||
>
|
||||
Click to refresh
|
||||
Refresh to check again
|
||||
</FormButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</LayoutPanel>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useAccountStore } from '~~/store/accounts'
|
||||
import { useDesktopService } from '~/lib/core/composables/desktopService'
|
||||
import type { BaseBridge } from '~/lib/bridge/base'
|
||||
|
||||
const accountStore = useAccountStore()
|
||||
const { pingDesktopService } = useDesktopService()
|
||||
|
||||
const customServerUrl = ref<string>('https://app.speckle.systems')
|
||||
|
||||
const { $accountBinding } = useNuxtApp()
|
||||
const canAddAccount = ['AddAccount', 'addAccount'].some((name) =>
|
||||
($accountBinding as unknown as BaseBridge).availableMethodNames.includes(name)
|
||||
)
|
||||
|
||||
const isDesktopServiceAvailable = ref(false) // this should be false default because there is a delay if /ping is not successful.
|
||||
|
||||
onMounted(async () => {
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<!-- CommonTiptapViewer.vue -->
|
||||
<template>
|
||||
<!-- read-only output -->
|
||||
<div
|
||||
v-if="html"
|
||||
class="p-1 pl-3 group w-full whitespace-pre-wrap break-words"
|
||||
v-html="html"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { JSONContent } from '@tiptap/core'
|
||||
|
||||
const props = defineProps<{
|
||||
doc: JSONContent | null | undefined
|
||||
}>()
|
||||
|
||||
const escapeHtml = (str: string): string =>
|
||||
str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
|
||||
function renderNode(node?: JSONContent): string {
|
||||
if (!node) return ''
|
||||
|
||||
const children = (node.content ?? []).map(renderNode).join('')
|
||||
|
||||
switch (node.type) {
|
||||
case 'doc':
|
||||
return children
|
||||
|
||||
case 'paragraph':
|
||||
// empty paragraph → visual empty line
|
||||
return children ? `<p>${children}</p>` : '<p><br /></p>'
|
||||
|
||||
case 'text': {
|
||||
const text = escapeHtml(node.text ?? '')
|
||||
// if you need marks later (bold, italic, etc.), handle here
|
||||
return text
|
||||
}
|
||||
|
||||
case 'hardBreak':
|
||||
return '<br />'
|
||||
|
||||
default:
|
||||
// unknown node → just render its children
|
||||
return children
|
||||
}
|
||||
}
|
||||
|
||||
const html = computed(() => (props.doc ? renderNode(props.doc) : ''))
|
||||
</script>
|
||||
@@ -0,0 +1,77 @@
|
||||
<template>
|
||||
<div class="p-0">
|
||||
<slot name="activator" :toggle="toggleDialog"></slot>
|
||||
<CommonDialog v-model:open="showIssuesDialog" :title="`Issues`" fullscreen="none">
|
||||
<div class="flex flex-col space-y-2">
|
||||
<div v-if="selectedIssue" class="flex flex-col space-y-1.5">
|
||||
<div class="relative flex items-center h-8">
|
||||
<div class="absolute left-0">
|
||||
<FormButton
|
||||
color="outline"
|
||||
hide-text
|
||||
:icon-left="ArrowLeft"
|
||||
@click="selectedIssue = undefined"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mx-auto text-foreground-2 font-medium font-mono text-body-xs">
|
||||
{{ selectedIssue.identifier }}
|
||||
</div>
|
||||
<div class="absolute right-0">
|
||||
<FormButton
|
||||
v-tippy="'Open issue in browser'"
|
||||
color="outline"
|
||||
hide-text
|
||||
:icon-left="ArrowTopRightOnSquareIcon"
|
||||
@click="openIssueOnWeb(selectedIssue.id)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<hr />
|
||||
<IssuesSelectedItem :issue="selectedIssue" :model-card="modelCard" />
|
||||
</div>
|
||||
|
||||
<div v-if="!selectedIssue" class="flex flex-col space-y-2">
|
||||
<IssuesItem
|
||||
v-for="issue in issues"
|
||||
:key="issue.id"
|
||||
:issue="issue"
|
||||
:model-card="modelCard"
|
||||
@select="selectedIssue = issue"
|
||||
@open-on-web="(issueId) => openIssueOnWeb(issueId)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CommonDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { IssuesItemFragment } from '~/lib/common/generated/gql/graphql'
|
||||
import type { IModelCard } from '~/lib/models/card'
|
||||
import { ArrowLeft } from 'lucide-vue-next'
|
||||
import { ArrowTopRightOnSquareIcon } from '@heroicons/vue/24/solid'
|
||||
|
||||
const props = defineProps<{
|
||||
issues: IssuesItemFragment[]
|
||||
modelCard: IModelCard
|
||||
}>()
|
||||
|
||||
const app = useNuxtApp()
|
||||
const showIssuesDialog = ref(false)
|
||||
const selectedIssue = ref<IssuesItemFragment | undefined>(undefined)
|
||||
|
||||
const toggleDialog = () => {
|
||||
showIssuesDialog.value = !showIssuesDialog.value
|
||||
}
|
||||
|
||||
const openIssueOnWeb = (issueId: string) => {
|
||||
app.$baseBinding.openUrl(
|
||||
`${props.modelCard.serverUrl}/projects/${props.modelCard?.projectId}/models/${props.modelCard.modelId}#threadId=${issueId}`
|
||||
)
|
||||
}
|
||||
|
||||
watch(showIssuesDialog, (open) => {
|
||||
if (!open) selectedIssue.value = undefined
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,142 @@
|
||||
<template>
|
||||
<button
|
||||
class="gap-1 border rounded-xl border-outline-3 p-1.5 pt-1 pl-3 group hover:shadow-md hover:cursor-pointer space-y-2"
|
||||
@click="emit('select'), highlightModel()"
|
||||
>
|
||||
<!-- Item Header -->
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="text-foreground-2 font-medium font-mono text-body-xs">
|
||||
{{ issue.identifier }}
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<FormButton
|
||||
v-if="store.hostAppName !== 'navisworks' && store.hostAppName !== 'etabs'"
|
||||
v-tippy="'Highlight'"
|
||||
color="subtle"
|
||||
:icon-left="CursorArrowRaysIcon"
|
||||
hide-text
|
||||
size="sm"
|
||||
@click.stop="highlightModel"
|
||||
/>
|
||||
<FormButton
|
||||
v-tippy="'Open issue in browser'"
|
||||
color="subtle"
|
||||
:icon-left="ArrowTopRightOnSquareIcon"
|
||||
hide-text
|
||||
size="sm"
|
||||
class="mr-1"
|
||||
@click.stop="emit('open-on-web', issue.id)"
|
||||
/>
|
||||
<UserAvatar :user="issue.assignee?.user" size="xs" class="rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- Item Title & status -->
|
||||
<div class="flex items-center gap-1">
|
||||
<IssuesStatusIcon :status="issue.status" />
|
||||
<div class="line-clamp-2 font-medium text-body-2xs text-foreground">
|
||||
{{ issue.title ? issue.title : 'No title' }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Remaining secondary fields -->
|
||||
<div class="flex items-center gap-4 ml-0.5">
|
||||
<IssuesPriorityIcon :priority="issue.priority" />
|
||||
<IssuesLabels :labels="issue.labels" />
|
||||
<div v-if="formattedDate" class="flex items-center gap-1 h-6">
|
||||
<Calendar class="text-foreground-2 shrink-0" :stroke-width="1.5" :size="12" />
|
||||
<span class="text-body-3xs text-foreground-2 font-medium">
|
||||
{{ formattedDate }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-else class="flex items-center gap-1 h-6">
|
||||
<Calendar class="text-foreground-2 shrink-0" :stroke-width="1.5" :size="12" />
|
||||
<span class="text-body-3xs text-foreground-2 font-medium">No due date</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { IssuesItemFragment } from '~/lib/common/generated/gql/graphql'
|
||||
import { CursorArrowRaysIcon } from '@heroicons/vue/24/outline'
|
||||
import { Calendar } from 'lucide-vue-next'
|
||||
import dayjs from 'dayjs'
|
||||
import { useHostAppStore } from '~~/store/hostApp'
|
||||
import { ToastNotificationType } from '@speckle/ui-components'
|
||||
import type { IModelCard } from '~/lib/models/card'
|
||||
import type { SenderModelCard } from '~/lib/models/card/send'
|
||||
import { ArrowTopRightOnSquareIcon } from '@heroicons/vue/24/solid'
|
||||
|
||||
const store = useHostAppStore()
|
||||
|
||||
const props = defineProps<{
|
||||
modelCard: IModelCard
|
||||
issue: IssuesItemFragment
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'select'): void
|
||||
(e: 'open-on-web', issueId: string): void
|
||||
}>()
|
||||
|
||||
const app = useNuxtApp()
|
||||
|
||||
type IssueViewerState = {
|
||||
ui: {
|
||||
filters: {
|
||||
selectedObjectApplicationIds?: Record<string, string>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const highlightModel = async () => {
|
||||
if (!props.issue.viewerState) {
|
||||
store.setNotification({
|
||||
title: 'Objects not found to highlight',
|
||||
type: ToastNotificationType.Info
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (props.modelCard.typeDiscriminator !== 'SenderModelCard') return
|
||||
|
||||
const sender = props.modelCard as SenderModelCard
|
||||
|
||||
type SelectedObjectMap = Record<string, string>
|
||||
|
||||
const selectedObjectApplicationIds = Object.values(
|
||||
((props.issue.viewerState as IssueViewerState).ui.filters
|
||||
.selectedObjectApplicationIds ?? {}) as SelectedObjectMap
|
||||
)
|
||||
|
||||
const appIdsToHighlight = (sender.sendFilter?.selectedObjectIds ?? []).filter((id) =>
|
||||
selectedObjectApplicationIds.includes(id)
|
||||
)
|
||||
|
||||
if (appIdsToHighlight.length > 0) {
|
||||
await app.$baseBinding.highlightObjects(appIdsToHighlight)
|
||||
} else {
|
||||
store.setNotification({
|
||||
title: 'Objects not found to highlight on this model.',
|
||||
type: ToastNotificationType.Info
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const formattedDate = computed((): string | null => {
|
||||
try {
|
||||
const date = props.issue.dueDate ? dayjs(props.issue.dueDate).toDate() : null
|
||||
|
||||
if (!(date instanceof Date)) return null
|
||||
|
||||
const time = date.getTime()
|
||||
if (isNaN(time)) return null
|
||||
|
||||
return new Intl.DateTimeFormat('en-GB', {
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
}).format(date)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,39 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div class="flex items-center -space-x-1">
|
||||
<template
|
||||
v-for="labelItem in maxVisible ? labels.slice(0, maxVisible) : labels"
|
||||
:key="labelItem.id"
|
||||
>
|
||||
<div
|
||||
v-if="labelItem.hexColor"
|
||||
class="w-2 h-2 rounded-full shrink-0"
|
||||
:style="{ backgroundColor: labelItem.hexColor }"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Single label -->
|
||||
<span
|
||||
v-if="labels.length === 1"
|
||||
class="text-body-3xs font-medium flex items-center gap-1"
|
||||
:style="{ color: labels[0].hexColor || undefined }"
|
||||
>
|
||||
{{ labels[0].name }}
|
||||
</span>
|
||||
|
||||
<!-- Multiple labels -->
|
||||
<span v-else class="text-body-3xs text-foreground-2 font-medium">
|
||||
{{ labels.length }} label{{ labels.length !== 1 ? 's' : '' }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Label } from '~/lib/issues/types'
|
||||
|
||||
defineProps<{
|
||||
labels: Label[]
|
||||
maxVisible?: number
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<Tippy interactive placement="bottom" :offset="[0, 6]">
|
||||
<!-- Trigger -->
|
||||
<template #default>
|
||||
<IssuesLabelGroup :labels="labels" />
|
||||
</template>
|
||||
|
||||
<!-- Tooltip content -->
|
||||
<template v-if="labels.length > 0" #content>
|
||||
<div class="rounded-md shadow-lg p-0.5 text-xs space-y-1">
|
||||
<div
|
||||
v-for="label in labels"
|
||||
:key="label.id"
|
||||
class="flex items-center space-x-2"
|
||||
>
|
||||
<span
|
||||
class="w-2 h-2 rounded-full"
|
||||
:style="{ backgroundColor: label.hexColor }"
|
||||
/>
|
||||
<span>{{ label.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Tippy>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Tippy } from 'vue-tippy'
|
||||
import type { Label } from '~/lib/issues/types'
|
||||
|
||||
defineProps<{
|
||||
labels: Label[]
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,60 @@
|
||||
<template>
|
||||
<div class="flex items-center space-x-2">
|
||||
<div
|
||||
v-if="priority !== null && priority !== 'none'"
|
||||
v-tippy="showLabel ? undefined : priorityText"
|
||||
class="flex flex-col gap-0.5 items-start justify-center w-3 h-3"
|
||||
>
|
||||
<!-- Top line -->
|
||||
<div
|
||||
class="h-0.5 rounded-full bg-foreground-2 w-3"
|
||||
:class="priority !== 'high' && 'opacity-25'"
|
||||
/>
|
||||
<!-- Middle line -->
|
||||
<div
|
||||
class="h-0.5 rounded-full bg-foreground-2 w-2"
|
||||
:class="priority === 'low' && 'opacity-25'"
|
||||
/>
|
||||
<!-- Bottom line -->
|
||||
<div class="h-0.5 rounded-full bg-foreground-2 w-1" />
|
||||
</div>
|
||||
<!-- No priority: Two dashes -->
|
||||
<div v-else class="flex gap-0.5 items-center justify-center h-3 w-3">
|
||||
<div class="h-px rounded-full bg-foreground-3 w-1" />
|
||||
<div class="h-px rounded-full bg-foreground-3 w-1" />
|
||||
</div>
|
||||
|
||||
<span v-if="showLabel" class="text-body-3xs text-foreground-2 font-medium">
|
||||
{{ priorityText }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
priority: 'none' | 'low' | 'medium' | 'high' | null
|
||||
showLabel?: boolean
|
||||
}>(),
|
||||
{
|
||||
showLabel: false
|
||||
}
|
||||
)
|
||||
|
||||
const priorityText = computed(() => {
|
||||
switch (props.priority) {
|
||||
case 'high':
|
||||
return 'High'
|
||||
case 'medium':
|
||||
return 'Medium'
|
||||
case 'low':
|
||||
return 'Low'
|
||||
case 'none':
|
||||
return 'No priority'
|
||||
case null:
|
||||
return 'No priority'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,195 @@
|
||||
<template>
|
||||
<div class="flex flex-col space-y-1.5">
|
||||
<div class="flex flex-col items-start space-y-2 p-2">
|
||||
<div class="line-clamp-2 font-medium text-body text-foreground">
|
||||
{{ issue.title ? issue.title : 'No title' }}
|
||||
</div>
|
||||
<IssuesBasicTiptap
|
||||
v-if="issue.description?.doc"
|
||||
class="border rounded-xl border-outline-3 w-full"
|
||||
:doc="issue.description?.doc"
|
||||
></IssuesBasicTiptap>
|
||||
|
||||
<div v-if="app.$parametersBinding && hasObjectDeltas" class="w-full pt-1 pb-1">
|
||||
<FormButton
|
||||
class="w-full justify-center"
|
||||
:disabled="isApplying || isResolved"
|
||||
@click="applyChanges"
|
||||
>
|
||||
{{
|
||||
isApplying ? 'Applying...' : isResolved ? 'Issue resolved' : 'Apply changes'
|
||||
}}
|
||||
</FormButton>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<IssuesStatusIcon :status="issue.status" show-label />
|
||||
<IssuesPriorityIcon :priority="issue.priority" show-label />
|
||||
<div class="flex items-center justify-between space-x-1">
|
||||
<UserAvatar :user="issue.assignee?.user" size="xs" />
|
||||
<span class="text-body-3xs text-foreground-2 font-medium">
|
||||
{{ issue.assignee ? issue.assignee?.user.name : 'No assignee' }}
|
||||
</span>
|
||||
</div>
|
||||
<IssuesLabels :labels="issue.labels" />
|
||||
<div v-if="formattedDate" class="flex items-center gap-1 h-6">
|
||||
<Calendar class="text-foreground-2 shrink-0" :stroke-width="1.5" :size="12" />
|
||||
<span class="text-body-3xs text-foreground-2 font-medium">
|
||||
{{ formattedDate }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-else class="flex items-center gap-1 h-6">
|
||||
<Calendar class="text-foreground-2 shrink-0" :stroke-width="1.5" :size="12" />
|
||||
<span class="text-body-3xs text-foreground-2 font-medium">No due date</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="issue.activities && issue.activities.totalCount > 0"
|
||||
class="flex items-center gap-2 p-1 min-w-0"
|
||||
>
|
||||
<UserAvatar
|
||||
:user="issue.activities?.items?.[0]?.actor?.user"
|
||||
size="xs"
|
||||
class="shrink-0"
|
||||
/>
|
||||
|
||||
<div class="text-body-2xs text-foreground-2 leading-tight min-w-0">
|
||||
<span class="font-medium">
|
||||
{{ issue.activities?.items?.[0]?.actor?.user.name }}
|
||||
</span>
|
||||
<span>
|
||||
created this issue ·
|
||||
{{ dayjs(issue.activities?.items?.[0].createdAt).from(dayjs()) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="issue.replies && issue.replies.totalCount > 0"
|
||||
class="flex flex-col justify-between space-y-2 w-full"
|
||||
>
|
||||
<div
|
||||
v-for="reply in issue.replies.items"
|
||||
:key="reply.id"
|
||||
class="flex flex-col items-start border rounded-xl border-outline-3 p-1 w-full"
|
||||
>
|
||||
<div class="flex items-center gap-2 w-full">
|
||||
<UserAvatar :user="reply.author?.user" size="xs" class="shrink-0" />
|
||||
<div class="text-body-2xs text-foreground-2 leading-tight min-w-0">
|
||||
<span class="font-medium">
|
||||
{{ reply.author?.user.name }}
|
||||
</span>
|
||||
<span>
|
||||
replied ·
|
||||
{{ dayjs(reply.createdAt).from(dayjs()) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<IssuesBasicTiptap
|
||||
v-if="reply.description?.doc"
|
||||
class="ml-4"
|
||||
:doc="reply.description?.doc"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useQuery } from '@vue/apollo-composable'
|
||||
import { ResourceMetaType, IssueStatus } from '~/lib/common/generated/gql/graphql'
|
||||
import { issueResourceMetaSearchQuery } from '~/lib/issues/graphql/queries'
|
||||
import type { IssuesItemFragment } from '~/lib/common/generated/gql/graphql'
|
||||
import type { IModelCard } from '~/lib/models/card'
|
||||
import dayjs from 'dayjs'
|
||||
import { Calendar } from 'lucide-vue-next'
|
||||
|
||||
const props = defineProps<{
|
||||
issue: IssuesItemFragment
|
||||
modelCard: IModelCard
|
||||
}>()
|
||||
|
||||
const app = useNuxtApp()
|
||||
const isApplying = ref(false)
|
||||
|
||||
const isResolved = computed(() => {
|
||||
return props.issue.status === IssueStatus.Resolved
|
||||
})
|
||||
|
||||
const queryVariables = computed(() => ({
|
||||
workspaceId: props.modelCard.workspaceId!,
|
||||
projectId: props.modelCard.projectId,
|
||||
resourceType: ResourceMetaType.Issue,
|
||||
resourceId: props.issue.id,
|
||||
metaType: 'objectDeltas'
|
||||
}))
|
||||
|
||||
const queryOptions = computed(() => ({
|
||||
fetchPolicy: 'cache-and-network' as const,
|
||||
enabled: !!props.modelCard.workspaceId,
|
||||
clientId: props.modelCard.accountId
|
||||
}))
|
||||
|
||||
const { result: resourceMetaResult } = useQuery(
|
||||
issueResourceMetaSearchQuery,
|
||||
queryVariables,
|
||||
queryOptions
|
||||
)
|
||||
|
||||
const hasObjectDeltas = computed<boolean>(() => {
|
||||
const metadata = resourceMetaResult.value?.resourceMetaSearch
|
||||
return Array.isArray(metadata) && metadata.length > 0
|
||||
})
|
||||
|
||||
const objectDeltasPayload = computed<unknown>(() => {
|
||||
if (!hasObjectDeltas.value) return null
|
||||
const metadata = resourceMetaResult.value?.resourceMetaSearch
|
||||
|
||||
if (Array.isArray(metadata) && metadata.length > 0) {
|
||||
return metadata[0]?.data as unknown
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
const applyChanges = async () => {
|
||||
if (!objectDeltasPayload.value) return
|
||||
|
||||
isApplying.value = true
|
||||
try {
|
||||
const payload =
|
||||
typeof objectDeltasPayload.value === 'string'
|
||||
? objectDeltasPayload.value
|
||||
: JSON.stringify(objectDeltasPayload.value)
|
||||
|
||||
if (app.$parametersBinding) {
|
||||
await app.$parametersBinding.update(payload)
|
||||
} else {
|
||||
console.warn('IParametersBinding not available in this host app')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to apply changes:', error)
|
||||
} finally {
|
||||
isApplying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const formattedDate = computed((): string | null => {
|
||||
try {
|
||||
const date = props.issue.dueDate ? dayjs(props.issue.dueDate).toDate() : null
|
||||
|
||||
if (!(date instanceof Date)) return null
|
||||
|
||||
const time = date.getTime()
|
||||
if (isNaN(time)) return null
|
||||
|
||||
return new Intl.DateTimeFormat('en-GB', {
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
}).format(date)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,49 @@
|
||||
<template>
|
||||
<div
|
||||
v-tippy="showLabel ? undefined : statusText"
|
||||
class="flex items-center gap-1 rounded-md hover:bg-foreground-1"
|
||||
>
|
||||
<GlobalIconStatusOpen v-if="status === 'open'" class="w-3 h-3 shrink-0" />
|
||||
<GlobalIconStatusReview
|
||||
v-else-if="status === 'readyForReview'"
|
||||
class="w-3 h-3 shrink-0"
|
||||
/>
|
||||
<GlobalIconStatusResolved
|
||||
v-else-if="status === 'resolved'"
|
||||
class="w-3 h-3 shrink-0"
|
||||
/>
|
||||
|
||||
<span v-if="showLabel" class="text-body-3xs text-foreground-2 font-medium">
|
||||
{{ statusText }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import GlobalIconStatusOpen from '~/components/global/icon/StatusOpen.vue'
|
||||
import GlobalIconStatusReview from '~/components/global/icon/StatusReview.vue'
|
||||
import GlobalIconStatusResolved from '~/components/global/icon/StatusResolved.vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
status: 'open' | 'readyForReview' | 'resolved'
|
||||
showLabel?: boolean
|
||||
}>(),
|
||||
{
|
||||
showLabel: false
|
||||
}
|
||||
)
|
||||
|
||||
const statusText = computed(() => {
|
||||
switch (props.status) {
|
||||
case 'open':
|
||||
return 'Open'
|
||||
case 'readyForReview':
|
||||
return 'Ready for review'
|
||||
case 'resolved':
|
||||
return 'Resolved'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,82 @@
|
||||
<template>
|
||||
<div class="px-2">
|
||||
<p class="h5">Layer Selection</p>
|
||||
<div class="space-y-2 my-2">
|
||||
<!-- Multi-select layer dropdown -->
|
||||
<FormSelectMulti
|
||||
:key="selectedLayers.length === 0 ? 'empty' : 'hasSelection'"
|
||||
:model-value="selectedLayers"
|
||||
name="layerSelection"
|
||||
label="Select layers"
|
||||
class="w-full"
|
||||
fixed-height
|
||||
size="sm"
|
||||
:items="layerOptions"
|
||||
:allow-unset="false"
|
||||
by="id"
|
||||
clearable
|
||||
:search="true"
|
||||
:search-placeholder="''"
|
||||
:filter-predicate="layerSearchFilterPredicate"
|
||||
mount-menu-on-body
|
||||
@update:model-value="(value) => $emit('update:selectedLayers', value as LayerOption[])"
|
||||
>
|
||||
<template #something-selected="{ value }">
|
||||
<span class="text-primary text-xs">
|
||||
{{ `${value.length} layer${value.length !== 1 ? 's' : ''} selected` }}
|
||||
</span>
|
||||
</template>
|
||||
<template #option="{ item }">
|
||||
<span class="text-xs">{{ item.name }}</span>
|
||||
</template>
|
||||
</FormSelectMulti>
|
||||
|
||||
<!-- Layer selection summary -->
|
||||
<div
|
||||
v-if="selectedLayers.length === 0"
|
||||
class="space-y-2 p-2 bg-highlight-1 rounded-md text-body-xs"
|
||||
>
|
||||
<div class="text-foreground-2">
|
||||
No layers selected, choose layers from the dropdown above!
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="space-y-2 p-2 bg-highlight-1 rounded-md text-body-xs">
|
||||
<div>
|
||||
Selected {{ selectedLayers.length }} layer{{
|
||||
selectedLayers.length !== 1 ? 's' : ''
|
||||
}}:
|
||||
{{ selectedLayers.map((l) => l.name).join(', ') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface LayerOption {
|
||||
id: string
|
||||
name: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
selectedLayers: LayerOption[]
|
||||
layerOptions: LayerOption[]
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'update:selectedLayers': [layers: LayerOption[]]
|
||||
}>()
|
||||
|
||||
// Search predicate for layer dropdown
|
||||
const layerSearchFilterPredicate = (
|
||||
item: LayerOption | string | number | Record<string, unknown>,
|
||||
query: string
|
||||
): boolean => {
|
||||
if (typeof item === 'object' && item !== null && 'name' in item) {
|
||||
const layerItem = item as LayerOption
|
||||
return layerItem.name.toLowerCase().includes(query.toLowerCase())
|
||||
}
|
||||
return false
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<div class="py-1 px-2 bg-foundation border rounded-lg">
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="text-xs font-medium grow">{{ categoryLabel }}</div>
|
||||
|
||||
<div class="flex space-x-1">
|
||||
<div class="flex justify-center items-center text-xs text-foreground-2 mr-1">
|
||||
{{ countText }}
|
||||
</div>
|
||||
<FormButton
|
||||
v-if="tooltipText"
|
||||
v-tippy="tooltipText"
|
||||
size="sm"
|
||||
color="outline"
|
||||
:icon-left="CursorArrowRaysIcon"
|
||||
hide-text
|
||||
@click="$emit('select')"
|
||||
/>
|
||||
<FormButton
|
||||
v-else
|
||||
size="sm"
|
||||
color="outline"
|
||||
:icon-left="CursorArrowRaysIcon"
|
||||
hide-text
|
||||
@click="$emit('select')"
|
||||
/>
|
||||
<FormButton class="!px-1.5" size="sm" color="outline" @click="$emit('clear')">
|
||||
<TrashIcon class="w-3 h-3" />
|
||||
</FormButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CursorArrowRaysIcon, TrashIcon } from '@heroicons/vue/24/outline'
|
||||
|
||||
defineProps<{
|
||||
categoryLabel: string
|
||||
countText: string
|
||||
tooltipText?: string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
select: []
|
||||
clear: []
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,15 @@
|
||||
<template>
|
||||
<div class="space-y-2 p-2 bg-highlight-1 rounded-md text-body-xs">
|
||||
<div v-if="!hasSelection">
|
||||
No objects selected, go ahead and select some from your model!
|
||||
</div>
|
||||
<div v-else>{{ selectionSummary }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
hasSelection: boolean
|
||||
selectionSummary: string
|
||||
}>()
|
||||
</script>
|
||||
@@ -5,6 +5,7 @@
|
||||
:icon-left="Bars3Icon"
|
||||
hide-text
|
||||
size="sm"
|
||||
:disabled="!!props.modelCard.progress"
|
||||
@click.stop="openModelCardActionsDialog = true"
|
||||
/>
|
||||
<CommonDialog
|
||||
@@ -32,6 +33,18 @@
|
||||
</button>
|
||||
</template>
|
||||
</ReportBase>
|
||||
<IssuesDialog
|
||||
v-if="issues && issues.length > 0"
|
||||
:model-card="modelCard"
|
||||
:issues="issues"
|
||||
>
|
||||
<template #activator="{ toggle }">
|
||||
<button class="action action-normal" @click="toggle()">
|
||||
<div class="truncate max-[275px]:text-xs">Issues</div>
|
||||
<div><Cog6ToothIcon class="w-5 h-5" /></div>
|
||||
</button>
|
||||
</template>
|
||||
</IssuesDialog>
|
||||
<button
|
||||
v-for="item in items"
|
||||
:key="item.name"
|
||||
@@ -57,6 +70,10 @@ import {
|
||||
} from '@heroicons/vue/24/outline'
|
||||
import type { IModelCard } from '~/lib/models/card'
|
||||
import { useMixpanel } from '~/lib/core/composables/mixpanel'
|
||||
import { issuesListQuery } from '~/lib/issues/graphql/queries'
|
||||
import { useAccountStore } from '~/store/accounts'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useQuery } from '@vue/apollo-composable'
|
||||
|
||||
const { trackEvent } = useMixpanel()
|
||||
|
||||
@@ -73,7 +90,7 @@ const hasSettings = computed(() => {
|
||||
})
|
||||
|
||||
const app = useNuxtApp()
|
||||
app.$baseBinding.on('documentChanged', () => {
|
||||
app.$baseBinding?.on('documentChanged', () => {
|
||||
openModelCardActionsDialog.value = false
|
||||
})
|
||||
|
||||
@@ -113,6 +130,22 @@ const items = [
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
const accountStore = useAccountStore()
|
||||
const { activeAccount } = storeToRefs(accountStore)
|
||||
const accountId = computed(() => activeAccount.value.accountInfo.id)
|
||||
|
||||
const { result: issuesResult } = useQuery(
|
||||
issuesListQuery,
|
||||
() => ({ projectId: props.modelCard.projectId }),
|
||||
() => ({
|
||||
clientId: accountId.value,
|
||||
debounce: 500,
|
||||
fetchPolicy: 'network-only'
|
||||
})
|
||||
)
|
||||
|
||||
const issues = computed(() => issuesResult?.value?.project.issues.items)
|
||||
</script>
|
||||
<style scoped lang="postcss">
|
||||
.action {
|
||||
|
||||
+121
-20
@@ -5,10 +5,12 @@
|
||||
>
|
||||
<div v-if="modelData" class="relative px-1 py-1">
|
||||
<div class="relative flex items-center space-x-2 min-w-0">
|
||||
<div class="text-foreground-2 mt-[2px] flex items-center -space-x-2 relative">
|
||||
<div
|
||||
v-tippy="buttonTooltip"
|
||||
class="text-foreground-2 mt-[2px] flex items-center -space-x-2 relative"
|
||||
>
|
||||
<!-- CTA button -->
|
||||
<FormButton
|
||||
v-tippy="buttonTooltip"
|
||||
color="outline"
|
||||
:icon-left="
|
||||
modelCard.progress
|
||||
@@ -19,7 +21,9 @@
|
||||
"
|
||||
hide-text
|
||||
class=""
|
||||
:disabled="!canEdit"
|
||||
:disabled="
|
||||
(!canEdit || isSettingsMissing || ctaDisabled) && !modelCard.progress
|
||||
"
|
||||
@click.stop="$emit('manual-publish-or-load')"
|
||||
></FormButton>
|
||||
</div>
|
||||
@@ -57,6 +61,32 @@
|
||||
</button>
|
||||
</template>
|
||||
</AutomateResultDialog>
|
||||
<!-- To test missing settings -->
|
||||
<!-- <FormButton
|
||||
v-if="!isSettingsMissing"
|
||||
v-tippy="'Refresh settings are needed'"
|
||||
color="subtle"
|
||||
:icon-left="TrashIcon"
|
||||
hide-text
|
||||
size="sm"
|
||||
@click="deleteSettings"
|
||||
/> -->
|
||||
<IssuesDialog
|
||||
v-if="issues && issues.length > 0"
|
||||
:model-card="modelCard"
|
||||
:issues="issues"
|
||||
>
|
||||
<template #activator="{ toggle }">
|
||||
<FormButton
|
||||
v-tippy="'Issues'"
|
||||
color="subtle"
|
||||
:icon-left="MessageCircleMore"
|
||||
hide-text
|
||||
size="sm"
|
||||
@click="toggle()"
|
||||
/>
|
||||
</template>
|
||||
</IssuesDialog>
|
||||
<FormButton
|
||||
v-if="store.hostAppName !== 'navisworks' && store.hostAppName !== 'etabs'"
|
||||
v-tippy="'Highlight'"
|
||||
@@ -92,7 +122,24 @@
|
||||
Fetching model data...
|
||||
<CommonLoadingBar loading />
|
||||
</div>
|
||||
<div v-else class="px-1 py-1">Error loading data.</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex flex-row items-center px-2 pt-2 text-body-2xs text-foreground-2 truncate text-red-500"
|
||||
>
|
||||
<span class="ml-1.5">Error on loading model data.</span>
|
||||
|
||||
<div class="flex items-center justify-end grow">
|
||||
<FormButton
|
||||
v-tippy="'Remove model card'"
|
||||
color="subtle"
|
||||
:icon-left="TrashIcon"
|
||||
hide-text
|
||||
size="sm"
|
||||
class="text-red-500"
|
||||
@click.stop="removeModel"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Slot to allow senders or receivers to hoist their own buttons/ui -->
|
||||
<!-- class="px-2 h-0 group-hover:h-auto transition-all overflow-hidden" -->
|
||||
@@ -152,18 +199,18 @@
|
||||
>
|
||||
<div
|
||||
v-tippy="
|
||||
`${latestCommentNotification.comment?.author.name} just left a
|
||||
`${latestCommentNotification.comment?.author?.name} just left a
|
||||
comment.`
|
||||
"
|
||||
class="flex items-center space-x-1"
|
||||
>
|
||||
<UserAvatarGroup
|
||||
size="xs"
|
||||
:users="[latestCommentNotification.comment?.author]"
|
||||
:users="[latestCommentNotification.comment?.author as AvatarUserWithId]"
|
||||
/>
|
||||
<span class="line-clamp-1">
|
||||
{{ latestCommentNotification.comment?.author.name }} just left a
|
||||
comment.
|
||||
{{ latestCommentNotification.comment?.author?.name }} just left a
|
||||
comment on the issue.
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
@@ -207,18 +254,28 @@ import { useMixpanel } from '~/lib/core/composables/mixpanel'
|
||||
import { useIntervalFn, useTimeoutFn } from '@vueuse/core'
|
||||
import type { ProjectCommentsUpdatedMessage } from '~/lib/common/generated/gql/graphql'
|
||||
import { useFunctionRunsStatusSummary } from '~/lib/automate/runStatus'
|
||||
import { CursorArrowRaysIcon, XCircleIcon } from '@heroicons/vue/24/outline'
|
||||
import { CursorArrowRaysIcon, XCircleIcon, TrashIcon } from '@heroicons/vue/24/outline'
|
||||
import type { AvatarUserWithId } from '@speckle/ui-components'
|
||||
import { issuesListQuery } from '~/lib/issues/graphql/queries'
|
||||
import { MessageCircleMore } from 'lucide-vue-next'
|
||||
|
||||
const app = useNuxtApp()
|
||||
const store = useHostAppStore()
|
||||
const accStore = useAccountStore()
|
||||
const { trackEvent } = useMixpanel()
|
||||
|
||||
const props = defineProps<{
|
||||
modelCard: IModelCard
|
||||
project: ProjectModelGroup
|
||||
canEdit: boolean
|
||||
}>()
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelCard: IModelCard
|
||||
project: ProjectModelGroup
|
||||
canEdit: boolean
|
||||
ctaDisabled?: boolean
|
||||
ctaDisabledMessage?: string
|
||||
}>(),
|
||||
{
|
||||
ctaDisabled: false
|
||||
}
|
||||
)
|
||||
|
||||
defineEmits<{
|
||||
(e: 'manual-publish-or-load'): void
|
||||
@@ -229,11 +286,9 @@ const isSender = computed(() => {
|
||||
})
|
||||
|
||||
const buttonTooltip = computed(() => {
|
||||
return props.modelCard.progress
|
||||
? 'Cancel'
|
||||
: isSender.value
|
||||
? 'Publish model'
|
||||
: 'Load selected version'
|
||||
if (props.modelCard.progress) return 'Cancel'
|
||||
if (props.ctaDisabled) return props.ctaDisabledMessage
|
||||
return isSender.value ? 'Publish model' : 'Load selected version'
|
||||
})
|
||||
|
||||
const projectAccount = computed(() =>
|
||||
@@ -299,6 +354,25 @@ const summary = computed(() => {
|
||||
})
|
||||
})
|
||||
|
||||
const { result: issuesResult, refetch: refetchIssues } = useQuery(
|
||||
issuesListQuery,
|
||||
() => ({ projectId: props.modelCard.projectId }),
|
||||
() => ({
|
||||
clientId,
|
||||
debounce: 500,
|
||||
fetchPolicy: 'network-only'
|
||||
})
|
||||
)
|
||||
|
||||
const issues = computed(() =>
|
||||
issuesResult?.value?.project.issues.items.filter(
|
||||
(issue) =>
|
||||
issue.status !== 'resolved' &&
|
||||
issue.resourceIdString &&
|
||||
(issue.resourceIdString as string).includes(props.modelCard.modelId)
|
||||
)
|
||||
)
|
||||
|
||||
provide<IModelCard>('cardBase', props.modelCard)
|
||||
|
||||
const highlightModel = () => {
|
||||
@@ -320,6 +394,31 @@ const highlightModel = () => {
|
||||
trackEvent('DUI3 Action', { name: 'Highlight Model' }, props.modelCard.accountId)
|
||||
}
|
||||
|
||||
const isSettingsMissing = computed(() =>
|
||||
isSender.value ? isSendSettingsMissing.value : isReceiveSettingsMissing.value
|
||||
)
|
||||
|
||||
const isSendSettingsMissing = computed(
|
||||
() =>
|
||||
isSender.value &&
|
||||
store.sendSettings &&
|
||||
store.sendSettings.length > 0 &&
|
||||
!props.modelCard.settings
|
||||
)
|
||||
|
||||
const isReceiveSettingsMissing = computed(
|
||||
() =>
|
||||
!isSender.value &&
|
||||
store.receiveSettings &&
|
||||
store.receiveSettings.length > 0 &&
|
||||
!props.modelCard.settings
|
||||
)
|
||||
|
||||
// To test missing settings
|
||||
// const deleteSettings = async () => {
|
||||
// await store.patchModel(props.modelCard.modelCardId, { settings: undefined })
|
||||
// }
|
||||
|
||||
const viewModel = () => {
|
||||
// previously with DUI2, it was Stream View but actually it is "Version View" now. Also having conflict with old/new terminology.
|
||||
trackEvent('DUI3 Action', { name: 'Version View' }, props.modelCard.accountId)
|
||||
@@ -430,8 +529,10 @@ const { start: startCommentClearTimeout, stop: stopCommentClearTimeout } = useTi
|
||||
)
|
||||
|
||||
onCommentResult((res) => {
|
||||
latestCommentNotification.value = res.data?.projectCommentsUpdated
|
||||
latestCommentNotification.value = res.data
|
||||
?.projectCommentsUpdated as ProjectCommentsUpdatedMessage
|
||||
startCommentClearTimeout()
|
||||
refetchIssues()
|
||||
})
|
||||
|
||||
const viewComment = () => {
|
||||
|
||||
@@ -1,186 +0,0 @@
|
||||
<template>
|
||||
<div class="p-0">
|
||||
<slot name="activator" :toggle="toggleDialog"></slot>
|
||||
<CommonDialog
|
||||
v-model:open="showModelCreateDialog"
|
||||
:title="canCreateModelInWorkspace ? `Create new model` : errorMessage?.title"
|
||||
fullscreen="none"
|
||||
>
|
||||
<form v-if="canCreateModelInWorkspace" @submit="onSubmitCreateNewModel">
|
||||
<div class="text-body-2xs mb-2 ml-1">Model name</div>
|
||||
<FormTextInput
|
||||
v-model="newModelName"
|
||||
class="text-xs"
|
||||
autocomplete="off"
|
||||
name="name"
|
||||
label="Model name"
|
||||
color="foundation"
|
||||
:show-clear="!!newModelName"
|
||||
:placeholder="hostAppStore.documentInfo?.name"
|
||||
:rules="[
|
||||
ValidationHelpers.isRequired,
|
||||
ValidationHelpers.isStringOfLength({ minLength: 3 })
|
||||
]"
|
||||
full-width
|
||||
/>
|
||||
<div class="mt-4 flex justify-end items-center space-x-2 w-full">
|
||||
<FormButton size="sm" text @click="showModelCreateDialog = false">
|
||||
Cancel
|
||||
</FormButton>
|
||||
<FormButton size="sm" submit :disabled="isCreatingModel">Create</FormButton>
|
||||
</div>
|
||||
</form>
|
||||
<div v-else class="m-2">
|
||||
{{ errorMessage?.description }}
|
||||
<div class="flex mt-2 space-x-2 justify-end">
|
||||
<FormButton size="sm" color="outline" @click="showModelCreateDialog = false">
|
||||
Close
|
||||
</FormButton>
|
||||
<FormButton
|
||||
v-if="errorMessage?.cta"
|
||||
size="sm"
|
||||
submit
|
||||
@click="errorMessage?.cta?.action(), (showModelCreateDialog = false)"
|
||||
>
|
||||
{{ errorMessage?.cta?.name }}
|
||||
</FormButton>
|
||||
</div>
|
||||
</div>
|
||||
</CommonDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useMutation, provideApolloClient, useQuery } from '@vue/apollo-composable'
|
||||
import type { ModelListModelItemFragment } from '~/lib/common/generated/gql/graphql'
|
||||
import { useForm } from 'vee-validate'
|
||||
import { ValidationHelpers } from '@speckle/ui-components'
|
||||
import type { DUIAccount } from '~/store/accounts'
|
||||
import { useAccountStore } from '~/store/accounts'
|
||||
import { useMixpanel } from '~/lib/core/composables/mixpanel'
|
||||
import { useHostAppStore } from '~/store/hostApp'
|
||||
import {
|
||||
canCreateModelInProjectQuery,
|
||||
createModelMutation
|
||||
} from '~/lib/graphql/mutationsAndQueries'
|
||||
|
||||
type WorkspacePermissionMessage = {
|
||||
title: string
|
||||
description: string
|
||||
cta?: {
|
||||
name: string
|
||||
action: () => void
|
||||
}
|
||||
}
|
||||
|
||||
const { $openUrl } = useNuxtApp()
|
||||
|
||||
const showModelCreateDialog = ref(false)
|
||||
const isCreatingModel = ref(false)
|
||||
|
||||
const props = defineProps<{
|
||||
projectId: string
|
||||
workspaceId?: string
|
||||
workspaceSlug?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'model:created', model: ModelListModelItemFragment): void
|
||||
}>()
|
||||
|
||||
const { trackEvent } = useMixpanel()
|
||||
const accountStore = useAccountStore()
|
||||
const hostAppStore = useHostAppStore()
|
||||
const { activeAccount } = storeToRefs(accountStore)
|
||||
|
||||
const accountId = computed(() => activeAccount.value.accountInfo.id)
|
||||
const newModelName = ref<string>()
|
||||
const errorMessage = ref<WorkspacePermissionMessage>()
|
||||
|
||||
const toggleDialog = () => {
|
||||
showModelCreateDialog.value = !showModelCreateDialog.value
|
||||
}
|
||||
|
||||
const account = computed(() => {
|
||||
return accountStore.accounts.find(
|
||||
(acc) => acc.accountInfo.id === accountId.value
|
||||
) as DUIAccount
|
||||
})
|
||||
|
||||
const canCreateModelInWorkspace = ref<boolean>()
|
||||
|
||||
const { result: canCreateModelInWorkspaceResult } = useQuery(
|
||||
canCreateModelInProjectQuery,
|
||||
() => ({ projectId: props.projectId }),
|
||||
() => ({
|
||||
clientId: accountId.value,
|
||||
debounce: 500,
|
||||
fetchPolicy: 'network-only'
|
||||
})
|
||||
)
|
||||
|
||||
watch(canCreateModelInWorkspaceResult, (val) => {
|
||||
if (val?.project.permissions.canCreateModel.code !== 'OK') {
|
||||
switch (val?.project.permissions.canCreateModel.code) {
|
||||
case 'WorkspaceLimitsReached':
|
||||
errorMessage.value = {
|
||||
title: 'Plan limit reached',
|
||||
description:
|
||||
'The model limit for this workspace has been reached. Upgrade the workspace plan to create or move more models.',
|
||||
cta: {
|
||||
name: 'Explore Plans',
|
||||
action: () =>
|
||||
$openUrl(
|
||||
`${account.value.accountInfo.serverInfo.url}/settings/workspaces/${props.workspaceSlug}/billing`
|
||||
)
|
||||
}
|
||||
}
|
||||
break
|
||||
// TODO: we should add more cases later according to `code`
|
||||
default:
|
||||
errorMessage.value = {
|
||||
title: 'Workspace warning',
|
||||
description: val?.project.permissions.canCreateModel.message ?? 'error'
|
||||
}
|
||||
break
|
||||
}
|
||||
canCreateModelInWorkspace.value = false
|
||||
} else {
|
||||
canCreateModelInWorkspace.value = true
|
||||
}
|
||||
})
|
||||
|
||||
const createNewModel = async (name: string) => {
|
||||
isCreatingModel.value = true
|
||||
|
||||
void trackEvent('DUI3 Action', { name: 'Model Create' }, account.value.accountInfo.id)
|
||||
|
||||
const { mutate } = provideApolloClient(account.value.client)(() =>
|
||||
useMutation(createModelMutation)
|
||||
)
|
||||
const res = await mutate({ input: { projectId: props.projectId, name } })
|
||||
if (res?.data?.modelMutations.create) {
|
||||
emit('model:created', res?.data?.modelMutations.create)
|
||||
// refetch() // Sorts the list with newly created model otherwise it will put the model at the bottom.
|
||||
// emit('next', res?.data?.modelMutations.create)
|
||||
} else {
|
||||
let errorMessage = 'Undefined error'
|
||||
if (res?.errors && res?.errors.length !== 0) {
|
||||
errorMessage = res?.errors[0].message
|
||||
}
|
||||
|
||||
hostAppStore.setNotification({
|
||||
type: 1,
|
||||
title: 'Failed to create model',
|
||||
description: errorMessage
|
||||
})
|
||||
}
|
||||
isCreatingModel.value = false
|
||||
}
|
||||
|
||||
const { handleSubmit } = useForm<{ name: string }>()
|
||||
const onSubmitCreateNewModel = handleSubmit(() => {
|
||||
void createNewModel(newModelName.value as string)
|
||||
})
|
||||
</script>
|
||||
@@ -19,7 +19,7 @@
|
||||
color="subtle"
|
||||
class="block text-foreground-2 hover:text-foreground overflow-hidden max-w-full !justify-start"
|
||||
full-width
|
||||
:disabled="!!modelCard.progress || noReadAccess"
|
||||
:disabled="!!modelCard.progress || !canEdit || isReceiveSettingsMissing"
|
||||
@click.stop="openVersionsDialog = true"
|
||||
>
|
||||
<span>
|
||||
@@ -40,7 +40,6 @@
|
||||
{{ createdAgo }}
|
||||
</span>
|
||||
</div> -->
|
||||
|
||||
<CommonDialog
|
||||
v-model:open="openVersionsDialog"
|
||||
fullscreen="none"
|
||||
@@ -52,10 +51,16 @@
|
||||
:model-id="modelCard.modelId"
|
||||
:workspace-slug="modelCard.workspaceSlug"
|
||||
:selected-version-id="modelCard.selectedVersionId"
|
||||
:settings="modelCard.settings"
|
||||
@next="handleVersionSelection"
|
||||
@update:settings="handleUpdateSettings"
|
||||
/>
|
||||
</CommonDialog>
|
||||
<template #states>
|
||||
<CommonModelNotification
|
||||
v-if="isReceiveSettingsMissing"
|
||||
:notification="receiveSettingsMissingNotification"
|
||||
/>
|
||||
<CommonModelNotification
|
||||
v-if="expiredNotification"
|
||||
:notification="expiredNotification"
|
||||
@@ -95,6 +100,7 @@ import type { VersionListItemFragment } from '~/lib/common/generated/gql/graphql
|
||||
import { useMixpanel } from '~/lib/core/composables/mixpanel'
|
||||
import { useInterval, watchOnce } from '@vueuse/core'
|
||||
import { useAccountStore } from '~~/store/accounts'
|
||||
import type { CardSetting } from '~/lib/models/card/setting'
|
||||
|
||||
const { trackEvent } = useMixpanel()
|
||||
const app = useNuxtApp()
|
||||
@@ -114,14 +120,44 @@ const projectAccount = computed(() =>
|
||||
accountStore.accountWithFallback(props.project.accountId, props.project.serverUrl)
|
||||
)
|
||||
|
||||
app.$baseBinding.on('documentChanged', () => {
|
||||
app.$baseBinding?.on('documentChanged', () => {
|
||||
openVersionsDialog.value = false
|
||||
})
|
||||
|
||||
const isReceiveSettingsMissing = computed(
|
||||
() =>
|
||||
store.receiveSettings &&
|
||||
store.receiveSettings.length > 0 &&
|
||||
!props.modelCard.settings
|
||||
)
|
||||
|
||||
const receiveSettingsMissingNotification = computed(() => {
|
||||
const notification = {} as ModelCardNotification
|
||||
notification.dismissible = false
|
||||
notification.level = 'danger'
|
||||
notification.text = 'Load settings are corrupted for some reason.'
|
||||
|
||||
notification.cta = {
|
||||
name: 'Refresh',
|
||||
action: async () => {
|
||||
await store.patchModel(props.modelCard.modelCardId, {
|
||||
settings: store.receiveSettings
|
||||
})
|
||||
}
|
||||
}
|
||||
return notification
|
||||
})
|
||||
|
||||
const isExpired = computed(() => {
|
||||
return props.modelCard.latestVersionId !== props.modelCard.selectedVersionId
|
||||
})
|
||||
|
||||
const handleUpdateSettings = async (settings: CardSetting[]) => {
|
||||
await store.patchModel(props.modelCard.modelCardId, {
|
||||
settings
|
||||
})
|
||||
}
|
||||
|
||||
// Cancels any in progress receive AND load selected version
|
||||
const handleVersionSelection = async (
|
||||
selectedVersion: VersionListItemFragment,
|
||||
@@ -178,7 +214,7 @@ const expiredNotification = computed(() => {
|
||||
if (props.modelCard.latestVersionId === props.modelCard.selectedVersionId) return
|
||||
const notification = {} as ModelCardNotification
|
||||
notification.dismissible = true
|
||||
notification.level = 'info'
|
||||
notification.level = 'success'
|
||||
notification.text = 'Newer version available!'
|
||||
notification.cta = {
|
||||
name: 'Update',
|
||||
@@ -203,13 +239,6 @@ const receiveResultNotificationText = computed(() => {
|
||||
return 'Model loaded!'
|
||||
})
|
||||
|
||||
const receiveResultNotificationLevel = computed(() => {
|
||||
if (failRate.value > 80) {
|
||||
return 'warning'
|
||||
}
|
||||
return 'info'
|
||||
})
|
||||
|
||||
const receiveResultNotification = computed(() => {
|
||||
if (
|
||||
!props.modelCard.bakedObjectIds ||
|
||||
@@ -219,7 +248,7 @@ const receiveResultNotification = computed(() => {
|
||||
|
||||
const notification = {} as ModelCardNotification
|
||||
notification.dismissible = true
|
||||
notification.level = receiveResultNotificationLevel.value
|
||||
notification.level = 'success'
|
||||
notification.text = receiveResultNotificationText.value
|
||||
notification.report = props.modelCard.report
|
||||
notification.cta = {
|
||||
@@ -241,6 +270,8 @@ const errorNotification = computed(() => {
|
||||
return notification
|
||||
})
|
||||
|
||||
const clientId = computed(() => projectAccount.value?.accountInfo.id)
|
||||
|
||||
const { result: versionDetailsResult, refetch } = useQuery(
|
||||
versionDetailsQuery,
|
||||
() => ({
|
||||
@@ -249,7 +280,8 @@ const { result: versionDetailsResult, refetch } = useQuery(
|
||||
versionId: props.modelCard.selectedVersionId
|
||||
}),
|
||||
() => ({
|
||||
clientId: projectAccount.value.accountInfo.id
|
||||
clientId: clientId.value,
|
||||
enabled: !!clientId.value
|
||||
})
|
||||
)
|
||||
|
||||
@@ -269,10 +301,6 @@ const latestVersionCreatedAt = computed(() => {
|
||||
return dayjs(props.modelCard.latestVersionCreatedAt).from(dayjs())
|
||||
})
|
||||
|
||||
const noReadAccess = computed(() => {
|
||||
return props.canEdit
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
refetch()
|
||||
})
|
||||
|
||||
+237
-19
@@ -4,6 +4,8 @@
|
||||
:model-card="modelCard"
|
||||
:project="project"
|
||||
:can-edit="canEdit"
|
||||
:cta-disabled="ctaDisabled"
|
||||
:cta-disabled-message="ctaDisabledMessage"
|
||||
@manual-publish-or-load="sendOrCancel"
|
||||
>
|
||||
<div class="flex max-[275px]:w-full overflow-hidden my-2">
|
||||
@@ -13,11 +15,10 @@
|
||||
size="sm"
|
||||
color="subtle"
|
||||
class="block text-foreground-2 hover:text-foreground overflow-hidden max-w-full !justify-start"
|
||||
:disabled="!!modelCard.progress || !props.canEdit"
|
||||
:disabled="!!modelCard.progress || !props.canEdit || isSendSettingsMissing"
|
||||
full-width
|
||||
@click.stop="openFilterDialog = true"
|
||||
>
|
||||
<!-- Sending -->
|
||||
<span class="font-bold">{{ modelCard.sendFilter?.name }}: </span>
|
||||
<span class="truncate">{{ modelCard.sendFilter?.summary }}</span>
|
||||
</FormButton>
|
||||
@@ -31,17 +32,69 @@
|
||||
<FilterListSelect :filter="modelCard.sendFilter" @update:filter="updateFilter" />
|
||||
|
||||
<div class="mt-4 flex justify-end items-center space-x-2">
|
||||
<!-- TODO: Ux wise, users might want to just save the selection and publish it later. -->
|
||||
<FormButton size="sm" color="outline" @click.stop="saveFilter()">
|
||||
<FormButton
|
||||
size="sm"
|
||||
color="outline"
|
||||
:disabled="isSaveDisabled"
|
||||
@click.stop="saveFilter()"
|
||||
>
|
||||
Save
|
||||
</FormButton>
|
||||
<FormButton size="sm" @click.stop="saveFilterAndSend()">
|
||||
Save & Publish
|
||||
</FormButton>
|
||||
<div v-tippy="!canCreateVersionPerm ? canCreateVersionMessage : ''">
|
||||
<FormButton
|
||||
size="sm"
|
||||
:disabled="!canCreateVersionPerm || isSaveDisabled"
|
||||
@click.stop="saveFilterAndSend()"
|
||||
>
|
||||
Save & Publish
|
||||
</FormButton>
|
||||
</div>
|
||||
</div>
|
||||
</CommonDialog>
|
||||
|
||||
<CommonDialog
|
||||
v-model:open="showSetMessageDialog"
|
||||
title="Version message"
|
||||
fullscreen="none"
|
||||
>
|
||||
<form @submit="setVersionMessage(versionMessage as string)">
|
||||
<div class="text-body-2xs mb-2 ml-1">
|
||||
Describe your latest changes to help keep track of design intent.
|
||||
</div>
|
||||
<FormTextArea
|
||||
v-model="versionMessage"
|
||||
class="text-xs"
|
||||
placeholder="Moved elements to prevent clash"
|
||||
autocomplete="off"
|
||||
name="name"
|
||||
label="Version message"
|
||||
color="foundation"
|
||||
:show-clear="!!versionMessage"
|
||||
:rules="[ValidationHelpers.isStringOfLength({ minLength: 3 })]"
|
||||
full-width
|
||||
/>
|
||||
<CommonLoadingBar v-if="isUpdatingVersionMessage" loading />
|
||||
<div class="mt-4 flex justify-end items-center space-x-2 w-full">
|
||||
<FormButton size="sm" text @click="showSetMessageDialog = false">
|
||||
Cancel
|
||||
</FormButton>
|
||||
<FormButton
|
||||
size="sm"
|
||||
submit
|
||||
:disabled="
|
||||
isUpdatingVersionMessage || !versionMessage || versionMessage.length < 3
|
||||
"
|
||||
>
|
||||
Save
|
||||
</FormButton>
|
||||
</div>
|
||||
</form>
|
||||
</CommonDialog>
|
||||
<template #states>
|
||||
<CommonModelNotification
|
||||
v-if="isSendSettingsMissing"
|
||||
:notification="sendSettingsMissingNotification"
|
||||
/>
|
||||
<CommonModelNotification
|
||||
v-if="expiredNotification"
|
||||
:notification="expiredNotification"
|
||||
@@ -66,7 +119,7 @@
|
||||
</ModelCardBase>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import ModelCardBase from '~/components/model/CardBase.vue'
|
||||
import { Square3Stack3DIcon } from '@heroicons/vue/20/solid'
|
||||
import type { ModelCardNotification } from '~/lib/models/card/notification'
|
||||
@@ -74,9 +127,24 @@ import type { ISendFilter, ISenderModelCard } from '~/lib/models/card/send'
|
||||
import type { ProjectModelGroup } from '~/store/hostApp'
|
||||
import { useHostAppStore } from '~/store/hostApp'
|
||||
import { useMixpanel } from '~/lib/core/composables/mixpanel'
|
||||
import { ToastNotificationType, ValidationHelpers } from '@speckle/ui-components'
|
||||
import {
|
||||
provideApolloClient,
|
||||
useMutation,
|
||||
useSubscription
|
||||
} from '@vue/apollo-composable'
|
||||
import { useAccountStore } from '~/store/accounts'
|
||||
import type { ApolloClient } from '@apollo/client/core'
|
||||
import { setVersionMessageMutation } from '~/lib/graphql/mutationsAndQueries'
|
||||
import { workspacePlanUsageUpdatedSubscription } from '~/lib/workspaces/graphql/subscriptions'
|
||||
import { useCheckGraphql } from '~/lib/core/composables/useCheckGraphql'
|
||||
|
||||
const store = useHostAppStore()
|
||||
const accountStore = useAccountStore()
|
||||
|
||||
const { trackEvent } = useMixpanel()
|
||||
const app = useNuxtApp()
|
||||
const { canCreateModelIngestion } = useCheckGraphql()
|
||||
|
||||
const cardBase = ref<InstanceType<typeof ModelCardBase>>()
|
||||
const props = defineProps<{
|
||||
@@ -85,46 +153,169 @@ const props = defineProps<{
|
||||
canEdit: boolean
|
||||
}>()
|
||||
|
||||
const store = useHostAppStore()
|
||||
const projectAccount = computed(() =>
|
||||
accountStore.accountWithFallback(props.project.accountId, props.project.serverUrl)
|
||||
)
|
||||
const clientId = computed(() => projectAccount.value?.accountInfo.id)
|
||||
|
||||
const openFilterDialog = ref(false)
|
||||
app.$baseBinding.on('documentChanged', () => {
|
||||
app.$baseBinding?.on('documentChanged', () => {
|
||||
openFilterDialog.value = false
|
||||
})
|
||||
|
||||
const canCreateVersionPerm = ref(true)
|
||||
const canCreateVersionMessage = ref<string | null>(null)
|
||||
|
||||
const checkPermissions = async () => {
|
||||
const res = await canCreateModelIngestion(
|
||||
props.modelCard.projectId,
|
||||
props.modelCard.modelId,
|
||||
props.modelCard.accountId
|
||||
)
|
||||
if (res.queryAvailable) {
|
||||
canCreateVersionPerm.value = res.authorized
|
||||
canCreateVersionMessage.value = res.message || null
|
||||
}
|
||||
}
|
||||
|
||||
const ctaDisabled = computed(
|
||||
() => !canCreateVersionPerm.value || !!props.modelCard.progress
|
||||
)
|
||||
const ctaDisabledMessage = computed(() => canCreateVersionMessage.value || undefined)
|
||||
|
||||
const { onResult: onWorkspacePlanUsageUpdated } = useSubscription(
|
||||
workspacePlanUsageUpdatedSubscription,
|
||||
() => ({
|
||||
input: {
|
||||
workspaceId: props.modelCard.workspaceId as string
|
||||
}
|
||||
}),
|
||||
() => ({ clientId: clientId.value })
|
||||
)
|
||||
|
||||
onWorkspacePlanUsageUpdated(() => {
|
||||
void checkPermissions()
|
||||
})
|
||||
|
||||
const sendOrCancel = () => {
|
||||
if (!props.canEdit) {
|
||||
// check for progress first to allow cancelling even if permissions changed
|
||||
if (props.modelCard.progress) {
|
||||
store.sendModelCancel(props.modelCard.modelCardId)
|
||||
return
|
||||
}
|
||||
if (props.modelCard.progress) store.sendModelCancel(props.modelCard.modelCardId)
|
||||
else store.sendModel(props.modelCard.modelCardId, 'ModelCardButton')
|
||||
|
||||
if (!props.canEdit || !canCreateVersionPerm.value) {
|
||||
return
|
||||
}
|
||||
|
||||
store.sendModel(props.modelCard.modelCardId, 'ModelCardButton')
|
||||
hasSetVersionMessage.value = false
|
||||
}
|
||||
|
||||
let newFilter: ISendFilter
|
||||
const newFilter = ref<ISendFilter>()
|
||||
const updateFilter = (filter: ISendFilter) => {
|
||||
newFilter = filter
|
||||
newFilter.value = filter
|
||||
}
|
||||
|
||||
const isSaveDisabled = computed(() => {
|
||||
const filterToCheck = newFilter.value || props.modelCard.sendFilter
|
||||
return !store.validateSendFilter(filterToCheck).valid
|
||||
})
|
||||
|
||||
const saveFilter = async () => {
|
||||
if (!newFilter.value) return // Safety check
|
||||
void trackEvent('DUI3 Action', {
|
||||
name: 'Publish Card Filter Change',
|
||||
filter: newFilter.typeDiscriminator
|
||||
filter: newFilter.value.typeDiscriminator
|
||||
})
|
||||
|
||||
// do not reset idmap while creating a new one because it is managed by host app
|
||||
newFilter.idMap = props.modelCard.sendFilter?.idMap
|
||||
newFilter.value.idMap = props.modelCard.sendFilter?.idMap
|
||||
|
||||
await store.patchModel(props.modelCard.modelCardId, {
|
||||
sendFilter: newFilter,
|
||||
sendFilter: newFilter.value,
|
||||
expired: true
|
||||
})
|
||||
openFilterDialog.value = false
|
||||
}
|
||||
|
||||
const showSetMessageDialog = ref(false)
|
||||
const isUpdatingVersionMessage = ref(false)
|
||||
const hasSetVersionMessage = ref(false)
|
||||
const versionMessage = ref<string>()
|
||||
|
||||
const setVersionMessage = async (message: string) => {
|
||||
if (!props.modelCard.latestCreatedVersionId) {
|
||||
return
|
||||
}
|
||||
|
||||
void trackEvent('DUI3 Action', {
|
||||
name: 'Set version message'
|
||||
})
|
||||
|
||||
isUpdatingVersionMessage.value = true
|
||||
const client = projectAccount.value?.client as ApolloClient<unknown> | undefined
|
||||
if (!client) return
|
||||
const { mutate } = provideApolloClient(client)(() =>
|
||||
useMutation(setVersionMessageMutation)
|
||||
)
|
||||
|
||||
const res = await mutate({
|
||||
input: {
|
||||
projectId: props.project.projectId,
|
||||
versionId: props.modelCard.latestCreatedVersionId,
|
||||
message
|
||||
}
|
||||
})
|
||||
|
||||
if (res?.data?.versionMutations.update.id) {
|
||||
// seemed to noisy, and autoclose does not work for some reason.
|
||||
// nicer ux to just close the dialog
|
||||
// store.setNotification({
|
||||
// type: ToastNotificationType.Info,
|
||||
// title: 'Version message saved',
|
||||
// autoClose: true
|
||||
// })
|
||||
hasSetVersionMessage.value = true
|
||||
} else {
|
||||
store.setNotification({
|
||||
type: ToastNotificationType.Danger,
|
||||
title: 'Request failed',
|
||||
description: 'Failed to update version message.',
|
||||
autoClose: true
|
||||
})
|
||||
}
|
||||
showSetMessageDialog.value = false
|
||||
isUpdatingVersionMessage.value = false
|
||||
}
|
||||
|
||||
const saveFilterAndSend = async () => {
|
||||
await saveFilter()
|
||||
store.sendModel(props.modelCard.modelCardId, 'Filter')
|
||||
hasSetVersionMessage.value = false
|
||||
}
|
||||
|
||||
const isSendSettingsMissing = computed(
|
||||
() => store.sendSettings && store.sendSettings.length > 0 && !props.modelCard.settings
|
||||
)
|
||||
|
||||
const sendSettingsMissingNotification = computed(() => {
|
||||
const notification = {} as ModelCardNotification
|
||||
notification.dismissible = false
|
||||
notification.level = 'danger'
|
||||
notification.text = 'Publish settings are corrupted for some reason.'
|
||||
|
||||
notification.cta = {
|
||||
name: 'Refresh',
|
||||
action: async () => {
|
||||
await store.patchModel(props.modelCard.modelCardId, {
|
||||
settings: store.sendSettings
|
||||
})
|
||||
}
|
||||
}
|
||||
return notification
|
||||
})
|
||||
|
||||
const expiredNotification = computed(() => {
|
||||
if (!props.modelCard.expired) return
|
||||
|
||||
@@ -138,7 +329,12 @@ const expiredNotification = computed(() => {
|
||||
const ctaType = props.modelCard.progress ? 'Restart' : 'Update'
|
||||
notification.cta = {
|
||||
name: ctaType,
|
||||
disabled: !canCreateVersionPerm.value,
|
||||
tooltipText: !canCreateVersionPerm.value
|
||||
? canCreateVersionMessage.value || 'Publish limit reached'
|
||||
: undefined,
|
||||
action: async () => {
|
||||
hasSetVersionMessage.value = false
|
||||
if (props.modelCard.progress) {
|
||||
await store.sendModelCancel(props.modelCard.modelCardId)
|
||||
}
|
||||
@@ -188,11 +384,33 @@ const latestVersionNotification = computed(() => {
|
||||
notification.level = sendResultNotificationLevel.value
|
||||
notification.text = sendResultNotificationText.value
|
||||
notification.report = props.modelCard.report
|
||||
|
||||
// NOTE: this prevents us displaying the set message button for non-updated
|
||||
// connectors that send over the root object id over instead of the commit id
|
||||
if (
|
||||
props.modelCard.latestCreatedVersionId.length === 10 &&
|
||||
!hasSetVersionMessage.value
|
||||
) {
|
||||
notification.secondaryCta = {
|
||||
name: 'Set message',
|
||||
tooltipText: 'Describe your changes',
|
||||
action: () => {
|
||||
showSetMessageDialog.value = true
|
||||
versionMessage.value = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
notification.cta = {
|
||||
name: 'View version',
|
||||
name: 'View',
|
||||
tooltipText: 'Check your model in the browser!',
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-member-access
|
||||
action: () => cardBase.value?.viewModel()
|
||||
}
|
||||
return notification
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
void checkPermissions()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -27,28 +27,29 @@ import type { CardSetting, CardSettingValue } from '~/lib/models/card/setting'
|
||||
import type { JsonFormsChangeEvent } from '@jsonforms/vue'
|
||||
import { cloneDeep, omit } from 'lodash-es'
|
||||
import type { JsonSchema } from '@jsonforms/core'
|
||||
import { useHostAppStore } from '~/store/hostApp'
|
||||
// import { useHostAppStore } from '~/store/hostApp'
|
||||
|
||||
const props = defineProps<{
|
||||
settings?: CardSetting[]
|
||||
defaultSettings: CardSetting[]
|
||||
expandable: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ (e: 'update:settings', value: CardSetting[]): void }>()
|
||||
|
||||
const store = useHostAppStore()
|
||||
// const store = useHostAppStore()
|
||||
|
||||
const defaultSendSettings = computed(() => store.sendSettings)
|
||||
const sendSettings = ref<CardSetting[] | undefined>(
|
||||
cloneDeep(props.settings ?? defaultSendSettings.value) // need to prevent mutation!
|
||||
// const defaultSendSettings = computed(() => store.sendSettings)
|
||||
const settings = ref<CardSetting[] | undefined>(
|
||||
cloneDeep(props.settings ?? props.defaultSettings) // need to prevent mutation!
|
||||
)
|
||||
|
||||
const showSettings = ref(!props.expandable)
|
||||
|
||||
const settingsJsonForms = computed(() => {
|
||||
if (sendSettings.value === undefined) return {}
|
||||
if (settings.value === undefined) return {}
|
||||
const obj: JsonSchema = { type: 'object', properties: {} }
|
||||
sendSettings.value.forEach((setting: CardSetting) => {
|
||||
settings.value.forEach((setting: CardSetting) => {
|
||||
const mappedSetting = omit({ ...setting, $id: setting.id }, ['id'])
|
||||
if (obj && obj.properties) {
|
||||
obj.properties[setting.id] = mappedSetting
|
||||
@@ -60,8 +61,8 @@ const settingsJsonForms = computed(() => {
|
||||
type DataType = Record<string, unknown>
|
||||
const data = computed(() => {
|
||||
const settingValues = {} as DataType
|
||||
if (sendSettings.value) {
|
||||
sendSettings.value.forEach((setting) => {
|
||||
if (settings.value) {
|
||||
settings.value.forEach((setting) => {
|
||||
settingValues[setting.id as string] = setting.value
|
||||
})
|
||||
}
|
||||
@@ -69,14 +70,14 @@ const data = computed(() => {
|
||||
})
|
||||
|
||||
const onParamsFormChange = (e: JsonFormsChangeEvent) => {
|
||||
if (sendSettings.value === undefined) return
|
||||
sendSettings.value?.forEach((setting) => {
|
||||
if (settings.value === undefined) return
|
||||
settings.value?.forEach((setting) => {
|
||||
if (setting) {
|
||||
if (setting.value !== (e.data as DataType)[setting.id]) {
|
||||
setting.value = (e.data as DataType)[setting.id] as CardSettingValue
|
||||
}
|
||||
}
|
||||
})
|
||||
emit('update:settings', sendSettings.value)
|
||||
emit('update:settings', settings.value)
|
||||
}
|
||||
</script>
|
||||
@@ -1,220 +0,0 @@
|
||||
<template>
|
||||
<div class="p-0">
|
||||
<slot name="activator" :toggle="toggleDialog"></slot>
|
||||
<CommonDialog
|
||||
v-model:open="showProjectCreateDialog"
|
||||
:title="`Create new project`"
|
||||
fullscreen="none"
|
||||
>
|
||||
<form @submit="onSubmitCreateNewProject">
|
||||
<div class="text-body-2xs mb-2 ml-1">Project name</div>
|
||||
<FormTextInput
|
||||
v-model="newProjectName"
|
||||
class="text-xs"
|
||||
placeholder="A Beautiful Home, A Small Bridge..."
|
||||
autocomplete="off"
|
||||
name="name"
|
||||
label="Project name"
|
||||
color="foundation"
|
||||
:show-clear="!!newProjectName"
|
||||
:rules="[
|
||||
ValidationHelpers.isRequired,
|
||||
ValidationHelpers.isStringOfLength({ minLength: 3 })
|
||||
]"
|
||||
full-width
|
||||
/>
|
||||
<div class="mt-4 flex justify-end items-center space-x-2 w-full">
|
||||
<FormButton size="sm" text @click="showProjectCreateDialog = false">
|
||||
Cancel
|
||||
</FormButton>
|
||||
<FormButton
|
||||
size="sm"
|
||||
submit
|
||||
:disabled="isCreatingProject || !canCreateProject"
|
||||
>
|
||||
Create
|
||||
</FormButton>
|
||||
</div>
|
||||
</form>
|
||||
</CommonDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useMutation, provideApolloClient, useQuery } from '@vue/apollo-composable'
|
||||
import type { ProjectListProjectItemFragment } from '~/lib/common/generated/gql/graphql'
|
||||
import {
|
||||
canCreatePersonalProjectQuery,
|
||||
canCreateProjectInWorkspaceQuery,
|
||||
createProjectInWorkspaceMutation,
|
||||
createProjectMutation
|
||||
} from '~/lib/graphql/mutationsAndQueries'
|
||||
import type { DUIAccount } from '~/store/accounts'
|
||||
import { useAccountStore } from '~/store/accounts'
|
||||
import { useMixpanel } from '~/lib/core/composables/mixpanel'
|
||||
import { useHostAppStore } from '~/store/hostApp'
|
||||
import { useForm } from 'vee-validate'
|
||||
import { ValidationHelpers } from '@speckle/ui-components'
|
||||
|
||||
const showProjectCreateDialog = ref(false)
|
||||
const isCreatingProject = ref(false)
|
||||
|
||||
const props = defineProps<{ workspaceId?: string }>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'project:created', result: ProjectListProjectItemFragment): void
|
||||
}>()
|
||||
|
||||
const { trackEvent } = useMixpanel()
|
||||
const accountStore = useAccountStore()
|
||||
const hostAppStore = useHostAppStore()
|
||||
const { activeAccount } = storeToRefs(accountStore)
|
||||
|
||||
const accountId = computed(() => activeAccount.value.accountInfo.id)
|
||||
const newProjectName = ref<string>()
|
||||
|
||||
const errorMessageForWorkspace = ref<string>()
|
||||
const errorMessageForPersonalProject = ref<string>()
|
||||
|
||||
const toggleDialog = () => {
|
||||
showProjectCreateDialog.value = !showProjectCreateDialog.value
|
||||
}
|
||||
|
||||
const account = computed(() => {
|
||||
return accountStore.accounts.find(
|
||||
(acc) => acc.accountInfo.id === accountId.value
|
||||
) as DUIAccount
|
||||
})
|
||||
|
||||
const canCreateProject = computed(() =>
|
||||
props.workspaceId === 'personalProject'
|
||||
? canCreatePersonalProject.value
|
||||
: canCreateProjectInWorkspace.value
|
||||
)
|
||||
|
||||
const { result: canCreatePersonalProjectResult } = useQuery(
|
||||
canCreatePersonalProjectQuery,
|
||||
() => ({}),
|
||||
() => ({
|
||||
clientId: accountId.value,
|
||||
debounce: 500,
|
||||
fetchPolicy: 'network-only'
|
||||
})
|
||||
)
|
||||
|
||||
watch(canCreatePersonalProjectResult, (val) => {
|
||||
if (val?.activeUser?.permissions.canCreatePersonalProject.code !== 'OK') {
|
||||
errorMessageForPersonalProject.value =
|
||||
val?.activeUser?.permissions.canCreatePersonalProject.message
|
||||
}
|
||||
})
|
||||
|
||||
const canCreatePersonalProject = computed(() => {
|
||||
try {
|
||||
return (
|
||||
canCreatePersonalProjectResult.value?.activeUser?.permissions
|
||||
.canCreatePersonalProject.code === 'OK'
|
||||
)
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
})
|
||||
|
||||
const { result: canCreateProjectInWorkspaceResult } = useQuery(
|
||||
canCreateProjectInWorkspaceQuery,
|
||||
() => ({ workspaceId: props.workspaceId ?? 'null' }), // TODO: i do not know the potential cause here
|
||||
() => ({
|
||||
clientId: accountId.value,
|
||||
debounce: 500,
|
||||
fetchPolicy: 'network-only'
|
||||
})
|
||||
)
|
||||
|
||||
watch(canCreateProjectInWorkspaceResult, (val) => {
|
||||
if (val?.workspace.permissions.canCreateProject.code !== 'OK') {
|
||||
errorMessageForWorkspace.value = val?.workspace.permissions.canCreateProject.message
|
||||
}
|
||||
})
|
||||
|
||||
const canCreateProjectInWorkspace = computed(() => {
|
||||
try {
|
||||
return (
|
||||
canCreateProjectInWorkspaceResult.value?.workspace.permissions.canCreateProject
|
||||
.code === 'OK'
|
||||
)
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
})
|
||||
|
||||
const { handleSubmit } = useForm<{ name: string }>()
|
||||
const onSubmitCreateNewProject = handleSubmit(() => {
|
||||
// TODO: Chat with Fabians
|
||||
// This works, but if we use handleSubmit(args) > args.name -> it is undefined in Production on netlify, but works fine on local dev
|
||||
void createNewProject(newProjectName.value as string)
|
||||
})
|
||||
|
||||
const createNewProject = async (name: string) => {
|
||||
isCreatingProject.value = true
|
||||
|
||||
if (props.workspaceId !== 'personalProject' && props.workspaceId !== undefined) {
|
||||
createNewProjectInWorkspace(name)
|
||||
isCreatingProject.value = false
|
||||
return
|
||||
}
|
||||
|
||||
void trackEvent(
|
||||
'DUI3 Action',
|
||||
{ name: 'Project Create', workspace: false },
|
||||
account.value.accountInfo.id
|
||||
)
|
||||
const { mutate } = provideApolloClient(account.value.client)(() =>
|
||||
useMutation(createProjectMutation)
|
||||
)
|
||||
const res = await mutate({ input: { name } })
|
||||
if (res?.data?.projectMutations.create) {
|
||||
emit('project:created', res?.data?.projectMutations.create)
|
||||
} else {
|
||||
let errorMessage = 'Undefined error'
|
||||
if (res?.errors && res?.errors.length !== 0) {
|
||||
errorMessage = res?.errors[0].message
|
||||
}
|
||||
|
||||
hostAppStore.setNotification({
|
||||
type: 1,
|
||||
title: 'Failed to create project',
|
||||
description: errorMessage
|
||||
})
|
||||
}
|
||||
isCreatingProject.value = false
|
||||
}
|
||||
|
||||
const createNewProjectInWorkspace = async (name: string) => {
|
||||
void trackEvent(
|
||||
'DUI3 Action',
|
||||
{ name: 'Project Create', workspace: true },
|
||||
account.value.accountInfo.id
|
||||
)
|
||||
const { mutate } = provideApolloClient(account.value.client)(() =>
|
||||
useMutation(createProjectInWorkspaceMutation)
|
||||
)
|
||||
const res = await mutate({
|
||||
input: { name, workspaceId: props.workspaceId as string }
|
||||
})
|
||||
if (res?.data?.workspaceMutations.projects.create) {
|
||||
emit('project:created', res?.data?.workspaceMutations.projects.create)
|
||||
} else {
|
||||
let errorMessage = 'Undefined error'
|
||||
if (res?.errors && res?.errors.length !== 0) {
|
||||
errorMessage = res?.errors[0].message
|
||||
}
|
||||
|
||||
hostAppStore.setNotification({
|
||||
type: 1,
|
||||
title: 'Failed to create project',
|
||||
description: errorMessage
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,141 +0,0 @@
|
||||
<template>
|
||||
<div class="p-0">
|
||||
<slot name="activator" :toggle="toggleDialog"></slot>
|
||||
<CommonDialog
|
||||
v-model:open="showProjectCreateDialog"
|
||||
:title="`Create new project`"
|
||||
fullscreen="none"
|
||||
>
|
||||
<form @submit="onSubmitCreateNewProject">
|
||||
<div class="text-body-2xs mb-2 ml-1">Project name</div>
|
||||
<FormTextInput
|
||||
v-model="newProjectName"
|
||||
class="text-xs"
|
||||
placeholder="A Beautiful Home, A Small Bridge..."
|
||||
autocomplete="off"
|
||||
name="name"
|
||||
label="Project name"
|
||||
color="foundation"
|
||||
:show-clear="!!newProjectName"
|
||||
:rules="[
|
||||
ValidationHelpers.isRequired,
|
||||
ValidationHelpers.isStringOfLength({ minLength: 3 })
|
||||
]"
|
||||
full-width
|
||||
/>
|
||||
<div class="mt-4 flex justify-end items-center space-x-2 w-full">
|
||||
<FormButton size="sm" text @click="showProjectCreateDialog = false">
|
||||
Cancel
|
||||
</FormButton>
|
||||
<FormButton
|
||||
size="sm"
|
||||
submit
|
||||
:disabled="isCreatingProject || !canCreatePersonalProject"
|
||||
>
|
||||
Create
|
||||
</FormButton>
|
||||
</div>
|
||||
</form>
|
||||
</CommonDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useMutation, provideApolloClient, useQuery } from '@vue/apollo-composable'
|
||||
import type { ProjectListProjectItemFragment } from '~/lib/common/generated/gql/graphql'
|
||||
import {
|
||||
canCreatePersonalProjectQuery,
|
||||
createProjectMutation
|
||||
} from '~/lib/graphql/mutationsAndQueries'
|
||||
import type { DUIAccount } from '~/store/accounts'
|
||||
import { useAccountStore } from '~/store/accounts'
|
||||
import { useMixpanel } from '~/lib/core/composables/mixpanel'
|
||||
import { useHostAppStore } from '~/store/hostApp'
|
||||
import { useForm } from 'vee-validate'
|
||||
import { ValidationHelpers } from '@speckle/ui-components'
|
||||
|
||||
const showProjectCreateDialog = ref(false)
|
||||
const isCreatingProject = ref(false)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'project:created', result: ProjectListProjectItemFragment): void
|
||||
}>()
|
||||
|
||||
const { trackEvent } = useMixpanel()
|
||||
const accountStore = useAccountStore()
|
||||
const hostAppStore = useHostAppStore()
|
||||
const { activeAccount } = storeToRefs(accountStore)
|
||||
|
||||
const accountId = computed(() => activeAccount.value.accountInfo.id)
|
||||
const newProjectName = ref<string>()
|
||||
|
||||
const errorMessage = ref<string>()
|
||||
|
||||
const toggleDialog = () => {
|
||||
showProjectCreateDialog.value = !showProjectCreateDialog.value
|
||||
}
|
||||
|
||||
const account = computed(() => {
|
||||
return accountStore.accounts.find(
|
||||
(acc) => acc.accountInfo.id === accountId.value
|
||||
) as DUIAccount
|
||||
})
|
||||
|
||||
const canCreatePersonalProject = ref<boolean>(false)
|
||||
|
||||
const { result: canCreatePersonalProjectResult } = useQuery(
|
||||
canCreatePersonalProjectQuery,
|
||||
() => ({}),
|
||||
() => ({
|
||||
clientId: accountId.value,
|
||||
debounce: 500,
|
||||
fetchPolicy: 'network-only'
|
||||
})
|
||||
)
|
||||
|
||||
watch(canCreatePersonalProjectResult, (val) => {
|
||||
if (val?.activeUser?.permissions.canCreatePersonalProject.code !== 'OK') {
|
||||
errorMessage.value = val?.activeUser?.permissions.canCreatePersonalProject.message
|
||||
canCreatePersonalProject.value = false
|
||||
} else {
|
||||
canCreatePersonalProject.value = true
|
||||
}
|
||||
})
|
||||
|
||||
const { handleSubmit } = useForm<{ name: string }>()
|
||||
const onSubmitCreateNewProject = handleSubmit(() => {
|
||||
// TODO: Chat with Fabians
|
||||
// This works, but if we use handleSubmit(args) > args.name -> it is undefined in Production on netlify, but works fine on local dev
|
||||
void createNewProject(newProjectName.value as string)
|
||||
})
|
||||
|
||||
const createNewProject = async (name: string) => {
|
||||
isCreatingProject.value = true
|
||||
|
||||
void trackEvent(
|
||||
'DUI3 Action',
|
||||
{ name: 'Project Create', workspace: false },
|
||||
account.value.accountInfo.id
|
||||
)
|
||||
const { mutate } = provideApolloClient(account.value.client)(() =>
|
||||
useMutation(createProjectMutation)
|
||||
)
|
||||
const res = await mutate({ input: { name } })
|
||||
if (res?.data?.projectMutations.create) {
|
||||
emit('project:created', res?.data?.projectMutations.create)
|
||||
} else {
|
||||
let errorMessage = 'Undefined error'
|
||||
if (res?.errors && res?.errors.length !== 0) {
|
||||
errorMessage = res?.errors[0].message
|
||||
}
|
||||
|
||||
hostAppStore.setNotification({
|
||||
type: 1,
|
||||
title: 'Failed to create project',
|
||||
description: errorMessage
|
||||
})
|
||||
}
|
||||
isCreatingProject.value = false
|
||||
}
|
||||
</script>
|
||||
@@ -1,198 +0,0 @@
|
||||
<template>
|
||||
<div class="p-0">
|
||||
<slot name="activator" :toggle="toggleDialog"></slot>
|
||||
<CommonDialog
|
||||
v-model:open="showProjectCreateDialog"
|
||||
:title="canCreateProjectInWorkspace ? `Create new project` : errorMessage?.title"
|
||||
fullscreen="none"
|
||||
>
|
||||
<form v-if="canCreateProjectInWorkspace" @submit="onSubmitCreateNewProject">
|
||||
<div class="text-body-2xs mb-2 ml-1">Project name</div>
|
||||
<FormTextInput
|
||||
v-model="newProjectName"
|
||||
class="text-xs"
|
||||
placeholder="A Beautiful Home, A Small Bridge..."
|
||||
autocomplete="off"
|
||||
name="name"
|
||||
label="Project name"
|
||||
color="foundation"
|
||||
:show-clear="!!newProjectName"
|
||||
:rules="[
|
||||
ValidationHelpers.isRequired,
|
||||
ValidationHelpers.isStringOfLength({ minLength: 3 })
|
||||
]"
|
||||
full-width
|
||||
/>
|
||||
<div class="mt-4 flex justify-end items-center space-x-2 w-full">
|
||||
<FormButton
|
||||
size="sm"
|
||||
color="outline"
|
||||
@click="showProjectCreateDialog = false"
|
||||
>
|
||||
Cancel
|
||||
</FormButton>
|
||||
<FormButton size="sm" submit :disabled="isCreatingProject">Create</FormButton>
|
||||
</div>
|
||||
</form>
|
||||
<div v-else class="m-2">
|
||||
{{ errorMessage?.description }}
|
||||
<div class="flex mt-2 space-x-2 justify-end">
|
||||
<FormButton
|
||||
size="sm"
|
||||
color="outline"
|
||||
@click="showProjectCreateDialog = false"
|
||||
>
|
||||
Close
|
||||
</FormButton>
|
||||
<FormButton
|
||||
v-if="errorMessage?.cta"
|
||||
size="sm"
|
||||
submit
|
||||
@click="errorMessage?.cta?.action(), (showProjectCreateDialog = false)"
|
||||
>
|
||||
{{ errorMessage?.cta?.name }}
|
||||
</FormButton>
|
||||
</div>
|
||||
</div>
|
||||
</CommonDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useMutation, provideApolloClient, useQuery } from '@vue/apollo-composable'
|
||||
import type {
|
||||
ProjectListProjectItemFragment,
|
||||
WorkspaceListWorkspaceItemFragment
|
||||
} from '~/lib/common/generated/gql/graphql'
|
||||
import {
|
||||
canCreateProjectInWorkspaceQuery,
|
||||
createProjectInWorkspaceMutation
|
||||
} from '~/lib/graphql/mutationsAndQueries'
|
||||
import type { DUIAccount } from '~/store/accounts'
|
||||
import { useAccountStore } from '~/store/accounts'
|
||||
import { useMixpanel } from '~/lib/core/composables/mixpanel'
|
||||
import { useHostAppStore } from '~/store/hostApp'
|
||||
import { useForm } from 'vee-validate'
|
||||
import { ValidationHelpers } from '@speckle/ui-components'
|
||||
|
||||
type WorkspacePermissionMessage = {
|
||||
title: string
|
||||
description: string
|
||||
cta?: {
|
||||
name: string
|
||||
action: () => void
|
||||
}
|
||||
}
|
||||
|
||||
const { $openUrl } = useNuxtApp()
|
||||
|
||||
const showProjectCreateDialog = ref(false)
|
||||
const isCreatingProject = ref(false)
|
||||
|
||||
const props = defineProps<{ workspace?: WorkspaceListWorkspaceItemFragment }>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'project:created', result: ProjectListProjectItemFragment): void
|
||||
}>()
|
||||
|
||||
const { trackEvent } = useMixpanel()
|
||||
const accountStore = useAccountStore()
|
||||
const hostAppStore = useHostAppStore()
|
||||
const { activeAccount } = storeToRefs(accountStore)
|
||||
|
||||
const accountId = computed(() => activeAccount.value.accountInfo.id)
|
||||
const newProjectName = ref<string>()
|
||||
|
||||
const errorMessage = ref<WorkspacePermissionMessage>()
|
||||
|
||||
const toggleDialog = () => {
|
||||
showProjectCreateDialog.value = !showProjectCreateDialog.value
|
||||
}
|
||||
|
||||
const account = computed(() => {
|
||||
return accountStore.accounts.find(
|
||||
(acc) => acc.accountInfo.id === accountId.value
|
||||
) as DUIAccount
|
||||
})
|
||||
|
||||
const canCreateProjectInWorkspace = ref<boolean>()
|
||||
|
||||
const { result: canCreateProjectInWorkspaceResult } = useQuery(
|
||||
canCreateProjectInWorkspaceQuery,
|
||||
() => ({ workspaceId: props.workspace?.id ?? 'null' }), // TODO: i do not know the potential cause here
|
||||
() => ({
|
||||
clientId: accountId.value,
|
||||
debounce: 500,
|
||||
fetchPolicy: 'network-only'
|
||||
})
|
||||
)
|
||||
|
||||
watch(canCreateProjectInWorkspaceResult, (val) => {
|
||||
if (val?.workspace.permissions.canCreateProject.code !== 'OK') {
|
||||
switch (val?.workspace.permissions.canCreateProject.code) {
|
||||
case 'WorkspaceLimitsReached':
|
||||
errorMessage.value = {
|
||||
title: 'Plan limit reached',
|
||||
description:
|
||||
'The project limit for this workspace has been reached. Upgrade the workspace plan to create or move more projects.',
|
||||
cta: {
|
||||
name: 'Explore Plans',
|
||||
action: () =>
|
||||
$openUrl(
|
||||
`${account.value.accountInfo.serverInfo.url}/settings/workspaces/${props.workspace?.slug}/billing`
|
||||
)
|
||||
}
|
||||
}
|
||||
break
|
||||
// TODO: we should add more cases later according to `code`
|
||||
default:
|
||||
errorMessage.value = {
|
||||
title: 'Workspace warning',
|
||||
description: val?.workspace.permissions.canCreateProject.message ?? 'error'
|
||||
}
|
||||
break
|
||||
}
|
||||
canCreateProjectInWorkspace.value = false
|
||||
} else {
|
||||
canCreateProjectInWorkspace.value = true
|
||||
}
|
||||
})
|
||||
|
||||
const { handleSubmit } = useForm<{ name: string }>()
|
||||
const onSubmitCreateNewProject = handleSubmit(() => {
|
||||
// TODO: Chat with Fabians
|
||||
// This works, but if we use handleSubmit(args) > args.name -> it is undefined in Production on netlify, but works fine on local dev
|
||||
void createNewProjectInWorkspace(newProjectName.value as string)
|
||||
})
|
||||
|
||||
const createNewProjectInWorkspace = async (name: string) => {
|
||||
isCreatingProject.value = true
|
||||
void trackEvent(
|
||||
'DUI3 Action',
|
||||
{ name: 'Project Create', workspace: true },
|
||||
account.value.accountInfo.id
|
||||
)
|
||||
const { mutate } = provideApolloClient(account.value.client)(() =>
|
||||
useMutation(createProjectInWorkspaceMutation)
|
||||
)
|
||||
const res = await mutate({
|
||||
input: { name, workspaceId: props.workspace?.id as string }
|
||||
})
|
||||
if (res?.data?.workspaceMutations.projects.create) {
|
||||
emit('project:created', res?.data?.workspaceMutations.projects.create)
|
||||
} else {
|
||||
let errorMessage = 'Undefined error'
|
||||
if (res?.errors && res?.errors.length !== 0) {
|
||||
errorMessage = res?.errors[0].message
|
||||
}
|
||||
|
||||
hostAppStore.setNotification({
|
||||
type: 1,
|
||||
title: 'Failed to create project',
|
||||
description: errorMessage
|
||||
})
|
||||
}
|
||||
isCreatingProject.value = false
|
||||
}
|
||||
</script>
|
||||
@@ -5,13 +5,19 @@
|
||||
:title="title"
|
||||
:show-back-button="step !== 1"
|
||||
@back="step--"
|
||||
@fully-closed="step = 1"
|
||||
@fully-closed="
|
||||
() => {
|
||||
step = 1
|
||||
settingsWereChanged = false
|
||||
}
|
||||
"
|
||||
>
|
||||
<div>
|
||||
<div v-if="step === 1">
|
||||
<WizardProjectSelector
|
||||
:is-sender="false"
|
||||
:show-new-project="false"
|
||||
:url-parse-error="urlParseError"
|
||||
@next="selectProject"
|
||||
@search-text-update="updateSearchText"
|
||||
/>
|
||||
@@ -36,10 +42,11 @@
|
||||
:workspace-slug="selectedWorkspace?.slug"
|
||||
:from-wizard="true"
|
||||
@next="selectVersionAndAddModel"
|
||||
@update:settings="handleUpdateSettings"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="urlParseError" class="p-2 text-xs text-danger">{{ urlParseError }}</div>
|
||||
<div v-if="urlParseError" class="p-2 text-danger">{{ urlParseError }}</div>
|
||||
</CommonDialog>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
@@ -54,10 +61,13 @@ import { useHostAppStore } from '~/store/hostApp'
|
||||
import { useAccountStore } from '~/store/accounts'
|
||||
import { ReceiverModelCard } from '~/lib/models/card/receiver'
|
||||
import { useMixpanel } from '~/lib/core/composables/mixpanel'
|
||||
import { useSettingsTracking } from '~/lib/core/composables/trackSettings'
|
||||
import { useAddByUrl } from '~/lib/core/composables/addByUrl'
|
||||
import { getSlugFromHostAppNameAndVersion } from '~/lib/common/helpers/hostAppSlug'
|
||||
import type { CardSetting } from '~/lib/models/card/setting'
|
||||
|
||||
const { trackEvent } = useMixpanel()
|
||||
const { trackSettingsChange } = useSettingsTracking()
|
||||
|
||||
const showReceiveDialog = defineModel<boolean>('open', { default: false })
|
||||
|
||||
@@ -82,6 +92,8 @@ const selectedAccountId = ref<string>(activeAccount.value?.accountInfo.id as str
|
||||
const selectedWorkspace = ref<WorkspaceListWorkspaceItemFragment>()
|
||||
const selectedProject = ref<ProjectListProjectItemFragment>()
|
||||
const selectedModel = ref<ModelListModelItemFragment>()
|
||||
const receieveSettings = ref<CardSetting[] | undefined>(undefined)
|
||||
const settingsWereChanged = ref(false)
|
||||
|
||||
const { tryParseUrl, urlParsedData, urlParseError } = useAddByUrl()
|
||||
const updateSearchText = (text: string | undefined) => {
|
||||
@@ -130,6 +142,11 @@ const title = computed(() => {
|
||||
return ''
|
||||
})
|
||||
|
||||
const handleUpdateSettings = (settings: CardSetting[]) => {
|
||||
receieveSettings.value = settings
|
||||
settingsWereChanged.value = true
|
||||
}
|
||||
|
||||
// accountId, serverUrl, ModelListModelItemFragment, VersionListItemFragment
|
||||
const selectVersionAndAddModel = async (
|
||||
version: VersionListItemFragment,
|
||||
@@ -147,23 +164,39 @@ const selectVersionAndAddModel = async (
|
||||
m.typeDiscriminator === 'ReceiverModelCard'
|
||||
) as ReceiverModelCard
|
||||
|
||||
// track settings only if user changed them on receive
|
||||
// compare against existing model settings if it exists, otherwise compare against defaults
|
||||
if (settingsWereChanged.value && receieveSettings.value) {
|
||||
trackSettingsChange(
|
||||
'Load Settings Changed',
|
||||
receieveSettings.value,
|
||||
existingModel?.settings || hostAppStore.receiveSettings || [],
|
||||
selectedAccountId.value,
|
||||
true
|
||||
)
|
||||
}
|
||||
|
||||
if (existingModel) {
|
||||
emit('close')
|
||||
// Patch the existing model card with new versions!
|
||||
await hostAppStore.patchModel(existingModel.modelCardId, {
|
||||
const patchPayload: Record<string, unknown> = {
|
||||
selectedVersionId: version.id,
|
||||
selectedVersionSourceApp: version.sourceApplication,
|
||||
selectedVersionUserId: version.authorUser?.id,
|
||||
latestVersionId: latestVersion.id,
|
||||
latestVersionSourceApp: latestVersion.sourceApplication,
|
||||
latestVersionUserId: latestVersion.authorUser?.id
|
||||
})
|
||||
}
|
||||
|
||||
// apply new settings to the existing model card if they were changed
|
||||
if (settingsWereChanged.value && receieveSettings.value) {
|
||||
patchPayload.settings = receieveSettings.value
|
||||
}
|
||||
|
||||
// patch the existing model card with new versions and settings
|
||||
await hostAppStore.patchModel(existingModel.modelCardId, patchPayload)
|
||||
await hostAppStore.receiveModel(existingModel.modelCardId, 'Wizard')
|
||||
return
|
||||
}
|
||||
|
||||
// We were tracking the source host app wrong before `getHostAppFromString`
|
||||
// i.e. we were having `Revit 2023` instead of `revit`
|
||||
const selectedVersionSourceApp = getSlugFromHostAppNameAndVersion(
|
||||
version.sourceApplication as string
|
||||
)
|
||||
@@ -172,6 +205,7 @@ const selectVersionAndAddModel = async (
|
||||
)
|
||||
|
||||
const modelCard = new ReceiverModelCard()
|
||||
modelCard.settings = receieveSettings.value
|
||||
modelCard.accountId = selectedAccountId.value
|
||||
modelCard.serverUrl = activeAccount.value.accountInfo.serverInfo.url
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ import {
|
||||
} from '@heroicons/vue/24/solid'
|
||||
import type { ConversionResult } from '~/lib/conversions/conversionResult'
|
||||
import { useAccountStore } from '~/store/accounts'
|
||||
import type { IModelCard } from 'lib/models/card'
|
||||
import type { IModelCard } from '~/lib/models/card'
|
||||
import { useHostAppStore } from '~/store/hostApp'
|
||||
|
||||
const app = useNuxtApp()
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<FilterListSelect @update:filter="updateFilter" />
|
||||
<SendSettings
|
||||
<ModelSettings
|
||||
v-if="hasSendSettings"
|
||||
expandable
|
||||
:default-settings="(store.sendSettings as unknown as CardSetting[])"
|
||||
@update:settings="updateSettings"
|
||||
></SendSettings>
|
||||
></ModelSettings>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
|
||||
@@ -6,11 +6,12 @@
|
||||
:title="`Settings`"
|
||||
fullscreen="none"
|
||||
>
|
||||
<SendSettings
|
||||
<ModelSettings
|
||||
:expandable="false"
|
||||
:default-settings="(store.sendSettings as unknown as CardSetting[])"
|
||||
:settings="props.settings"
|
||||
@update:settings="updateSettings"
|
||||
></SendSettings>
|
||||
></ModelSettings>
|
||||
<div class="mt-4 flex justify-end items-center space-x-2">
|
||||
<FormButton size="sm" color="outline" @click="showSettingsDialog = false">
|
||||
Cancel
|
||||
@@ -22,11 +23,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useMixpanel } from '~/lib/core/composables/mixpanel'
|
||||
import { useSettingsTracking } from '~/lib/core/composables/trackSettings'
|
||||
import { useHostAppStore } from '~/store/hostApp'
|
||||
import type { CardSetting } from '~/lib/models/card/setting'
|
||||
|
||||
const { trackEvent } = useMixpanel()
|
||||
const { trackSettingsChange } = useSettingsTracking()
|
||||
|
||||
const props = defineProps<{
|
||||
settings?: CardSetting[]
|
||||
@@ -47,9 +48,12 @@ const updateSettings = (settings: CardSetting[]) => {
|
||||
}
|
||||
|
||||
const saveSettings = async () => {
|
||||
void trackEvent('DUI3 Action', {
|
||||
name: 'Send Settings Updated'
|
||||
})
|
||||
trackSettingsChange(
|
||||
'Model Card Settings Updated',
|
||||
newSettings,
|
||||
store.sendSettings || []
|
||||
)
|
||||
|
||||
await store.patchModel(props.modelCardId, {
|
||||
settings: newSettings,
|
||||
expired: true
|
||||
|
||||
+128
-21
@@ -5,17 +5,22 @@
|
||||
:title="title"
|
||||
:show-back-button="step !== 1"
|
||||
@back="step--"
|
||||
@fully-closed="step = 1"
|
||||
@fully-closed="
|
||||
() => {
|
||||
step = 1
|
||||
settingsWereChanged = false
|
||||
}
|
||||
"
|
||||
>
|
||||
<div v-if="step === 1">
|
||||
<WizardProjectSelector
|
||||
is-sender
|
||||
disable-no-write-access-projects
|
||||
:url-parse-error="urlParseError"
|
||||
@next="selectProject"
|
||||
@search-text-update="updateSearchText"
|
||||
/>
|
||||
</div>
|
||||
<!-- Model selector wizard -->
|
||||
<div v-if="step === 2 && selectedProject && selectedAccountId">
|
||||
<WizardModelSelector
|
||||
:project="selectedProject"
|
||||
@@ -26,24 +31,36 @@
|
||||
@next="selectModel"
|
||||
/>
|
||||
</div>
|
||||
<!-- Version selector wizard -->
|
||||
<div v-if="step === 3">
|
||||
<SendFiltersAndSettings
|
||||
v-model="filter"
|
||||
@update:filter="(f) => (filter = f)"
|
||||
@update:settings="(s) => (settings = s)"
|
||||
@update:settings="
|
||||
(s) => {
|
||||
settings = s
|
||||
settingsWereChanged = true
|
||||
}
|
||||
"
|
||||
/>
|
||||
<div class="mt-2">
|
||||
<FormButton full-width @click="addModel">Publish</FormButton>
|
||||
<div v-tippy="publishTooltipMessage" class="mt-2">
|
||||
<FormButton
|
||||
full-width
|
||||
:disabled="isPublishDisabled"
|
||||
:loading="isLoadingPermissions"
|
||||
@click="addModel"
|
||||
>
|
||||
Publish
|
||||
</FormButton>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="urlParseError" class="p-2 text-xs text-danger">
|
||||
<div v-if="urlParseError" class="p-2 text-danger">
|
||||
{{ urlParseError }}
|
||||
</div>
|
||||
</CommonDialog>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useSubscription } from '@vue/apollo-composable'
|
||||
import type {
|
||||
ModelListModelItemFragment,
|
||||
ProjectListProjectItemFragment
|
||||
@@ -52,11 +69,16 @@ import type { ISendFilter } from '~/lib/models/card/send'
|
||||
import { SenderModelCard } from '~/lib/models/card/send'
|
||||
import { useHostAppStore } from '~/store/hostApp'
|
||||
import { useAccountStore } from '~/store/accounts'
|
||||
import { useSelectionStore } from '~/store/selection'
|
||||
import { useMixpanel } from '~/lib/core/composables/mixpanel'
|
||||
import { useSettingsTracking } from '~/lib/core/composables/trackSettings'
|
||||
import type { CardSetting } from '~/lib/models/card/setting'
|
||||
import { useAddByUrl } from '~/lib/core/composables/addByUrl'
|
||||
import { useCheckGraphql } from '~/lib/core/composables/useCheckGraphql'
|
||||
import { workspacePlanUsageUpdatedSubscription } from '~/lib/workspaces/graphql/subscriptions'
|
||||
|
||||
const { trackEvent } = useMixpanel()
|
||||
const { trackSettingsChange } = useSettingsTracking()
|
||||
|
||||
const showSendDialog = defineModel<boolean>('open', { default: false })
|
||||
|
||||
@@ -71,8 +93,32 @@ const selectedProject = ref<ProjectListProjectItemFragment>()
|
||||
const selectedModel = ref<ModelListModelItemFragment>()
|
||||
const filter = ref<ISendFilter | undefined>(undefined)
|
||||
const settings = ref<CardSetting[] | undefined>(undefined)
|
||||
const settingsWereChanged = ref(false)
|
||||
|
||||
const { tryParseUrl, urlParsedData, urlParseError } = useAddByUrl()
|
||||
const { canCreateModelIngestion, canCreateVersion } = useCheckGraphql()
|
||||
|
||||
const canPublish = ref(false)
|
||||
const publishLimitMessage = ref<string | undefined>(undefined)
|
||||
const isLoadingPermissions = ref(false)
|
||||
const hostAppStore = useHostAppStore()
|
||||
const selectionStore = useSelectionStore()
|
||||
|
||||
const publishValidation = computed(() => hostAppStore.validateSendFilter(filter.value))
|
||||
|
||||
const isPublishDisabled = computed(() => {
|
||||
return (
|
||||
!canPublish.value || isLoadingPermissions.value || !publishValidation.value.valid
|
||||
)
|
||||
})
|
||||
|
||||
const publishTooltipMessage = computed(() => {
|
||||
if (!publishValidation.value.valid) return publishValidation.value.reason
|
||||
if (!canPublish.value && !isLoadingPermissions.value)
|
||||
return publishLimitMessage.value || ''
|
||||
return ''
|
||||
})
|
||||
|
||||
const updateSearchText = (text: string | undefined) => {
|
||||
urlParseError.value = undefined
|
||||
if (!text) return
|
||||
@@ -88,9 +134,72 @@ watch(urlParsedData, (newVal) => {
|
||||
watch(showSendDialog, (newVal) => {
|
||||
if (newVal) {
|
||||
urlParseError.value = undefined
|
||||
void selectionStore.refreshSelectionFromHostApp()
|
||||
}
|
||||
})
|
||||
|
||||
const checkPermissions = async () => {
|
||||
if (!selectedProject.value || !selectedModel.value) return
|
||||
|
||||
isLoadingPermissions.value = true
|
||||
|
||||
try {
|
||||
const res = await canCreateModelIngestion(
|
||||
selectedProject.value.id,
|
||||
selectedModel.value.id,
|
||||
selectedAccountId.value
|
||||
)
|
||||
if (res.queryAvailable) {
|
||||
canPublish.value = res.authorized
|
||||
publishLimitMessage.value = res.message || undefined
|
||||
} else {
|
||||
// check legacy canCreateVersion in else block
|
||||
const legacyRes = await canCreateVersion(
|
||||
selectedProject.value.id,
|
||||
selectedModel.value.id,
|
||||
selectedAccountId.value
|
||||
)
|
||||
canPublish.value = legacyRes.authorized
|
||||
publishLimitMessage.value = legacyRes.message || undefined
|
||||
}
|
||||
} finally {
|
||||
isLoadingPermissions.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(step, async (newVal, oldVal) => {
|
||||
if (newVal > oldVal) {
|
||||
if (newVal === 3) {
|
||||
await checkPermissions()
|
||||
}
|
||||
return // exit fast on forward
|
||||
}
|
||||
if (newVal === 1) {
|
||||
selectedProject.value = undefined
|
||||
selectedModel.value = undefined
|
||||
}
|
||||
if (newVal === 2) selectedModel.value = undefined
|
||||
})
|
||||
|
||||
const workspaceId = computed(() => selectedProject.value?.workspace?.id)
|
||||
|
||||
const { onResult: onUsageUpdate } = useSubscription(
|
||||
workspacePlanUsageUpdatedSubscription,
|
||||
() => ({
|
||||
input: {
|
||||
workspaceId: workspaceId.value || ''
|
||||
}
|
||||
}),
|
||||
() => ({
|
||||
enabled: !!workspaceId.value && step.value === 3,
|
||||
clientId: selectedAccountId.value
|
||||
})
|
||||
)
|
||||
|
||||
onUsageUpdate(() => {
|
||||
void checkPermissions()
|
||||
})
|
||||
|
||||
const selectProject = (accountId: string, project: ProjectListProjectItemFragment) => {
|
||||
step.value++
|
||||
selectedAccountId.value = accountId
|
||||
@@ -111,20 +220,6 @@ const selectModel = (model: ModelListModelItemFragment) => {
|
||||
void trackEvent('DUI3 Action', { name: 'Publish Wizard', step: 'model selected' })
|
||||
}
|
||||
|
||||
// Clears data if going backwards in the wizard
|
||||
watch(step, (newVal, oldVal) => {
|
||||
if (newVal > oldVal) {
|
||||
return // exit fast on forward
|
||||
}
|
||||
if (newVal === 1) {
|
||||
selectedProject.value = undefined
|
||||
selectedModel.value = undefined
|
||||
}
|
||||
if (newVal === 2) selectedModel.value = undefined
|
||||
})
|
||||
|
||||
const hostAppStore = useHostAppStore()
|
||||
|
||||
// accountId, serverUrl, projectId, modelId, sendFilter, settings
|
||||
const addModel = async () => {
|
||||
void trackEvent('DUI3 Action', {
|
||||
@@ -138,6 +233,18 @@ const addModel = async () => {
|
||||
m.modelId === selectedModel.value?.id &&
|
||||
m.typeDiscriminator.includes('SenderModelCard')
|
||||
) as SenderModelCard
|
||||
|
||||
// track settings only if user changed them
|
||||
// compare against existing model card settings
|
||||
if (settingsWereChanged.value && settings.value) {
|
||||
trackSettingsChange(
|
||||
'Publish Settings Changed',
|
||||
settings.value,
|
||||
existingModel?.settings || hostAppStore.sendSettings || [],
|
||||
selectedAccountId.value,
|
||||
true
|
||||
)
|
||||
}
|
||||
if (existingModel) {
|
||||
emit('close')
|
||||
// Patch the existing model card with new send filter and non-expired state!
|
||||
|
||||
@@ -1,163 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
:class="[
|
||||
'text-foreground-on-primary flex shrink-0 items-center justify-center overflow-hidden rounded-full font-semibold uppercase transition',
|
||||
sizeClasses,
|
||||
bgClasses,
|
||||
borderClasses,
|
||||
hoverClasses,
|
||||
activeClasses
|
||||
]"
|
||||
>
|
||||
<slot>
|
||||
<div
|
||||
v-if="user?.avatar"
|
||||
class="h-full w-full bg-cover bg-center bg-no-repeat"
|
||||
:style="{ backgroundImage: `url('${user.avatar}')` }"
|
||||
/>
|
||||
<div
|
||||
v-else-if="initials"
|
||||
class="flex h-full w-full select-none items-center justify-center"
|
||||
>
|
||||
{{ initials }}
|
||||
</div>
|
||||
<div v-else><UserCircleIcon :class="iconClasses" /></div>
|
||||
</slot>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { UserCircleIcon } from '@heroicons/vue/20/solid'
|
||||
type UserAvatar = {
|
||||
name: string
|
||||
avatar?: string | null | undefined
|
||||
}
|
||||
|
||||
type UserAvatarSize = 'xs' | 'sm' | 'base' | 'lg' | 'xl' | 'editable'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
user?: UserAvatar
|
||||
size?: UserAvatarSize
|
||||
hoverEffect?: boolean
|
||||
active?: boolean
|
||||
noBorder?: boolean
|
||||
noBackground?: boolean
|
||||
}>(),
|
||||
{
|
||||
user: undefined,
|
||||
size: 'base',
|
||||
hoverEffect: false
|
||||
}
|
||||
)
|
||||
|
||||
const initials = computed(() => {
|
||||
if (!props.user?.name.length) return
|
||||
const parts = props.user.name.split(' ')
|
||||
const firstLetter = parts[0]?.[0] || ''
|
||||
const secondLetter = parts[1]?.[0] || ''
|
||||
|
||||
if (props.size === 'sm' || props.size === 'xs') return firstLetter
|
||||
return firstLetter + secondLetter
|
||||
})
|
||||
|
||||
const borderClasses = computed(() => {
|
||||
if (props.noBorder) return ''
|
||||
return 'border-2 border-foundation'
|
||||
})
|
||||
|
||||
const bgClasses = computed(() => {
|
||||
if (props.noBackground) return ''
|
||||
return 'bg-primary'
|
||||
})
|
||||
|
||||
const hoverClasses = computed(() => {
|
||||
if (props.hoverEffect)
|
||||
return 'hover:border-primary focus:border-primary active:scale-95'
|
||||
return ''
|
||||
})
|
||||
|
||||
const activeClasses = computed(() => {
|
||||
if (props.active) return 'border-primary'
|
||||
return ''
|
||||
})
|
||||
|
||||
const heightClasses = computed(() => {
|
||||
const size = props.size
|
||||
switch (size) {
|
||||
case 'xs':
|
||||
return 'h-5'
|
||||
case 'sm':
|
||||
return 'h-6'
|
||||
case 'lg':
|
||||
return 'h-10'
|
||||
case 'xl':
|
||||
return 'h-14'
|
||||
case 'editable':
|
||||
return 'h-60'
|
||||
case 'base':
|
||||
default:
|
||||
return 'h-8'
|
||||
}
|
||||
})
|
||||
|
||||
const widthClasses = computed(() => {
|
||||
const size = props.size
|
||||
switch (size) {
|
||||
case 'xs':
|
||||
return 'w-5'
|
||||
case 'sm':
|
||||
return 'w-6'
|
||||
case 'lg':
|
||||
return 'w-10'
|
||||
case 'xl':
|
||||
return 'w-14'
|
||||
case 'editable':
|
||||
return 'w-60'
|
||||
case 'base':
|
||||
default:
|
||||
return 'w-8'
|
||||
}
|
||||
})
|
||||
|
||||
const textClasses = computed(() => {
|
||||
const size = props.size
|
||||
switch (size) {
|
||||
case 'xs':
|
||||
return 'text-tiny'
|
||||
case 'sm':
|
||||
return 'text-xs'
|
||||
case 'lg':
|
||||
return 'text-md'
|
||||
case 'xl':
|
||||
return 'text-2xl'
|
||||
case 'editable':
|
||||
return 'h1'
|
||||
case 'base':
|
||||
default:
|
||||
return 'text-sm'
|
||||
}
|
||||
})
|
||||
|
||||
const iconClasses = computed(() => {
|
||||
const size = props.size
|
||||
switch (size) {
|
||||
case 'xs':
|
||||
return 'w-3 h-3'
|
||||
case 'sm':
|
||||
return 'w-3 h-3'
|
||||
case 'lg':
|
||||
return 'w-5 h-5'
|
||||
case 'xl':
|
||||
return 'w-8 h-8'
|
||||
case 'editable':
|
||||
return 'w-20 h-20'
|
||||
case 'base':
|
||||
default:
|
||||
return 'w-4 h-4'
|
||||
}
|
||||
})
|
||||
|
||||
const sizeClasses = computed(
|
||||
() => `${widthClasses.value} ${heightClasses.value} ${textClasses.value}`
|
||||
)
|
||||
</script>
|
||||
@@ -13,22 +13,24 @@
|
||||
full-width
|
||||
color="foundation"
|
||||
/>
|
||||
<ModelCreateDialog
|
||||
:project-id="project.id"
|
||||
:workspace-id="workspaceId"
|
||||
:workspace-slug="workspaceSlug"
|
||||
@model:created="(result: ModelListModelItemFragment) => handleModelCreated(result)"
|
||||
<div
|
||||
v-tippy="
|
||||
canCreateModelResult?.project.permissions.canCreateModel.authorized
|
||||
? 'Create new model'
|
||||
: canCreateModelResult?.project.permissions.canCreateModel.message
|
||||
"
|
||||
>
|
||||
<template #activator="{ toggle }">
|
||||
<button
|
||||
v-tippy="'New model'"
|
||||
class="p-1.5 bg-foundation hover:bg-primary-muted rounded text-foreground border"
|
||||
@click="toggle()"
|
||||
>
|
||||
<PlusIcon class="w-4" />
|
||||
</button>
|
||||
</template>
|
||||
</ModelCreateDialog>
|
||||
<FormButton
|
||||
color="outline"
|
||||
:disabled="
|
||||
!canCreateModelResult?.project.permissions.canCreateModel.authorized
|
||||
"
|
||||
:class="`p-1.5 bg-foundation hover:bg-primary-muted rounded text-foreground border`"
|
||||
@click="showNewModelDialog = true"
|
||||
>
|
||||
<PlusIcon class="w-4 -mx-2" />
|
||||
</FormButton>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative grid grid-cols-1 gap-2">
|
||||
<CommonLoadingBar v-if="loading" loading />
|
||||
@@ -37,6 +39,7 @@
|
||||
v-for="model in models"
|
||||
:key="model.id"
|
||||
:model="model"
|
||||
:token="token"
|
||||
@click="handleModelSelect(model)"
|
||||
/>
|
||||
|
||||
@@ -77,6 +80,20 @@
|
||||
</template>
|
||||
</CommonDialog>
|
||||
<FormButton
|
||||
v-if="
|
||||
models?.length === 0 &&
|
||||
!!searchText &&
|
||||
canCreateModelResult?.project.permissions.canCreateModel?.authorized
|
||||
"
|
||||
full-width
|
||||
color="outline"
|
||||
:disabled="isCreatingModel"
|
||||
@click="createNewModel(searchText)"
|
||||
>
|
||||
Create "{{ searchText }}"
|
||||
</FormButton>
|
||||
<FormButton
|
||||
v-else
|
||||
color="outline"
|
||||
full-width
|
||||
:disabled="hasReachedEnd"
|
||||
@@ -91,7 +108,7 @@
|
||||
title="Create new model"
|
||||
fullscreen="none"
|
||||
>
|
||||
<form @submit="onSubmit">
|
||||
<form @submit="createNewModel(newModelName as string)">
|
||||
<FormTextInput
|
||||
v-model="newModelName"
|
||||
:rules="rules"
|
||||
@@ -107,7 +124,9 @@
|
||||
<FormButton size="sm" text @click="showNewModelDialog = false">
|
||||
Cancel
|
||||
</FormButton>
|
||||
<FormButton size="sm" submit :disabled="isCreatingModel">Create</FormButton>
|
||||
<FormButton size="sm" submit :disabled="isCreatingModel || !newModelName">
|
||||
Create
|
||||
</FormButton>
|
||||
</div>
|
||||
</form>
|
||||
</CommonDialog>
|
||||
@@ -122,10 +141,10 @@ import type {
|
||||
} from '~/lib/common/generated/gql/graphql'
|
||||
import { useModelNameValidationRules } from '~/lib/validation'
|
||||
import {
|
||||
canCreateModelInProjectQuery,
|
||||
createModelMutation,
|
||||
projectModelsQuery
|
||||
} from '~/lib/graphql/mutationsAndQueries'
|
||||
import { useForm } from 'vee-validate'
|
||||
import type { DUIAccount } from '~/store/accounts'
|
||||
import { useAccountStore } from '~/store/accounts'
|
||||
import { useHostAppStore } from '~/store/hostApp'
|
||||
@@ -152,13 +171,20 @@ const props = withDefaults(
|
||||
|
||||
const accountStore = useAccountStore()
|
||||
|
||||
const account = computed(
|
||||
() =>
|
||||
accountStore.accounts.find(
|
||||
(acc) => acc.accountInfo.id === props.accountId
|
||||
) as DUIAccount
|
||||
)
|
||||
|
||||
const showNewModelDialog = ref(false)
|
||||
const showSelectionHasProblemsDialog = ref(false)
|
||||
|
||||
const searchText = ref<string>()
|
||||
const newModelName = ref<string>()
|
||||
const newModelName = ref<string>(hostAppStore.documentInfo?.name ?? 'unnamed model')
|
||||
|
||||
watch(searchText, () => (newModelName.value = searchText.value))
|
||||
watch(searchText, () => (newModelName.value = searchText.value as string))
|
||||
|
||||
let selectedModel: ModelListModelItemFragment | undefined = undefined
|
||||
const existingModelProblem = ref(false)
|
||||
@@ -187,12 +213,6 @@ const confirmModelSelection = () => {
|
||||
}
|
||||
|
||||
const rules = useModelNameValidationRules()
|
||||
const { handleSubmit } = useForm<{ name: string }>()
|
||||
const onSubmit = handleSubmit(() => {
|
||||
// TODO: Chat with Fabians
|
||||
// This works, but if we use handleSubmit(args) > args.name -> it is undefined in Production on netlify, but works fine on local dev
|
||||
void createNewModel(newModelName.value as string)
|
||||
})
|
||||
|
||||
const handleModelCreated = (result: ModelListModelItemFragment) => {
|
||||
refetch() // Sorts the list with newly created project otherwise it will put the project at the bottom.
|
||||
@@ -200,21 +220,30 @@ const handleModelCreated = (result: ModelListModelItemFragment) => {
|
||||
}
|
||||
|
||||
const isCreatingModel = ref(false)
|
||||
|
||||
const createNewModel = async (name: string) => {
|
||||
if (!canCreateModelResult.value?.project.permissions.canCreateModel.authorized) {
|
||||
hostAppStore.setNotification({
|
||||
type: 1,
|
||||
title: 'Failed to create model',
|
||||
description:
|
||||
canCreateModelResult.value?.project.permissions.canCreateModel.message
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
isCreatingModel.value = true
|
||||
const account = accountStore.accounts.find(
|
||||
(acc) => acc.accountInfo.id === props.accountId
|
||||
) as DUIAccount
|
||||
|
||||
void trackEvent('DUI3 Action', { name: 'Model Create' }, account.accountInfo.id)
|
||||
void trackEvent('DUI3 Action', { name: 'Model Create' }, account.value.accountInfo.id)
|
||||
|
||||
const { mutate } = provideApolloClient(account.client)(() =>
|
||||
const { mutate } = provideApolloClient(account.value.client)(() =>
|
||||
useMutation(createModelMutation)
|
||||
)
|
||||
const res = await mutate({ input: { projectId: props.project.id, name } })
|
||||
if (res?.data?.modelMutations.create) {
|
||||
refetch() // Sorts the list with newly created model otherwise it will put the model at the bottom.
|
||||
emit('next', res?.data?.modelMutations.create)
|
||||
// emit('next', res?.data?.modelMutations.create)
|
||||
handleModelCreated(res?.data?.modelMutations.create)
|
||||
} else {
|
||||
let errorMessage = 'Undefined error'
|
||||
if (res?.errors && res?.errors.length !== 0) {
|
||||
@@ -230,6 +259,15 @@ const createNewModel = async (name: string) => {
|
||||
isCreatingModel.value = false
|
||||
}
|
||||
|
||||
const { result: canCreateModelResult } = useQuery(
|
||||
canCreateModelInProjectQuery,
|
||||
() => ({ projectId: props.project.id }),
|
||||
() => ({
|
||||
clientId: props.accountId,
|
||||
fetchPolicy: 'network-only'
|
||||
})
|
||||
)
|
||||
|
||||
const {
|
||||
result: projectModelsResult,
|
||||
loading,
|
||||
@@ -247,6 +285,7 @@ const {
|
||||
() => ({ clientId: props.accountId, debounce: 500, fetchPolicy: 'cache-and-network' })
|
||||
)
|
||||
|
||||
const token = computed(() => account.value.accountInfo.token)
|
||||
const models = computed(() => projectModelsResult.value?.project.models.items)
|
||||
const totalCount = computed(() => projectModelsResult.value?.project.models.totalCount)
|
||||
const hasReachedEnd = ref(false)
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
class="px-3 py-1 rounded-md shadow transition overflow-hidden bg-foundation border-foundation-2 hover:shadow-md border-1 group"
|
||||
>
|
||||
<div class="flex flex-col sm:flex-row sm:gap-2 text-foreground">
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="text-body-xs">
|
||||
<h1
|
||||
class="mb-1 text-sm font-semibold w-full inline-block py-1 bg-clip-text"
|
||||
>
|
||||
Move your projects to a workspace
|
||||
</h1>
|
||||
<p class="mb-2">
|
||||
<span class="text-xs">
|
||||
Personal projects are being phased out. Move your projects to a
|
||||
workspace to create new projects and models, invite new project
|
||||
collaborators, and view comments and versions older than 7 days. By
|
||||
January 1st 2026, all projects will be archived if not moved into a
|
||||
workspace.
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<FormButton
|
||||
color="primary"
|
||||
size="sm"
|
||||
class="mb-2"
|
||||
@click="
|
||||
$openUrl(
|
||||
`${accountStore.activeAccount.accountInfo.serverInfo.url}/projects`
|
||||
)
|
||||
"
|
||||
>
|
||||
Move projects
|
||||
</FormButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useAccountStore } from '~/store/accounts'
|
||||
|
||||
const accountStore = useAccountStore()
|
||||
const { $openUrl } = useNuxtApp()
|
||||
</script>
|
||||
@@ -1,16 +1,20 @@
|
||||
<template>
|
||||
<div class="space-y-2">
|
||||
<div class="space-y-2 relative">
|
||||
<div
|
||||
v-if="workspacesEnabled && workspaces"
|
||||
class="flex items-center space-x-2 bg-foundation -mx-3 -mt-2 px-3 py-2 shadow-sm border-b"
|
||||
>
|
||||
<div v-if="workspacesEnabled && workspaces" class="flex items-center space-x-2">
|
||||
<div class="flex-grow min-w-0">
|
||||
<div v-if="workspaces.length === 0">
|
||||
<FormButton
|
||||
full-width
|
||||
class="flex items-center"
|
||||
@click="$openUrl('https://app.speckle.systems/workspaces/actions/create')"
|
||||
@click="
|
||||
$openUrl(
|
||||
`${activeAccount.accountInfo.serverInfo.url.replace(
|
||||
/\/$/,
|
||||
''
|
||||
)}/workspaces/actions/create`
|
||||
)
|
||||
"
|
||||
>
|
||||
<div class="min-w-0 truncate flex-grow">
|
||||
<span>{{ 'Create a workspace' }}</span>
|
||||
@@ -33,33 +37,35 @@
|
||||
<WorkspaceAvatar
|
||||
:size="'xs'"
|
||||
:name="selectedWorkspace.name || ''"
|
||||
:logo="selectedWorkspace.logo"
|
||||
:logo="selectedWorkspace.logoUrl"
|
||||
/>
|
||||
<div class="min-w-0 truncate flex-grow text-left">
|
||||
<span>{{ selectedWorkspace.name }}</span>
|
||||
</div>
|
||||
<button
|
||||
v-if="selectedWorkspace.slug"
|
||||
v-tippy="'Open workspace in browser'"
|
||||
class="transition mr-1 opacity-70 hover:opacity-100"
|
||||
@click.stop="
|
||||
$openUrl(
|
||||
`${accountStore.activeAccount.accountInfo.serverInfo.url}/workspaces/${selectedWorkspace.slug}`
|
||||
)
|
||||
"
|
||||
>
|
||||
<ArrowTopRightOnSquareIcon class="w-3.5" />
|
||||
</button>
|
||||
<ChevronDownIcon class="h-3 w-3 shrink-0" />
|
||||
</button>
|
||||
</template>
|
||||
</WorkspaceMenu>
|
||||
</div>
|
||||
<div class="px-0.5 shrink-0">
|
||||
<div class="shrink-0 pt-1 px-1">
|
||||
<AccountsMenu
|
||||
:current-selected-account-id="accountId"
|
||||
@select="(e) => selectAccount(e)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- we can message to user about the non-workspace scenario -->
|
||||
<!-- <div v-if="workspaces && workspaces.length === 0">
|
||||
<CommonAlert size="xs" :color="'warning'">
|
||||
<template #description>
|
||||
You are listing legacy personal projects which will be deprecated end of
|
||||
2025. We suggest you to move your personal projects into a workspace before
|
||||
then.
|
||||
</template>
|
||||
</CommonAlert>
|
||||
</div> -->
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center space-x-1 justify-between">
|
||||
<FormTextInput
|
||||
@@ -72,36 +78,68 @@
|
||||
color="foundation"
|
||||
/>
|
||||
<div class="flex justify-between items-center space-x-2">
|
||||
<ProjectCreateWorkspaceDialog
|
||||
v-if="selectedWorkspace && selectedWorkspace.id !== 'personalProject'"
|
||||
:workspace="selectedWorkspace"
|
||||
@project:created="(result : ProjectListProjectItemFragment) => handleProjectCreated(result)"
|
||||
>
|
||||
<template #activator="{ toggle }">
|
||||
<button
|
||||
v-tippy="'New project in workspace'"
|
||||
class="p-1.5 bg-foundation hover:bg-primary-muted rounded text-foreground border"
|
||||
@click="toggle()"
|
||||
>
|
||||
<PlusIcon class="w-4" />
|
||||
</button>
|
||||
</template>
|
||||
</ProjectCreateWorkspaceDialog>
|
||||
<!-- TODO: once we deprecate personal projects, else block is bye bye -->
|
||||
<ProjectCreatePersonalDialog
|
||||
<div v-if="canCreateProject" v-tippy="'Create new project'">
|
||||
<FormButton
|
||||
color="outline"
|
||||
:disabled="!canCreateProject"
|
||||
:class="`p-1.5 bg-foundation hover:bg-primary-muted rounded text-foreground border`"
|
||||
@click="showProjectCreateDialog = true"
|
||||
>
|
||||
<PlusIcon class="w-4 -mx-2" />
|
||||
</FormButton>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
@project:created="(result : ProjectListProjectItemFragment) => handleProjectCreated(result)"
|
||||
v-tippy="
|
||||
canCreateProject
|
||||
? 'Create new project'
|
||||
: canCreateProjectPermissionCheck?.message
|
||||
"
|
||||
>
|
||||
<template #activator="{ toggle }">
|
||||
<button
|
||||
v-tippy="'New personal project'"
|
||||
class="p-1.5 bg-foundation hover:bg-primary-muted rounded text-foreground border"
|
||||
@click="toggle()"
|
||||
>
|
||||
<PlusIcon class="w-4" />
|
||||
</button>
|
||||
</template>
|
||||
</ProjectCreatePersonalDialog>
|
||||
<FormButton
|
||||
color="primary"
|
||||
:class="`p-1.5 bg-foundation rounded text-foreground border`"
|
||||
@click="upgradePlanButtonAction"
|
||||
>
|
||||
<ArrowUpCircleIcon class="w-4 -mx-2" />
|
||||
</FormButton>
|
||||
</div>
|
||||
<CommonDialog
|
||||
v-model:open="showProjectCreateDialog"
|
||||
:title="`Create new project`"
|
||||
fullscreen="none"
|
||||
>
|
||||
<form @submit="createProject(newProjectName as string)">
|
||||
<div class="text-body-2xs mb-2 ml-1">Project name</div>
|
||||
<FormTextInput
|
||||
v-model="newProjectName"
|
||||
class="text-xs"
|
||||
placeholder="A Beautiful Home, A Small Bridge..."
|
||||
autocomplete="off"
|
||||
name="name"
|
||||
label="Project name"
|
||||
color="foundation"
|
||||
:show-clear="!!newProjectName"
|
||||
:rules="[
|
||||
ValidationHelpers.isRequired,
|
||||
ValidationHelpers.isStringOfLength({ minLength: 3 })
|
||||
]"
|
||||
full-width
|
||||
/>
|
||||
<div class="mt-4 flex justify-end items-center space-x-2 w-full">
|
||||
<FormButton size="sm" text @click="showProjectCreateDialog = false">
|
||||
Cancel
|
||||
</FormButton>
|
||||
<FormButton
|
||||
size="sm"
|
||||
submit
|
||||
:disabled="isCreatingProject || !newProjectName"
|
||||
>
|
||||
Create
|
||||
</FormButton>
|
||||
</div>
|
||||
</form>
|
||||
</CommonDialog>
|
||||
<div v-if="!workspacesEnabled || !workspaces" class="mt-1">
|
||||
<AccountsMenu
|
||||
:current-selected-account-id="accountId"
|
||||
@@ -110,9 +148,12 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<CommonLoadingBar v-if="loading" loading />
|
||||
|
||||
<WizardPersonalProjectsWarning v-if="isPersonalProjectsAsWorkspace" />
|
||||
|
||||
<CommonLoadingBar v-if="loading || isCreatingProject" loading />
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-2 relative z-0">
|
||||
<div v-if="!urlParseError" class="grid grid-cols-1 gap-2 relative z-0">
|
||||
<WizardListProjectCard
|
||||
v-for="project in projects"
|
||||
:key="project.id"
|
||||
@@ -120,7 +161,27 @@
|
||||
:is-sender="isSender"
|
||||
@click="handleProjectCardClick(project)"
|
||||
/>
|
||||
<p v-if="projects?.length === 0 && !!searchText" class="text-sm">
|
||||
No projects found
|
||||
</p>
|
||||
<FormButton
|
||||
v-if="
|
||||
projects?.length === 0 &&
|
||||
!!searchText &&
|
||||
canCreateProjectPermissionCheck?.authorized
|
||||
"
|
||||
full-width
|
||||
color="outline"
|
||||
:disabled="isCreatingProject"
|
||||
class="block truncate overflow-hidden"
|
||||
@click="createProject(searchText)"
|
||||
>
|
||||
Create "{{
|
||||
searchText.length > 10 ? searchText.substring(0, 10) + '...' : searchText
|
||||
}}"
|
||||
</FormButton>
|
||||
<FormButton
|
||||
v-else
|
||||
full-width
|
||||
:disabled="hasReachedEnd"
|
||||
color="outline"
|
||||
@@ -135,24 +196,30 @@
|
||||
<script setup lang="ts">
|
||||
import { ChevronDownIcon, ArrowTopRightOnSquareIcon } from '@heroicons/vue/24/outline'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { PlusIcon } from '@heroicons/vue/20/solid'
|
||||
import { PlusIcon, ArrowUpCircleIcon } from '@heroicons/vue/20/solid'
|
||||
import type { DUIAccount } from '~/store/accounts'
|
||||
import { useAccountStore } from '~/store/accounts'
|
||||
import {
|
||||
activeWorkspaceQuery,
|
||||
canCreatePersonalProjectQuery,
|
||||
createProjectInWorkspaceMutation,
|
||||
createProjectMutation,
|
||||
projectsListQuery,
|
||||
serverInfoQuery,
|
||||
setActiveWorkspaceMutation,
|
||||
workspacesListQuery
|
||||
} from '~/lib/graphql/mutationsAndQueries'
|
||||
import { useMutation, provideApolloClient, useQuery } from '@vue/apollo-composable'
|
||||
import { ValidationHelpers } from '@speckle/ui-components'
|
||||
import type {
|
||||
ProjectListProjectItemFragment,
|
||||
WorkspaceListWorkspaceItemFragment
|
||||
} from 'lib/common/generated/gql/graphql'
|
||||
} from '~/lib/common/generated/gql/graphql'
|
||||
import { useMixpanel } from '~/lib/core/composables/mixpanel'
|
||||
import { useConfigStore } from '~/store/config'
|
||||
import { useHostAppStore } from '~/store/hostApp'
|
||||
|
||||
const hostAppStore = useHostAppStore()
|
||||
const { trackEvent } = useMixpanel()
|
||||
const { $openUrl } = useNuxtApp()
|
||||
|
||||
@@ -174,6 +241,7 @@ const props = withDefaults(
|
||||
* For the send wizard - not allowing selecting projects we can't write to.
|
||||
*/
|
||||
disableNoWriteAccessProjects?: boolean
|
||||
urlParseError?: string
|
||||
}>(),
|
||||
{
|
||||
showNewProject: true,
|
||||
@@ -210,7 +278,11 @@ const handleProjectCreated = (result: ProjectListProjectItemFragment) => {
|
||||
const { result: serverInfoResult, refetch: refetchServerInfo } = useQuery(
|
||||
serverInfoQuery,
|
||||
() => ({}),
|
||||
() => ({ clientId: accountId.value, debounce: 500, fetchPolicy: 'network-only' })
|
||||
() => ({
|
||||
clientId: accountId.value,
|
||||
debounce: 500,
|
||||
fetchPolicy: 'network-only'
|
||||
})
|
||||
)
|
||||
|
||||
const workspacesEnabled = computed(
|
||||
@@ -222,7 +294,11 @@ const { result: workspacesResult, refetch: refetchWorkspaces } = useQuery(
|
||||
() => ({
|
||||
limit: 100
|
||||
}),
|
||||
() => ({ clientId: accountId.value, debounce: 500, fetchPolicy: 'network-only' })
|
||||
() => ({
|
||||
clientId: accountId.value,
|
||||
debounce: 500,
|
||||
fetchPolicy: 'network-only'
|
||||
})
|
||||
)
|
||||
|
||||
const workspaces = computed(() => workspacesResult.value?.activeUser?.workspaces.items)
|
||||
@@ -230,7 +306,11 @@ const workspaces = computed(() => workspacesResult.value?.activeUser?.workspaces
|
||||
const { result: activeWorkspaceResult, refetch: refetchActiveWorkspace } = useQuery(
|
||||
activeWorkspaceQuery,
|
||||
() => ({}),
|
||||
() => ({ clientId: accountId.value, debounce: 500, fetchPolicy: 'network-only' })
|
||||
() => ({
|
||||
clientId: accountId.value,
|
||||
debounce: 500,
|
||||
fetchPolicy: 'network-only'
|
||||
})
|
||||
)
|
||||
|
||||
const activeWorkspace = computed(() => {
|
||||
@@ -243,15 +323,29 @@ const activeWorkspace = computed(() => {
|
||||
return previouslySelectedWorkspace
|
||||
}
|
||||
}
|
||||
// fallback to activeWorkspace query result
|
||||
return activeWorkspaceResult.value?.activeUser
|
||||
|
||||
const activeLimitedWorkspace = activeWorkspaceResult.value?.activeUser
|
||||
?.activeWorkspace as WorkspaceListWorkspaceItemFragment
|
||||
|
||||
// fallback to activeWorkspace query result
|
||||
if (activeLimitedWorkspace) {
|
||||
const activeWorkspace = workspaces.value?.find(
|
||||
(w) => w.id === activeLimitedWorkspace.id
|
||||
)
|
||||
if (activeWorkspace) return activeWorkspace
|
||||
}
|
||||
|
||||
return workspaces.value?.[0] // fallback to first workspace if none is active
|
||||
})
|
||||
|
||||
const selectedWorkspace = ref<WorkspaceListWorkspaceItemFragment | undefined>(
|
||||
activeWorkspace.value
|
||||
)
|
||||
|
||||
const isPersonalProjectsAsWorkspace = computed(
|
||||
() => selectedWorkspace.value?.id === 'personalProject'
|
||||
)
|
||||
|
||||
watch(
|
||||
workspaces,
|
||||
(newItems) => {
|
||||
@@ -320,12 +414,11 @@ const {
|
||||
limit: 10, // stupid hack, increased it since we do manual filter to be able to see more project, see below TODO note, once we have `personalOnly` filter, decrease back to 10
|
||||
filter: {
|
||||
search: (searchText.value || '').trim() || null,
|
||||
workspaceId:
|
||||
selectedWorkspace.value?.id === 'personalProject'
|
||||
? null
|
||||
: selectedWorkspace.value?.id,
|
||||
workspaceId: isPersonalProjectsAsWorkspace.value
|
||||
? null
|
||||
: selectedWorkspace.value?.id,
|
||||
includeImplicitAccess: true,
|
||||
personalOnly: selectedWorkspace.value?.id === 'personalProject'
|
||||
personalOnly: isPersonalProjectsAsWorkspace.value
|
||||
}
|
||||
}),
|
||||
() => ({
|
||||
@@ -337,7 +430,7 @@ const {
|
||||
)
|
||||
|
||||
const projects = computed(() =>
|
||||
selectedWorkspace.value?.id === 'personalProject' // TODO: we need to replace this logic with `personalOnly` filter when it is implemented into app.speckle.systems
|
||||
isPersonalProjectsAsWorkspace.value // TODO: we need to replace this logic with `personalOnly` filter when it is implemented into app.speckle.systems
|
||||
? projectsResult.value?.activeUser?.projects.items.filter(
|
||||
(i) => i.workspaceId === null
|
||||
)
|
||||
@@ -361,6 +454,152 @@ watch(projectsResult, (newVal) => {
|
||||
}
|
||||
})
|
||||
|
||||
const { result: canCreatePersonalProjectResult } = useQuery(
|
||||
canCreatePersonalProjectQuery,
|
||||
{},
|
||||
() => ({
|
||||
clientId: accountId.value
|
||||
})
|
||||
)
|
||||
|
||||
const canCreateProject = computed(() => {
|
||||
// If a workspace is selected, return that permission check
|
||||
if (selectedWorkspace.value && selectedWorkspace.value.permissions) {
|
||||
return selectedWorkspace.value.permissions.canCreateProject.authorized //as boolean
|
||||
}
|
||||
// Otherwise, check for personal projects
|
||||
if (canCreatePersonalProjectResult) {
|
||||
return canCreatePersonalProjectResult.value?.activeUser?.permissions
|
||||
.canCreatePersonalProject.authorized
|
||||
}
|
||||
// To be always safe, default to false
|
||||
return false
|
||||
})
|
||||
|
||||
const canCreateProjectPermissionCheck = computed(() => {
|
||||
if (selectedWorkspace.value && selectedWorkspace.value.permissions) {
|
||||
return selectedWorkspace.value.permissions.canCreateProject
|
||||
}
|
||||
if (canCreatePersonalProjectResult) {
|
||||
return canCreatePersonalProjectResult.value?.activeUser?.permissions
|
||||
.canCreatePersonalProject
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
const isCreatingProject = ref(false)
|
||||
const showProjectCreateDialog = ref(false)
|
||||
|
||||
const createProject = (name: string) => {
|
||||
if (
|
||||
canCreateProjectPermissionCheck.value &&
|
||||
!canCreateProjectPermissionCheck.value.authorized
|
||||
) {
|
||||
hostAppStore.setNotification({
|
||||
type: 1,
|
||||
title: 'Failed to create project',
|
||||
description: canCreateProjectPermissionCheck.value.message as string
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (isPersonalProjectsAsWorkspace.value || !selectedWorkspace.value) {
|
||||
return void createNewPersonalProject(name)
|
||||
} else {
|
||||
return void createNewWorkspaceProject(name)
|
||||
}
|
||||
}
|
||||
|
||||
const account = computed(() => {
|
||||
return accountStore.accounts.find(
|
||||
(acc) => acc.accountInfo.id === accountId.value
|
||||
) as DUIAccount
|
||||
})
|
||||
|
||||
const createNewWorkspaceProject = async (name: string) => {
|
||||
isCreatingProject.value = true
|
||||
void trackEvent(
|
||||
'DUI3 Action',
|
||||
{ name: 'Project Create', workspace: true },
|
||||
accountId.value
|
||||
)
|
||||
const { mutate, onError } = provideApolloClient(account.value.client)(() =>
|
||||
useMutation(createProjectInWorkspaceMutation)
|
||||
)
|
||||
|
||||
onError((err) => {
|
||||
hostAppStore.setNotification({
|
||||
type: 1,
|
||||
title: 'Failed to create project',
|
||||
description: err.cause?.message ?? err.message ?? 'Unknown error'
|
||||
})
|
||||
})
|
||||
|
||||
const res = await mutate({
|
||||
input: { name, workspaceId: selectedWorkspace.value?.id as string }
|
||||
})
|
||||
|
||||
if (res?.data?.workspaceMutations.projects.create) {
|
||||
handleProjectCreated(res?.data?.workspaceMutations.projects.create)
|
||||
}
|
||||
isCreatingProject.value = false
|
||||
}
|
||||
|
||||
const createNewPersonalProject = async (name: string) => {
|
||||
isCreatingProject.value = true
|
||||
|
||||
void trackEvent(
|
||||
'DUI3 Action',
|
||||
{ name: 'Project Create', workspace: false },
|
||||
account.value.accountInfo.id
|
||||
)
|
||||
|
||||
const { mutate, onError } = provideApolloClient(account.value.client)(() =>
|
||||
useMutation(createProjectMutation)
|
||||
)
|
||||
|
||||
onError((err) => {
|
||||
hostAppStore.setNotification({
|
||||
type: 1,
|
||||
title: 'Failed to create project',
|
||||
description: err.cause?.message ?? err.message ?? 'Unknown error'
|
||||
})
|
||||
})
|
||||
|
||||
const res = await mutate({ input: { name } })
|
||||
|
||||
if (res?.data?.projectMutations.create) {
|
||||
return handleProjectCreated(res?.data?.projectMutations.create)
|
||||
}
|
||||
|
||||
isCreatingProject.value = false
|
||||
}
|
||||
|
||||
const upgradePlanButtonAction = () => {
|
||||
if (!canCreateProjectPermissionCheck.value) return
|
||||
if (canCreateProjectPermissionCheck.value.code === 'WorkspaceNoEditorSeat') {
|
||||
// open url to workspace/settings/users
|
||||
$openUrl(
|
||||
`${account.value.accountInfo.serverInfo.url}/settings/workspaces/${selectedWorkspace.value?.slug}/members`
|
||||
)
|
||||
return
|
||||
}
|
||||
if (canCreateProjectPermissionCheck.value.code === 'WorkspaceLimitsReached') {
|
||||
// open url to workspace/billing
|
||||
$openUrl(
|
||||
`${account.value.accountInfo.serverInfo.url}/settings/workspaces/${selectedWorkspace.value?.slug}/billing`
|
||||
)
|
||||
return
|
||||
}
|
||||
// catch SSO session expired / any other unhandled permission flags
|
||||
// redirecting to the workspace root will trigger the standard web authentication flow.
|
||||
if (selectedWorkspace.value?.slug) {
|
||||
$openUrl(
|
||||
`${account.value.accountInfo.serverInfo.url}/workspaces/${selectedWorkspace.value?.slug}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const loadMore = () => {
|
||||
fetchMore({
|
||||
variables: { cursor: projectsResult.value?.activeUser?.projects.cursor },
|
||||
|
||||
@@ -13,6 +13,17 @@
|
||||
Upgrade
|
||||
</FormButton>
|
||||
</div>
|
||||
<div v-if="hasReceiveSettings">
|
||||
<ModelSettings
|
||||
class="mb-2"
|
||||
expandable
|
||||
:settings="settings"
|
||||
:default-settings="(store.receiveSettings as unknown as CardSetting[])"
|
||||
@update:settings="updateSettings"
|
||||
></ModelSettings>
|
||||
<hr />
|
||||
</div>
|
||||
|
||||
<div v-if="latestVersion" class="grid grid-cols-2 gap-3 max-[275px]:grid-cols-1">
|
||||
<WizardListVersionCard
|
||||
v-for="(version, index) in versions"
|
||||
@@ -44,27 +55,40 @@ import { useQuery } from '@vue/apollo-composable'
|
||||
import { modelVersionsQuery } from '~/lib/graphql/mutationsAndQueries'
|
||||
import type { VersionListItemFragment } from '~/lib/common/generated/gql/graphql'
|
||||
import { useAccountStore } from '~/store/accounts'
|
||||
import type { CardSetting } from '~/lib/models/card/setting'
|
||||
import { useHostAppStore } from '~/store/hostApp'
|
||||
|
||||
defineEmits<{
|
||||
const emit = defineEmits<{
|
||||
(
|
||||
e: 'next',
|
||||
version: VersionListItemFragment,
|
||||
latestVersion: VersionListItemFragment
|
||||
): void
|
||||
(e: 'update:settings', settings: CardSetting[]): void
|
||||
}>()
|
||||
|
||||
const props = defineProps<{
|
||||
accountId: string
|
||||
projectId: string
|
||||
modelId: string
|
||||
settings?: CardSetting[]
|
||||
selectedVersionId?: string
|
||||
workspaceSlug?: string
|
||||
fromWizard?: boolean
|
||||
}>()
|
||||
|
||||
const store = useHostAppStore()
|
||||
const accountStore = useAccountStore()
|
||||
const serverUrl = computed(() => accountStore.activeAccount.accountInfo.serverInfo.url)
|
||||
|
||||
const hasReceiveSettings = computed(
|
||||
() => store.receiveSettings && store.receiveSettings.length > 0
|
||||
)
|
||||
|
||||
const updateSettings = (settings: CardSetting[]) => {
|
||||
emit('update:settings', settings)
|
||||
}
|
||||
|
||||
const {
|
||||
result: modelVersionResults,
|
||||
loading,
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="text-body-2xs mb-2 ml-1">Project workspace</div>
|
||||
<FormSelectBase
|
||||
key="name"
|
||||
v-model="selectedWorkspace"
|
||||
clearable
|
||||
label="Workspaces"
|
||||
placeholder="Nothing selected"
|
||||
name="Workspaces"
|
||||
:items="workspaces"
|
||||
:disabled-item-predicate="userCantCreateWorkspace"
|
||||
mount-menu-on-body
|
||||
>
|
||||
<template #something-selected="{ value }">
|
||||
<span>{{ value.name }}</span>
|
||||
</template>
|
||||
<template #option="{ item }">
|
||||
<div
|
||||
v-tippy="{
|
||||
content: item.readOnly
|
||||
? 'This workspace is read-only.'
|
||||
: item.role === 'workspace:guest'
|
||||
? 'You do not have write access on this workspace.'
|
||||
: undefined,
|
||||
disabled: !(item.readOnly || item.role === 'workspace:guest')
|
||||
}"
|
||||
class="flex items-center"
|
||||
>
|
||||
<span class="truncate">{{ item.name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</FormSelectBase>
|
||||
<div
|
||||
v-if="selectedWorkspace"
|
||||
class="text-body-sm caption rounded p-2 bg-blue-500/10 my-2"
|
||||
>
|
||||
Project will be created in the selected workspace.
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useQuery } from '@vue/apollo-composable'
|
||||
import { workspacesListQuery } from '~/lib/graphql/mutationsAndQueries'
|
||||
import type { WorkspaceListWorkspaceItemFragment } from 'lib/common/generated/gql/graphql'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useAccountStore } from '~/store/accounts'
|
||||
|
||||
const emit = defineEmits<{
|
||||
(
|
||||
e: 'update:selectedWorkspace',
|
||||
value: WorkspaceListWorkspaceItemFragment | undefined
|
||||
): void
|
||||
}>()
|
||||
|
||||
const accountStore = useAccountStore()
|
||||
const { activeAccount } = storeToRefs(accountStore)
|
||||
const accountId = computed(() => activeAccount.value.accountInfo.id)
|
||||
|
||||
const searchText = ref<string>()
|
||||
|
||||
const { result: workspacesResult } = useQuery(
|
||||
workspacesListQuery,
|
||||
() => ({
|
||||
limit: 5,
|
||||
filter: {
|
||||
search: (searchText.value || '').trim() || null
|
||||
}
|
||||
}),
|
||||
() => ({ clientId: accountId.value, debounce: 500, fetchPolicy: 'network-only' })
|
||||
)
|
||||
|
||||
const workspaces = computed(() => workspacesResult.value?.activeUser?.workspaces.items)
|
||||
const selectedWorkspace = ref<WorkspaceListWorkspaceItemFragment>()
|
||||
|
||||
watch(selectedWorkspace, (newVal) => {
|
||||
emit('update:selectedWorkspace', newVal)
|
||||
})
|
||||
|
||||
// Utility function to check if the user cannot create a workspace
|
||||
const userCantCreateWorkspace = (item: WorkspaceListWorkspaceItemFragment) =>
|
||||
(!!item?.role && item.role === 'workspace:guest') || !!item.readOnly
|
||||
</script>
|
||||
@@ -4,9 +4,15 @@
|
||||
>
|
||||
<div class="flex items-center space-x-2 max-[275px]:space-x-0">
|
||||
<div class="max-[275px]:hidden">
|
||||
<div v-if="model.previewUrl" class="h-12 w-12">
|
||||
<div
|
||||
v-if="model.versions.totalCount === 0"
|
||||
class="h-12 w-12 bg-blue-500/10 rounded flex items-center justify-center"
|
||||
>
|
||||
<CubeTransparentIcon class="w-5 h-5 text-foreground-2" />
|
||||
</div>
|
||||
<div v-else-if="previewUrl" class="h-12 w-12">
|
||||
<img
|
||||
:src="model.previewUrl"
|
||||
:src="previewUrl"
|
||||
alt="preview image for model"
|
||||
class="h-12 w-12 object-cover"
|
||||
/>
|
||||
@@ -15,7 +21,7 @@
|
||||
v-else
|
||||
class="h-12 w-12 bg-blue-500/10 rounded flex items-center justify-center"
|
||||
>
|
||||
<CubeTransparentIcon class="w-5 h-5 text-foreground-2" />
|
||||
<CommonLoadingIcon />
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-w-0 w-full">
|
||||
@@ -52,9 +58,16 @@ import { ClockIcon } from '@heroicons/vue/24/outline'
|
||||
import type { SourceAppName } from '@speckle/shared'
|
||||
import { SourceApps } from '@speckle/shared'
|
||||
import type { ModelListModelItemFragment } from '~/lib/common/generated/gql/graphql'
|
||||
import { computedAsync } from '@vueuse/core'
|
||||
import { usePreviewUrl } from '~/lib/core/composables/previewUrl'
|
||||
|
||||
const props = defineProps<{
|
||||
model: ModelListModelItemFragment
|
||||
/**
|
||||
* Token to retrieve preview url
|
||||
* @note by convention we pass around `accountId` but it doesn't make sense to get token for every model card. more efficient with this way.
|
||||
*/
|
||||
token: string
|
||||
}>()
|
||||
|
||||
const folderPath = computed(() => {
|
||||
@@ -68,6 +81,11 @@ const updatedAgo = computed(() => {
|
||||
return dayjs(props.model.updatedAt).from(dayjs())
|
||||
})
|
||||
|
||||
const previewUrl = computedAsync(async () => {
|
||||
if (props.model.previewUrl === null) return
|
||||
return await usePreviewUrl(props.token, props.model.previewUrl)
|
||||
})
|
||||
|
||||
const sourceApp = computed(() => {
|
||||
if (props.model.versions.items.length === 0) return
|
||||
const version = props.model.versions.items[0]
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
>
|
||||
<UserAvatar
|
||||
v-tippy="`Authored by ${version.authorUser?.name}`"
|
||||
:user="version.authorUser"
|
||||
:user="{ avatar: version.authorUser?.avatar, name: version.authorUser?.name as string }"
|
||||
size="sm"
|
||||
class="absolute inset-1"
|
||||
/>
|
||||
@@ -24,7 +24,15 @@
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="flex items-center justify-center w-full h-24">
|
||||
<img :src="version.previewUrl" alt="version preview" />
|
||||
<div v-if="previewUrl">
|
||||
<img :src="previewUrl" alt="preview image for version" />
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="h-12 w-12 bg-blue-500/10 rounded flex items-center justify-center"
|
||||
>
|
||||
<CommonLoadingIcon />
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-1.5 border-t dark:border-gray-700">
|
||||
<div class="flex space-x-2 items-center min-w-0">
|
||||
@@ -100,9 +108,14 @@ import dayjs from 'dayjs'
|
||||
import type { SourceAppName } from '@speckle/shared'
|
||||
import { SourceApps } from '@speckle/shared'
|
||||
import type { VersionListItemFragment } from '~/lib/common/generated/gql/graphql'
|
||||
import { useAccountStore, type DUIAccount } from '~/store/accounts'
|
||||
import { computedAsync } from '@vueuse/core'
|
||||
import { usePreviewUrl } from '~/lib/core/composables/previewUrl'
|
||||
// import { objectQuery } from '~/lib/graphql/mutationsAndQueries'
|
||||
// import { useQuery } from '@vue/apollo-composable'
|
||||
|
||||
const accountStore = useAccountStore()
|
||||
|
||||
const props = defineProps<{
|
||||
version: VersionListItemFragment
|
||||
index: number
|
||||
@@ -120,6 +133,18 @@ const createdAgo = computed(() => {
|
||||
|
||||
const isLimited = computed(() => props.version.referencedObject === null)
|
||||
|
||||
const token = computed(() => {
|
||||
const account = accountStore.accounts.find(
|
||||
(acc) => acc.accountInfo.id === props.accountId
|
||||
) as DUIAccount
|
||||
return account.accountInfo.token
|
||||
})
|
||||
|
||||
const previewUrl = computedAsync(async () => {
|
||||
if (props.version.previewUrl === null) return
|
||||
return await usePreviewUrl(token.value, props.version.previewUrl)
|
||||
})
|
||||
|
||||
// NOTE!!!: This logic somehow caused regression on versionList fetchMore, but we do not know exactly why yet.
|
||||
// const { result: objectQueryResult } = useQuery(
|
||||
// objectQuery,
|
||||
|
||||
@@ -14,18 +14,34 @@
|
||||
<WorkspaceAvatar
|
||||
:size="'sm'"
|
||||
:name="workspace.name || ''"
|
||||
:logo="workspace.logo"
|
||||
:logo="workspace.logoUrl"
|
||||
/>
|
||||
<div class="min-w-0 grow">
|
||||
<div class="truncate overflow-hidden min-w-0 flex items-center space-x-2">
|
||||
<span>{{ workspace.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
v-if="workspace.slug"
|
||||
v-tippy="'Open workspace in browser'"
|
||||
class="hidden transition mr-1 opacity-70 group-hover:block"
|
||||
@click.stop="
|
||||
$openUrl(
|
||||
`${accountStore.activeAccount.accountInfo.serverInfo.url}/workspaces/${workspace.slug}`
|
||||
)
|
||||
"
|
||||
>
|
||||
<ArrowTopRightOnSquareIcon class="w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import type { WorkspaceListWorkspaceItemFragment } from '~/lib/common/generated/gql/graphql'
|
||||
import { ArrowTopRightOnSquareIcon } from '@heroicons/vue/24/outline'
|
||||
import { useAccountStore } from '~/store/accounts'
|
||||
|
||||
const accountStore = useAccountStore()
|
||||
|
||||
defineProps<{
|
||||
workspace: WorkspaceListWorkspaceItemFragment
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
@select="
|
||||
$emit('workspace:selected', workspace), (showWorkspaceSelectorDialog = false)
|
||||
"
|
||||
></WorkspaceListItem>
|
||||
/>
|
||||
</CommonDialog>
|
||||
</div>
|
||||
</template>
|
||||
@@ -33,13 +33,7 @@ defineEmits<{
|
||||
(e: 'workspace:selected', result: WorkspaceListWorkspaceItemFragment): void
|
||||
}>()
|
||||
|
||||
const workspacesWithPersonalProjects = computed(() => [
|
||||
...props.workspaces,
|
||||
{
|
||||
id: 'personalProject',
|
||||
name: 'Personal Projects'
|
||||
} as WorkspaceListWorkspaceItemFragment
|
||||
])
|
||||
const workspacesWithPersonalProjects = computed(() => [...props.workspaces])
|
||||
|
||||
const toggleDialog = () => {
|
||||
showWorkspaceSelectorDialog.value = !showWorkspaceSelectorDialog.value
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
FROM node:22-bookworm@sha256:7e791fc54bd02fc89fd4fb39eb37e5bea753c75679c8022478d81679367d995a AS build-stage
|
||||
WORKDIR /app
|
||||
RUN corepack enable
|
||||
COPY package.json .
|
||||
COPY yarn.lock .
|
||||
COPY .yarnrc.yml .
|
||||
RUN yarn install --immutable || (cat /tmp/xfs-*/build.log && exit 1)
|
||||
COPY . .
|
||||
# NODE_ENV must be set after the dependencies are installed because @nuxt/kit is a devDependency and is required to build the application
|
||||
ARG NODE_ENV=production
|
||||
ENV NODE_ENV=${NODE_ENV}
|
||||
ENV NUXT_PUBLIC_MIXPANEL_TOKEN_ID=acd87c5a50b56df91a795e999812a3a4
|
||||
ENV NUXT_PUBLIC_MIXPANEL_API_HOST=https://analytics.speckle.systems
|
||||
RUN yarn generate
|
||||
|
||||
FROM joseluisq/static-web-server:2.40@sha256:63528bfba5d86b00572e23b4e44ed0f7a791f931df650125156d0c24f7a8f877 AS production-stage
|
||||
WORKDIR /app
|
||||
COPY --from=build-stage /app/dist /app/dist
|
||||
CMD ["--config-file", "/app/configuration.toml"]
|
||||
@@ -0,0 +1,23 @@
|
||||
# Patterns to ignore when building packages.
|
||||
# This supports shell glob matching, relative path matching, and
|
||||
# negation (prefixed with !). Only one pattern per line.
|
||||
.DS_Store
|
||||
# Common VCS dirs
|
||||
.git/
|
||||
.gitignore
|
||||
.bzr/
|
||||
.bzrignore
|
||||
.hg/
|
||||
.hgignore
|
||||
.svn/
|
||||
# Common backup files
|
||||
*.swp
|
||||
*.bak
|
||||
*.tmp
|
||||
*.orig
|
||||
*~
|
||||
# Various IDEs
|
||||
.project
|
||||
.idea/
|
||||
*.tmproj
|
||||
.vscode/
|
||||
@@ -0,0 +1,24 @@
|
||||
apiVersion: v2
|
||||
name: speckle-dui-chart
|
||||
description: A Helm chart for deploying the Speckle DUI3 application
|
||||
|
||||
# A chart can be either an 'application' or a 'library' chart.
|
||||
#
|
||||
# Application charts are a collection of templates that can be packaged into versioned archives
|
||||
# to be deployed.
|
||||
#
|
||||
# Library charts provide useful utilities or functions for the chart developer. They're included as
|
||||
# a dependency of application charts to inject those utilities and functions into the rendering
|
||||
# pipeline. Library charts do not define any templates and therefore cannot be deployed.
|
||||
type: application
|
||||
|
||||
# This is the chart version. This version number should be incremented each time you make changes
|
||||
# to the chart and its templates, including the app version.
|
||||
# Versions are expected to follow Semantic Versioning (https://semver.org/)
|
||||
version: 0.1.0
|
||||
|
||||
# This is the version number of the application being deployed. This version number should be
|
||||
# incremented each time you make changes to the application. Versions are not expected to
|
||||
# follow Semantic Versioning. They should reflect the version the application is using.
|
||||
# It is recommended to use it with quotes.
|
||||
appVersion: "0.1.0"
|
||||
@@ -0,0 +1,62 @@
|
||||
{{/*
|
||||
Expand the name of the chart.
|
||||
*/}}
|
||||
{{- define "speckle-dui.name" -}}
|
||||
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Create a default fully qualified app name.
|
||||
We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
|
||||
If release name contains chart name it will be used as a full name.
|
||||
*/}}
|
||||
{{- define "speckle-dui.fullname" -}}
|
||||
{{- if .Values.fullnameOverride }}
|
||||
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
|
||||
{{- else }}
|
||||
{{- $name := default .Chart.Name .Values.nameOverride }}
|
||||
{{- if contains $name .Release.Name }}
|
||||
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
|
||||
{{- else }}
|
||||
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Create chart name and version as used by the chart label.
|
||||
*/}}
|
||||
{{- define "speckle-dui.chart" -}}
|
||||
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Common labels
|
||||
*/}}
|
||||
{{- define "speckle-dui.labels" -}}
|
||||
helm.sh/chart: {{ include "speckle-dui.chart" . }}
|
||||
{{ include "speckle-dui.selectorLabels" . }}
|
||||
{{- if .Chart.AppVersion }}
|
||||
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
|
||||
{{- end }}
|
||||
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Selector labels
|
||||
*/}}
|
||||
{{- define "speckle-dui.selectorLabels" -}}
|
||||
app.kubernetes.io/name: {{ include "speckle-dui.name" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Create the name of the service account to use
|
||||
*/}}
|
||||
{{- define "speckle-dui.serviceAccountName" -}}
|
||||
{{- if .Values.serviceAccount.create }}
|
||||
{{- default (include "speckle-dui.fullname" .) .Values.serviceAccount.name }}
|
||||
{{- else }}
|
||||
{{- default "default" .Values.serviceAccount.name }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,122 @@
|
||||
kind: ConfigMap
|
||||
apiVersion: v1
|
||||
metadata:
|
||||
name: {{ include "speckle-dui.fullname" . }}-configuration
|
||||
labels:
|
||||
{{- include "speckle-dui.labels" . | nindent 4 }}
|
||||
data:
|
||||
configuration.toml: |
|
||||
[general]
|
||||
|
||||
#### Address & Root dir
|
||||
host = "::"
|
||||
port = 80
|
||||
root = "/app/dist"
|
||||
|
||||
#### Logging
|
||||
log-level = "info"
|
||||
|
||||
#### Cache Control headers
|
||||
cache-control-headers = true
|
||||
|
||||
#### Auto Compression
|
||||
compression = true
|
||||
compression-level = "default"
|
||||
|
||||
#### Error pages
|
||||
# Note: If a relative path is used then it will be resolved under the root directory.
|
||||
page404 = "./404.html"
|
||||
page50x = "./50x.html"
|
||||
|
||||
#### HTTP/2 + TLS
|
||||
# Note: We expect TLS termination to be handled by a reverse proxy (e.g. Nginx, Traefik, Cloudflare, etc.)
|
||||
http2 = false
|
||||
http2-tls-cert = ""
|
||||
http2-tls-key = ""
|
||||
## we are terminating https upstream; redirect is at edge proxy (ingress/gateway)
|
||||
https-redirect = false
|
||||
https-redirect-host = "localhost"
|
||||
https-redirect-from-port = 80
|
||||
https-redirect-from-hosts = "localhost"
|
||||
|
||||
#### CORS & Security headers
|
||||
## security-headers must be disabled for iframe compatibility as they include x-frame-options: deny as default
|
||||
# security-headers = false
|
||||
## cors-allows-origins is unset as iframe embedding does not require CORS, we are not fetching from another origin via XHR/fetch, and wildcard increases attack surface.
|
||||
# cors-allow-origins = ""
|
||||
|
||||
#### Directory listing
|
||||
directory-listing = false
|
||||
|
||||
#### Directory listing sorting code
|
||||
directory-listing-order = 1
|
||||
|
||||
#### Directory listing content format
|
||||
directory-listing-format = "html"
|
||||
|
||||
#### Directory listing download format
|
||||
directory-listing-download = []
|
||||
|
||||
#### File descriptor binding
|
||||
# fd = ""
|
||||
|
||||
#### Worker threads
|
||||
threads-multiplier = 1
|
||||
|
||||
#### Grace period after a graceful shutdown
|
||||
grace-period = 0
|
||||
|
||||
#### Page fallback for 404s
|
||||
# page-fallback = ""
|
||||
|
||||
#### Log request Remote Address if available
|
||||
log-remote-address = true
|
||||
|
||||
#### Log real IP from X-Forwarded-For header if available
|
||||
log-forwarded-for = true
|
||||
|
||||
#### IPs to accept the X-Forwarded-For header from. Empty means all
|
||||
trusted-proxies = {{ .Values.security.trustedProxies | toJson }}
|
||||
|
||||
#### Redirect to trailing slash in the requested directory uri
|
||||
redirect-trailing-slash = true
|
||||
|
||||
#### Check for existing pre-compressed files
|
||||
compression-static = true
|
||||
|
||||
#### Health-check endpoint (GET or HEAD `/health`)
|
||||
health = true
|
||||
|
||||
#### Markdown content negotiation
|
||||
accept-markdown = false
|
||||
|
||||
#### Maintenance Mode
|
||||
|
||||
maintenance-mode = false
|
||||
# maintenance-mode-status = 503
|
||||
# maintenance-mode-file = "./maintenance.html"
|
||||
|
||||
[advanced]
|
||||
|
||||
#### HTTP Headers customization
|
||||
[[advanced.headers]]
|
||||
source = "/*.html"
|
||||
[advanced.headers.headers]
|
||||
# Cache-Control = "public, max-age=36000"
|
||||
Content-Security-Policy = """\
|
||||
frame-ancestors {{ if .Values.security.frameAncestors }}{{ .Values.security.frameAncestors | join " " }}{{ else }}'self'{{ end }}; \
|
||||
default-src 'self'; \
|
||||
frame-src {{ if .Values.security.frameSource }}{{ .Values.security.frameSource | join " " }}{{ else }}'self'{{ end }}; \
|
||||
script-src {{ if .Values.security.frameSource }}{{ .Values.security.frameSource | join " " }}{{ else }}'self'{{ end }} 'unsafe-inline'; \
|
||||
style-src {{ if .Values.security.frameSource }}{{ .Values.security.frameSource | join " " }}{{ else }}'self'{{ end }} 'unsafe-inline'; \
|
||||
img-src {{ if .Values.security.frameSource }}{{ .Values.security.frameSource | join " " }}{{ else }}'self'{{ end }} data: blob:; \
|
||||
connect-src {{ if .Values.security.frameAncestors }}{{ .Values.security.frameAncestors | join " " }}{{ else }}'self'{{ end }}; \
|
||||
object-src 'none'; \
|
||||
base-uri 'self'; \
|
||||
form-action {{ if .Values.security.frameAncestors }}{{ .Values.security.frameAncestors | join " " }}{{ else }}'self'{{ end }};\
|
||||
"""
|
||||
# Strict-Transport-Security = "max-age=63072000; includeSubDomains; preload"
|
||||
X-Content-Type-Options = "nosniff"
|
||||
Referrer-Policy = "strict-origin-when-cross-origin"
|
||||
Permissions-Policy = "geolocation=(), microphone=(), camera=()"
|
||||
## Purposefully do not set X-Frame-Options as this is intended to be an iframe
|
||||
@@ -0,0 +1,84 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ include "speckle-dui.fullname" . }}
|
||||
labels:
|
||||
{{- include "speckle-dui.labels" . | nindent 4 }}
|
||||
spec:
|
||||
replicas: {{ .Values.replicaCount }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "speckle-dui.selectorLabels" . | nindent 6 }}
|
||||
template:
|
||||
metadata:
|
||||
{{- with .Values.podAnnotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
labels:
|
||||
{{- include "speckle-dui.labels" . | nindent 8 }}
|
||||
{{- with .Values.podLabels }}
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
{{- with .Values.imagePullSecrets }}
|
||||
imagePullSecrets:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
serviceAccountName: {{ include "speckle-dui.serviceAccountName" . }}
|
||||
automountServiceAccountToken: {{ .Values.serviceAccount.automount }}
|
||||
{{- with .Values.podSecurityContext }}
|
||||
securityContext:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
containers:
|
||||
- name: {{ .Chart.Name }}
|
||||
{{- with .Values.securityContext }}
|
||||
securityContext:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
|
||||
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 80 # Needs to match port defined in deployment/docker/configuration.toml
|
||||
protocol: TCP
|
||||
{{- with .Values.livenessProbe }}
|
||||
livenessProbe:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with .Values.readinessProbe }}
|
||||
readinessProbe:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with .Values.resources }}
|
||||
resources:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
volumeMounts:
|
||||
- name: configuration
|
||||
mountPath: /app/configuration.toml
|
||||
subPath: configuration.toml
|
||||
readOnly: true
|
||||
{{- with .Values.volumeMounts }}
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
volumes:
|
||||
- name: configuration
|
||||
configMap:
|
||||
name: {{ include "speckle-dui.fullname" . }}-configuration
|
||||
{{- with .Values.volumes }}
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.nodeSelector }}
|
||||
nodeSelector:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.affinity }}
|
||||
affinity:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.tolerations }}
|
||||
tolerations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,38 @@
|
||||
{{- if .Values.httpRoute.enabled -}}
|
||||
{{- $fullName := include "speckle-dui.fullname" . -}}
|
||||
{{- $svcPort := .Values.service.port -}}
|
||||
apiVersion: gateway.networking.k8s.io/v1
|
||||
kind: HTTPRoute
|
||||
metadata:
|
||||
name: {{ $fullName }}
|
||||
labels:
|
||||
{{- include "speckle-dui.labels" . | nindent 4 }}
|
||||
{{- with .Values.httpRoute.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
parentRefs:
|
||||
{{- with .Values.httpRoute.parentRefs }}
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- with .Values.httpRoute.hostnames }}
|
||||
hostnames:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
rules:
|
||||
{{- range .Values.httpRoute.rules }}
|
||||
- backendRefs:
|
||||
- name: {{ $fullName }}
|
||||
port: {{ $svcPort }}
|
||||
weight: 1
|
||||
{{- with .filters }}
|
||||
filters:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .matches }}
|
||||
matches:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,43 @@
|
||||
{{- if .Values.ingress.enabled -}}
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: {{ include "speckle-dui.fullname" . }}
|
||||
labels:
|
||||
{{- include "speckle-dui.labels" . | nindent 4 }}
|
||||
{{- with .Values.ingress.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
{{- with .Values.ingress.className }}
|
||||
ingressClassName: {{ . }}
|
||||
{{- end }}
|
||||
{{- if .Values.ingress.tls }}
|
||||
tls:
|
||||
{{- range .Values.ingress.tls }}
|
||||
- hosts:
|
||||
{{- range .hosts }}
|
||||
- {{ . | quote }}
|
||||
{{- end }}
|
||||
secretName: {{ .secretName }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
rules:
|
||||
{{- range .Values.ingress.hosts }}
|
||||
- host: {{ .host | quote }}
|
||||
http:
|
||||
paths:
|
||||
{{- range .paths }}
|
||||
- path: {{ .path }}
|
||||
{{- with .pathType }}
|
||||
pathType: {{ . }}
|
||||
{{- end }}
|
||||
backend:
|
||||
service:
|
||||
name: {{ include "speckle-dui.fullname" $ }}
|
||||
port:
|
||||
number: {{ $.Values.service.port }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,13 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ include "speckle-dui.fullname" . }}
|
||||
labels: {{- include "speckle-dui.labels" . | nindent 4 }}
|
||||
spec:
|
||||
type: {{ .Values.service.type }}
|
||||
ports:
|
||||
- port: {{ .Values.service.port }}
|
||||
targetPort: http
|
||||
protocol: TCP
|
||||
name: http
|
||||
selector: {{- include "speckle-dui.selectorLabels" . | nindent 4 }}
|
||||
@@ -0,0 +1,13 @@
|
||||
{{- if .Values.serviceAccount.create -}}
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: {{ include "speckle-dui.serviceAccountName" . }}
|
||||
labels:
|
||||
{{- include "speckle-dui.labels" . | nindent 4 }}
|
||||
{{- with .Values.serviceAccount.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
automountServiceAccountToken: {{ .Values.serviceAccount.automount }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,150 @@
|
||||
# Default values for speckle-dui3.
|
||||
# This is a YAML-formatted file.
|
||||
# Declare variables to be passed into your templates.
|
||||
|
||||
# This will set the replicaset count more information can be found here: https://kubernetes.io/docs/concepts/workloads/controllers/replicaset/
|
||||
replicaCount: 1
|
||||
|
||||
# This sets the container image more information can be found here: https://kubernetes.io/docs/concepts/containers/images/
|
||||
image:
|
||||
repository: ghcr.io/specklesystems/speckle-dui
|
||||
# This sets the pull policy for images.
|
||||
pullPolicy: IfNotPresent
|
||||
# Overrides the image tag whose default is the chart appVersion.
|
||||
tag: ""
|
||||
|
||||
# This is for the secrets for pulling an image from a private repository more information can be found here: https://kubernetes.io/docs/tasks/configure-pod-container/pull-image-private-registry/
|
||||
imagePullSecrets: []
|
||||
# This is to override the chart name.
|
||||
nameOverride: ""
|
||||
fullnameOverride: ""
|
||||
|
||||
# This section builds out the service account more information can be found here: https://kubernetes.io/docs/concepts/security/service-accounts/
|
||||
serviceAccount:
|
||||
# Specifies whether a service account should be created.
|
||||
create: true
|
||||
# Automatically mount a ServiceAccount's API credentials?
|
||||
automount: false
|
||||
# Annotations to add to the service account.
|
||||
annotations: {}
|
||||
# The name of the service account to use.
|
||||
# If not set and create is true, a name is generated using the fullname template.
|
||||
name: ""
|
||||
|
||||
# This is for setting Kubernetes Annotations to a Pod.
|
||||
# For more information checkout: https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/
|
||||
podAnnotations: {}
|
||||
# This is for setting Kubernetes Labels to a Pod.
|
||||
# For more information checkout: https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/
|
||||
podLabels: {}
|
||||
|
||||
podSecurityContext: {}
|
||||
# fsGroup: 2000
|
||||
|
||||
securityContext: {}
|
||||
# capabilities:
|
||||
# drop:
|
||||
# - ALL
|
||||
# readOnlyRootFilesystem: true
|
||||
# runAsNonRoot: true
|
||||
# runAsUser: 1000
|
||||
|
||||
# This is for setting up a service more information can be found here: https://kubernetes.io/docs/concepts/services-networking/service/
|
||||
service:
|
||||
# This sets the service type more information can be found here: https://kubernetes.io/docs/concepts/services-networking/service/#publishing-services-service-types
|
||||
type: ClusterIP
|
||||
# This sets the ports more information can be found here: https://kubernetes.io/docs/concepts/services-networking/service/#field-spec-ports
|
||||
port: 80
|
||||
|
||||
security:
|
||||
## The IP addresses of trusted proxies, such as loadbalancers or WAFs, that may be forwarding traffic to the dashboards. This is important for correctly handling the X-Forwarded-For header and ensuring accurate client IP logging and security measures. Empty means all proxies are trusted, which may not be secure in production environments. We recommend setting this to the specific IP addresses of your trusted proxies.
|
||||
trustedProxies: []
|
||||
## A list of urls to be added as frame-ancestors of the Content-Security-Policy header. Empty means 'self', allowing embedding only from the same origin as the dashboards. We recommend setting this to the specific hostnames of your parent applications that will be embedding the dashboards in iframes.
|
||||
frameAncestors: []
|
||||
## A list of urls to be added as frame-src (and script-src, style-src, img-src) of the Content-Security-Policy header. Empty means 'self', allowing embedding of dashboards resources only from the same origin. We recommend setting this to the specific hostnames of Speckle DUI3.
|
||||
frameSource: []
|
||||
|
||||
# This block is for setting up the ingress for more information can be found here: https://kubernetes.io/docs/concepts/services-networking/ingress/
|
||||
ingress:
|
||||
enabled: false
|
||||
className: ""
|
||||
annotations: {}
|
||||
# kubernetes.io/ingress.class: nginx
|
||||
# kubernetes.io/tls-acme: "true"
|
||||
hosts:
|
||||
# As dashboards expect to serve all paths under the root, we recommend using a dedicated hostname for the service, e.g. dashboards.example.com, and not sharing it with other services.
|
||||
- host: chart-example.local
|
||||
paths:
|
||||
# Please retain this path, the dashboards expect to serve all paths under the root.
|
||||
- path: /
|
||||
pathType: ImplementationSpecific
|
||||
tls: []
|
||||
# - secretName: chart-example-tls
|
||||
# hosts:
|
||||
# - chart-example.local
|
||||
|
||||
# -- Expose the service via gateway-api HTTPRoute
|
||||
# Requires Gateway API resources and suitable controller installed within the cluster
|
||||
# (see: https://gateway-api.sigs.k8s.io/guides/)
|
||||
httpRoute:
|
||||
# HTTPRoute enabled.
|
||||
enabled: false
|
||||
# HTTPRoute annotations.
|
||||
annotations: {}
|
||||
# Which Gateways this Route is attached to.
|
||||
parentRefs:
|
||||
- name: gateway
|
||||
sectionName: http
|
||||
# namespace: default
|
||||
# Hostnames matching HTTP header.
|
||||
hostnames:
|
||||
# As dashboards expect to serve all paths under the root, we recommend using a dedicated hostname for the service, e.g. dashboards.example.com, and not sharing it with other services.
|
||||
- chart-example.local
|
||||
# List of rules and filters applied.
|
||||
rules:
|
||||
- matches:
|
||||
# Please retain this path, the dashboards expect to serve all paths under the root.
|
||||
- path:
|
||||
type: PathPrefix
|
||||
value: /
|
||||
|
||||
resources: {}
|
||||
# We usually recommend not to specify default resources and to leave this as a conscious
|
||||
# choice for the user. This also increases chances charts run on environments with little
|
||||
# resources, such as Minikube. If you do want to specify resources, uncomment the following
|
||||
# lines, adjust them as necessary, and remove the curly braces after 'resources:'.
|
||||
# limits:
|
||||
# cpu: 100m
|
||||
# memory: 128Mi
|
||||
# requests:
|
||||
# cpu: 100m
|
||||
# memory: 128Mi
|
||||
|
||||
# This is to setup the liveness and readiness probes more information can be found here: https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: http
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: http
|
||||
|
||||
# Additional volumes on the output Deployment definition.
|
||||
volumes: []
|
||||
# - name: foo
|
||||
# secret:
|
||||
# secretName: mysecret
|
||||
# optional: false
|
||||
|
||||
# Additional volumeMounts on the output Deployment definition.
|
||||
volumeMounts: []
|
||||
# - name: foo
|
||||
# mountPath: "/etc/foo"
|
||||
# readOnly: true
|
||||
|
||||
nodeSelector: {}
|
||||
|
||||
tolerations: []
|
||||
|
||||
affinity: {}
|
||||
@@ -1,4 +1,4 @@
|
||||
HOST=0.0.0.0
|
||||
HOST=127.0.0.1
|
||||
PORT=8082
|
||||
|
||||
NUXT_PUBLIC_MIXPANEL_TOKEN_ID=acd87c5a50b56df91a795e999812a3a4
|
||||
+53
-7
@@ -1,7 +1,19 @@
|
||||
import { omit } from 'lodash-es'
|
||||
import { baseConfigs, globals, getESMDirname } from '../../eslint.config.mjs'
|
||||
import withNuxt from './.nuxt/eslint.config.mjs'
|
||||
import pluginVueA11y from 'eslint-plugin-vuejs-accessibility'
|
||||
import globals from 'globals'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { dirname } from 'path'
|
||||
import prettierConfig from 'eslint-config-prettier'
|
||||
import js from '@eslint/js'
|
||||
|
||||
/**
|
||||
* Feed in import.meta.url in your .mjs module to get the equivalent of __dirname
|
||||
* @param {string} importMetaUrl
|
||||
*/
|
||||
export const getESMDirname = (importMetaUrl) => {
|
||||
return dirname(fileURLToPath(importMetaUrl))
|
||||
}
|
||||
|
||||
const configs = await withNuxt([
|
||||
{
|
||||
@@ -62,6 +74,7 @@ const configs = await withNuxt([
|
||||
'@typescript-eslint/require-await': 'error',
|
||||
'no-undef': 'off',
|
||||
|
||||
'@typescript-eslint/no-empty-object-type': 'off', // too restrictive
|
||||
'@typescript-eslint/unified-signatures': 'off', // DX sucks in vue event definitions
|
||||
'@typescript-eslint/no-dynamic-delete': 'off', // too restrictive
|
||||
'@typescript-eslint/restrict-template-expressions': 'off', // too restrictive
|
||||
@@ -78,10 +91,7 @@ const configs = await withNuxt([
|
||||
{
|
||||
files: ['**/*.vue'],
|
||||
rules: {
|
||||
'vue/component-tags-order': [
|
||||
'error',
|
||||
{ order: ['docs', 'template', 'script', 'style'] }
|
||||
],
|
||||
'vue/block-order': ['error', { order: ['docs', 'template', 'script', 'style'] }],
|
||||
'vue/require-default-prop': 'off',
|
||||
'vue/multi-word-component-names': 'off',
|
||||
'vue/component-name-in-template-casing': [
|
||||
@@ -116,10 +126,46 @@ const configs = await withNuxt([
|
||||
'./lib/common/generated/**/*',
|
||||
'storybook-static',
|
||||
'.nuxt/**',
|
||||
'.output/**'
|
||||
'.output/**',
|
||||
'**/dist/**',
|
||||
'**/dist-*/**',
|
||||
'**/public/**',
|
||||
'**/events.json',
|
||||
'**/generated/**/*'
|
||||
]
|
||||
},
|
||||
...baseConfigs
|
||||
{
|
||||
files: ['**/*.mjs'],
|
||||
languageOptions: {
|
||||
sourceType: 'module'
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ['**/*.cjs'],
|
||||
languageOptions: {
|
||||
sourceType: 'commonjs'
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ['**/*.{js,mjs,cjs}', '**/.*.{js,mjs,cjs}'],
|
||||
...js.configs.recommended
|
||||
},
|
||||
prettierConfig,
|
||||
{
|
||||
rules: {
|
||||
camelcase: [
|
||||
1,
|
||||
{
|
||||
properties: 'always'
|
||||
}
|
||||
],
|
||||
'no-var': 'error',
|
||||
'no-alert': 'error',
|
||||
eqeqeq: 'error',
|
||||
'prefer-const': 'warn',
|
||||
'object-shorthand': 'warn'
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
export default configs
|
||||
|
||||
+15
-2
@@ -8,7 +8,7 @@
|
||||
v-if="hasNoModelCards"
|
||||
class="px-3 text-body-3xs text-foreground-2 justify-center bg-red-200/1 py-2 flex items-center w-full space-x-2"
|
||||
>
|
||||
<span>Version {{ hostApp.connectorVersion }}</span>
|
||||
<span>Version {{ hostApp.connectorVersion || 'dev' }}</span>
|
||||
<FormButton
|
||||
size="sm"
|
||||
text
|
||||
@@ -19,6 +19,17 @@
|
||||
>
|
||||
Toggle theme
|
||||
</FormButton>
|
||||
<FormButton
|
||||
v-if="hostApp.hostAppName?.toLowerCase() === 'revit'"
|
||||
size="sm"
|
||||
text
|
||||
color="subtle"
|
||||
:icon-right="WrenchScrewdriverIcon"
|
||||
hide-text
|
||||
@click="app.$showDevTools()"
|
||||
>
|
||||
Open dev tools
|
||||
</FormButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -27,7 +38,9 @@
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useHostAppStore } from '~/store/hostApp'
|
||||
import { useConfigStore } from '~/store/config'
|
||||
import { MoonIcon, SunIcon } from '@heroicons/vue/24/outline'
|
||||
import { MoonIcon, SunIcon, WrenchScrewdriverIcon } from '@heroicons/vue/24/outline'
|
||||
|
||||
const app = useNuxtApp()
|
||||
|
||||
const uiConfigStore = useConfigStore()
|
||||
const { isDarkTheme } = storeToRefs(uiConfigStore)
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
import { ApolloClient, gql } from '@apollo/client/core'
|
||||
import { ApolloClients } from '@vue/apollo-composable'
|
||||
import type { ComputedRef, Ref } from 'vue'
|
||||
import type { Account } from '~/lib/bindings/definitions/IBasicConnectorBinding'
|
||||
import { resolveClientConfig } from '~/lib/core/configs/apollo'
|
||||
|
||||
export type DUIAccount = {
|
||||
/** account info coming from the host app */
|
||||
accountInfo: Account
|
||||
/** the graphql client; a bit superflous */
|
||||
client?: ApolloClient<unknown>
|
||||
/** whether an intial serverinfo query succeeded. */
|
||||
isValid: boolean
|
||||
}
|
||||
|
||||
export type DUIAccountsState = {
|
||||
accounts: Ref<DUIAccount[]>
|
||||
validAccounts: ComputedRef<DUIAccount[]>
|
||||
refreshAccounts: () => Promise<void>
|
||||
defaultAccount: ComputedRef<DUIAccount | undefined>
|
||||
loading: Ref<boolean>
|
||||
}
|
||||
|
||||
const AccountsInjectionKey = 'DUI_ACCOUNTS_STATE'
|
||||
|
||||
/**
|
||||
* Use this composable to set up the account bindings and graphql clients at the top of the app.
|
||||
* TODO: Properly handle cases when user was not connected to the internet,
|
||||
* and then actually got connected.
|
||||
*/
|
||||
export function useAccountsSetup(): DUIAccountsState {
|
||||
const app = useNuxtApp()
|
||||
const $baseBinding = app.$baseBinding
|
||||
|
||||
const accounts = ref<DUIAccount[]>([])
|
||||
|
||||
const apolloClients = {} as Record<string, ApolloClient<unknown>>
|
||||
|
||||
// Tries to connect to the accounts and sets their is valid prop to false if fails.
|
||||
const testAccounts = async (accs: DUIAccount[]) => {
|
||||
const accountTestQuery = gql`
|
||||
query AcccountTestQuery {
|
||||
serverInfo {
|
||||
version
|
||||
name
|
||||
company
|
||||
}
|
||||
}
|
||||
`
|
||||
for (const acc of accs) {
|
||||
if (!acc.client) continue
|
||||
try {
|
||||
await acc.client.query({ query: accountTestQuery })
|
||||
acc.isValid = true
|
||||
} catch {
|
||||
// TODO: properly dispose and kill this client. It's unclear how to do it.
|
||||
acc.isValid = false
|
||||
// NOTE: we do not want to delete the client, as we might want to "refresh" in
|
||||
// case the user was not connected to the interweb.
|
||||
// acc.client.disableNetworkFetches = true
|
||||
// acc.client.stop()
|
||||
// delete acc.client
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const loading = ref(false)
|
||||
|
||||
// Matches local accounts coming from the host app to app state.
|
||||
const refreshAccounts = async () => {
|
||||
loading.value = true
|
||||
|
||||
const accs = await $baseBinding.getAccounts()
|
||||
// We create a whole new list of accounts that will replace the old list. This way we ensure we drop
|
||||
// out of scope old accounts that not exist anymore (TODO: test), and we don't need to do complex diffing.
|
||||
const newAccs = [] as DUIAccount[]
|
||||
for (const acc of accs) {
|
||||
const existing = accounts.value.find((a) => a.accountInfo.id === acc.id)
|
||||
if (existing) {
|
||||
newAccs.push(existing as DUIAccount)
|
||||
continue
|
||||
}
|
||||
|
||||
const client = new ApolloClient(
|
||||
resolveClientConfig({
|
||||
httpEndpoint: new URL('/graphql', acc.serverInfo.url).href,
|
||||
authToken: () => acc.token
|
||||
})
|
||||
)
|
||||
|
||||
apolloClients[acc.id] = client
|
||||
|
||||
newAccs.push({
|
||||
accountInfo: acc,
|
||||
client,
|
||||
isValid: true
|
||||
})
|
||||
}
|
||||
// We test accounts here so we try to prevent the app from querying/using invalid accounts.
|
||||
await testAccounts(newAccs)
|
||||
// Once we have tested the new accounts, finally set them.
|
||||
accounts.value = newAccs
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
void refreshAccounts() // Promise that we do not want to await (convention with void)
|
||||
|
||||
const defaultAccount = computed(() =>
|
||||
accounts.value.find((acc) => acc.accountInfo.isDefault)
|
||||
)
|
||||
|
||||
const validAccounts = computed(() => {
|
||||
return accounts.value.filter((a) => a.isValid)
|
||||
})
|
||||
|
||||
const accState = {
|
||||
accounts,
|
||||
defaultAccount,
|
||||
validAccounts,
|
||||
refreshAccounts,
|
||||
loading
|
||||
}
|
||||
|
||||
app.vueApp.provide(ApolloClients, apolloClients)
|
||||
provide(AccountsInjectionKey, accState)
|
||||
|
||||
return accState // as DUIAccountsState
|
||||
}
|
||||
|
||||
/**
|
||||
* Use this composable to access the users' local accounts and their corresponding graphql client.
|
||||
*/
|
||||
export function useInjectedAccounts(): DUIAccountsState {
|
||||
const state = inject(AccountsInjectionKey) as DUIAccountsState
|
||||
return state
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
const CHALLENGE_KEY = 'speckle_challenge'
|
||||
const CHALLENGE_URL_KEY = 'speckle_url_challenge'
|
||||
const CODE_VERIFIER_KEY = 'speckle_code_verifier'
|
||||
|
||||
function toBase64Url(buffer: Uint8Array): string {
|
||||
const base64 = btoa(String.fromCharCode(...buffer))
|
||||
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a cryptographically random base64url-encoded string.
|
||||
* 32 bytes → 43 characters after base64url encoding (within the RFC 7636 range of 43-128).
|
||||
*/
|
||||
function createCodeVerifier(): string {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(32))
|
||||
return toBase64Url(bytes)
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes SHA-256 hash of a string and returns it as base64url.
|
||||
* This is the PKCE code_challenge derivation from a code_verifier.
|
||||
*/
|
||||
async function computeS256Challenge(codeVerifier: string): Promise<string> {
|
||||
const data = new TextEncoder().encode(codeVerifier)
|
||||
const digest = await crypto.subtle.digest('SHA-256', data)
|
||||
return toBase64Url(new Uint8Array(digest))
|
||||
}
|
||||
|
||||
export interface ChallengeData {
|
||||
/** The raw random string (code_verifier in PKCE terms) */
|
||||
codeVerifier: string
|
||||
/** SHA-256 base64url hash of codeVerifier (code_challenge for S256 method) */
|
||||
codeChallenge: string
|
||||
}
|
||||
|
||||
export function useAuthManager() {
|
||||
/**
|
||||
* Generates a PKCE code_verifier + code_challenge pair and persists to localStorage.
|
||||
* Used by redirect-based sign-in flows (SignInFlow) that need to
|
||||
* recover the values after the browser navigates away and back.
|
||||
*/
|
||||
const generateChallenge = async (url: string): Promise<ChallengeData> => {
|
||||
const codeVerifier = createCodeVerifier()
|
||||
const codeChallenge = await computeS256Challenge(codeVerifier)
|
||||
localStorage.setItem(CHALLENGE_KEY, codeChallenge)
|
||||
localStorage.setItem(CODE_VERIFIER_KEY, codeVerifier)
|
||||
localStorage.setItem(CHALLENGE_URL_KEY, url)
|
||||
return { codeVerifier, codeChallenge }
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a PKCE code_verifier + code_challenge pair without persisting to localStorage.
|
||||
* Used by flows that keep values in memory (ExchangeTokenSignInFlow)
|
||||
* so they don't overwrite the redirect flow's stored data.
|
||||
*/
|
||||
const generateLocalChallenge = async (): Promise<ChallengeData> => {
|
||||
const codeVerifier = createCodeVerifier()
|
||||
const codeChallenge = await computeS256Challenge(codeVerifier)
|
||||
return { codeVerifier, codeChallenge }
|
||||
}
|
||||
|
||||
const getChallenge = (): string | null => {
|
||||
return localStorage.getItem(CHALLENGE_KEY)
|
||||
}
|
||||
|
||||
const getCodeVerifier = (): string | null => {
|
||||
return localStorage.getItem(CODE_VERIFIER_KEY)
|
||||
}
|
||||
|
||||
const getChallengeUrl = (): string | null => {
|
||||
return localStorage.getItem(CHALLENGE_URL_KEY)
|
||||
}
|
||||
|
||||
return {
|
||||
getChallenge,
|
||||
getCodeVerifier,
|
||||
getChallengeUrl,
|
||||
generateChallenge,
|
||||
generateLocalChallenge
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { md5 } from '@speckle/shared'
|
||||
import type { Account } from '~/lib/bindings/definitions/IAccountBinding'
|
||||
|
||||
/**
|
||||
* Checks if the server supports the new /oauth/token endpoint.
|
||||
* The server exposes GET /oauth/token returning 'supported' when available.
|
||||
*/
|
||||
export async function supportsOAuthToken(serverUrl: string): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(new URL('/oauth/token', serverUrl), { method: 'GET' })
|
||||
return res.ok
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function useTokenExchange() {
|
||||
const { $accountBinding } = useNuxtApp()
|
||||
|
||||
const exchangeAccessCode = async (
|
||||
rawServerUrl: string,
|
||||
accessCode: string,
|
||||
challenge: string,
|
||||
codeVerifier?: string
|
||||
): Promise<void> => {
|
||||
// Normalize to origin (strips trailing slash, path, etc.)
|
||||
// so account IDs stay consistent with connectors
|
||||
const serverUrl = new URL(rawServerUrl).origin
|
||||
const tokenHeaders = { 'Content-Type': 'application/json' }
|
||||
let tokenResponse: Response
|
||||
|
||||
// If we have a codeVerifier, try the new PKCE-based /oauth/token endpoint first
|
||||
if (codeVerifier && (await supportsOAuthToken(serverUrl))) {
|
||||
tokenResponse = await fetch(new URL('/oauth/token', serverUrl), {
|
||||
method: 'POST',
|
||||
headers: tokenHeaders,
|
||||
body: JSON.stringify({
|
||||
appId: 'sdui',
|
||||
appSecret: 'sdui',
|
||||
accessCode,
|
||||
codeVerifier
|
||||
})
|
||||
})
|
||||
} else {
|
||||
// Fall back to legacy /auth/token with plain challenge
|
||||
tokenResponse = await fetch(new URL('/auth/token', serverUrl), {
|
||||
method: 'POST',
|
||||
headers: tokenHeaders,
|
||||
body: JSON.stringify({
|
||||
appId: 'sdui',
|
||||
appSecret: 'sdui',
|
||||
accessCode,
|
||||
challenge
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
const errorText = await tokenResponse.text()
|
||||
throw new Error(
|
||||
`Token exchange failed with status ${tokenResponse.status}: ${errorText}`
|
||||
)
|
||||
}
|
||||
|
||||
const { token, refreshToken } = (await tokenResponse.json()) as {
|
||||
token: string
|
||||
refreshToken: string
|
||||
}
|
||||
|
||||
// Query user and server info
|
||||
const graphqlQuery = {
|
||||
query:
|
||||
'query { activeUser { id name email company avatar } serverInfo { name company adminContact description version } }'
|
||||
}
|
||||
|
||||
const userAndServerInfoResponse = await fetch(new URL('/graphql', serverUrl), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify(graphqlQuery)
|
||||
})
|
||||
|
||||
if (!userAndServerInfoResponse.ok) {
|
||||
throw new Error(
|
||||
`Failed to fetch user info with status ${userAndServerInfoResponse.status}`
|
||||
)
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const userAndServerInfo = await userAndServerInfoResponse.json()
|
||||
|
||||
const accountId = md5(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
userAndServerInfo.data.activeUser.email + serverUrl
|
||||
).toUpperCase()
|
||||
|
||||
const account: Account = {
|
||||
id: accountId,
|
||||
token,
|
||||
refreshToken,
|
||||
isDefault: true,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
|
||||
serverInfo: { url: serverUrl, ...userAndServerInfo.data.serverInfo },
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
|
||||
userInfo: userAndServerInfo.data.activeUser
|
||||
}
|
||||
|
||||
await $accountBinding.addAccount(accountId, account)
|
||||
}
|
||||
|
||||
return { exchangeAccessCode }
|
||||
}
|
||||
@@ -1,10 +1,15 @@
|
||||
import type { IBinding, IBindingSharedEvents } from 'lib/bindings/definitions/IBinding'
|
||||
import type {
|
||||
IBinding,
|
||||
IBindingSharedEvents
|
||||
} from '~/lib/bindings/definitions/IBinding'
|
||||
|
||||
export const IAccountBindingKey = 'accountsBinding'
|
||||
|
||||
export interface IAccountBinding extends IBinding<IAccountBindingEvents> {
|
||||
getAccounts: () => Promise<Account[]>
|
||||
addAccount: (accountId: string, account: Account) => Promise<void>
|
||||
removeAccount: (accountId: string) => Promise<void>
|
||||
authenticateAccount: (serverUrl: string) => Promise<Account>
|
||||
}
|
||||
|
||||
// An almost 1-1 mapping of what we need from the Core accounts class.
|
||||
@@ -12,6 +17,7 @@ export type Account = {
|
||||
id: string
|
||||
isDefault: boolean
|
||||
token: string
|
||||
refreshToken: string
|
||||
serverInfo: {
|
||||
name: string
|
||||
url: string
|
||||
@@ -28,3 +34,65 @@ export type Account = {
|
||||
}
|
||||
|
||||
export interface IAccountBindingEvents extends IBindingSharedEvents {}
|
||||
|
||||
export class MockedAccountBinding implements IAccountBinding {
|
||||
public async getAccounts() {
|
||||
const config = useRuntimeConfig()
|
||||
return (await [
|
||||
{
|
||||
id: 'whatever',
|
||||
isDefault: true,
|
||||
token: config.public.speckleToken,
|
||||
serverInfo: {
|
||||
name: 'test',
|
||||
url: config.public.speckleUrl,
|
||||
frontend2: true
|
||||
},
|
||||
userInfo: {
|
||||
id: 'whatever',
|
||||
avatar: 'whatever',
|
||||
email: ''
|
||||
}
|
||||
}
|
||||
]) as Account[]
|
||||
}
|
||||
|
||||
public async addAccount(accountId: string, account: Account) {
|
||||
return await console.log('no way dude', accountId, account)
|
||||
}
|
||||
|
||||
public async authenticateAccount(serverUrl: string) {
|
||||
const config = useRuntimeConfig()
|
||||
return (await {
|
||||
id: 'whatever',
|
||||
isDefault: true,
|
||||
token: config.public.speckleToken,
|
||||
serverInfo: {
|
||||
name: 'test',
|
||||
url: serverUrl,
|
||||
frontend2: true
|
||||
},
|
||||
userInfo: {
|
||||
id: 'whatever',
|
||||
avatar: 'whatever',
|
||||
email: ''
|
||||
}
|
||||
}) as Account
|
||||
}
|
||||
|
||||
public async removeAccount(accountId: string) {
|
||||
return await console.log('no way dude', accountId)
|
||||
}
|
||||
|
||||
public async showDevTools() {
|
||||
await console.log('No way dude')
|
||||
}
|
||||
|
||||
public async openUrl(url: string) {
|
||||
await window.open(url)
|
||||
}
|
||||
|
||||
public on() {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ export interface IBasicConnectorBinding
|
||||
highlightObjects: (objectIds: string[]) => Promise<void>
|
||||
removeModel: (model: IModelCard) => Promise<void>
|
||||
removeModels: (models: IModelCard[]) => Promise<void>
|
||||
updateParameters: (payload: string) => Promise<void>
|
||||
}
|
||||
|
||||
export interface IBasicConnectorBindingHostEvents
|
||||
@@ -57,3 +58,69 @@ export type ToastAction = {
|
||||
url: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export class MockedBaseBinding implements IBasicConnectorBinding {
|
||||
public async getSourceApplicationName() {
|
||||
return await 'headless'
|
||||
}
|
||||
|
||||
public async getSourceApplicationVersion() {
|
||||
return await 'dev'
|
||||
}
|
||||
|
||||
public async getConnectorVersion() {
|
||||
return await 'dev'
|
||||
}
|
||||
|
||||
public async getDocumentInfo() {
|
||||
return (await {
|
||||
id: 'whatever',
|
||||
name: 'test',
|
||||
location: 'whocares'
|
||||
}) as DocumentInfo
|
||||
}
|
||||
|
||||
public async getDocumentState() {
|
||||
return (await { models: [] }) as DocumentModelStore
|
||||
}
|
||||
|
||||
public async addModel(_model: IModelCard) {
|
||||
await console.log('no way dude')
|
||||
}
|
||||
|
||||
public async removeModel(_model: IModelCard) {
|
||||
await console.log('no way dude')
|
||||
}
|
||||
|
||||
public async removeModels(_models: IModelCard[]) {
|
||||
await console.log('no way dude')
|
||||
}
|
||||
|
||||
public async updateModel(_model: IModelCard) {
|
||||
await console.log('no way dude')
|
||||
}
|
||||
|
||||
public async highlightModel(_modelCardId: string) {
|
||||
await console.log('no way dude')
|
||||
}
|
||||
|
||||
public async highlightObjects(_objectIds: string[]) {
|
||||
await console.log('no way dude')
|
||||
}
|
||||
|
||||
public async updateParameters(payload: string) {
|
||||
await console.log('Mock: updateParameters called with payload:', payload)
|
||||
}
|
||||
|
||||
public async showDevTools() {
|
||||
await console.log('No way dude')
|
||||
}
|
||||
|
||||
public async openUrl(url: string) {
|
||||
await window.open(url)
|
||||
}
|
||||
|
||||
public on() {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { BaseBridge } from '~/lib/bridge/base'
|
||||
import type {
|
||||
IBinding,
|
||||
IBindingSharedEvents
|
||||
@@ -15,6 +14,7 @@ export const IConfigBindingKey = 'configBinding'
|
||||
export interface IConfigBinding extends IBinding<IConfigBindingEvents> {
|
||||
getIsDevMode: () => Promise<boolean>
|
||||
getConfig: () => Promise<ConnectorConfig>
|
||||
getGlobalConfig: () => Promise<GlobalConfig>
|
||||
updateConfig: (config: ConnectorConfig) => void
|
||||
setUserSelectedAccountId: (accountId: string) => void
|
||||
getUserSelectedAccountId: () => Promise<AccountsConfig>
|
||||
@@ -25,8 +25,13 @@ export interface IConfigBinding extends IBinding<IConfigBindingEvents> {
|
||||
|
||||
export interface IConfigBindingEvents extends IBindingSharedEvents {}
|
||||
|
||||
export type GlobalConfig = {
|
||||
isUpdateNotificationDisabled: boolean
|
||||
}
|
||||
|
||||
export type ConnectorConfig = {
|
||||
darkTheme: boolean
|
||||
disableCache?: boolean
|
||||
}
|
||||
|
||||
export type AccountsConfig = {
|
||||
@@ -38,4 +43,52 @@ export type WorkspacesConfig = {
|
||||
}
|
||||
|
||||
// Useless, but will do for now :)
|
||||
export class MockedConfigBinding extends BaseBridge {}
|
||||
export class MockedConfigBinding implements IConfigBinding {
|
||||
public async getIsDevMode() {
|
||||
return await true
|
||||
}
|
||||
|
||||
public async getConfig() {
|
||||
return await { darkTheme: false, disableCache: false }
|
||||
}
|
||||
|
||||
public async getGlobalConfig() {
|
||||
return await { isUpdateNotificationDisabled: true }
|
||||
}
|
||||
|
||||
public async updateConfig() {
|
||||
return await console.log('')
|
||||
}
|
||||
|
||||
public async setUserSelectedAccountId(accountId: string) {
|
||||
return await console.log(accountId)
|
||||
}
|
||||
|
||||
public async setUserSelectedWorkspaceId(workspaceId: string) {
|
||||
return await console.log(workspaceId)
|
||||
}
|
||||
|
||||
public async getAccountsConfig() {
|
||||
return (await { userSelectedAccountId: 'whatever' }) as AccountsConfig
|
||||
}
|
||||
|
||||
public async getWorkspacesConfig() {
|
||||
return (await { userSelectedWorkspaceId: 'whatever' }) as WorkspacesConfig
|
||||
}
|
||||
|
||||
public async getUserSelectedAccountId() {
|
||||
return (await { userSelectedAccountId: 'whatever' }) as AccountsConfig
|
||||
}
|
||||
|
||||
public async showDevTools() {
|
||||
await console.log('No way dude')
|
||||
}
|
||||
|
||||
public async openUrl(url: string) {
|
||||
await window.open(url)
|
||||
}
|
||||
|
||||
public on() {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface IParametersBinding {
|
||||
update: (payload: string) => Promise<void>
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ConversionResult } from 'lib/conversions/conversionResult'
|
||||
import type { ConversionResult } from '~/lib/conversions/conversionResult'
|
||||
import type { IModelCardSharedEvents } from '~/lib/models/card'
|
||||
import type { CardSetting } from '~/lib/models/card/setting'
|
||||
import type {
|
||||
@@ -24,3 +24,29 @@ export interface IReceiveBindingEvents
|
||||
conversionResults: ConversionResult[]
|
||||
}) => void
|
||||
}
|
||||
|
||||
export class MockedReceiveBinding implements IReceiveBinding {
|
||||
public async getReceiveSettings() {
|
||||
return await []
|
||||
}
|
||||
|
||||
public async receive(_modelCardId: string) {
|
||||
return await console.log('no way dude')
|
||||
}
|
||||
|
||||
public async cancelReceive(_modelCardId: string) {
|
||||
return await console.log('no way dude')
|
||||
}
|
||||
|
||||
public async showDevTools() {
|
||||
await console.log('No way dude')
|
||||
}
|
||||
|
||||
public async openUrl(url: string) {
|
||||
await window.open(url)
|
||||
}
|
||||
|
||||
public on() {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import type {
|
||||
IBinding,
|
||||
IBindingSharedEvents
|
||||
} from '~/lib/bindings/definitions/IBinding'
|
||||
|
||||
export const IRevitMapperBindingKey = 'revitMapperBinding'
|
||||
|
||||
export interface IRevitMapperBinding extends IBinding<IMapperBindingEvents> {
|
||||
// Gets list of defined layers in doc
|
||||
getAvailableLayers: () => Promise<LayerOption[]>
|
||||
|
||||
// Object methods
|
||||
assignObjectsToCategory: (objectIds: string[], categoryValue: string) => Promise<void>
|
||||
clearObjectsCategoryAssignment: (objectIds: string[]) => Promise<void>
|
||||
clearAllObjectsCategoryAssignments: () => Promise<void>
|
||||
getCurrentObjectsMappings: () => Promise<CategoryMapping[]>
|
||||
getCategoryMappingsForObjects: (objectIds: string[]) => Promise<string[]>
|
||||
|
||||
// Layer methods
|
||||
assignLayerToCategory: (layerIds: string[], categoryValue: string) => Promise<void>
|
||||
clearLayerCategoryAssignment: (layerIds: string[]) => Promise<void>
|
||||
clearAllLayerCategoryAssignments: () => Promise<void>
|
||||
getCurrentLayerMappings: () => Promise<LayerCategoryMapping[]>
|
||||
getEffectiveObjectsForLayerMapping: (
|
||||
layerIds: string[],
|
||||
categoryValue: string
|
||||
) => Promise<string[]>
|
||||
getCategoryMappingsForLayers: (layerIds: string[]) => Promise<string[]>
|
||||
}
|
||||
|
||||
export interface IMapperBindingEvents extends IBindingSharedEvents {
|
||||
mappingsChanged: (mappings: CategoryMapping[]) => void
|
||||
layersChanged: (layers: LayerOption[]) => void
|
||||
}
|
||||
|
||||
export interface Category {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export interface CategoryMapping {
|
||||
categoryValue: string
|
||||
categoryLabel: string
|
||||
objectIds: string[]
|
||||
objectCount: number
|
||||
}
|
||||
|
||||
export interface LayerCategoryMapping {
|
||||
categoryValue: string
|
||||
categoryLabel: string
|
||||
layerIds: string[]
|
||||
layerNames: string[]
|
||||
layerCount: number
|
||||
}
|
||||
|
||||
export interface LayerOption {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
// Mock implementation for dev/testing
|
||||
export class MockedMapperBinding implements IRevitMapperBinding {
|
||||
private mockMappings: CategoryMapping[] = []
|
||||
|
||||
public assignObjectsToCategory(
|
||||
objectIds: string[],
|
||||
categoryValue: string
|
||||
): Promise<void> {
|
||||
console.log('Mock: Assigning objects to category', { objectIds, categoryValue })
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
public getAvailableLayers(): Promise<LayerOption[]> {
|
||||
return Promise.resolve([
|
||||
{ id: 'layer1', name: 'Ground Floor' },
|
||||
{ id: 'layer2', name: 'Ground Floor/Walls' },
|
||||
{ id: 'layer3', name: 'Ground Floor/Walls/Interior' },
|
||||
{ id: 'layer4', name: 'Second Floor' }
|
||||
])
|
||||
}
|
||||
|
||||
public clearObjectsCategoryAssignment(objectIds: string[]): Promise<void> {
|
||||
console.log('Mock: Clearing category assignment', { objectIds })
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
public clearAllObjectsCategoryAssignments(): Promise<void> {
|
||||
console.log('Mock: Clearing all assignments')
|
||||
this.mockMappings = []
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
public getCurrentObjectsMappings(): Promise<CategoryMapping[]> {
|
||||
return Promise.resolve(this.mockMappings)
|
||||
}
|
||||
|
||||
public assignLayerToCategory(
|
||||
layerIds: string[],
|
||||
categoryValue: string
|
||||
): Promise<void> {
|
||||
console.log('Mock: Assigning layers to category', { layerIds, categoryValue })
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
public clearLayerCategoryAssignment(layerIds: string[]): Promise<void> {
|
||||
console.log('Mock: Clearing layer category assignment', { layerIds })
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
public clearAllLayerCategoryAssignments(): Promise<void> {
|
||||
console.log('Mock: Clearing all layer assignments')
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
public getCurrentLayerMappings(): Promise<LayerCategoryMapping[]> {
|
||||
return Promise.resolve([])
|
||||
}
|
||||
|
||||
public getEffectiveObjectsForLayerMapping(
|
||||
layerIds: string[],
|
||||
categoryValue: string
|
||||
): Promise<string[]> {
|
||||
console.log('Mock: Getting effective objects for layer mapping', {
|
||||
layerIds,
|
||||
categoryValue
|
||||
})
|
||||
return Promise.resolve(['obj1', 'obj2', 'obj3'])
|
||||
}
|
||||
|
||||
public getCategoryMappingsForObjects(objectIds: string[]): Promise<string[]> {
|
||||
console.log('Mock: Getting category mappings for objects', { objectIds })
|
||||
// Mock returning some categories for testing
|
||||
return Promise.resolve(
|
||||
objectIds.length > 1 ? ['OST_Walls', 'OST_Doors'] : ['OST_Walls']
|
||||
)
|
||||
}
|
||||
|
||||
public getCategoryMappingsForLayers(layerIds: string[]): Promise<string[]> {
|
||||
console.log('Mock: Getting category mappings for layers', { layerIds })
|
||||
return Promise.resolve(
|
||||
layerIds.length > 1 ? ['OST_Floors', 'OST_Ceilings'] : ['OST_Floors']
|
||||
)
|
||||
}
|
||||
|
||||
public showDevTools(): Promise<void> {
|
||||
console.log('Braaaaa, no way!')
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
public openUrl(url: string): Promise<void> {
|
||||
window.open(url)
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
public on() {
|
||||
// Mock event handler
|
||||
}
|
||||
}
|
||||
@@ -17,3 +17,24 @@ export type SelectionInfo = {
|
||||
summary?: string
|
||||
selectedObjectIds: string[]
|
||||
}
|
||||
|
||||
export class MockedSelectionBinding implements ISelectionBinding {
|
||||
public async getSelection() {
|
||||
return (await {
|
||||
summary: '2 objects selected over mock binding',
|
||||
selectedObjectIds: ['1', '2', '3']
|
||||
}) as SelectionInfo
|
||||
}
|
||||
|
||||
public async showDevTools() {
|
||||
await console.log('No way dude')
|
||||
}
|
||||
|
||||
public async openUrl(url: string) {
|
||||
await window.open(url)
|
||||
}
|
||||
|
||||
public on() {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import type {
|
||||
} from '~~/lib/bindings/definitions/IBinding'
|
||||
import type { CardSetting } from '~/lib/models/card/setting'
|
||||
import type { IModelCardSharedEvents } from '~/lib/models/card'
|
||||
import type { ConversionResult } from 'lib/conversions/conversionResult'
|
||||
import type { ConversionResult } from '~/lib/conversions/conversionResult'
|
||||
import type { CreateVersionArgs } from '~/lib/bridge/server'
|
||||
|
||||
export const ISendBindingKey = 'sendBinding'
|
||||
@@ -26,6 +26,7 @@ export interface ISendBindingEvents
|
||||
modelCardId: string
|
||||
versionId: string
|
||||
sendConversionResults: ConversionResult[]
|
||||
ingestionId?: string
|
||||
}) => void
|
||||
setIdMap: (args: {
|
||||
modelCardId: string
|
||||
@@ -38,3 +39,33 @@ export interface ISendBindingEvents
|
||||
triggerCancel: (modelCardId: string) => void
|
||||
triggerCreateVersion: (args: CreateVersionArgs) => void
|
||||
}
|
||||
|
||||
export class MockedSendBinding implements ISendBinding {
|
||||
public async getSendFilters() {
|
||||
return await []
|
||||
}
|
||||
|
||||
public async getSendSettings() {
|
||||
return await []
|
||||
}
|
||||
|
||||
public async send(_modelCardId: string) {
|
||||
return await console.log('no way dude')
|
||||
}
|
||||
|
||||
public async cancelSend(_modelCardId: string) {
|
||||
return await console.log('no way dude')
|
||||
}
|
||||
|
||||
public async showDevTools() {
|
||||
await console.log('No way dude')
|
||||
}
|
||||
|
||||
public async openUrl(url: string) {
|
||||
await window.open(url)
|
||||
}
|
||||
|
||||
public on() {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user