Compare commits
130 Commits
fabians/move
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a38a699123 | |||
| 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 | |||
| f2d7493c2a |
@@ -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/
|
||||
@@ -0,0 +1,8 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
indent_size = 2
|
||||
indent_style = space
|
||||
insert_final_newline = true
|
||||
@@ -0,0 +1,4 @@
|
||||
/.yarn/** linguist-vendored
|
||||
/.yarn/releases/* binary
|
||||
/.yarn/plugins/**/* binary
|
||||
/.pnp.* binary linguist-generated
|
||||
@@ -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
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
node_modules
|
||||
*.log*
|
||||
.nuxt
|
||||
.nitro
|
||||
.cache
|
||||
.output
|
||||
.env
|
||||
dist
|
||||
.DS_Store
|
||||
.env
|
||||
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/sdks
|
||||
!.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
+18
@@ -0,0 +1,18 @@
|
||||
{
|
||||
// See https://go.microsoft.com/fwlink/?LinkId=827846 to learn about workspace recommendations.
|
||||
// Extension identifier format: ${publisher}.${name}. Example: vscode.csharp
|
||||
|
||||
// List of extensions which should be recommended for users of this workspace.
|
||||
"recommendations": [
|
||||
"dbaeumer.vscode-eslint",
|
||||
"esbenp.prettier-vscode",
|
||||
"Vue.volar",
|
||||
"bradlc.vscode-tailwindcss",
|
||||
"stylelint.vscode-stylelint",
|
||||
"cpylua.language-postcss",
|
||||
"graphql.vscode-graphql",
|
||||
"graphql.vscode-graphql-syntax"
|
||||
],
|
||||
// List of extensions recommended by VS Code that should not be recommended for users of this workspace.
|
||||
"unwantedRecommendations": ["octref.vetur"]
|
||||
}
|
||||
Vendored
+61
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"css.validate": false,
|
||||
"less.validate": false,
|
||||
"scss.validate": false,
|
||||
"stylelint.validate": ["css", "scss", "vue", "postcss"],
|
||||
"stylelint.enable": true,
|
||||
"javascript.suggest.autoImports": true,
|
||||
"typescript.suggest.autoImports": true,
|
||||
"typescript.preferences.importModuleSpecifier": "non-relative",
|
||||
"javascript.preferences.importModuleSpecifier": "non-relative",
|
||||
|
||||
"explorer.confirmDelete": false,
|
||||
"files.associations": {
|
||||
"*.vue": "vue"
|
||||
},
|
||||
"editor.formatOnPaste": true,
|
||||
"editor.multiCursorModifier": "ctrlCmd",
|
||||
"editor.snippetSuggestions": "top",
|
||||
"editor.formatOnSave": true,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "explicit"
|
||||
},
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"search.useParentIgnoreFiles": true,
|
||||
"[html]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"files.eol": "\n",
|
||||
"cSpell.words": [
|
||||
"Automations",
|
||||
"Bursty",
|
||||
"discoverability",
|
||||
"Encryptor",
|
||||
"Gendo",
|
||||
"GENDOAI",
|
||||
"Insertable",
|
||||
"mjml",
|
||||
"multiregion",
|
||||
"OIDC",
|
||||
"Prorotation"
|
||||
],
|
||||
"editor.tabSize": 2,
|
||||
"search.exclude": {
|
||||
"**/node_modules": true,
|
||||
"**/bower_components": true,
|
||||
"**/*.code-search": true,
|
||||
"**/.nuxt": true,
|
||||
"**/.output": true
|
||||
},
|
||||
"[vue]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[dockercompose]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[json]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"vue.complete.casing.props": "kebab",
|
||||
"vue.inlayHints.missingProps": true
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
nodeLinker: node-modules
|
||||
@@ -1,2 +1,34 @@
|
||||
# speckle-connectors-dui
|
||||
Web UI to use accross connectors (aka dui3)
|
||||
# Speckle Connectors DUI
|
||||
|
||||
DUI v3 is a Speckle interface embedded inside the desktop connectors that allows users to interact with them - sync streams, manage servers etc. It's built in Vue 3 with Nuxt 3 and only supports client side rendering.
|
||||
|
||||
Look at the [Nuxt 3 documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.
|
||||
|
||||
## Setup
|
||||
|
||||
Make sure to install the dependencies:
|
||||
|
||||
```bash
|
||||
# yarn
|
||||
yarn install
|
||||
```
|
||||
|
||||
And create an `.env` file from `.env.example`.
|
||||
|
||||
## Development
|
||||
|
||||
Start the development server on `http://localhost:8082`
|
||||
|
||||
```bash
|
||||
yarn dev
|
||||
```
|
||||
|
||||
## Production
|
||||
|
||||
Build the application for production:
|
||||
|
||||
```bash
|
||||
yarn build
|
||||
```
|
||||
|
||||
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information...
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
<template>
|
||||
<div id="speckle" class="bg-foundation-page text-foreground overflow-auto">
|
||||
<NuxtLayout>
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
<!-- Teleport is fixing the non-clickable toast notifications if any dialog is active. It was marking div as inert and causing the issue -->
|
||||
<Teleport to="body">
|
||||
<SingletonToastManager />
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { useMixpanel } from '~/lib/core/composables/mixpanel'
|
||||
import { useConfigStore } from '~/store/config'
|
||||
import { useAccountStore } from '~/store/accounts'
|
||||
import { 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: computed(
|
||||
() =>
|
||||
`CNX: (hostApp: ${hostAppName.value}:v${hostAppVersion.value}),(version: ${connectorVersion.value})`
|
||||
),
|
||||
htmlAttrs: {
|
||||
lang: 'en',
|
||||
class: computed(() => (isDarkTheme.value ? `dark` : ``))
|
||||
},
|
||||
bodyAttrs: {
|
||||
class: 'simple-scrollbar bg-foundation-page text-foreground '
|
||||
},
|
||||
// For standalone vue devtools see: https://devtools.vuejs.org/guide/installation.html#standalone
|
||||
script: import.meta.dev ? ['http://localhost:8098'] : []
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
const { trackEvent, addConnectorToProfile, identifyProfile } = useMixpanel()
|
||||
// TODO: some host apps can open DUI3 automatically, with this case we shouldn't mark track event as `"type": "action"`,
|
||||
// we need to get this info from source app. (TBD which apps: Rhino opens automatically, not sure acad, sketchup and revit needs trigger button to init)
|
||||
trackEvent('DUI3 Action', { name: 'Launch' })
|
||||
|
||||
const { accounts } = useAccountStore()
|
||||
|
||||
const uniqueEmails = new Set<string>()
|
||||
accounts.forEach((account) => {
|
||||
const email = account?.accountInfo.userInfo.email
|
||||
if (email && !uniqueEmails.has(email)) {
|
||||
addConnectorToProfile(email)
|
||||
identifyProfile(email)
|
||||
uniqueEmails.add(email)
|
||||
}
|
||||
})
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { $intercom } = useNuxtApp() // needed her for initialisation
|
||||
|
||||
logToSeq('Information', 'DUI3 initialized')
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,31 @@
|
||||
/* stylelint-disable selector-id-pattern */
|
||||
@import url('@speckle/ui-components/style.css');
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/**
|
||||
* Don't pollute this - it's going to be bundled in all pages!
|
||||
*/
|
||||
|
||||
/**
|
||||
* Making sure page is always stretched to the bottom of the screen even if there's nothing in it
|
||||
*/
|
||||
html,
|
||||
body,
|
||||
div#__nuxt,
|
||||
div#__nuxt > div {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
div#__nuxt {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.tippy-content {
|
||||
@apply text-body-3xs;
|
||||
@apply !px-2 !py-1;
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.3 KiB |
+28
@@ -0,0 +1,28 @@
|
||||
import type { CodegenConfig } from '@graphql-codegen/cli'
|
||||
|
||||
const config: CodegenConfig = {
|
||||
schema: 'https://app.speckle.systems/graphql',
|
||||
documents: ['{lib,components,layouts,pages,middleware}/**/*.{vue,js,ts}'],
|
||||
ignoreNoDocuments: true, // for better experience with the watcher
|
||||
generates: {
|
||||
'./lib/common/generated/gql/': {
|
||||
preset: 'client',
|
||||
config: {
|
||||
useTypeImports: true,
|
||||
fragmentMasking: false,
|
||||
dedupeFragments: true,
|
||||
scalars: {
|
||||
JSONObject: '{}',
|
||||
DateTime: 'string'
|
||||
}
|
||||
},
|
||||
presetConfig: {
|
||||
fragmentMasking: false,
|
||||
dedupeFragments: true
|
||||
},
|
||||
plugins: []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default config
|
||||
@@ -0,0 +1,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>
|
||||
@@ -0,0 +1,93 @@
|
||||
<template>
|
||||
<button
|
||||
v-tippy="account.accountInfo.userInfo.email"
|
||||
:class="`group block w-full p-1 text-left rounded-md items-center space-x-2 select-none group transition hover:bg-primary-muted hover:cursor-pointer hover:text-primary ${
|
||||
!account.isValid
|
||||
? 'text-danger bg-rose-500/10 cursor-not-allowed'
|
||||
: 'cursor-pointer'
|
||||
} ${
|
||||
currentSelectedAccountId === account.accountInfo.id
|
||||
? 'bg-blue-500/5 text-primary'
|
||||
: ''
|
||||
}`"
|
||||
:disabled="!account.isValid"
|
||||
@click="$emit('select', account)"
|
||||
>
|
||||
<div class="flex items-center space-x-2">
|
||||
<UserAvatar
|
||||
:user="userAvatar"
|
||||
:active="account.accountInfo.isDefault"
|
||||
size="sm"
|
||||
/>
|
||||
<div class="min-w-0 grow">
|
||||
<div class="truncate overflow-hidden min-w-0 flex items-center space-x-2">
|
||||
<span>{{ account.accountInfo.serverInfo.name }}</span>
|
||||
<span class="text-foreground-2 truncate min-w-0 caption">
|
||||
{{ account.accountInfo.serverInfo.url.split('//')[1] }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
v-if="canRemoveAccount"
|
||||
class="flex hidden group-hover:block px-2 py-1 text-danger"
|
||||
@click.stop="showRemoveAccountDialog = true"
|
||||
>
|
||||
<TrashIcon class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</button>
|
||||
<CommonDialog v-model:open="showRemoveAccountDialog" fullscreen="none">
|
||||
<template #header>Remove Account</template>
|
||||
<div class="text-xs mb-4">
|
||||
Removing the account will remove the related model cards from your file. Do you
|
||||
want to remove the account?
|
||||
</div>
|
||||
<div class="flex justify-between center py-2 space-x-3">
|
||||
<FormButton
|
||||
size="sm"
|
||||
color="outline"
|
||||
full-width
|
||||
@click="showRemoveAccountDialog = false"
|
||||
>
|
||||
No
|
||||
</FormButton>
|
||||
<FormButton size="sm" full-width @click="handleRemove(account)">
|
||||
Remove
|
||||
</FormButton>
|
||||
</div>
|
||||
</CommonDialog>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import type { DUIAccount } from '~~/store/accounts'
|
||||
import { TrashIcon } from '@heroicons/vue/24/outline'
|
||||
import type { BaseBridge } from '~/lib/bridge/base'
|
||||
|
||||
const { $accountBinding } = useNuxtApp()
|
||||
|
||||
const canRemoveAccount = ['RemoveAccount', 'removeAccount'].some((name) =>
|
||||
($accountBinding as unknown as BaseBridge).availableMethodNames.includes(name)
|
||||
)
|
||||
|
||||
const props = defineProps<{
|
||||
account: DUIAccount
|
||||
currentSelectedAccountId?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'select', account: DUIAccount): void
|
||||
(e: 'remove', account: DUIAccount): void
|
||||
}>()
|
||||
|
||||
const showRemoveAccountDialog = ref(false)
|
||||
|
||||
const handleRemove = (account: DUIAccount) => {
|
||||
emit('remove', account)
|
||||
}
|
||||
|
||||
const userAvatar = computed(() => {
|
||||
return {
|
||||
name: props.account.accountInfo.userInfo.name,
|
||||
avatar: props.account.accountInfo.userInfo.avatar
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,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>
|
||||
@@ -0,0 +1,177 @@
|
||||
<template>
|
||||
<div>
|
||||
<button
|
||||
v-if="!justDialog"
|
||||
v-tippy="`Click to change the account.`"
|
||||
@click="showAccountsDialog = true"
|
||||
>
|
||||
<UserAvatar v-if="!showAccountsDialog" :user="user" hover-effect size="sm" />
|
||||
<UserAvatar v-else hover-effect size="sm">
|
||||
<XMarkIcon class="w-6 h-6" />
|
||||
</UserAvatar>
|
||||
</button>
|
||||
<CommonDialog
|
||||
v-model:open="showAccountsDialog"
|
||||
:title="`${justDialog ? 'Your accounts' : 'Select account'}`"
|
||||
fullscreen="none"
|
||||
>
|
||||
<div class="pb-2">
|
||||
<CommonLoadingBar :loading="isLoading" class="my-0" />
|
||||
<AccountsItem
|
||||
v-for="acc in accounts"
|
||||
:key="acc.accountInfo.id"
|
||||
:current-selected-account-id="currentSelectedAccountId"
|
||||
:account="(acc as DUIAccount)"
|
||||
@select="selectAccount(acc as DUIAccount)"
|
||||
@remove="removeAccount(acc as DUIAccount)"
|
||||
/>
|
||||
<div class="mt-4">
|
||||
<FormButton
|
||||
text
|
||||
full-width
|
||||
size="sm"
|
||||
@click="showAddNewAccount = !showAddNewAccount"
|
||||
>
|
||||
Add a new account
|
||||
</FormButton>
|
||||
<CommonDialog
|
||||
v-model:open="showAddNewAccount"
|
||||
title="Add a new account"
|
||||
fullscreen="none"
|
||||
>
|
||||
<div 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>
|
||||
</div>
|
||||
</div>
|
||||
</CommonDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { XMarkIcon } from '@heroicons/vue/20/solid'
|
||||
import type { DUIAccount } from '~/store/accounts'
|
||||
import { useAccountStore } from '~/store/accounts'
|
||||
import { useMixpanel } from '~/lib/core/composables/mixpanel'
|
||||
import { useDesktopService } from '~/lib/core/composables/desktopService'
|
||||
import type { BaseBridge } from '~/lib/bridge/base'
|
||||
|
||||
const { trackEvent } = useMixpanel()
|
||||
const app = 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<{
|
||||
currentSelectedAccountId?: string
|
||||
justDialog?: boolean
|
||||
}>(),
|
||||
{
|
||||
justDialog: false
|
||||
}
|
||||
)
|
||||
|
||||
defineEmits<{
|
||||
(e: 'select', account: DUIAccount): void
|
||||
}>()
|
||||
|
||||
const showAddNewAccount = ref(false)
|
||||
const signInMode = ref<'default' | 'exchange' | 'legacy'>('default')
|
||||
|
||||
const showAccountsDialog = defineModel<boolean>('open', {
|
||||
required: false,
|
||||
default: false
|
||||
})
|
||||
|
||||
const isDesktopServiceAvailable = ref(false) // this should be false default because there is a delay if /ping is not successful.
|
||||
|
||||
app.$baseBinding?.on('documentChanged', () => {
|
||||
showAccountsDialog.value = false
|
||||
})
|
||||
|
||||
watch(showAccountsDialog, (newVal) => {
|
||||
if (newVal) {
|
||||
void accountStore.refreshAccounts()
|
||||
void trackEvent('DUI3 Action', { name: 'Account menu open' })
|
||||
}
|
||||
})
|
||||
|
||||
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)
|
||||
|
||||
watch(accounts, (newVal, oldVal) => {
|
||||
if (newVal.length !== oldVal.length) {
|
||||
showAddNewAccount.value = false
|
||||
}
|
||||
})
|
||||
|
||||
const selectAccount = (acc: DUIAccount) => {
|
||||
if (props.justDialog) {
|
||||
app.$openUrl(acc.accountInfo.serverInfo.url)
|
||||
return
|
||||
}
|
||||
userSelectedAccount.value = acc
|
||||
accountStore.setUserSelectedAccount(acc) // saves the selected account id into DUI3Config.db for later use
|
||||
showAccountsDialog.value = false
|
||||
void trackEvent('DUI3 Action', { name: 'Account change' })
|
||||
}
|
||||
|
||||
const removeAccount = async (acc: DUIAccount) => {
|
||||
await accountStore.removeAccount(acc)
|
||||
void trackEvent('DUI3 Action', { name: 'Account removed' })
|
||||
}
|
||||
|
||||
const user = computed(() => {
|
||||
// if (!defaultAccount.value) return undefined
|
||||
// let acc = defaultAccount.value
|
||||
// if (props.currentSelectedAccountId) {
|
||||
// const currentSelectedAccount = accounts.value.find(
|
||||
// (acc) => acc.accountInfo.id === props.currentSelectedAccountId
|
||||
// ) as DUIAccount
|
||||
// // currentSelectedAccount could be removed by user
|
||||
// if (currentSelectedAccount) {
|
||||
// acc = currentSelectedAccount
|
||||
// }
|
||||
// }
|
||||
return {
|
||||
name: activeAccount.value.accountInfo.userInfo.name,
|
||||
avatar: activeAccount.value.accountInfo.userInfo.avatar
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
isDesktopServiceAvailable.value = await pingDesktopService()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<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 { 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'
|
||||
|
||||
const props = defineProps<{
|
||||
serverUrl: string
|
||||
}>()
|
||||
|
||||
const accountStore = useAccountStore()
|
||||
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 { generateChallenge } = useAuthManager()
|
||||
|
||||
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:
|
||||
"Log in could not completed. Make sure you have logged in successfully, otherwise try 'Log in with OAuth token'"
|
||||
})
|
||||
}
|
||||
} else {
|
||||
const { codeChallenge } = await generateChallenge(serverUrl)
|
||||
const authUrl = `${serverUrl}/authn/verify/sdui/${codeChallenge}?code_challenge_method=S256`
|
||||
window.location.href = authUrl
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,39 @@
|
||||
<template>
|
||||
<div class="p-0">
|
||||
<slot name="activator" :toggle="toggleDialog"></slot>
|
||||
<CommonDialog
|
||||
v-model:open="showAutomateReportDialog"
|
||||
:title="`Automation Report`"
|
||||
fullscreen="none"
|
||||
>
|
||||
<div v-if="props.automationRuns" class="space-y-2">
|
||||
<AutomateFunctionRunsRows
|
||||
v-for="aRun in automationRuns"
|
||||
:key="aRun.id"
|
||||
:model-card="modelCard"
|
||||
:automation-name="aRun.automation.name"
|
||||
:runs="aRun.functionRuns"
|
||||
:project-id="modelCard.projectId"
|
||||
:model-id="modelId"
|
||||
/>
|
||||
</div>
|
||||
</CommonDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { IModelCard } from '~/lib/models/card'
|
||||
import type { AutomationRunItemFragment } from '~/lib/common/generated/gql/graphql'
|
||||
|
||||
const props = defineProps<{
|
||||
modelCard: IModelCard
|
||||
modelId: string
|
||||
automationRuns: AutomationRunItemFragment[] | undefined
|
||||
}>()
|
||||
|
||||
const showAutomateReportDialog = ref(false)
|
||||
|
||||
const toggleDialog = () => {
|
||||
showAutomateReportDialog.value = !showAutomateReportDialog.value
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,60 @@
|
||||
<template>
|
||||
<div :class="classes">
|
||||
<img v-if="finalLogo" :src="finalLogo" alt="Function logo" class="h-10 w-10" />
|
||||
<span v-else :class="fallbackIconClasses">λ</span>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import type { MaybeNullOrUndefined, Nullable } from '@speckle/shared'
|
||||
|
||||
type Size = 'base' | 'xs'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
logo?: MaybeNullOrUndefined<string>
|
||||
size?: Size
|
||||
}>(),
|
||||
{
|
||||
size: 'base'
|
||||
}
|
||||
)
|
||||
|
||||
const cleanFunctionLogo = (logo: MaybeNullOrUndefined<string>): Nullable<string> => {
|
||||
if (!logo?.length) return null
|
||||
if (logo.startsWith('data:')) return logo
|
||||
if (logo.startsWith('http:')) return logo
|
||||
if (logo.startsWith('https:')) return logo
|
||||
return null
|
||||
}
|
||||
|
||||
const finalLogo = computed(() => cleanFunctionLogo(props.logo))
|
||||
const classes = computed(() => {
|
||||
const classParts = [
|
||||
'bg-foundation-focus text-primary font-medium rounded-full shrink-0 flex justify-center text-center items-center overflow-hidden select-none'
|
||||
]
|
||||
|
||||
switch (props.size) {
|
||||
case 'xs':
|
||||
classParts.push('h-4 w-4')
|
||||
break
|
||||
case 'base':
|
||||
default:
|
||||
classParts.push('h-10 w-10')
|
||||
break
|
||||
}
|
||||
|
||||
return classParts.join(' ')
|
||||
})
|
||||
|
||||
const fallbackIconClasses = computed(() => {
|
||||
const classParts: string[] = []
|
||||
|
||||
switch (props.size) {
|
||||
case 'xs':
|
||||
classParts.push('text-xs')
|
||||
break
|
||||
}
|
||||
|
||||
return classParts.join(' ')
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,134 @@
|
||||
<template>
|
||||
<div
|
||||
:class="`border border-blue-500/10 rounded-md space-y-2 overflow-hidden ${
|
||||
expanded ? 'shadow' : ''
|
||||
}`"
|
||||
>
|
||||
<button
|
||||
class="flex space-x-1 items-center max-w-full w-full px-1 py-1 h-8 transition hover:bg-primary-muted"
|
||||
@click="expanded = !expanded"
|
||||
>
|
||||
<div>
|
||||
<Component
|
||||
:is="statusMetaData.icon"
|
||||
v-tippy="functionRun.status"
|
||||
:class="['h-4 w-4 outline-none', statusMetaData.iconColor]"
|
||||
/>
|
||||
</div>
|
||||
<AutomateFunctionLogo :logo="functionRun.function?.logo" size="xs" />
|
||||
<div class="font-medium text-xs truncate">
|
||||
{{ automationName ? automationName + ' / ' : ''
|
||||
}}{{ functionRun.function?.name || 'Unknown function' }}
|
||||
</div>
|
||||
|
||||
<div class="h-full grow flex justify-end">
|
||||
<button
|
||||
class="hover:bg-primary-muted hover:text-primary flex h-full items-center justify-center rounded"
|
||||
>
|
||||
<ChevronDownIcon
|
||||
:class="`h-3 w-3 transition ${!expanded ? '-rotate-90' : 'rotate-0'}`"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</button>
|
||||
<div v-if="expanded" class="px-2 pb-2 space-y-4">
|
||||
<!-- Status message -->
|
||||
<div class="space-y-1">
|
||||
<div class="text-xs font-medium text-foreground-2">Status</div>
|
||||
<div
|
||||
v-if="
|
||||
[
|
||||
AutomateRunStatus.Initializing,
|
||||
AutomateRunStatus.Running,
|
||||
AutomateRunStatus.Pending
|
||||
].includes(functionRun.status)
|
||||
"
|
||||
class="text-xs text-foreground-2 italic"
|
||||
>
|
||||
Function is {{ functionRun.status.toLowerCase() }}.
|
||||
</div>
|
||||
<div v-else class="text-xs text-foreground-2 italic">
|
||||
{{ functionRun.statusMessage || 'No status message' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Attachments -->
|
||||
<!-- <div
|
||||
v-if="attachments.length !== 0"
|
||||
class="border-t pt-2 border-foreground-2 space-y-1"
|
||||
>
|
||||
<div class="text-xs font-medium text-foreground-2">Attachments</div>
|
||||
<div class="ml-[2px] justify-start">
|
||||
<AutomateRunsAttachmentButton
|
||||
v-for="id in attachments"
|
||||
:key="id"
|
||||
:blob-id="id"
|
||||
:project-id="projectId"
|
||||
size="xs"
|
||||
link
|
||||
class="mr-2"
|
||||
/>
|
||||
</div>
|
||||
</div> -->
|
||||
<!-- Results -->
|
||||
<div
|
||||
v-if="!!results?.values.objectResults.length"
|
||||
class="border-t pt-2 border-foreground-2"
|
||||
>
|
||||
<div class="text-xs font-medium text-foreground-2 mb-2">Results</div>
|
||||
<div class="space-y-1">
|
||||
<AutomateFunctionRunRowObjectResult
|
||||
v-for="(result, index) in results.values.objectResults.slice(
|
||||
0,
|
||||
pageRunLimit
|
||||
)"
|
||||
:key="index"
|
||||
:model-card="modelCard"
|
||||
:function-id="functionRun.function?.id"
|
||||
:result="result"
|
||||
/>
|
||||
<FormButton
|
||||
v-if="pageRunLimit < results.values.objectResults.length"
|
||||
size="sm"
|
||||
color="outline"
|
||||
class="w-full"
|
||||
@click="pageRunLimit += 10"
|
||||
>
|
||||
Load more ({{ results.values.objectResults.length - pageRunLimit }}
|
||||
hidden results)
|
||||
</FormButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ChevronDownIcon } from '@heroicons/vue/24/outline'
|
||||
import { AutomateRunStatus } from '~/lib/common/generated/gql/graphql'
|
||||
import type { AutomateFunctionRunItemFragment } from '~/lib/common/generated/gql/graphql'
|
||||
import {
|
||||
useRunStatusMetadata,
|
||||
useAutomationFunctionRunResults
|
||||
} from '~/lib/automate/runStatus'
|
||||
import type { IModelCard } from '~/lib/models/card'
|
||||
|
||||
const props = defineProps<{
|
||||
modelCard: IModelCard
|
||||
functionRun: AutomateFunctionRunItemFragment
|
||||
automationName: string
|
||||
}>()
|
||||
|
||||
const results = useAutomationFunctionRunResults({
|
||||
results: computed(() => props.functionRun.results)
|
||||
})
|
||||
const { metadata: statusMetaData } = useRunStatusMetadata({
|
||||
status: computed(() => props.functionRun.status)
|
||||
})
|
||||
|
||||
const pageRunLimit = ref(5)
|
||||
const expanded = ref(false)
|
||||
|
||||
// const attachments = computed(() =>
|
||||
// (results.value?.values.blobIds || []).filter((b) => !!b)
|
||||
// )
|
||||
</script>
|
||||
@@ -0,0 +1,76 @@
|
||||
<template>
|
||||
<div :class="`overflow-hidden`">
|
||||
<button
|
||||
:class="`block transition text-left hover:bg-primary-muted hover:shadow-md rounded-md p-1 cursor-pointer border-l-2 border-primary bg-primary-muted shadow-md`"
|
||||
@click="handleClick()"
|
||||
>
|
||||
<div class="flex items-center space-x-1">
|
||||
<div>
|
||||
<Component :is="iconAndColor.icon" :class="`w-4 h-4 ${iconAndColor.color}`" />
|
||||
</div>
|
||||
<div :class="`text-xs ${iconAndColor.color}`">
|
||||
{{ result.category }}:
|
||||
{{
|
||||
'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">
|
||||
{{ result.message }}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
XMarkIcon,
|
||||
InformationCircleIcon,
|
||||
ExclamationTriangleIcon
|
||||
} from '@heroicons/vue/24/outline'
|
||||
import type { Automate } from '@speckle/shared'
|
||||
import type { IModelCard } from '~/lib/models/card'
|
||||
|
||||
type ObjectResult = Automate.AutomateTypes.ResultsSchema['values']['objectResults'][0]
|
||||
|
||||
const props = defineProps<{
|
||||
modelCard: IModelCard
|
||||
result: ObjectResult
|
||||
functionId?: string
|
||||
}>()
|
||||
const app = useNuxtApp()
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
const iconAndColor = computed(() => {
|
||||
switch (props.result.level) {
|
||||
case 'ERROR':
|
||||
return {
|
||||
icon: XMarkIcon,
|
||||
color: 'text-danger font-medium'
|
||||
}
|
||||
case 'WARNING':
|
||||
return {
|
||||
icon: ExclamationTriangleIcon,
|
||||
color: 'text-warning font-medium'
|
||||
}
|
||||
case 'INFO':
|
||||
default:
|
||||
return {
|
||||
icon: InformationCircleIcon,
|
||||
color: 'text-foreground font-medium'
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<div class="space-y-2">
|
||||
<AutomateFunctionRunRow
|
||||
v-for="fRun in runs"
|
||||
:key="fRun.id"
|
||||
:model-card="modelCard"
|
||||
:automation-name="automationName"
|
||||
:function-run="fRun"
|
||||
:project-id="projectId"
|
||||
:model-id="modelId"
|
||||
:version-id="versionId"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import type { IModelCard } from '~/lib/models/card'
|
||||
import type { AutomateFunctionRunItemFragment } from '~/lib/common/generated/gql/graphql'
|
||||
|
||||
defineProps<{
|
||||
runs: AutomateFunctionRunItemFragment[]
|
||||
modelCard: IModelCard
|
||||
automationName: string
|
||||
projectId: string
|
||||
modelId: string
|
||||
versionId?: string
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,73 @@
|
||||
<template>
|
||||
<svg width="120" height="120" viewBox="0 0 120 120">
|
||||
<circle cx="60" cy="60" r="40" fill="none" stroke="#e6e6e6" stroke-width="12" />
|
||||
<circle
|
||||
class="base stroke-red-400 origin-center"
|
||||
:style="`${styles.failed}`"
|
||||
cx="60"
|
||||
cy="60"
|
||||
r="40"
|
||||
fill="none"
|
||||
stroke-width="25"
|
||||
pathLength="100"
|
||||
/>
|
||||
<circle
|
||||
class="base stroke-green-400 origin-center"
|
||||
:style="`${styles.passed}`"
|
||||
cx="60"
|
||||
cy="60"
|
||||
r="40"
|
||||
fill="none"
|
||||
stroke-width="25"
|
||||
pathLength="100"
|
||||
/>
|
||||
<circle
|
||||
class="base stroke-amber-400 origin-center"
|
||||
:style="`${styles.inProgress}`"
|
||||
cx="60"
|
||||
cy="60"
|
||||
r="40"
|
||||
fill="none"
|
||||
stroke-width="25"
|
||||
pathLength="100"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import type { RunsStatusSummary } from '~/lib/automate/runStatus'
|
||||
|
||||
const props = defineProps<{
|
||||
summary: RunsStatusSummary
|
||||
}>()
|
||||
|
||||
// segment: percentage + offset, where offset = prev percentage in radians
|
||||
|
||||
const styles = computed(() => {
|
||||
const failed = (props.summary.failed / props.summary.total) * 100
|
||||
const offsetFailed = 0
|
||||
const passed = (props.summary.passed / props.summary.total) * 100
|
||||
const offsetPassed = 360 * (failed / 100)
|
||||
const inProgress = (props.summary.inProgress / props.summary.total) * 100
|
||||
const offsetInProgress = offsetPassed + 360 * (passed / 100)
|
||||
|
||||
const stylePack = {
|
||||
failed: `stroke-dashoffset: ${
|
||||
100 - failed
|
||||
}; transform: rotate(${offsetFailed}deg);`,
|
||||
passed: `stroke-dashoffset: ${
|
||||
100 - passed
|
||||
}; transform: rotate(${offsetPassed}deg);`,
|
||||
inProgress: `stroke-dashoffset: ${
|
||||
100 - inProgress
|
||||
}; transform: rotate(${offsetInProgress}deg);`
|
||||
}
|
||||
|
||||
return stylePack
|
||||
})
|
||||
</script>
|
||||
<style scoped>
|
||||
.base {
|
||||
stroke-dasharray: 100;
|
||||
transform-origin: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,336 @@
|
||||
<template>
|
||||
<TransitionRoot as="template" :show="open">
|
||||
<Dialog as="div" class="relative z-50" open @close="onClose">
|
||||
<TransitionChild
|
||||
as="template"
|
||||
enter="ease-out duration-300"
|
||||
enter-from="opacity-0"
|
||||
enter-to="opacity-100"
|
||||
leave="ease-in duration-400"
|
||||
leave-from="opacity-100"
|
||||
leave-to="opacity-0"
|
||||
>
|
||||
<div
|
||||
class="fixed top-0 left-0 w-full h-full backdrop-blur-xs bg-black/60 dark:bg-neutral-900/60 transition-opacity"
|
||||
/>
|
||||
</TransitionChild>
|
||||
<div class="fixed top-0 left-0 z-10 h-screen !h-[100dvh] w-screen">
|
||||
<div
|
||||
class="flex md:justify-center h-full w-full"
|
||||
:class="[
|
||||
fullscreen === 'none' || fullscreen === 'desktop'
|
||||
? 'p-1 items-center'
|
||||
: 'items-end md:items-center'
|
||||
]"
|
||||
>
|
||||
<TransitionChild
|
||||
as="template"
|
||||
enter="ease-out duration-5000"
|
||||
:enter-from="`md:opacity-0 ${
|
||||
fullscreen === 'mobile' || fullscreen === 'all'
|
||||
? 'translate-y-[100%]'
|
||||
: 'translate-y-4'
|
||||
} md:translate-y-4`"
|
||||
enter-to="md:opacity-100 translate-y-0"
|
||||
leave="ease-in duration-5000"
|
||||
leave-from="md:opacity-100 translate-y-0"
|
||||
:leave-to="`md:opacity-0 ${
|
||||
fullscreen === 'mobile' || fullscreen === 'all'
|
||||
? 'translate-y-[100%]'
|
||||
: 'translate-y-4'
|
||||
} md:translate-y-4`"
|
||||
@after-leave="$emit('fully-closed')"
|
||||
>
|
||||
<DialogPanel
|
||||
:class="dialogPanelClasses"
|
||||
dialog-panel-classes
|
||||
:as="isForm ? 'form' : 'div'"
|
||||
@submit.prevent="onFormSubmit"
|
||||
>
|
||||
<div
|
||||
v-if="hasTitle"
|
||||
class="border-b border-outline-3"
|
||||
:class="scrolledFromTop && 'relative z-20 shadow-lg'"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-start rounded-t-lg shrink-0 min-h-[2rem] sm:min-h-[3rem] px-2 py-2 truncate text-heading-sm"
|
||||
>
|
||||
<div class="flex items-center pr-12 space-x-2">
|
||||
<FormButton
|
||||
v-if="showBackButton"
|
||||
color="subtle"
|
||||
size="sm"
|
||||
class="!w-6 !h-6 !p-0"
|
||||
@click="$emit('back')"
|
||||
>
|
||||
<ChevronLeftIcon class="w-4 h-4 text-foreground-2" />
|
||||
</FormButton>
|
||||
<div class="w-full truncate">
|
||||
{{ title }}
|
||||
<slot name="header" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
Due to how forms work, if there's no other submit button, on form submission the first button
|
||||
will be clicked. This is a workaround to prevent the close button from being that first button.
|
||||
https://stackoverflow.com/a/4763911/3194577
|
||||
-->
|
||||
<button class="hidden" type="button" />
|
||||
|
||||
<FormButton
|
||||
v-if="!hideCloser"
|
||||
color="subtle"
|
||||
size="sm"
|
||||
class="absolute z-20 top-2 right-2 shrink-0 !w-6 !h-6 !p-0"
|
||||
@click="open = false"
|
||||
>
|
||||
<XMarkIcon class="h-6 w-6 text-foreground-2" />
|
||||
</FormButton>
|
||||
<div ref="slotContainer" :class="slotContainerClasses" @scroll="onScroll">
|
||||
<slot>Put your content here!</slot>
|
||||
</div>
|
||||
<div
|
||||
v-if="hasButtons"
|
||||
class="relative z-50 flex justify-end px-2 pb-6 space-x-2 shrink-0 bg-foundation-page"
|
||||
:class="{
|
||||
'shadow-t pt-6': !scrolledToBottom,
|
||||
[buttonsWrapperClasses || '']: true
|
||||
}"
|
||||
>
|
||||
<template v-if="buttons">
|
||||
<FormButton
|
||||
v-for="(button, index) in buttons"
|
||||
:key="button.id || index"
|
||||
v-bind="button.props || {}"
|
||||
:disabled="button.props?.disabled || button.disabled"
|
||||
:submit="button.props?.submit || button.submit"
|
||||
@click="($event) => button.onClick?.($event)"
|
||||
>
|
||||
{{ button.text }}
|
||||
</FormButton>
|
||||
</template>
|
||||
<template v-else>
|
||||
<slot name="buttons" />
|
||||
</template>
|
||||
</div>
|
||||
</DialogPanel>
|
||||
</TransitionChild>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</TransitionRoot>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { Dialog, DialogPanel, TransitionChild, TransitionRoot } from '@headlessui/vue'
|
||||
import { FormButton, type LayoutDialogButton } from '@speckle/ui-components'
|
||||
import { XMarkIcon, ChevronLeftIcon } from '@heroicons/vue/24/outline'
|
||||
import { useResizeObserver, type ResizeObserverCallback } from '@vueuse/core'
|
||||
import { computed, ref, useSlots, watch, onUnmounted, type SetupContext } from 'vue'
|
||||
import { throttle } from 'lodash'
|
||||
import { isClient } from '@vueuse/core'
|
||||
|
||||
type MaxWidthValue = 'xs' | 'sm' | 'md' | 'lg' | 'xl'
|
||||
type FullscreenValues = 'mobile' | 'desktop' | 'all' | 'none'
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:open', v: boolean): void
|
||||
(e: 'fully-closed'): void
|
||||
(e: 'back'): void
|
||||
}>()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
open: boolean
|
||||
maxWidth?: MaxWidthValue
|
||||
fullscreen?: FullscreenValues
|
||||
hideCloser?: boolean
|
||||
showBackButton?: boolean
|
||||
/**
|
||||
* Prevent modal from closing when the user clicks outside of the modal or presses Esc
|
||||
*/
|
||||
preventCloseOnClickOutside?: boolean
|
||||
title?: string
|
||||
buttons?: Array<LayoutDialogButton>
|
||||
/**
|
||||
* Extra classes to apply to the button container.
|
||||
*/
|
||||
buttonsWrapperClasses?: string
|
||||
/**
|
||||
* If set, the modal will be wrapped in a form element and the `onSubmit` callback will be invoked when the user submits the form
|
||||
*/
|
||||
onSubmit?: (e: SubmitEvent) => void
|
||||
isTransparent?: boolean
|
||||
}>(),
|
||||
{
|
||||
fullscreen: 'mobile'
|
||||
}
|
||||
)
|
||||
|
||||
const slots: SetupContext['slots'] = useSlots()
|
||||
|
||||
const scrolledFromTop = ref(false)
|
||||
const scrolledToBottom = ref(true)
|
||||
const slotContainer = ref<HTMLElement | null>(null)
|
||||
|
||||
useResizeObserver(
|
||||
slotContainer,
|
||||
throttle<ResizeObserverCallback>(() => {
|
||||
// Triggering onScroll on size change too so that we don't get stuck with shadows
|
||||
// even tho the new content is not scrollable
|
||||
onScroll({ target: slotContainer.value })
|
||||
}, 60)
|
||||
)
|
||||
|
||||
const isForm = computed(() => !!props.onSubmit)
|
||||
const hasButtons = computed(() => props.buttons || slots.buttons)
|
||||
const hasTitle = computed(() => !!props.title || !!slots.header)
|
||||
|
||||
const open = computed({
|
||||
get: () => props.open,
|
||||
set: (newVal) => emit('update:open', newVal)
|
||||
})
|
||||
|
||||
const maxWidthWeight = computed(() => {
|
||||
switch (props.maxWidth) {
|
||||
case 'xs':
|
||||
return 0
|
||||
case 'sm':
|
||||
return 1
|
||||
case 'md':
|
||||
return 2
|
||||
case 'lg':
|
||||
return 3
|
||||
case 'xl':
|
||||
return 4
|
||||
default:
|
||||
return 10000
|
||||
}
|
||||
})
|
||||
|
||||
const widthClasses = computed(() => {
|
||||
const classParts: string[] = ['w-full', 'sm:w-full']
|
||||
|
||||
if (!isFullscreenDesktop.value) {
|
||||
if (maxWidthWeight.value === 0) {
|
||||
classParts.push('md:max-w-sm')
|
||||
}
|
||||
if (maxWidthWeight.value >= 1) {
|
||||
classParts.push('md:max-w-lg')
|
||||
}
|
||||
if (maxWidthWeight.value >= 2) {
|
||||
classParts.push('md:max-w-2xl')
|
||||
}
|
||||
if (maxWidthWeight.value >= 3) {
|
||||
classParts.push('lg:max-w-3xl')
|
||||
}
|
||||
if (maxWidthWeight.value >= 4) {
|
||||
classParts.push('xl:max-w-6xl')
|
||||
} else {
|
||||
classParts.push('md:max-w-2xl')
|
||||
}
|
||||
}
|
||||
|
||||
return classParts.join(' ')
|
||||
})
|
||||
|
||||
const isFullscreenDesktop = computed(
|
||||
() => props.fullscreen === 'desktop' || props.fullscreen === 'all'
|
||||
)
|
||||
|
||||
const dialogPanelClasses = computed(() => {
|
||||
const classParts: string[] = [
|
||||
'transform md:rounded-xl text-foreground overflow-hidden transition-all text-left flex flex-col md:h-auto'
|
||||
]
|
||||
|
||||
if (!props.isTransparent) {
|
||||
classParts.push('bg-foundation-page shadow-xl border border-outline-2')
|
||||
}
|
||||
|
||||
if (isFullscreenDesktop.value) {
|
||||
classParts.push('md:h-full')
|
||||
} else {
|
||||
classParts.push('md:max-h-[90vh]')
|
||||
}
|
||||
|
||||
if (props.fullscreen === 'mobile' || props.fullscreen === 'all') {
|
||||
classParts.push('max-md:h-[98vh] max-md:!h-[98dvh]')
|
||||
}
|
||||
|
||||
if (props.fullscreen === 'none' || props.fullscreen === 'desktop') {
|
||||
classParts.push('rounded-lg max-h-[90vh]')
|
||||
} else {
|
||||
classParts.push('rounded-t-lg')
|
||||
}
|
||||
|
||||
classParts.push(widthClasses.value)
|
||||
return classParts.join(' ')
|
||||
})
|
||||
|
||||
const slotContainerClasses = computed(() => {
|
||||
const classParts: string[] = ['flex-1 simple-scrollbar overflow-y-auto text-body-xs']
|
||||
|
||||
if (!props.isTransparent) {
|
||||
if (hasTitle.value) {
|
||||
classParts.push('px-2 py-2')
|
||||
if (isFullscreenDesktop.value) {
|
||||
classParts.push('md:p-0')
|
||||
}
|
||||
} else if (!isFullscreenDesktop.value) {
|
||||
classParts.push('px-2 py-2')
|
||||
}
|
||||
}
|
||||
|
||||
return classParts.join(' ')
|
||||
})
|
||||
|
||||
const onClose = () => {
|
||||
if (props.preventCloseOnClickOutside) return
|
||||
open.value = false
|
||||
}
|
||||
|
||||
const onFormSubmit = (e: SubmitEvent) => {
|
||||
props.onSubmit?.(e)
|
||||
}
|
||||
|
||||
const onScroll = throttle((e: { target: EventTarget | null }) => {
|
||||
if (!e.target) return
|
||||
|
||||
const target = e.target as HTMLElement
|
||||
const { scrollTop, offsetHeight, scrollHeight } = target
|
||||
scrolledFromTop.value = scrollTop > 0
|
||||
scrolledToBottom.value = scrollTop + offsetHeight >= scrollHeight
|
||||
}, 60)
|
||||
|
||||
// Toggle 'dialog-open' class on <html> to prevent scroll jumping and disable background scroll.
|
||||
// This maintains user scroll position when Headless UI dialogs are activated.
|
||||
watch(open, (newValue) => {
|
||||
if (isClient) {
|
||||
const html = document.documentElement
|
||||
if (newValue) {
|
||||
html.classList.add('dialog-open')
|
||||
} else {
|
||||
html.classList.remove('dialog-open')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Clean up when the component unmounts
|
||||
onUnmounted(() => {
|
||||
if (isClient) {
|
||||
document.documentElement.classList.remove('dialog-open')
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
html.dialog-open {
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
html.dialog-open body {
|
||||
overflow: hidden !important;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,104 @@
|
||||
<template>
|
||||
<div class="bg-highlight-1 border-t border-t-highlight-3">
|
||||
<div class="flex">
|
||||
<div class="flex grow justify-between items-center py-2 pl-1 pr-1 min-w-0">
|
||||
<div class="grow w-full min-w-0 flex space-x-1 items-center">
|
||||
<ReportBase
|
||||
v-if="notification.report"
|
||||
:report="notification.report"
|
||||
class="mt-[3px]"
|
||||
/>
|
||||
<div
|
||||
v-tippy="notification.text"
|
||||
:class="`${textClassColor} text-body-3xs transition line-clamp-1 text-ellipsis`"
|
||||
>
|
||||
{{ notification.text }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center group">
|
||||
<FormButton
|
||||
v-if="notification.secondaryCta"
|
||||
v-tippy="notification.secondaryCta.tooltipText"
|
||||
size="sm"
|
||||
color="outline"
|
||||
full-width
|
||||
class="mr-1"
|
||||
@click.stop="notification.secondaryCta.action"
|
||||
>
|
||||
{{ 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
|
||||
v-if="notification.dismissible"
|
||||
class="flex items-center w-0 group-hover:w-5 transition-[width]"
|
||||
>
|
||||
<FormButton
|
||||
v-tippy="'Dismiss'"
|
||||
color="subtle"
|
||||
size="sm"
|
||||
:icon-left="XMarkIcon"
|
||||
hide-text
|
||||
:disabled="!notification.dismissible"
|
||||
@click.stop="$emit('dismiss')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useTimeoutFn } from '@vueuse/core'
|
||||
import type { ModelCardNotification } from '~/lib/models/card/notification'
|
||||
import { XMarkIcon } from '@heroicons/vue/24/outline'
|
||||
const props = defineProps<{
|
||||
notification: ModelCardNotification
|
||||
}>()
|
||||
|
||||
const emit = defineEmits(['dismiss'])
|
||||
|
||||
if (props.notification.timeout) {
|
||||
useTimeoutFn(() => emit('dismiss'), props.notification.timeout)
|
||||
}
|
||||
|
||||
// const notificationButtonColor = (notificationLevel: ModelCardNotificationLevel) => {
|
||||
// switch (notificationLevel) {
|
||||
// case 'info':
|
||||
// return 'outline'
|
||||
// case 'danger':
|
||||
// return 'danger'
|
||||
// case 'success':
|
||||
// return 'primary'
|
||||
// case 'warning':
|
||||
// return 'danger'
|
||||
// default:
|
||||
// return 'outline'
|
||||
// }
|
||||
// }
|
||||
|
||||
const textClassColor = computed(() => {
|
||||
switch (props.notification.level) {
|
||||
case 'danger':
|
||||
return 'text-red-500'
|
||||
case 'info':
|
||||
return 'text-foreground-2'
|
||||
case 'success':
|
||||
return 'text-foreground-2'
|
||||
case 'warning':
|
||||
return 'text-foreground-2'
|
||||
default:
|
||||
return 'text-foreground-2'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,277 @@
|
||||
<template>
|
||||
<div v-if="projectDetails" class="px-[2px] rounded-md">
|
||||
<button
|
||||
:class="`flex w-full items-center text-foreground-2 justify-between hover:bg-foundation-2 ${
|
||||
showModels ? 'bg-foundation-2' : 'bg-foundation-2'
|
||||
} rounded-md transition group`"
|
||||
@click="showModels = !showModels"
|
||||
>
|
||||
<div class="flex items-center transition group-hover:text-primary h-8 min-w-0">
|
||||
<CommonIconsArrowFilled
|
||||
:class="`w-5 ${showModels ? '' : '-rotate-90'} transition`"
|
||||
/>
|
||||
<div class="text-sm text-left truncate select-none flex items-center leading-1">
|
||||
<div class="text-heading-sm">{{ projectDetails.name }}</div>
|
||||
<div v-if="!showModels" class="text-body-3xs opacity-50 ml-2 pt-[1px]">
|
||||
{{ project.senders.length + project.receivers.length }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
:class="
|
||||
isPersonalProject ? '' : 'opacity-0 group-hover:opacity-100 transition flex'
|
||||
"
|
||||
>
|
||||
<button
|
||||
v-tippy="projectNavigatorTippy"
|
||||
class="hover:text-primary flex items-center space-x-2 p-2 relative animate-pulse"
|
||||
>
|
||||
<div class="relative w-4 h-4">
|
||||
<ArrowTopRightOnSquareIcon
|
||||
class="w-4 h-4"
|
||||
@click.stop="
|
||||
$openUrl(projectUrl),
|
||||
trackEvent('DUI3 Action', { name: 'Project View' }, project.accountId)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div v-show="showModels" class="space-y-2 mt-2 pb-1">
|
||||
<CommonAlert
|
||||
v-if="isWorkspaceReadOnly"
|
||||
size="xs"
|
||||
:color="'warning'"
|
||||
:actions="[
|
||||
{
|
||||
title: 'Subscribe',
|
||||
onClick: () => $openUrl(workspaceUrl)
|
||||
}
|
||||
]"
|
||||
>
|
||||
<template #description>
|
||||
The workspace is in a read-only locked state until there's an active
|
||||
subscription. Subscribe to a plan to regain full access.
|
||||
</template>
|
||||
</CommonAlert>
|
||||
<ModelSender
|
||||
v-for="model in project.senders"
|
||||
:key="model.modelCardId"
|
||||
:model-card="model"
|
||||
:project="project"
|
||||
:can-edit="canPublish"
|
||||
/>
|
||||
<ModelReceiver
|
||||
v-for="model in project.receivers"
|
||||
:key="model.modelCardId"
|
||||
:model-card="model"
|
||||
:project="project"
|
||||
:can-edit="canLoad"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="projectIsAccesible === false"
|
||||
class="px-2 py-4 bg-foundation dark:bg-neutral-700/10 rounded-md shadow"
|
||||
>
|
||||
<CommonAlert
|
||||
color="danger"
|
||||
with-dismiss
|
||||
@dismiss="askDismissProjectQuestionDialog = true"
|
||||
>
|
||||
<template #title>
|
||||
Whoops - project
|
||||
<code>{{ project.projectId }}</code>
|
||||
is inaccessible.
|
||||
</template>
|
||||
</CommonAlert>
|
||||
<CommonDialog v-model:open="askDismissProjectQuestionDialog" fullscreen="none">
|
||||
<template #header>Remove Project</template>
|
||||
<div class="text-xs mb-4">Do you want to remove the project from this file?</div>
|
||||
<div class="flex justify-between center py-2 space-x-3">
|
||||
<FormButton size="sm" full-width @click="removeProjectModels">Yes</FormButton>
|
||||
<FormButton
|
||||
size="sm"
|
||||
full-width
|
||||
@click="askDismissProjectQuestionDialog = false"
|
||||
>
|
||||
Hide error
|
||||
</FormButton>
|
||||
</div>
|
||||
</CommonDialog>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { useQuery, useSubscription } from '@vue/apollo-composable'
|
||||
import { ArrowTopRightOnSquareIcon } from '@heroicons/vue/20/solid'
|
||||
import type { ProjectModelGroup } from '~~/store/hostApp'
|
||||
import { useHostAppStore } from '~~/store/hostApp'
|
||||
import { useAccountStore } from '~~/store/accounts'
|
||||
import {
|
||||
projectDetailsQuery,
|
||||
versionCreatedSubscription,
|
||||
userProjectsUpdatedSubscription,
|
||||
projectUpdatedSubscription
|
||||
} from '~~/lib/graphql/mutationsAndQueries'
|
||||
import { useMixpanel } from '~/lib/core/composables/mixpanel'
|
||||
|
||||
const { trackEvent } = useMixpanel()
|
||||
const accountStore = useAccountStore()
|
||||
const hostAppStore = useHostAppStore()
|
||||
const { $openUrl } = useNuxtApp()
|
||||
|
||||
const props = defineProps<{
|
||||
project: ProjectModelGroup
|
||||
}>()
|
||||
|
||||
const showModels = ref(true)
|
||||
const askDismissProjectQuestionDialog = ref(false)
|
||||
const writeAccessRequested = ref(false)
|
||||
const projectIsAccesible = ref<boolean | undefined>(undefined)
|
||||
|
||||
const projectAccount = computed(() =>
|
||||
accountStore.accountWithFallback(props.project.accountId, props.project.serverUrl)
|
||||
)
|
||||
|
||||
const isPersonalProject = computed(() => !projectDetails.value?.workspace)
|
||||
const projectNavigatorTippy = computed(() =>
|
||||
isPersonalProject.value
|
||||
? 'Move personal project into a workspace'
|
||||
: 'Open project in browser'
|
||||
)
|
||||
|
||||
const clientId = projectAccount.value.accountInfo.id
|
||||
|
||||
const accountExists = accountStore.isAccountExistsById(props.project.accountId)
|
||||
|
||||
if (!accountExists) {
|
||||
projectIsAccesible.value = false
|
||||
}
|
||||
|
||||
const {
|
||||
result: projectDetailsResult,
|
||||
refetch: refetchProjectDetails,
|
||||
onError: onProjectDetailsError
|
||||
} = useQuery(
|
||||
projectDetailsQuery,
|
||||
() => ({ projectId: props.project.projectId }),
|
||||
() => ({
|
||||
clientId,
|
||||
debounce: 500,
|
||||
fetchPolicy: 'network-only',
|
||||
enabled: accountExists
|
||||
})
|
||||
)
|
||||
|
||||
const removeProjectModels = async () => {
|
||||
await hostAppStore.removeProjectModels(props.project.projectId)
|
||||
askDismissProjectQuestionDialog.value = false
|
||||
}
|
||||
|
||||
const projectDetails = computed(() => projectDetailsResult.value?.project)
|
||||
|
||||
watch(projectDetails, (newValue) => {
|
||||
projectIsAccesible.value = newValue !== undefined
|
||||
})
|
||||
|
||||
onProjectDetailsError(() => {
|
||||
projectIsAccesible.value = false
|
||||
})
|
||||
|
||||
const canLoad = computed(() => !!projectDetails.value?.permissions.canLoad.authorized)
|
||||
const canPublish = computed(
|
||||
() => !!projectDetails.value?.permissions.canPublish.authorized
|
||||
)
|
||||
|
||||
const isWorkspaceReadOnly = computed(() => {
|
||||
if (!projectDetails.value?.workspace) return false // project is not even in a workspace
|
||||
return projectDetails.value?.workspace?.readOnly
|
||||
})
|
||||
|
||||
// Enable later when FE2 is ready for accepting/denying requested accesses
|
||||
// const hasServerMatch = computed(() =>
|
||||
// accountStore.isAccountExistsByServer(props.project.serverUrl)
|
||||
// )
|
||||
|
||||
// const requestWriteAccess = async () => {
|
||||
// if (hasServerMatch.value) {
|
||||
// const { mutate } = provideApolloClient((projectAccount.value as DUIAccount).client)(
|
||||
// () => useMutation(requestProjectAccess)
|
||||
// )
|
||||
// const res = await mutate({
|
||||
// input: projectDetails.value?.id as string
|
||||
// })
|
||||
// writeAccessRequested.value = true
|
||||
// // TODO: It throws if it has already pending request, handle it!
|
||||
// console.log(res)
|
||||
// }
|
||||
// }
|
||||
|
||||
const { onResult: userProjectsUpdated } = useSubscription(
|
||||
userProjectsUpdatedSubscription,
|
||||
() => ({}),
|
||||
() => ({ clientId, enabled: accountExists })
|
||||
)
|
||||
|
||||
const { onResult: projectUpdated } = useSubscription(
|
||||
projectUpdatedSubscription,
|
||||
() => ({ projectId: props.project.projectId }),
|
||||
() => ({ clientId, enabled: accountExists })
|
||||
)
|
||||
|
||||
// to catch changes on visibility of project
|
||||
projectUpdated((res) => {
|
||||
// TODO: FIX needed: whenever project visibility changed from "discoverable" to "private", we can't get message if the `clientId` is not part of the team
|
||||
// validated with Fabians this is a current behavior.
|
||||
if (!res.data) return
|
||||
refetchProjectDetails()
|
||||
})
|
||||
|
||||
// to catch changes on team of the project
|
||||
userProjectsUpdated((res) => {
|
||||
if (!res.data) return
|
||||
refetchProjectDetails()
|
||||
writeAccessRequested.value = false
|
||||
})
|
||||
|
||||
const projectUrl = computed(() => {
|
||||
const acc = accountStore.accounts.find((acc) => acc.accountInfo.id === clientId)
|
||||
return `${acc?.accountInfo.serverInfo.url as string}/projects/${
|
||||
props.project.projectId
|
||||
}`
|
||||
})
|
||||
|
||||
const workspaceUrl = computed(() => {
|
||||
const acc = accountStore.accounts.find((acc) => acc.accountInfo.id === clientId)
|
||||
return `${acc?.accountInfo.serverInfo.url as string}/workspaces/${
|
||||
projectDetails.value?.workspace?.slug
|
||||
}`
|
||||
})
|
||||
|
||||
// Subscribe to version created events at a project level, and filter to any receivers (if any)
|
||||
const { onResult } = useSubscription(
|
||||
versionCreatedSubscription,
|
||||
() => ({ projectId: props.project.projectId }),
|
||||
() => ({ clientId, enabled: accountExists })
|
||||
)
|
||||
|
||||
onResult((res) => {
|
||||
if (!res.data) return
|
||||
if (res.data?.projectVersionsUpdated?.type !== 'CREATED') return
|
||||
|
||||
const relevantReceiver = props.project.receivers.find(
|
||||
(r) => r.modelId === res.data?.projectVersionsUpdated.version?.model.id
|
||||
)
|
||||
if (!relevantReceiver) return
|
||||
|
||||
hostAppStore.patchModel(relevantReceiver.modelCardId, {
|
||||
latestVersionId: res.data.projectVersionsUpdated.version?.id,
|
||||
latestVersionCreatedAt: res.data.projectVersionsUpdated.version?.createdAt,
|
||||
hasDismissedUpdateWarning: false,
|
||||
displayReceiveComplete: false
|
||||
})
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,46 @@
|
||||
<template>
|
||||
<CommonAlert
|
||||
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="mt-1"
|
||||
>
|
||||
<template #description>
|
||||
<div class="flex items-center">
|
||||
<div class="text-body-3xs truncate line-clamp-1 min-w-0">Update available</div>
|
||||
<div class="inline-flex justify-end -mr-3 grow">
|
||||
<FormButton size="sm" color="outline" @click="store.downloadLatestVersion()">
|
||||
Download
|
||||
</FormButton>
|
||||
<FormButton
|
||||
size="sm"
|
||||
color="subtle"
|
||||
hide-text
|
||||
:icon-left="XMarkIcon"
|
||||
@click="hasDismissedAlert = true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</CommonAlert>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import dayjs from 'dayjs'
|
||||
import { XMarkIcon } from '@heroicons/vue/24/outline'
|
||||
import { useHostAppStore } from '~~/store/hostApp'
|
||||
const store = useHostAppStore()
|
||||
const hasDismissedAlert = ref(false)
|
||||
const createdAgo = computed(() => {
|
||||
return dayjs(store.latestAvailableVersion?.Date).from(dayjs())
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,14 @@
|
||||
<template>
|
||||
<svg
|
||||
width="16"
|
||||
height="32"
|
||||
viewBox="0 0 16 32"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M7.64645 17.7498C7.84171 17.9451 8.15829 17.9451 8.35355 17.7498L11.1464 14.9569C11.4614 14.642 11.2383 14.1034 10.7929 14.1034H5.20711C4.76165 14.1034 4.53857 14.642 4.85355 14.9569L7.64645 17.7498Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,56 @@
|
||||
<template>
|
||||
<div :class="[containerStyle, loading ? 'opacity-100' : 'opacity-0']">
|
||||
<div
|
||||
:class="[progress ? '' : 'swoosher top-0', barStyle]"
|
||||
:style="widthStyle"
|
||||
></div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
const props = defineProps<{
|
||||
/**
|
||||
* Whether we're actively loading. If set, the progress bar will be indefinite unless a progress argument is passed in too (see below).
|
||||
*/
|
||||
loading: boolean
|
||||
/**
|
||||
* A number between 0 and 1. If set, the progress bar will no longer be indefinite and have a fixed progress.
|
||||
*/
|
||||
progress?: number
|
||||
}>()
|
||||
|
||||
const widthStyle = computed(() => {
|
||||
if (!props.progress) return ''
|
||||
return `width: ${props.progress * 100}%;`
|
||||
})
|
||||
|
||||
const containerStyle = computed(() => {
|
||||
return 'relative w-full h-1 bg-blue-500/30 text-xs text-foreground-on-primary overflow-hidden rounded-xl'
|
||||
})
|
||||
|
||||
const barStyle = computed(() => {
|
||||
return 'h-full relative bg-blue-500/50 transition-[width]'
|
||||
})
|
||||
</script>
|
||||
<style scoped>
|
||||
.swoosher {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
animation: swoosh 1s infinite linear;
|
||||
transform-origin: 0% 30%;
|
||||
}
|
||||
|
||||
@keyframes swoosh {
|
||||
0% {
|
||||
transform: translateX(0) scaleX(0);
|
||||
}
|
||||
|
||||
40% {
|
||||
transform: translateX(0) scaleX(0.4);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateX(100%) scaleX(0.5);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,37 @@
|
||||
<template>
|
||||
<!-- ONLY FOR TEST FOR NOW-->
|
||||
<form class="flex flex-col space-y-4 form-json-form">
|
||||
<FormJsonForm :schema="jsonSchema" @change="onParamsFormChange"></FormJsonForm>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { JsonFormsChangeEvent } from '@jsonforms/vue'
|
||||
|
||||
const jsonSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
acceptTerms: {
|
||||
type: 'boolean',
|
||||
title: 'I accept the terms and conditions'
|
||||
},
|
||||
username: { type: 'string', title: 'Username', default: 'a' },
|
||||
color: {
|
||||
type: 'string',
|
||||
title: 'Favorite Color',
|
||||
enum: ['red', 'green', 'blue']
|
||||
},
|
||||
multiSelect: {
|
||||
type: 'array',
|
||||
title: 'Multi Favorite Chars',
|
||||
enum: ['a', 'b', 'c', 'd']
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const paramsFormState = ref<JsonFormsChangeEvent>()
|
||||
const onParamsFormChange = (e: JsonFormsChangeEvent) => {
|
||||
paramsFormState.value = e
|
||||
console.log(e)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,33 @@
|
||||
<template>
|
||||
<CommonDialog
|
||||
v-model:open="store.showErrorDialog"
|
||||
fullscreen="none"
|
||||
@close="store.showErrorDialog = false"
|
||||
@fully-closed="store.setHostAppError(null)"
|
||||
>
|
||||
<template #header>
|
||||
<div class="h5 font-bold">Host App Error</div>
|
||||
</template>
|
||||
<div class="text-foreground-2 text-sm font-normal mx-2 -mt-2">
|
||||
<div class="text-s font-bold mb-2">{{ store.hostAppError?.message }}</div>
|
||||
<div class="text-xs whitespace-pre-line truncate">
|
||||
{{ store.hostAppError?.error }}
|
||||
</div>
|
||||
<button class="text-s font-bold my-2" @click="toggleStackTrace">
|
||||
{{ showStackTrace ? 'Hide' : 'Show' }} Stack Trace
|
||||
</button>
|
||||
<div v-if="showStackTrace" class="text-xs whitespace-pre-line truncate">
|
||||
{{ store.hostAppError?.stackTrace }}
|
||||
</div>
|
||||
</div>
|
||||
</CommonDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useHostAppStore } from '~/store/hostApp'
|
||||
const store = useHostAppStore()
|
||||
const showStackTrace = ref(false)
|
||||
const toggleStackTrace = () => {
|
||||
showStackTrace.value = !showStackTrace.value
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,114 @@
|
||||
<template>
|
||||
<CommonDialog
|
||||
v-model:open="isOpen"
|
||||
:title="dialogTitle"
|
||||
:buttons="dialogButtons"
|
||||
:on-submit="onSubmit"
|
||||
max-width="md"
|
||||
fullscreen="none"
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<p class="text-body-xs text-foreground font-medium">
|
||||
{{ dialogIntro }}
|
||||
</p>
|
||||
<FormTextArea
|
||||
v-model="feedback"
|
||||
name="feedback"
|
||||
label="Feedback"
|
||||
color="foundation"
|
||||
/>
|
||||
<p v-if="!hideSuppport" class="text-body-xs !leading-4">
|
||||
Need help? For support, head over to our
|
||||
<FormButton
|
||||
target="_blank"
|
||||
link
|
||||
text
|
||||
@click="$openUrl(`https://speckle.community/`)"
|
||||
>
|
||||
community forum
|
||||
</FormButton>
|
||||
where we can chat and solve problems together.
|
||||
</p>
|
||||
</div>
|
||||
</CommonDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ToastNotificationType, type LayoutDialogButton } from '@speckle/ui-components'
|
||||
import { useForm } from 'vee-validate'
|
||||
import { useZapier } from '~/lib/core/composables/zapier'
|
||||
import { useMixpanel } from '~/lib/core/composables/mixpanel'
|
||||
import { useAccountStore } from '~/store/accounts'
|
||||
import { useHostAppStore } from '~/store/hostApp'
|
||||
|
||||
type FormValues = { feedback: string }
|
||||
|
||||
const props = defineProps<{
|
||||
title?: string
|
||||
intro?: string
|
||||
hideSuppport?: boolean
|
||||
metadata?: Record<string, unknown>
|
||||
}>()
|
||||
|
||||
const isOpen = defineModel<boolean>('open', { required: true })
|
||||
|
||||
const { trackEvent } = useMixpanel()
|
||||
const { sendWebhook } = useZapier()
|
||||
const { handleSubmit } = useForm<FormValues>()
|
||||
const accountStore = useAccountStore()
|
||||
const hostApp = useHostAppStore()
|
||||
|
||||
const feedback = ref('')
|
||||
|
||||
const dialogButtons = computed((): LayoutDialogButton[] => [
|
||||
{
|
||||
text: 'Send',
|
||||
props: { color: 'primary' },
|
||||
submit: true,
|
||||
id: 'sendFeedback'
|
||||
}
|
||||
])
|
||||
|
||||
const dialogTitle = computed(() => props.title || 'Give us feedback')
|
||||
|
||||
const dialogIntro = computed(
|
||||
() =>
|
||||
props.intro ||
|
||||
'How can we improve Speckle? If you have a feature request, please also share how you would use it and why its important to you'
|
||||
)
|
||||
|
||||
const onSubmit = handleSubmit(async () => {
|
||||
if (!feedback.value) return
|
||||
|
||||
isOpen.value = false
|
||||
|
||||
trackEvent('Feedback Sent', {
|
||||
message: feedback.value,
|
||||
feedbackType: 'dui3',
|
||||
...props.metadata
|
||||
})
|
||||
|
||||
hostApp.setNotification({
|
||||
type: ToastNotificationType.Success,
|
||||
title: 'Thank you for your feedback!'
|
||||
})
|
||||
|
||||
const userId = accountStore.defaultAccount.accountInfo.userInfo.id ?? ''
|
||||
|
||||
await sendWebhook('https://hooks.zapier.com/hooks/catch/12120532/2m4okri/', {
|
||||
userId,
|
||||
feedback: [
|
||||
`**Action:** User Feedback`,
|
||||
`**Type:** dui3`,
|
||||
`**User ID:** ${userId}`,
|
||||
`**Feedback:** ${feedback.value}`
|
||||
].join('\n')
|
||||
})
|
||||
})
|
||||
|
||||
watch(isOpen, (newVal) => {
|
||||
if (newVal) {
|
||||
feedback.value = ''
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,133 @@
|
||||
<template>
|
||||
<div class="space-y-2">
|
||||
<div>
|
||||
<FormSelectBase
|
||||
v-model="selectedFilterName"
|
||||
name="sendFilter"
|
||||
label="Selected filter"
|
||||
class="w-full"
|
||||
fixed-height
|
||||
size="sm"
|
||||
:items="filterNames"
|
||||
:allow-unset="false"
|
||||
mount-menu-on-body
|
||||
>
|
||||
<template #something-selected="{ value }">
|
||||
<span class="text-primary text-base text-sm">{{ value }}</span>
|
||||
</template>
|
||||
<template #option="{ item }">
|
||||
<span class="text-base text-sm">{{ item }}</span>
|
||||
</template>
|
||||
</FormSelectBase>
|
||||
</div>
|
||||
<div v-if="selectedFilter">
|
||||
<div
|
||||
v-if="
|
||||
selectedFilter.id === 'everything' || selectedFilter.name === 'Everything' // TODO: damn. remove name check later, if we remove now it will break production... we should differentiate its id and display name
|
||||
"
|
||||
>
|
||||
<div class="p-4 text-primary bg-blue-500/10 rounded-md text-xs">
|
||||
All supported objects will be sent. Depending on the model, this might take a
|
||||
while.
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="
|
||||
selectedFilter.type === 'Select' &&
|
||||
store.availableSelectSendFilters[selectedFilter.id]
|
||||
"
|
||||
>
|
||||
<FilterFormSelect
|
||||
:label="selectedFilter.name"
|
||||
:items="(store.availableSelectSendFilters[selectedFilter.id].items as ISendFilterSelectItem[])"
|
||||
:filter="(selectedFilter as SendFilterSelect)"
|
||||
@update:filter="(filter : ISendFilter) => (selectedFilter = filter)"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="
|
||||
selectedFilter.id === 'selection' || selectedFilter.name === 'Selection' // TODO: damn. remove name check later, if we remove now it will break production... we should differentiate its id and display name
|
||||
"
|
||||
>
|
||||
<FilterSelection
|
||||
:filter="(selectedFilter as IDirectSelectionSendFilter)"
|
||||
@update:filter="(filter : ISendFilter) => (selectedFilter = filter)"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="selectedFilter.id === 'revitViews'">
|
||||
<FilterRevitViews
|
||||
:filter="(selectedFilter as RevitViewsSendFilter)"
|
||||
@update:filter="(filter : ISendFilter) => (selectedFilter = filter)"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="selectedFilter.id === 'revitCategories'">
|
||||
<FilterRevitCategories
|
||||
:filter="(selectedFilter as RevitCategoriesSendFilter)"
|
||||
@update:filter="(filter : ISendFilter) => (selectedFilter = filter)"
|
||||
/>
|
||||
</div>
|
||||
<!-- 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
|
||||
label="Saved Sets"
|
||||
:items="(store.navisworksAvailableSavedSets as ISendFilterSelectItem[])"
|
||||
:filter="(selectedFilter as SendFilterSelect)"
|
||||
@update:filter="(filter : ISendFilter) => (selectedFilter = filter)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!!filter" class="text-xs caption rounded p-2 bg-orange-500/10">
|
||||
This action will replace the existing
|
||||
<b>{{ selectedFilterName }}</b>
|
||||
filter.
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import type {
|
||||
ISendFilter,
|
||||
IDirectSelectionSendFilter,
|
||||
RevitCategoriesSendFilter,
|
||||
ISendFilterSelectItem,
|
||||
SendFilterSelect,
|
||||
RevitViewsSendFilter
|
||||
} from '~/lib/models/card/send'
|
||||
import { useHostAppStore } from '~~/store/hostApp'
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
const store = useHostAppStore()
|
||||
const { sendFilters, selectionFilter } = storeToRefs(store)
|
||||
|
||||
// NOTE: we're forcefully refreshing filters here because revit 2022 does not surface up views on change events, so we cannot trigger it from the host app
|
||||
// on a need by basis. This way, we're forcing all host apps to give us an updated list of send filters, as it's a cheap operation (and should stay so!).
|
||||
void store.refreshSendFilters()
|
||||
|
||||
const props = defineProps<{
|
||||
filter?: ISendFilter
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ (e: 'update:filter', value: ISendFilter): void }>()
|
||||
const selectedFilter = ref<ISendFilter>(props.filter || selectionFilter.value)
|
||||
|
||||
const selectedFilterName = ref(
|
||||
props.filter?.name || sendFilters.value?.find((f) => f.isDefault)?.name
|
||||
)
|
||||
const filterNames = computed(() => sendFilters.value?.map((f) => f.name))
|
||||
|
||||
watch(selectedFilterName, (newValue) => {
|
||||
selectedFilter.value = sendFilters.value?.find(
|
||||
(f) => f.name === newValue
|
||||
) as ISendFilter
|
||||
})
|
||||
|
||||
watch(selectedFilter, (newValue) => {
|
||||
emit('update:filter', newValue)
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<div class="space-y-2 p-2 bg-highlight-1 rounded-md text-body-xs">
|
||||
<div v-if="selectionStore.selectionInfo.selectedObjectIds?.length === 0">
|
||||
No objects selected, go ahead and select some from your model!
|
||||
</div>
|
||||
<div v-else>{{ selectionStore.selectionInfo.summary }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import type { IDirectSelectionSendFilter, ISendFilter } from '~/lib/models/card/send'
|
||||
import { useHostAppStore } from '~~/store/hostApp'
|
||||
import { useSelectionStore } from '~~/store/selection'
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:filter', filter: ISendFilter): void
|
||||
}>()
|
||||
|
||||
const store = useHostAppStore()
|
||||
const { selectionFilter } = storeToRefs(store)
|
||||
|
||||
const selectionStore = useSelectionStore()
|
||||
const { selectionInfo } = storeToRefs(selectionStore)
|
||||
|
||||
defineProps<{
|
||||
filter: IDirectSelectionSendFilter
|
||||
}>()
|
||||
|
||||
watch(
|
||||
selectionInfo,
|
||||
(newValue) => {
|
||||
const filter = { ...selectionFilter.value } as IDirectSelectionSendFilter
|
||||
filter.selectedObjectIds = newValue.selectedObjectIds
|
||||
filter.summary = newValue.summary as string
|
||||
emit('update:filter', filter)
|
||||
},
|
||||
{ deep: true, immediate: true }
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
selectionStore.refreshSelectionFromHostApp()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,83 @@
|
||||
<template>
|
||||
<FormSelectBase
|
||||
v-model="selectedItems"
|
||||
:items="items"
|
||||
:search="true"
|
||||
:search-placeholder="''"
|
||||
:filter-predicate="searchFilterPredicate"
|
||||
:label="label"
|
||||
:name="label"
|
||||
placeholder="Nothing selected"
|
||||
class="w-full"
|
||||
fixed-height
|
||||
show-label
|
||||
:allow-unset="false"
|
||||
mount-menu-on-body
|
||||
:multiple="filter.isMultiSelectable"
|
||||
by="id"
|
||||
>
|
||||
<template #option="{ item }">
|
||||
<span class="text-base text-sm">{{ item.name }}</span>
|
||||
</template>
|
||||
<template #something-selected="{ value }">
|
||||
<span class="text-primary text-base text-sm">
|
||||
{{
|
||||
filter.isMultiSelectable
|
||||
? (value as ISendFilterSelectItem[]).map((v) => v.name).join(', ')
|
||||
: (value as ISendFilterSelectItem).name
|
||||
}}
|
||||
</span>
|
||||
</template>
|
||||
</FormSelectBase>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type {
|
||||
ISendFilter,
|
||||
SendFilterSelect,
|
||||
ISendFilterSelectItem
|
||||
} from '~/lib/models/card/send'
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:filter', filter: ISendFilter): void
|
||||
}>()
|
||||
|
||||
const props = defineProps<{
|
||||
label: string
|
||||
filter: SendFilterSelect
|
||||
items: ISendFilterSelectItem[]
|
||||
}>()
|
||||
|
||||
const selectedItems = ref<ISendFilterSelectItem[]>(props.filter.selectedItems)
|
||||
|
||||
const searchFilterPredicate = (item: ISendFilterSelectItem, search: string) =>
|
||||
item.name.toLocaleLowerCase().includes(search.toLocaleLowerCase())
|
||||
|
||||
watch(
|
||||
selectedItems,
|
||||
(newValue) => {
|
||||
// At first it trigger undefined change
|
||||
if (!newValue) {
|
||||
return
|
||||
}
|
||||
// unless isMultiSelectable, newValue arrives as ISendFilterSelectItem
|
||||
if (!Array.isArray(newValue)) {
|
||||
const filter = { ...props.filter } as SendFilterSelect
|
||||
filter.selectedItems = [newValue]
|
||||
filter.summary = (newValue as ISendFilterSelectItem).name
|
||||
emit('update:filter', filter)
|
||||
return
|
||||
}
|
||||
|
||||
// if isMultiSelectable, newValue arrives as ISendFilterSelectItem[]
|
||||
const filter = { ...props.filter } as SendFilterSelect
|
||||
filter.selectedItems = newValue as ISendFilterSelectItem[]
|
||||
filter.summary = props.filter.isMultiSelectable
|
||||
? newValue.map((v) => v.name).join(', ')
|
||||
: newValue[0].name
|
||||
|
||||
emit('update:filter', filter)
|
||||
},
|
||||
{ deep: true, immediate: true }
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,146 @@
|
||||
<!-- eslint-disable vuejs-accessibility/no-static-element-interactions -->
|
||||
<!-- eslint-disable vuejs-accessibility/click-events-have-key-events -->
|
||||
<template>
|
||||
<div class="mt-4 space-y-2">
|
||||
<div class="flex items-center space-x-2 justify-between">
|
||||
<FormTextInput
|
||||
v-model="searchValue"
|
||||
placeholder="Search"
|
||||
name="search"
|
||||
autocomplete="off"
|
||||
:show-clear="!!searchValue"
|
||||
full-width
|
||||
color="foundation"
|
||||
/>
|
||||
<FormButton color="outline" size="sm" @click="selectAllCategories">
|
||||
{{ allSelected ? 'Deselect all' : 'Select all' }}
|
||||
</FormButton>
|
||||
</div>
|
||||
<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)
|
||||
)"
|
||||
:key="cat.id"
|
||||
>
|
||||
<!-- We were use to use FormButton for this but our lovely Revit 2022 (CEF 65) but it didn't work properly in terms of CSS. -->
|
||||
<div
|
||||
v-tippy="'Remove'"
|
||||
:class="`block h-6 text-body-2xs px-2 py-1 rounded-md flex align-center justify-between w-full hover:cursor-pointer hover:shadow-md bg-primary text-foreground-on-primary border-outline-2 text-foreground font-medium p-1 border focus-visible:border-foundation`"
|
||||
@click="selectOrUnselectCategory(cat.id)"
|
||||
>
|
||||
<span>{{ cat.name }}</span>
|
||||
<XMarkIcon class="w-4" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex space-y-1 flex-col simple-scrollbar overflow-y-auto min-h-0 max-h-48 overflow-x-hidden"
|
||||
>
|
||||
<!-- We were use to use FormButton for this but our lovely Revit 2022 (CEF 65) but it didn't work properly in terms of CSS. -->
|
||||
<div
|
||||
v-for="cat in searchResults.sort((a, b) => a.name.localeCompare(b.name))"
|
||||
:key="cat.id"
|
||||
v-tippy="'Add'"
|
||||
:class="`block h-6 text-body-2xs ${
|
||||
selectedCategories.includes(cat.id) ? 'bg-primary' : ''
|
||||
} px-2 py-1 rounded-md align-center justify-between w-full hover:cursor-pointer hover:shadow-md bg-foundation border-outline-2 text-foreground font-medium p-1 hover:bg-primary-muted border disabled:hover:bg-foundation focus-visible:border-foundation`"
|
||||
@click="selectOrUnselectCategory(cat.id)"
|
||||
>
|
||||
<span>{{ cat.name }}</span>
|
||||
<!-- <PlusIcon class="w-4" /> -->
|
||||
</div>
|
||||
<div v-if="searchResults.length === 0" class="text-xs text-center">
|
||||
Nothing found
|
||||
<FormButton color="outline" size="sm" @click="searchValue = undefined">
|
||||
Clear search
|
||||
</FormButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { XMarkIcon } from '@heroicons/vue/20/solid'
|
||||
import type {
|
||||
CategoriesData,
|
||||
ISendFilter,
|
||||
RevitCategoriesSendFilter
|
||||
} from '~/lib/models/card/send'
|
||||
|
||||
const searchValue = ref<string>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:filter', filter: ISendFilter): void
|
||||
}>()
|
||||
|
||||
const props = defineProps<{
|
||||
filter: RevitCategoriesSendFilter
|
||||
}>()
|
||||
|
||||
const availableCategories = ref<CategoriesData[]>(props.filter.availableCategories)
|
||||
|
||||
const searchResults = computed(() => {
|
||||
const searchVal = searchValue.value
|
||||
if (!searchVal?.length)
|
||||
return availableCategories.value.filter(
|
||||
(cat) => !selectedCategories.value.includes(cat.id)
|
||||
)
|
||||
|
||||
return availableCategories.value.filter(
|
||||
(cat) =>
|
||||
cat.name.toLowerCase().includes(searchVal.toLowerCase()) &&
|
||||
!selectedCategories.value.includes(cat.id)
|
||||
)
|
||||
})
|
||||
|
||||
const selectedCategories = ref<string[]>(props.filter.selectedCategories || [])
|
||||
|
||||
const 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) {
|
||||
selectedCategories.value.splice(index, 1)
|
||||
} else {
|
||||
selectedCategories.value.push(id)
|
||||
}
|
||||
}
|
||||
|
||||
const selectedCategoriesObjects = computed(() => {
|
||||
return selectedCategories.value.map((id) =>
|
||||
availableCategories.value.find((cat) => cat.id === id)
|
||||
) as CategoriesData[]
|
||||
})
|
||||
|
||||
watch(
|
||||
selectedCategoriesObjects,
|
||||
(newValue) => {
|
||||
const filter = { ...props.filter } as RevitCategoriesSendFilter
|
||||
const names = newValue.map((v) => v.name)
|
||||
filter.selectedCategories = availableCategories.value
|
||||
.filter((c) => names.includes(c.name))
|
||||
.map((c) => c.id)
|
||||
filter.summary = names.join(', ')
|
||||
emit('update:filter', filter)
|
||||
},
|
||||
{ deep: true, immediate: true }
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<div class="mt-4 space-y-2">
|
||||
<FormSelectBase
|
||||
key="name"
|
||||
v-model="selectedView"
|
||||
:search="true"
|
||||
:search-placeholder="''"
|
||||
:filter-predicate="searchFilterPredicate"
|
||||
name="view"
|
||||
label="View"
|
||||
placeholder="Nothing selected"
|
||||
class="w-full"
|
||||
fixed-height
|
||||
show-label
|
||||
:items="store.availableViews"
|
||||
:allow-unset="false"
|
||||
mount-menu-on-body
|
||||
>
|
||||
<template #something-selected="{ value }">
|
||||
<span class="text-primary text-base text-sm">{{ value }}</span>
|
||||
</template>
|
||||
<template #option="{ item }">
|
||||
<span class="text-base text-sm">{{ item }}</span>
|
||||
</template>
|
||||
</FormSelectBase>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useHostAppStore } from '~/store/hostApp'
|
||||
import type { ISendFilter, RevitViewsSendFilter } from '~/lib/models/card/send'
|
||||
|
||||
const store = useHostAppStore()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:filter', filter: ISendFilter): void
|
||||
}>()
|
||||
|
||||
const props = defineProps<{
|
||||
filter: RevitViewsSendFilter
|
||||
}>()
|
||||
|
||||
const selectedView = ref<string>(props.filter.selectedView)
|
||||
|
||||
const searchFilterPredicate = (item: string, search: string) =>
|
||||
item.toLocaleLowerCase().includes(search.toLocaleLowerCase())
|
||||
|
||||
watch(
|
||||
selectedView,
|
||||
(newValue) => {
|
||||
const filter = { ...props.filter } as RevitViewsSendFilter
|
||||
filter.selectedView = newValue as string
|
||||
filter.summary = newValue
|
||||
emit('update:filter', filter)
|
||||
},
|
||||
{ deep: true, immediate: true }
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,38 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="text-foreground-2 text-body-2xs mb-1 pl-1">{{ control.label }}</div>
|
||||
<FormSwitch
|
||||
:name="fieldName"
|
||||
:disabled="!control.enabled"
|
||||
:model-value="modelValue"
|
||||
:rules="validator"
|
||||
:label="control.label"
|
||||
:value="true"
|
||||
:description="control.description"
|
||||
:show-label="false"
|
||||
size="xl"
|
||||
:validate-on-value-update="validateOnValueUpdate"
|
||||
@update:model-value="handleChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import type { ControlElement } from '@jsonforms/core'
|
||||
import { rendererProps, useJsonFormsControl } from '@jsonforms/vue'
|
||||
import { useJsonRendererBaseSetup } from '~/lib/form/composables/jsonRenderers'
|
||||
|
||||
const props = defineProps({
|
||||
...rendererProps<ControlElement>()
|
||||
})
|
||||
|
||||
const { handleChange, control, validator, fieldName, validateOnValueUpdate } =
|
||||
useJsonRendererBaseSetup(useJsonFormsControl(props), {
|
||||
onChangeValueConverter: (val: true | undefined) => {
|
||||
return !!val
|
||||
}
|
||||
})
|
||||
|
||||
const modelValue = computed(() => {
|
||||
return control.value.data ? true : undefined
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,35 @@
|
||||
<template>
|
||||
<FormTextInput
|
||||
:name="fieldName"
|
||||
:disabled="!control.enabled"
|
||||
:model-value="control.data"
|
||||
:rules="validator"
|
||||
:label="control.label"
|
||||
show-label
|
||||
type="date"
|
||||
size="lg"
|
||||
max="9999-12-31"
|
||||
:placeholder="appliedOptions['placeholder']"
|
||||
:help="control.description"
|
||||
:validate-on-value-update="validateOnValueUpdate"
|
||||
@update:model-value="handleChange"
|
||||
/>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import type { ControlElement } from '@jsonforms/core'
|
||||
import { rendererProps, useJsonFormsControl } from '@jsonforms/vue'
|
||||
import { useJsonRendererBaseSetup } from '~/lib/form/composables/jsonRenderers'
|
||||
|
||||
const props = defineProps({
|
||||
...rendererProps<ControlElement>()
|
||||
})
|
||||
|
||||
const {
|
||||
handleChange,
|
||||
control,
|
||||
validator,
|
||||
appliedOptions,
|
||||
fieldName,
|
||||
validateOnValueUpdate
|
||||
} = useJsonRendererBaseSetup(useJsonFormsControl(props))
|
||||
</script>
|
||||
@@ -0,0 +1,49 @@
|
||||
<template>
|
||||
<FormTextInput
|
||||
:name="fieldName"
|
||||
:disabled="!control.enabled"
|
||||
:model-value="modelValue"
|
||||
:rules="validator"
|
||||
:label="control.label"
|
||||
show-label
|
||||
type="datetime-local"
|
||||
size="lg"
|
||||
max="9999-12-31T23:59"
|
||||
:placeholder="appliedOptions['placeholder']"
|
||||
:help="control.description"
|
||||
:validate-on-value-update="validateOnValueUpdate"
|
||||
@update:model-value="handleChange"
|
||||
/>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import type { ControlElement } from '@jsonforms/core'
|
||||
import { rendererProps, useJsonFormsControl } from '@jsonforms/vue'
|
||||
import { useJsonRendererBaseSetup } from '~/lib/form/composables/jsonRenderers'
|
||||
|
||||
const zuluTimeSuffix = ':00.000Z'
|
||||
|
||||
const props = defineProps({
|
||||
...rendererProps<ControlElement>()
|
||||
})
|
||||
|
||||
const toISOString = (inputDateTime: string) => {
|
||||
return inputDateTime ? inputDateTime + zuluTimeSuffix : undefined
|
||||
}
|
||||
|
||||
const {
|
||||
handleChange,
|
||||
control,
|
||||
validator,
|
||||
appliedOptions,
|
||||
fieldName,
|
||||
validateOnValueUpdate
|
||||
} = useJsonRendererBaseSetup(useJsonFormsControl(props), {
|
||||
onChangeValueConverter: (val) => toISOString(val as string)
|
||||
})
|
||||
|
||||
const modelValue = computed(() =>
|
||||
control.value.data
|
||||
? (control.value.data as string).replace(zuluTimeSuffix, '')
|
||||
: undefined
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,124 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="text-foreground-2 text-body-2xs mb-1 pl-1">{{ control.label }}</div>
|
||||
<FormSelectBase
|
||||
:model-value="modelValue"
|
||||
:name="fieldName"
|
||||
:rules="validator"
|
||||
:label="control.label"
|
||||
:items="control.options"
|
||||
:multiple="multiple"
|
||||
:help="control.description"
|
||||
:allow-unset="false"
|
||||
by="value"
|
||||
button-style="tinted"
|
||||
:validate-on-value-update="validateOnValueUpdate"
|
||||
mount-menu-on-body
|
||||
@update:model-value="handleChange"
|
||||
>
|
||||
<template #nothing-selected>
|
||||
{{
|
||||
appliedOptions['placeholder']
|
||||
? appliedOptions['placeholder']
|
||||
: multiple
|
||||
? 'Select values'
|
||||
: 'Select a value'
|
||||
}}
|
||||
</template>
|
||||
<template #something-selected="{ value }">
|
||||
<template v-if="isMultiItemArrayValue(value)">
|
||||
<div ref="elementToWatchForChanges" class="flex items-center space-x-0.5">
|
||||
<div
|
||||
ref="itemContainer"
|
||||
class="flex flex-wrap overflow-hidden space-x-0.5 h-6"
|
||||
>
|
||||
<div v-for="(item, i) in value" :key="item.value" class="text-foreground">
|
||||
{{ item.label + (i < value.length - 1 ? ', ' : '') }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="hiddenSelectedItemCount > 0" class="text-foreground-2 normal">
|
||||
+{{ hiddenSelectedItemCount }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="flex items-center">
|
||||
<span class="truncate text-foreground">
|
||||
{{ (isArrayValue(value) ? value[0] : value).label }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
<template #option="{ item }">
|
||||
<div class="flex items-center text-foreground-2 text-body-2xs">
|
||||
<span class="truncate">{{ item.label }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</FormSelectBase>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import type { ControlElement } from '@jsonforms/core'
|
||||
import { rendererProps, useJsonFormsEnumControl } from '@jsonforms/vue'
|
||||
import type { Nullable } from '@speckle/shared'
|
||||
import { useFormSelectChildInternals } from '@speckle/ui-components'
|
||||
import { useJsonRendererBaseSetup } from '~/lib/form/composables/jsonRenderers'
|
||||
|
||||
type OptionType = { value: string; label: string }
|
||||
type ValueType = OptionType | OptionType[] | undefined
|
||||
|
||||
const emit = defineEmits<(e: 'update:modelValue', v: ValueType) => void>()
|
||||
|
||||
const props = defineProps({
|
||||
...rendererProps<ControlElement>(),
|
||||
// TODO: Doesn't appear that jsonforms properly supports multiple selection
|
||||
multiple: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
controlOverrides: {
|
||||
type: Object as PropType<Nullable<ReturnType<typeof useJsonFormsEnumControl>>>,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
|
||||
const elementToWatchForChanges = ref(null as Nullable<HTMLElement>)
|
||||
const itemContainer = ref(null as Nullable<HTMLElement>)
|
||||
|
||||
const { hiddenSelectedItemCount, isArrayValue, isMultiItemArrayValue } =
|
||||
useFormSelectChildInternals<OptionType>({
|
||||
props: toRefs(props),
|
||||
emit,
|
||||
dynamicVisibility: { elementToWatchForChanges, itemContainer }
|
||||
})
|
||||
|
||||
const {
|
||||
handleChange,
|
||||
control,
|
||||
validator,
|
||||
appliedOptions,
|
||||
fieldName,
|
||||
validateOnValueUpdate
|
||||
} = useJsonRendererBaseSetup(props.controlOverrides || useJsonFormsEnumControl(props), {
|
||||
onChangeValueConverter: (newVal: ValueType) => {
|
||||
if (props.multiple && isArrayValue(newVal)) {
|
||||
return newVal.map((v) => v.value)
|
||||
} else if (newVal && !props.multiple && !isArrayValue(newVal)) {
|
||||
return newVal.value
|
||||
} else {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const modelValue = computed(() => {
|
||||
const val = control.value.data as string
|
||||
const res = control.value.options.find((o) => o.value === val)
|
||||
|
||||
if (props.multiple) {
|
||||
return res ? [res] : []
|
||||
} else {
|
||||
return res || undefined
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<FormJsonEnumControlRenderer
|
||||
v-bind="$props"
|
||||
:multiple="false"
|
||||
:control-overrides="controlOverrides"
|
||||
/>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import type { ControlElement } from '@jsonforms/core'
|
||||
import { rendererProps, useJsonFormsOneOfEnumControl } from '@jsonforms/vue'
|
||||
|
||||
const props = defineProps({
|
||||
...rendererProps<ControlElement>()
|
||||
})
|
||||
|
||||
const controlOverrides = useJsonFormsOneOfEnumControl(props)
|
||||
</script>
|
||||
@@ -0,0 +1,79 @@
|
||||
<template>
|
||||
<form class="flex flex-col space-y-4 form-json-form">
|
||||
<JsonForms
|
||||
ref="internalRef"
|
||||
:renderers="renderers"
|
||||
:schema="finalSchema"
|
||||
:uischema="finalUiSchema"
|
||||
:data="data"
|
||||
@change="onChange"
|
||||
/>
|
||||
</form>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import type { JsonSchema, UISchemaElement } from '@jsonforms/core'
|
||||
import type { JsonFormsChangeEvent } from '@jsonforms/vue'
|
||||
import { JsonForms } from '@jsonforms/vue'
|
||||
import type { Nullable, Optional } from '@speckle/shared'
|
||||
import { omit } from 'lodash-es'
|
||||
import { useForm } from 'vee-validate'
|
||||
import { renderers } from '~/lib/form/jsonRenderers'
|
||||
|
||||
type DataType = Record<string, unknown>
|
||||
|
||||
const emit = defineEmits<(e: 'change', val: JsonFormsChangeEvent) => void>()
|
||||
|
||||
const props = defineProps<{
|
||||
schema: JsonSchema
|
||||
uiSchema?: UISchemaElement
|
||||
data?: DataType
|
||||
}>()
|
||||
|
||||
const { validate } = useForm()
|
||||
|
||||
const internalRef = ref<Nullable<{ jsonforms: { core: JsonFormsChangeEvent } }>>(null)
|
||||
// const data = ref({})
|
||||
|
||||
const finalSchema = computed(() => {
|
||||
const base = props.schema
|
||||
return omit(base, ['$schema', '$id'])
|
||||
})
|
||||
|
||||
const autoGeneratedUiSchema = computed(() => {
|
||||
const properties = Object.keys(props.schema.properties || {})
|
||||
return {
|
||||
type: 'VerticalLayout',
|
||||
elements: properties.map((p) => ({
|
||||
type: 'Control',
|
||||
scope: `#/properties/${p}`
|
||||
}))
|
||||
}
|
||||
})
|
||||
const finalUiSchema = computed(() => props.uiSchema || autoGeneratedUiSchema.value)
|
||||
|
||||
const onChange = async (e: JsonFormsChangeEvent) => {
|
||||
// console.log(JSON.parse(JSON.stringify(e)))
|
||||
// NOTE: setting data.value causes trigger again
|
||||
// data.value = e.data as DataType
|
||||
await validate({ mode: 'force' })
|
||||
emit('change', e)
|
||||
}
|
||||
|
||||
const getFormState = (): Optional<JsonFormsChangeEvent> =>
|
||||
internalRef.value?.jsonforms.core
|
||||
? ({
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
data: internalRef.value.jsonforms.core.data,
|
||||
errors: internalRef.value.jsonforms.core.errors
|
||||
} as JsonFormsChangeEvent)
|
||||
: undefined
|
||||
|
||||
defineExpose({ getFormState })
|
||||
</script>
|
||||
<style lang="postcss">
|
||||
.form-json-form {
|
||||
.vertical-layout {
|
||||
@apply space-y-4;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,37 @@
|
||||
<template>
|
||||
<FormTextInput
|
||||
:name="fieldName"
|
||||
:disabled="!control.enabled"
|
||||
:model-value="control.data + ''"
|
||||
:rules="validator"
|
||||
:label="control.label"
|
||||
:placeholder="appliedOptions['placeholder']"
|
||||
:help="control.description"
|
||||
type="number"
|
||||
step="1"
|
||||
size="lg"
|
||||
show-label
|
||||
:validate-on-value-update="validateOnValueUpdate"
|
||||
@update:model-value="handleChange"
|
||||
/>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import type { ControlElement } from '@jsonforms/core'
|
||||
import { rendererProps, useJsonFormsControl } from '@jsonforms/vue'
|
||||
import { useJsonRendererBaseSetup } from '~/lib/form/composables/jsonRenderers'
|
||||
|
||||
const props = defineProps({
|
||||
...rendererProps<ControlElement>()
|
||||
})
|
||||
|
||||
const {
|
||||
handleChange,
|
||||
control,
|
||||
validator,
|
||||
appliedOptions,
|
||||
fieldName,
|
||||
validateOnValueUpdate
|
||||
} = useJsonRendererBaseSetup(useJsonFormsControl(props), {
|
||||
onChangeValueConverter: (val: string) => (val ? parseInt(val) : undefined)
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,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>
|
||||
@@ -0,0 +1,32 @@
|
||||
<template>
|
||||
<FormTextArea
|
||||
:name="fieldName"
|
||||
:disabled="!control.enabled"
|
||||
:model-value="control.data"
|
||||
:rules="validator"
|
||||
:placeholder="appliedOptions['placeholder']"
|
||||
:help="control.description"
|
||||
:label="control.label"
|
||||
show-label
|
||||
:validate-on-value-update="validateOnValueUpdate"
|
||||
@update:model-value="handleChange"
|
||||
/>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import type { ControlElement } from '@jsonforms/core'
|
||||
import { rendererProps, useJsonFormsControl } from '@jsonforms/vue'
|
||||
import { useJsonRendererBaseSetup } from '~/lib/form/composables/jsonRenderers'
|
||||
|
||||
const props = defineProps({
|
||||
...rendererProps<ControlElement>()
|
||||
})
|
||||
|
||||
const {
|
||||
handleChange,
|
||||
control,
|
||||
validator,
|
||||
appliedOptions,
|
||||
fieldName,
|
||||
validateOnValueUpdate
|
||||
} = useJsonRendererBaseSetup(useJsonFormsControl(props))
|
||||
</script>
|
||||
@@ -0,0 +1,36 @@
|
||||
<template>
|
||||
<FormTextInput
|
||||
:name="fieldName"
|
||||
:disabled="!control.enabled"
|
||||
:model-value="control.data + ''"
|
||||
:rules="validator"
|
||||
:label="control.label"
|
||||
type="number"
|
||||
size="lg"
|
||||
show-label
|
||||
:placeholder="appliedOptions['placeholder']"
|
||||
:help="control.description"
|
||||
:validate-on-value-update="validateOnValueUpdate"
|
||||
@update:model-value="handleChange"
|
||||
/>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import type { ControlElement } from '@jsonforms/core'
|
||||
import { rendererProps, useJsonFormsControl } from '@jsonforms/vue'
|
||||
import { useJsonRendererBaseSetup } from '~/lib/form/composables/jsonRenderers'
|
||||
|
||||
const props = defineProps({
|
||||
...rendererProps<ControlElement>()
|
||||
})
|
||||
|
||||
const {
|
||||
handleChange,
|
||||
control,
|
||||
validator,
|
||||
appliedOptions,
|
||||
fieldName,
|
||||
validateOnValueUpdate
|
||||
} = useJsonRendererBaseSetup(useJsonFormsControl(props), {
|
||||
onChangeValueConverter: (val: string) => (val ? Number(val) : undefined)
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,33 @@
|
||||
<template>
|
||||
<FormTextInput
|
||||
:name="fieldName"
|
||||
:disabled="!control.enabled"
|
||||
:model-value="control.data"
|
||||
:rules="validator"
|
||||
:label="control.label"
|
||||
:placeholder="appliedOptions['placeholder']"
|
||||
:help="control.description"
|
||||
color="foundation"
|
||||
show-label
|
||||
:validate-on-value-update="validateOnValueUpdate"
|
||||
@update:model-value="handleChange"
|
||||
/>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import type { ControlElement } from '@jsonforms/core'
|
||||
import { rendererProps, useJsonFormsControl } from '@jsonforms/vue'
|
||||
import { useJsonRendererBaseSetup } from '~/lib/form/composables/jsonRenderers'
|
||||
|
||||
const props = defineProps({
|
||||
...rendererProps<ControlElement>()
|
||||
})
|
||||
|
||||
const {
|
||||
handleChange,
|
||||
control,
|
||||
validator,
|
||||
appliedOptions,
|
||||
fieldName,
|
||||
validateOnValueUpdate
|
||||
} = useJsonRendererBaseSetup(useJsonFormsControl(props))
|
||||
</script>
|
||||
@@ -0,0 +1,35 @@
|
||||
<template>
|
||||
<FormTextInput
|
||||
:name="fieldName"
|
||||
:disabled="!control.enabled"
|
||||
:model-value="control.data"
|
||||
:rules="validator"
|
||||
:label="control.label"
|
||||
show-label
|
||||
type="time"
|
||||
step="1"
|
||||
size="lg"
|
||||
:placeholder="appliedOptions['placeholder']"
|
||||
:help="control.description"
|
||||
:validate-on-value-update="validateOnValueUpdate"
|
||||
@update:model-value="handleChange"
|
||||
/>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import type { ControlElement } from '@jsonforms/core'
|
||||
import { rendererProps, useJsonFormsControl } from '@jsonforms/vue'
|
||||
import { useJsonRendererBaseSetup } from '~/lib/form/composables/jsonRenderers'
|
||||
|
||||
const props = defineProps({
|
||||
...rendererProps<ControlElement>()
|
||||
})
|
||||
|
||||
const {
|
||||
handleChange,
|
||||
control,
|
||||
validator,
|
||||
appliedOptions,
|
||||
fieldName,
|
||||
validateOnValueUpdate
|
||||
} = useJsonRendererBaseSetup(useJsonFormsControl(props))
|
||||
</script>
|
||||
@@ -0,0 +1,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>
|
||||
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<button
|
||||
class="relative group p-1 rounded-md text-foreground-2 hover:text-primary hover:bg-highlight-1 transition"
|
||||
>
|
||||
<slot default></slot>
|
||||
</button>
|
||||
</template>
|
||||
@@ -0,0 +1,25 @@
|
||||
<template>
|
||||
<!-- <NuxtLink class="flex items-center" to="/"> -->
|
||||
<img
|
||||
class="block h-5 w-5"
|
||||
:class="{ 'mr-2': !minimal, grayscale: active }"
|
||||
src="~~/assets/images/speckle_logo_big.png"
|
||||
alt="Speckle"
|
||||
/>
|
||||
<!-- <div v-if="!minimal" class="text-primary h6 mt-0 font-bold leading-7 md:flex">
|
||||
Speckle
|
||||
</div> -->
|
||||
<!-- </NuxtLink> -->
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
defineProps({
|
||||
minimal: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
active: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,139 @@
|
||||
<template>
|
||||
<nav
|
||||
v-if="!hasNoModelCards"
|
||||
class="fixed top-0 h-9 flex items-center bg-foundation border-b border-outline-2 w-full transition z-20"
|
||||
>
|
||||
<div class="flex items-center transition-all justify-between w-full">
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="max-[200px]:hidden block ml-2">
|
||||
<img
|
||||
class="block h-6 w-6"
|
||||
src="~~/assets/images/speckle_logo_big.png"
|
||||
alt="Speckle"
|
||||
/>
|
||||
</div>
|
||||
<div class="relative group flex items-center">
|
||||
<FormButton
|
||||
v-tippy="'Publish objects from this file to a new Speckle model'"
|
||||
color="outline"
|
||||
size="sm"
|
||||
class="relative group px-0"
|
||||
:icon-left="ArrowUpTrayIcon"
|
||||
hide-text
|
||||
@click="showSendDialog = true"
|
||||
></FormButton>
|
||||
</div>
|
||||
<div class="relative group flex items-center">
|
||||
<FormButton
|
||||
v-if="app.$receiveBinding"
|
||||
v-tippy="'Load a model from Speckle into this file'"
|
||||
color="outline"
|
||||
size="sm"
|
||||
class="relative group px-0"
|
||||
:icon-left="ArrowDownTrayIcon"
|
||||
hide-text
|
||||
@click="showReceiveDialog = true"
|
||||
></FormButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between items-center pr-1">
|
||||
<!-- <FormButton
|
||||
v-if="!hostAppStore.isConnectorUpToDate"
|
||||
v-tippy="hostAppStore.latestAvailableVersion?.Number.replace('+0', '')"
|
||||
:icon-right="ArrowUpCircleIcon"
|
||||
size="sm"
|
||||
color="subtle"
|
||||
class="flex min-w-0 transition text-primary py-1 mr-1"
|
||||
@click.stop="hostAppStore.downloadLatestVersion()"
|
||||
>
|
||||
<span class="">Update</span>
|
||||
</FormButton> -->
|
||||
|
||||
<div
|
||||
class="text-[8px] text-foreground-disabled max-[150px]:hidden"
|
||||
: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://docs.speckle.systems/connectors/${hostAppStore.hostAppName}?utm=dui`
|
||||
)
|
||||
"
|
||||
>
|
||||
<QuestionMarkCircleIcon
|
||||
class="w-4 text-foreground-disabled group-hover:text-foreground-2"
|
||||
/>
|
||||
</HeaderButton>
|
||||
<HeaderButton
|
||||
v-if="hostAppStore.isDistributedBySpeckle"
|
||||
v-tippy="'Send us feedback'"
|
||||
@click="openFeedbackDialog()"
|
||||
>
|
||||
<ChatBubbleLeftIcon
|
||||
class="w-4 text-foreground-disabled group-hover:text-foreground-2"
|
||||
/>
|
||||
</HeaderButton>
|
||||
<HeaderUserMenu />
|
||||
</div>
|
||||
</div>
|
||||
<FeedbackDialog v-model:open="showFeedbackDialog" />
|
||||
<SendWizard v-model:open="showSendDialog" @close="showSendDialog = false" />
|
||||
<ReceiveWizard
|
||||
v-model:open="showReceiveDialog"
|
||||
@close="showReceiveDialog = false"
|
||||
/>
|
||||
</nav>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ArrowUpTrayIcon,
|
||||
ArrowDownTrayIcon,
|
||||
QuestionMarkCircleIcon,
|
||||
ChatBubbleLeftIcon
|
||||
} from '@heroicons/vue/24/solid'
|
||||
|
||||
import { useHostAppStore } from '~/store/hostApp'
|
||||
const app = useNuxtApp()
|
||||
const hostAppStore = useHostAppStore()
|
||||
const hasNoModelCards = computed(() => hostAppStore.projectModelGroups.length === 0)
|
||||
const showFeedbackDialog = ref<boolean>(false)
|
||||
const showSendDialog = ref<boolean>(false)
|
||||
const showReceiveDialog = ref<boolean>(false)
|
||||
|
||||
app.$baseBinding?.on('documentChanged', () => {
|
||||
showSendDialog.value = false
|
||||
showReceiveDialog.value = false
|
||||
})
|
||||
|
||||
const { $intercom } = useNuxtApp()
|
||||
|
||||
const openFeedbackDialog = () => {
|
||||
if (
|
||||
(hostAppStore.hostAppName?.toLowerCase() === 'revit' &&
|
||||
hostAppStore.hostAppVersion?.includes('2022')) ||
|
||||
!hostAppStore.isDistributedBySpeckle
|
||||
) {
|
||||
showFeedbackDialog.value = true
|
||||
} else {
|
||||
$intercom.show()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,46 @@
|
||||
<template>
|
||||
<nav
|
||||
v-if="!hasNoModelCards"
|
||||
class="fixed top-0 h-10 bg-foundation max-w-full w-full shadow hover:shadow-md transition z-20"
|
||||
>
|
||||
<div class="px-2 select-none">
|
||||
<div class="flex items-center h-10 transition-all justify-between">
|
||||
<div class="flex items-center">
|
||||
<HeaderLogoBlock :active="false" minimal class="mr-0" />
|
||||
<!-- <div class="ml-2">Speckle</div> -->
|
||||
<!-- <div
|
||||
title="3.0 is coming!"
|
||||
class="ml-1 text-tiny bg-primary rounded-full px-2 py-[2px] text-foreground-on-primary transition hover:scale-110"
|
||||
>
|
||||
beta
|
||||
</div> -->
|
||||
<div class="flex flex-shrink-0 items-center -ml-2 md:ml-0">
|
||||
<PortalTarget name="navigation"></PortalTarget>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between items-center">
|
||||
<FormButton
|
||||
v-if="!hostAppStore.isConnectorUpToDate"
|
||||
v-tippy="hostAppStore.latestAvailableVersion?.Number.replace('+0', '')"
|
||||
:icon-right="ArrowUpCircleIcon"
|
||||
size="sm"
|
||||
color="subtle"
|
||||
class="flex min-w-0 transition text-primary py-1 mr-1"
|
||||
@click.stop="hostAppStore.downloadLatestVersion()"
|
||||
>
|
||||
<span class="">Update</span>
|
||||
</FormButton>
|
||||
<HeaderUserMenu />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ArrowUpCircleIcon } from '@heroicons/vue/24/outline'
|
||||
import { useHostAppStore } from '~/store/hostApp'
|
||||
|
||||
const hostAppStore = useHostAppStore()
|
||||
const hasNoModelCards = computed(() => hostAppStore.projectModelGroups.length === 0)
|
||||
</script>
|
||||
@@ -0,0 +1,33 @@
|
||||
<template>
|
||||
<div class="transition text-foreground hover:text-primary-focus">
|
||||
<NuxtLink
|
||||
:to="to"
|
||||
class="flex items-center text-sm"
|
||||
active-class="text-primary font-bold"
|
||||
>
|
||||
<div v-if="separator">
|
||||
<ChevronRightIcon class="flex w-4 h-4 mt-[3px] mx-0 md:mx-1" />
|
||||
</div>
|
||||
<div class="max-w-[120px] md:max-w-[200px] lg:max-w-[300px] truncate">
|
||||
{{ name || to }}
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ChevronRightIcon } from '@heroicons/vue/20/solid'
|
||||
defineProps({
|
||||
separator: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
to: {
|
||||
type: String,
|
||||
default: '/'
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,178 @@
|
||||
<template>
|
||||
<div>
|
||||
<AccountsMenu v-model:open="showAccountsDialog" just-dialog />
|
||||
<Menu as="div" class="flex items-center z-100">
|
||||
<MenuButton v-slot="{ open }">
|
||||
<span class="sr-only">Open user menu</span>
|
||||
<FormButton
|
||||
color="subtle"
|
||||
size="sm"
|
||||
:icon-left="!open ? Bars3Icon : XMarkIcon"
|
||||
hide-text
|
||||
/>
|
||||
<!-- <HeaderButton>
|
||||
<Bars3Icon v-if="!open" class="w-4" />
|
||||
<XMarkIcon v-else class="w-4" />
|
||||
</HeaderButton> -->
|
||||
</MenuButton>
|
||||
<Transition
|
||||
enter-active-class="transition ease-out duration-200"
|
||||
enter-from-class="transform opacity-0 scale-95"
|
||||
enter-to-class="transform opacity-100 scale-100"
|
||||
leave-active-class="transition ease-in duration-75"
|
||||
leave-from-class="transform opacity-100 scale-100"
|
||||
leave-to-class="transform opacity-0 scale-95"
|
||||
>
|
||||
<MenuItems
|
||||
class="absolute right-1 top-8 origin-top-right bg-foundation outline outline-1 outline-outline-5 rounded-md shadow-lg overflow-hidden"
|
||||
>
|
||||
<MenuItem v-slot="{ active }" @click="toggleTheme">
|
||||
<div
|
||||
:class="[
|
||||
active ? 'bg-highlight-1' : '',
|
||||
'my-1 text-body-2xs flex px-2 py-1 text-foreground cursor-pointer transition mx-1 rounded'
|
||||
]"
|
||||
>
|
||||
{{ isDarkTheme ? 'Light theme' : 'Dark theme' }}
|
||||
</div>
|
||||
</MenuItem>
|
||||
|
||||
<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="
|
||||
(e) => {
|
||||
showAccountsDialog = true
|
||||
e.preventDefault()
|
||||
}
|
||||
"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
active ? 'bg-highlight-1' : '',
|
||||
'my-1 text-body-2xs flex px-2 py-1 text-foreground cursor-pointer transition mx-1 rounded'
|
||||
]"
|
||||
>
|
||||
Manage accounts
|
||||
</div>
|
||||
</MenuItem>
|
||||
</div>
|
||||
<div class="border-t border-outline-3 mt-1">
|
||||
<MenuItem
|
||||
v-slot="{ active }"
|
||||
@click="$openUrl(`https://www.speckle.systems?utm=dui`)"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
active ? 'bg-highlight-1' : '',
|
||||
'my-1 text-body-2xs flex px-2 py-1 text-foreground cursor-pointer transition mx-1 rounded'
|
||||
]"
|
||||
>
|
||||
About Speckle
|
||||
</div>
|
||||
</MenuItem>
|
||||
</div>
|
||||
<div
|
||||
v-if="hasConfigBindings && isDevMode"
|
||||
class="mb-2 border-t border-outline-3"
|
||||
>
|
||||
<MenuItem v-slot="{ active }" @click="$showDevTools">
|
||||
<div
|
||||
:class="[
|
||||
active ? 'bg-highlight-1' : '',
|
||||
'my-1 text-body-3xs flex px-2 py-1 text-foreground-2 cursor-pointer transition mx-1 rounded'
|
||||
]"
|
||||
>
|
||||
Open Dev Tools
|
||||
</div>
|
||||
</MenuItem>
|
||||
|
||||
<MenuItem v-slot="{ active }">
|
||||
<NuxtLink
|
||||
to="/test"
|
||||
:class="[
|
||||
active ? 'bg-highlight-1' : '',
|
||||
'text-body-3xs flex px-2 py-1 text-foreground-2 cursor-pointer transition mx-1 rounded'
|
||||
]"
|
||||
>
|
||||
Test Page
|
||||
</NuxtLink>
|
||||
</MenuItem>
|
||||
</div>
|
||||
</MenuItems>
|
||||
</Transition>
|
||||
</Menu>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { XMarkIcon, Bars3Icon } from '@heroicons/vue/20/solid'
|
||||
import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/vue'
|
||||
import { useConfigStore } from '~/store/config'
|
||||
import { useHostAppStore } from '~/store/hostApp'
|
||||
|
||||
const app = useNuxtApp()
|
||||
|
||||
const uiConfigStore = useConfigStore()
|
||||
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)
|
||||
</script>
|
||||
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<div>
|
||||
<Menu as="div" class="ml-1 flex items-center z-100">
|
||||
<MenuButton v-slot="{ open }">
|
||||
<span class="sr-only">Open user menu</span>
|
||||
<button
|
||||
class="rounded-full transition hover:bg-primary hover:text-foreground-on-primary p-1"
|
||||
>
|
||||
<Bars3Icon v-if="!open" class="w-4" />
|
||||
<XMarkIcon v-else class="w-4" />
|
||||
</button>
|
||||
</MenuButton>
|
||||
<Transition
|
||||
enter-active-class="transition ease-out duration-200"
|
||||
enter-from-class="transform opacity-0 scale-95"
|
||||
enter-to-class="transform opacity-100 scale-100"
|
||||
leave-active-class="transition ease-in duration-75"
|
||||
leave-from-class="transform opacity-100 scale-100"
|
||||
leave-to-class="transform opacity-0 scale-95"
|
||||
>
|
||||
<MenuItems
|
||||
class="absolute right-1 top-11 origin-top-right bg-foundation outline outline-1 outline-primary-muted rounded-md shadow-lg overflow-hidden"
|
||||
>
|
||||
<MenuItem v-slot="{ active }" @click="showFeedbackDialog = true">
|
||||
<div
|
||||
:class="[
|
||||
active ? 'bg-highlight-1' : '',
|
||||
'my-1 text-body-xs flex px-2 py-1 text-foreground cursor-pointer transition mx-1 rounded'
|
||||
]"
|
||||
>
|
||||
Feedback
|
||||
</div>
|
||||
</MenuItem>
|
||||
<MenuItem v-slot="{ active }" @click="toggleTheme">
|
||||
<div
|
||||
:class="[
|
||||
active ? 'bg-highlight-1' : '',
|
||||
'my-1 text-body-xs flex px-2 py-1 text-foreground cursor-pointer transition mx-1 rounded'
|
||||
]"
|
||||
>
|
||||
{{ isDarkTheme ? 'Light mode' : 'Dark mode' }}
|
||||
</div>
|
||||
</MenuItem>
|
||||
<div v-if="hasConfigBindings && isDevMode">
|
||||
<div class="border-t border-outline-3 py-1 mt-1">
|
||||
<MenuItem v-slot="{ active }" @click="$showDevTools">
|
||||
<div
|
||||
:class="[
|
||||
active ? 'bg-highlight-1' : '',
|
||||
'my-1 text-body-xs flex px-2 py-1 text-foreground cursor-pointer transition mx-1 rounded'
|
||||
]"
|
||||
>
|
||||
Open Dev Tools
|
||||
</div>
|
||||
</MenuItem>
|
||||
</div>
|
||||
<MenuItem v-slot="{ active }">
|
||||
<NuxtLink
|
||||
to="/test"
|
||||
:class="[
|
||||
active ? 'bg-highlight-1' : '',
|
||||
'text-body-xs flex px-2 py-1 text-foreground cursor-pointer transition mx-1 rounded'
|
||||
]"
|
||||
>
|
||||
Test Page
|
||||
</NuxtLink>
|
||||
</MenuItem>
|
||||
</div>
|
||||
<div class="border-t border-outline-3 py-1 mt-1">
|
||||
<MenuItem>
|
||||
<div class="px-3 pt-1 text-tiny text-foreground-2">
|
||||
Version {{ hostApp.connectorVersion }}
|
||||
</div>
|
||||
</MenuItem>
|
||||
</div>
|
||||
</MenuItems>
|
||||
</Transition>
|
||||
</Menu>
|
||||
<FeedbackDialog v-model:open="showFeedbackDialog" />
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { XMarkIcon, Bars3Icon } from '@heroicons/vue/20/solid'
|
||||
import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/vue'
|
||||
import { useConfigStore } from '~/store/config'
|
||||
import { useHostAppStore } from '~/store/hostApp'
|
||||
|
||||
const uiConfigStore = useConfigStore()
|
||||
const { isDarkTheme, hasConfigBindings, isDevMode } = storeToRefs(uiConfigStore)
|
||||
const { toggleTheme } = uiConfigStore
|
||||
const hostApp = useHostAppStore()
|
||||
|
||||
const { $showDevTools } = useNuxtApp()
|
||||
|
||||
const showFeedbackDialog = ref<boolean>(false)
|
||||
</script>
|
||||
@@ -0,0 +1,72 @@
|
||||
<template>
|
||||
<LayoutPanel fancy-glow class="transition pointer-events-auto w-full">
|
||||
<h1
|
||||
class="h4 w-full bg-red-400 text-center font-bold bg-gradient-to-r from-blue-500 via-blue-400 to-blue-600 inline-block py-1 text-transparent bg-clip-text"
|
||||
>
|
||||
Welcome to Speckle
|
||||
</h1>
|
||||
<div v-if="isDesktopServiceAvailable || 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">
|
||||
To sign in and start using Speckle, you'll need the Desktop Service running.
|
||||
This lightweight background service handles secure authentication.
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<FormButton full-width @click="$openUrl('https://releases.speckle.systems')">
|
||||
Download Desktop Service
|
||||
</FormButton>
|
||||
<div class="text-center">
|
||||
<div class="text-foreground-2 text-xs mb-2">Already installed?</div>
|
||||
<FormButton
|
||||
size="sm"
|
||||
full-width
|
||||
text
|
||||
link
|
||||
@click="accountStore.refreshAccounts()"
|
||||
>
|
||||
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 () => {
|
||||
isDesktopServiceAvailable.value = await pingDesktopService()
|
||||
})
|
||||
</script>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,162 @@
|
||||
<template>
|
||||
<div>
|
||||
<FormButton
|
||||
color="subtle"
|
||||
:icon-left="Bars3Icon"
|
||||
hide-text
|
||||
size="sm"
|
||||
:disabled="!!props.modelCard.progress"
|
||||
@click.stop="openModelCardActionsDialog = true"
|
||||
/>
|
||||
<CommonDialog
|
||||
v-model:open="openModelCardActionsDialog"
|
||||
:title="`${modelName} actions`"
|
||||
fullscreen="none"
|
||||
>
|
||||
<SendSettingsDialog
|
||||
v-if="hasSettings"
|
||||
:model-card-id="props.modelCard.modelCardId"
|
||||
:settings="props.modelCard.settings"
|
||||
>
|
||||
<template #activator="{ toggle }">
|
||||
<button class="action action-normal" @click="toggle()">
|
||||
<div class="truncate max-[275px]:text-xs">Settings</div>
|
||||
<div><Cog6ToothIcon class="w-5 h-5" /></div>
|
||||
</button>
|
||||
</template>
|
||||
</SendSettingsDialog>
|
||||
<ReportBase v-if="modelCard.report" :report="modelCard.report">
|
||||
<template #activator="{ toggle }">
|
||||
<button class="action action-normal" @click="toggle()">
|
||||
<div class="truncate max-[275px]:text-xs">View Report</div>
|
||||
<div><InformationCircleIcon class="w-5 h-5" /></div>
|
||||
</button>
|
||||
</template>
|
||||
</ReportBase>
|
||||
<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"
|
||||
:class="`action ${item.danger ? 'action-danger' : 'action-normal'}`"
|
||||
@click="item.action"
|
||||
>
|
||||
<div class="truncate max-[275px]:text-xs">{{ item.name }}</div>
|
||||
<div>
|
||||
<Component :is="item.icon" class="w-5 h-5" />
|
||||
</div>
|
||||
</button>
|
||||
</CommonDialog>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
InformationCircleIcon,
|
||||
Cog6ToothIcon,
|
||||
ArrowTopRightOnSquareIcon,
|
||||
ClockIcon,
|
||||
ArchiveBoxXMarkIcon,
|
||||
Bars3Icon
|
||||
} from '@heroicons/vue/24/outline'
|
||||
import type { IModelCard } from '~/lib/models/card'
|
||||
import { useMixpanel } from '~/lib/core/composables/mixpanel'
|
||||
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()
|
||||
|
||||
const openModelCardActionsDialog = ref(false)
|
||||
const emit = defineEmits(['view', 'view-versions', 'copy-model-link', 'remove'])
|
||||
|
||||
const props = defineProps<{
|
||||
modelName: string
|
||||
modelCard: IModelCard
|
||||
}>()
|
||||
|
||||
const hasSettings = computed(() => {
|
||||
return !!props.modelCard.settings
|
||||
})
|
||||
|
||||
const app = useNuxtApp()
|
||||
app.$baseBinding?.on('documentChanged', () => {
|
||||
openModelCardActionsDialog.value = false
|
||||
})
|
||||
|
||||
const items = [
|
||||
{
|
||||
name: 'View 3D model in browser',
|
||||
icon: ArrowTopRightOnSquareIcon,
|
||||
action: () => {
|
||||
void trackEvent('DUI3 Action', {
|
||||
name: 'Version View',
|
||||
source: 'model actions dialog'
|
||||
})
|
||||
emit('view')
|
||||
openModelCardActionsDialog.value = false
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'View model versions',
|
||||
icon: ClockIcon,
|
||||
action: () => {
|
||||
void trackEvent('DUI3 Action', {
|
||||
name: 'Model History View',
|
||||
source: 'model actions dialog'
|
||||
})
|
||||
emit('view-versions')
|
||||
openModelCardActionsDialog.value = false
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'Remove from file',
|
||||
danger: true,
|
||||
icon: ArchiveBoxXMarkIcon,
|
||||
action: () => {
|
||||
// NOTE: Mixpanel event tracking is in host app store
|
||||
emit('remove')
|
||||
openModelCardActionsDialog.value = false
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
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 {
|
||||
@apply text-body-sm flex items-center justify-between w-full rounded-lg text-left space-x-2 transition p-2 select-none hover:cursor-pointer min-w-0;
|
||||
}
|
||||
|
||||
.action-normal {
|
||||
@apply hover:text-primary;
|
||||
}
|
||||
|
||||
.action-danger {
|
||||
@apply text-danger hover:bg-rose-500/10;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,588 @@
|
||||
<template>
|
||||
<!-- eslint-disable-next-line vuejs-accessibility/click-events-have-key-events vuejs-accessibility/no-static-element-interactions -->
|
||||
<div
|
||||
:class="`rounded-md hover:shadow-md shadow transition overflow-hidden ${cardBgColor} border-foundation hover:border-outline-2 border-2 group`"
|
||||
>
|
||||
<div v-if="modelData" class="relative px-1 py-1">
|
||||
<div class="relative flex items-center space-x-2 min-w-0">
|
||||
<div
|
||||
v-tippy="buttonTooltip"
|
||||
class="text-foreground-2 mt-[2px] flex items-center -space-x-2 relative"
|
||||
>
|
||||
<!-- CTA button -->
|
||||
<FormButton
|
||||
color="outline"
|
||||
:icon-left="
|
||||
modelCard.progress
|
||||
? XCircleIcon
|
||||
: isSender
|
||||
? ArrowUpTrayIcon
|
||||
: ArrowDownTrayIcon
|
||||
"
|
||||
hide-text
|
||||
class=""
|
||||
:disabled="
|
||||
(!canEdit || isSettingsMissing || ctaDisabled) && !modelCard.progress
|
||||
"
|
||||
@click.stop="$emit('manual-publish-or-load')"
|
||||
></FormButton>
|
||||
</div>
|
||||
|
||||
<div class="grow min-w-0 max-[160px]:hidden">
|
||||
<div class="text-body-3xs text-foreground-2 truncate">
|
||||
{{ folderPath }}
|
||||
</div>
|
||||
<div
|
||||
class="text-heading-sm truncate text-foreground dark:text-foreground-2 select-none leading-4"
|
||||
>
|
||||
{{ modelData.displayName }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TODO: uncomment if needed, this is a hack to hide this from two apps where we don't support it -->
|
||||
<div class="flex items-center justify-end grow">
|
||||
<AutomateResultDialog
|
||||
v-if="isSender && summary"
|
||||
:model-card="modelCard"
|
||||
:automation-runs="automationRuns"
|
||||
:project-id="modelCard.projectId"
|
||||
:model-id="modelCard.modelId"
|
||||
>
|
||||
<template #activator="{ toggle }">
|
||||
<button
|
||||
v-tippy="summary.summary.value.longSummary"
|
||||
class="action action-normal p-1 hover:bg-highlight-2 rounded-md transition"
|
||||
@click.stop="toggle()"
|
||||
>
|
||||
<AutomateRunsTriggerStatusIcon
|
||||
:summary="summary.summary.value"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</button>
|
||||
</template>
|
||||
</AutomateResultDialog>
|
||||
<!-- 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'"
|
||||
color="subtle"
|
||||
:icon-left="CursorArrowRaysIcon"
|
||||
hide-text
|
||||
size="sm"
|
||||
@click="highlightModel"
|
||||
/>
|
||||
<ModelActionsDialog
|
||||
:model-card="modelCard"
|
||||
:model-name="modelData.displayName"
|
||||
@view="viewModel"
|
||||
@view-versions="viewModelVersions"
|
||||
@remove="removeModel"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="max-[160px]:flex w-full hidden px-1 mt-2 h-[40px] items-center">
|
||||
<div class="grow min-w-0">
|
||||
<div class="text-body-3xs text-foreground-2 truncate">
|
||||
{{ folderPath }}
|
||||
</div>
|
||||
<div
|
||||
class="text-heading-sm truncate text-foreground dark:text-foreground-2 select-none leading-4"
|
||||
>
|
||||
{{ modelData.displayName }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="loading" class="px-1 py-1">
|
||||
Fetching model data...
|
||||
<CommonLoadingBar loading />
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="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" -->
|
||||
<div v-if="canEdit" class="px-1">
|
||||
<slot></slot>
|
||||
</div>
|
||||
|
||||
<!-- Progress state -->
|
||||
<div
|
||||
v-if="modelCard.progress"
|
||||
:class="`${
|
||||
modelCard.progress ? 'h-10 opacity-100' : 'h-0 opacity-0 py-0'
|
||||
} overflow-hidden bg-highlight-2`"
|
||||
>
|
||||
<CommonLoadingProgressBar
|
||||
:loading="!!modelCard.progress"
|
||||
:progress="modelCard.progress ? modelCard.progress.progress : undefined"
|
||||
/>
|
||||
<div class="text-body-3xs px-2 h-full flex items-center text-foreground">
|
||||
{{ modelCard.progress?.status || '...' }}
|
||||
{{
|
||||
modelCard.progress?.progress
|
||||
? ((props.modelCard.progress?.progress as number) * 100).toFixed() + '%'
|
||||
: ''
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="canEdit">
|
||||
<!-- Card States: Expiry, errors, new version created, etc. -->
|
||||
<slot name="states"></slot>
|
||||
<div class="relative">
|
||||
<!-- Swanky web app integration: show users who is viewing the model -->
|
||||
<Transition name="bounce">
|
||||
<div
|
||||
v-if="currentlyViewingUsers.length !== 0"
|
||||
class="text-body-3xs text-foreground-2 py-1 px-1 bg-highlight-1 border-t border-t-highlight-3 flex space-x-1 items-center justify-between"
|
||||
>
|
||||
<div class="flex items-center space-x-1">
|
||||
<UserAvatarGroup size="xs" :users="currentlyViewingUsers" />
|
||||
<span class="line-clamp-1">
|
||||
{{ currentlyViewingUsers.length === 1 ? 'is' : 'are' }} now viewing
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<FormButton size="sm" color="outline" full-width @click="viewModel()">
|
||||
Join
|
||||
</FormButton>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
<!-- Swanky web app integration: show comment created notification -->
|
||||
<Transition name="bounce">
|
||||
<div v-if="latestCommentNotification">
|
||||
<div class="h-[1px] bg-blue-500/20 disappearing-bar"></div>
|
||||
<div
|
||||
class="text-body-3xs text-foreground-2 py-1 px-1 bg-highlight-1 flex space-x-1 items-center justify-between"
|
||||
>
|
||||
<div
|
||||
v-tippy="
|
||||
`${latestCommentNotification.comment?.author?.name} just left a
|
||||
comment.`
|
||||
"
|
||||
class="flex items-center space-x-1"
|
||||
>
|
||||
<UserAvatarGroup
|
||||
size="xs"
|
||||
:users="[latestCommentNotification.comment?.author as AvatarUserWithId]"
|
||||
/>
|
||||
<span class="line-clamp-1">
|
||||
{{ latestCommentNotification.comment?.author?.name }} just left a
|
||||
comment on the issue.
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<FormButton size="sm" color="outline" full-width @click="viewComment()">
|
||||
Reply
|
||||
</FormButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<CommonModelNotification
|
||||
:notification="{
|
||||
modelCardId: modelCard.modelCardId,
|
||||
dismissible: false,
|
||||
level: 'danger',
|
||||
text: disabledMessage
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { useQuery, useSubscription } from '@vue/apollo-composable'
|
||||
import {
|
||||
automateRunsSubscription,
|
||||
automateStatusQuery,
|
||||
modelCommentCreatedSubscription,
|
||||
modelDetailsQuery,
|
||||
modelViewingSubscription
|
||||
} from '~/lib/graphql/mutationsAndQueries'
|
||||
import { ArrowUpTrayIcon, ArrowDownTrayIcon } from '@heroicons/vue/24/solid'
|
||||
import type { ProjectModelGroup } from '~~/store/hostApp'
|
||||
import { useHostAppStore } from '~~/store/hostApp'
|
||||
import type { IModelCard } from '~~/lib/models/card'
|
||||
import { useAccountStore } from '~/store/accounts'
|
||||
import type { IReceiverModelCard } from '~/lib/models/card/receiver'
|
||||
import { useMixpanel } from '~/lib/core/composables/mixpanel'
|
||||
import { useIntervalFn, useTimeoutFn } from '@vueuse/core'
|
||||
import type { ProjectCommentsUpdatedMessage } from '~/lib/common/generated/gql/graphql'
|
||||
import { useFunctionRunsStatusSummary } from '~/lib/automate/runStatus'
|
||||
import { CursorArrowRaysIcon, XCircleIcon, 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 = withDefaults(
|
||||
defineProps<{
|
||||
modelCard: IModelCard
|
||||
project: ProjectModelGroup
|
||||
canEdit: boolean
|
||||
ctaDisabled?: boolean
|
||||
ctaDisabledMessage?: string
|
||||
}>(),
|
||||
{
|
||||
ctaDisabled: false
|
||||
}
|
||||
)
|
||||
|
||||
defineEmits<{
|
||||
(e: 'manual-publish-or-load'): void
|
||||
}>()
|
||||
|
||||
const isSender = computed(() => {
|
||||
return props.modelCard.typeDiscriminator.includes('SenderModelCard')
|
||||
})
|
||||
|
||||
const buttonTooltip = computed(() => {
|
||||
if (props.modelCard.progress) return 'Cancel'
|
||||
if (props.ctaDisabled) return props.ctaDisabledMessage
|
||||
return isSender.value ? 'Publish model' : 'Load selected version'
|
||||
})
|
||||
|
||||
const projectAccount = computed(() =>
|
||||
accStore.accountWithFallback(props.project.accountId, props.project.serverUrl)
|
||||
)
|
||||
|
||||
const disabledMessage = computed(() =>
|
||||
isSender.value
|
||||
? 'Publish is not permitted by your role on this project.'
|
||||
: 'Load is not permitted by your role on this project.'
|
||||
)
|
||||
|
||||
const clientId = projectAccount.value.accountInfo.id
|
||||
|
||||
const { result: modelResult, loading } = useQuery(
|
||||
modelDetailsQuery,
|
||||
() => ({
|
||||
projectId: props.project.projectId,
|
||||
modelId: props.modelCard.modelId
|
||||
}),
|
||||
() => ({ clientId })
|
||||
)
|
||||
|
||||
const modelData = computed(() => modelResult.value?.project.model)
|
||||
const queryData = computed(() => modelResult.value?.project)
|
||||
|
||||
const folderPath = computed(() => {
|
||||
const splitName = modelData.value?.name.split('/')
|
||||
if (!splitName || splitName.length === 1) return ' '
|
||||
const withoutLast = splitName.slice(0, -1)
|
||||
return withoutLast.join('/')
|
||||
})
|
||||
|
||||
const { result: automateResult, refetch } = useQuery(
|
||||
automateStatusQuery,
|
||||
() => ({
|
||||
projectId: props.project.projectId,
|
||||
modelId: props.modelCard.modelId
|
||||
}),
|
||||
() => ({ clientId })
|
||||
)
|
||||
|
||||
const automationRuns = computed(
|
||||
() => automateResult.value?.project.model.automationsStatus?.automationRuns
|
||||
)
|
||||
|
||||
const { onResult: onAutomateRunResult } = useSubscription(
|
||||
automateRunsSubscription,
|
||||
() => ({ projectId: props.project.projectId }),
|
||||
() => ({ clientId })
|
||||
)
|
||||
|
||||
onAutomateRunResult(() => {
|
||||
refetch()
|
||||
})
|
||||
|
||||
const summary = computed(() => {
|
||||
if (!automationRuns.value) {
|
||||
return undefined
|
||||
}
|
||||
return useFunctionRunsStatusSummary({
|
||||
runs: automationRuns.value
|
||||
})
|
||||
})
|
||||
|
||||
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 = () => {
|
||||
if (!modelData.value) return
|
||||
|
||||
// Some host apps aren't friendly enough to handle highlighting models when some other ops are running.
|
||||
if (props.modelCard.progress) return
|
||||
|
||||
// Do not highlight if baked object ids not set yet. Otherwise we rely on connector to handle it, don't if possible to handle here!
|
||||
if (!isSender.value && !(props.modelCard as IReceiverModelCard).bakedObjectIds) {
|
||||
store.setModelError({
|
||||
modelCardId: props.modelCard.modelCardId,
|
||||
error: 'No objects found to highlight.'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
app.$baseBinding.highlightModel(props.modelCard.modelCardId)
|
||||
trackEvent('DUI3 Action', { name: 'Highlight Model' }, props.modelCard.accountId)
|
||||
}
|
||||
|
||||
const 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)
|
||||
app.$baseBinding.openUrl(
|
||||
`${projectAccount.value.accountInfo.serverInfo.url}/projects/${props.modelCard?.projectId}/models/${props.modelCard.modelId}`
|
||||
)
|
||||
}
|
||||
|
||||
const viewModelVersions = () => {
|
||||
app.$baseBinding.openUrl(
|
||||
`${projectAccount.value.accountInfo.serverInfo.url}/projects/${props.modelCard?.projectId}/models/${props.modelCard.modelId}/versions`
|
||||
)
|
||||
}
|
||||
|
||||
const removeModel = () => {
|
||||
store.removeModel(props.modelCard)
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
viewModel,
|
||||
modelData,
|
||||
queryData
|
||||
})
|
||||
|
||||
const cardBgColor = computed(() => {
|
||||
// if (props.modelCard.error || !props.canEdit)
|
||||
// return 'bg-red-500/10 hover:bg-red-500/20'
|
||||
// if (props.modelCard.expired) return 'bg-blue-500/10 hover:bg-blue-500/20'
|
||||
// if (
|
||||
// (props.modelCard as ISenderModelCard).latestCreatedVersionId ||
|
||||
// (props.modelCard as IReceiverModelCard).displayReceiveComplete === true
|
||||
// ) {
|
||||
// if (failRate.value > 80) {
|
||||
// return 'bg-orange-500/10'
|
||||
// }
|
||||
// return 'bg-blue-500/10 hover:bg-blue-500/20'
|
||||
// }
|
||||
// if (
|
||||
// (props.modelCard as IReceiverModelCard).selectedVersionId !==
|
||||
// (props.modelCard as IReceiverModelCard).latestVersionId &&
|
||||
// !(props.modelCard as IReceiverModelCard).hasDismissedUpdateWarning
|
||||
// )
|
||||
// return 'bg-orange-500/10'
|
||||
return 'bg-foundation xxxhover:bg-highlight-1'
|
||||
})
|
||||
|
||||
const { onResult: onModelViewingResult } = useSubscription(
|
||||
modelViewingSubscription,
|
||||
() => ({
|
||||
target: {
|
||||
projectId: props.modelCard.projectId,
|
||||
resourceIdString: props.modelCard.modelId
|
||||
}
|
||||
}),
|
||||
() => ({ clientId })
|
||||
)
|
||||
|
||||
const currentlyViewingUsersMap = ref<
|
||||
Record<string, { name: string; id: string; avatar?: string | null; lastSeen: number }>
|
||||
>({})
|
||||
|
||||
const currentlyViewingUsers = computed(() =>
|
||||
Object.values(currentlyViewingUsersMap.value)
|
||||
)
|
||||
|
||||
onModelViewingResult((res) => {
|
||||
const user = res.data?.viewerUserActivityBroadcasted.user
|
||||
if (res.data?.viewerUserActivityBroadcasted.status === 'VIEWING' && user) {
|
||||
// add user to currently viewing people
|
||||
currentlyViewingUsersMap.value[user.id] = { ...user, lastSeen: Date.now() }
|
||||
} else if (
|
||||
res.data?.viewerUserActivityBroadcasted.status === 'DISCONNECTED' &&
|
||||
user
|
||||
) {
|
||||
// remove user from currently viewing people
|
||||
delete currentlyViewingUsersMap.value[user.id]
|
||||
}
|
||||
})
|
||||
|
||||
// NOTE: FE does not send a disconnect event on page unload, so we need to do our own cleanup
|
||||
useIntervalFn(() => {
|
||||
const now = Date.now()
|
||||
for (const key in currentlyViewingUsersMap.value) {
|
||||
const { lastSeen } = currentlyViewingUsersMap.value[key]
|
||||
if (now - lastSeen > 5_000) delete currentlyViewingUsersMap.value[key]
|
||||
}
|
||||
}, 1000)
|
||||
|
||||
const { onResult: onCommentResult } = useSubscription(
|
||||
modelCommentCreatedSubscription,
|
||||
() => ({
|
||||
target: {
|
||||
projectId: props.modelCard.projectId,
|
||||
resourceIdString: props.modelCard.modelId
|
||||
}
|
||||
}),
|
||||
() => ({ clientId })
|
||||
)
|
||||
|
||||
const latestCommentNotification = ref<ProjectCommentsUpdatedMessage>()
|
||||
|
||||
const { start: startCommentClearTimeout, stop: stopCommentClearTimeout } = useTimeoutFn(
|
||||
() => {
|
||||
latestCommentNotification.value = undefined
|
||||
stopCommentClearTimeout()
|
||||
},
|
||||
30_000
|
||||
)
|
||||
|
||||
onCommentResult((res) => {
|
||||
latestCommentNotification.value = res.data
|
||||
?.projectCommentsUpdated as ProjectCommentsUpdatedMessage
|
||||
startCommentClearTimeout()
|
||||
refetchIssues()
|
||||
})
|
||||
|
||||
const viewComment = () => {
|
||||
trackEvent('DUI3 Action', { name: 'Comment View' }, props.modelCard.accountId)
|
||||
if (!latestCommentNotification.value?.comment) return
|
||||
|
||||
const commentId =
|
||||
latestCommentNotification.value?.comment?.parent?.id ||
|
||||
latestCommentNotification.value?.comment.id
|
||||
|
||||
app.$baseBinding.openUrl(
|
||||
`${projectAccount.value.accountInfo.serverInfo.url}/projects/${props.modelCard?.projectId}/models/${props.modelCard.modelId}#threadId=${commentId}`
|
||||
)
|
||||
}
|
||||
</script>
|
||||
<style scoped lang="css">
|
||||
@keyframes disappear-width {
|
||||
0% {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
100% {
|
||||
display: none;
|
||||
width: 0%;
|
||||
}
|
||||
}
|
||||
|
||||
.disappearing-bar {
|
||||
animation: disappear-width 30s;
|
||||
}
|
||||
|
||||
.bounce-enter-active {
|
||||
animation: bounce-in 0.5s;
|
||||
}
|
||||
|
||||
.bounce-leave-active {
|
||||
animation: bounce-in 0.5s reverse;
|
||||
}
|
||||
|
||||
@keyframes bounce-in {
|
||||
0% {
|
||||
transform: scale(0);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,332 @@
|
||||
<template>
|
||||
<ModelCardBase
|
||||
:model-card="modelCard"
|
||||
:project="project"
|
||||
:can-edit="canEdit"
|
||||
@manual-publish-or-load="handleMainButtonClick"
|
||||
>
|
||||
<div class="flex max-[275px]:w-full items-center space-x-2 my-2">
|
||||
<FormButton
|
||||
v-tippy="
|
||||
isExpired
|
||||
? 'A new version was pushed ' +
|
||||
latestVersionCreatedAt +
|
||||
'. Click to load a different version.'
|
||||
: 'Load a different version'
|
||||
"
|
||||
:icon-left="ClockIcon"
|
||||
size="sm"
|
||||
color="subtle"
|
||||
class="block text-foreground-2 hover:text-foreground overflow-hidden max-w-full !justify-start"
|
||||
full-width
|
||||
:disabled="!!modelCard.progress || !canEdit || isReceiveSettingsMissing"
|
||||
@click.stop="openVersionsDialog = true"
|
||||
>
|
||||
<span>
|
||||
Loaded
|
||||
<b>version</b>
|
||||
</span>
|
||||
from
|
||||
<span class="truncate">{{ createdAgo }}</span>
|
||||
</FormButton>
|
||||
</div>
|
||||
<!-- <div
|
||||
class="min-w-0 truncate text-foreground-2 -mt-1"
|
||||
:title="
|
||||
versionDetailsResult?.project.model.version.message || 'No message provided'
|
||||
"
|
||||
>
|
||||
<span class="truncate max-[275px]:truncate-no select-none text-xs">
|
||||
{{ createdAgo }}
|
||||
</span>
|
||||
</div> -->
|
||||
|
||||
<CommonDialog
|
||||
v-model:open="openVersionsDialog"
|
||||
fullscreen="none"
|
||||
title="Change loaded version"
|
||||
>
|
||||
<WizardVersionSelector
|
||||
:account-id="modelCard.accountId"
|
||||
:project-id="modelCard.projectId"
|
||||
:model-id="modelCard.modelId"
|
||||
:workspace-slug="modelCard.workspaceSlug"
|
||||
:selected-version-id="modelCard.selectedVersionId"
|
||||
:settings="modelCard.settings"
|
||||
@next="handleVersionSelection"
|
||||
@update:settings="handleUpdateSettings"
|
||||
/>
|
||||
</CommonDialog>
|
||||
<template #states>
|
||||
<CommonModelNotification
|
||||
v-if="isReceiveSettingsMissing"
|
||||
:notification="receiveSettingsMissingNotification"
|
||||
/>
|
||||
<CommonModelNotification
|
||||
v-if="expiredNotification"
|
||||
:notification="expiredNotification"
|
||||
@dismiss="
|
||||
store.patchModel(modelCard.modelCardId, {
|
||||
hasDismissedUpdateWarning: true
|
||||
})
|
||||
"
|
||||
/>
|
||||
<CommonModelNotification
|
||||
v-if="errorNotification"
|
||||
:notification="errorNotification"
|
||||
@dismiss="store.patchModel(modelCard.modelCardId, { error: undefined })"
|
||||
/>
|
||||
<CommonModelNotification
|
||||
v-if="receiveResultNotification"
|
||||
:notification="receiveResultNotification"
|
||||
@dismiss="
|
||||
store.patchModel(modelCard.modelCardId, {
|
||||
displayReceiveComplete: false
|
||||
})
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
</ModelCardBase>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import dayjs from 'dayjs'
|
||||
import { useQuery } from '@vue/apollo-composable'
|
||||
import { ClockIcon } from '@heroicons/vue/24/solid'
|
||||
import type { ModelCardNotification } from '~/lib/models/card/notification'
|
||||
import type { ProjectModelGroup } from '~/store/hostApp'
|
||||
import { useHostAppStore } from '~/store/hostApp'
|
||||
import type { IReceiverModelCard } from '~/lib/models/card/receiver'
|
||||
import { versionDetailsQuery } from '~/lib/graphql/mutationsAndQueries'
|
||||
import type { VersionListItemFragment } from '~/lib/common/generated/gql/graphql'
|
||||
import { useMixpanel } from '~/lib/core/composables/mixpanel'
|
||||
import { useInterval, watchOnce } from '@vueuse/core'
|
||||
import { useAccountStore } from '~~/store/accounts'
|
||||
import type { CardSetting } from '~/lib/models/card/setting'
|
||||
|
||||
const { trackEvent } = useMixpanel()
|
||||
const app = useNuxtApp()
|
||||
const accountStore = useAccountStore()
|
||||
|
||||
const props = defineProps<{
|
||||
modelCard: IReceiverModelCard
|
||||
project: ProjectModelGroup
|
||||
canEdit: boolean
|
||||
}>()
|
||||
|
||||
const store = useHostAppStore()
|
||||
|
||||
const openVersionsDialog = ref(false)
|
||||
|
||||
const projectAccount = computed(() =>
|
||||
accountStore.accountWithFallback(props.project.accountId, props.project.serverUrl)
|
||||
)
|
||||
|
||||
app.$baseBinding?.on('documentChanged', () => {
|
||||
openVersionsDialog.value = false
|
||||
})
|
||||
|
||||
const 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,
|
||||
latestVersion: VersionListItemFragment
|
||||
) => {
|
||||
openVersionsDialog.value = false
|
||||
void trackEvent('DUI3 Action', {
|
||||
name: 'Load Card Version Change',
|
||||
isLatestVersion: selectedVersion === latestVersion
|
||||
})
|
||||
if (props.modelCard.progress) {
|
||||
await store.receiveModelCancel(props.modelCard.modelCardId)
|
||||
}
|
||||
await store.patchModel(props.modelCard.modelCardId, {
|
||||
selectedVersionId: selectedVersion.id,
|
||||
selectedVersionSourceApp: selectedVersion.sourceApplication,
|
||||
selectedVersionUserId: selectedVersion.authorUser?.id,
|
||||
latestVersionId: latestVersion.id, // patch this dude as well, to make sure
|
||||
latestVersionSourceApp: latestVersion.sourceApplication,
|
||||
latestVersionUserId: latestVersion.authorUser?.id,
|
||||
hasSelectedOldVersion: selectedVersion.id === latestVersion.id
|
||||
})
|
||||
|
||||
await store.receiveModel(props.modelCard.modelCardId, 'VersionSelector')
|
||||
}
|
||||
|
||||
// Cancels any in progress receive OR receives latest version
|
||||
const handleMainButtonClick = async () => {
|
||||
if (props.modelCard.progress)
|
||||
return await store.receiveModelCancel(props.modelCard.modelCardId)
|
||||
await receiveCurrentVersion()
|
||||
}
|
||||
|
||||
const receiveCurrentVersion = async () => {
|
||||
await store.receiveModel(props.modelCard.modelCardId, 'ModelCardButton')
|
||||
}
|
||||
|
||||
// Cancels any in progress receive AND receives latest version
|
||||
const receiveLatestVersion = async () => {
|
||||
// Note: here we're updating the model card info, and afterwards we're hitting the receive action
|
||||
await store.patchModel(props.modelCard.modelCardId, {
|
||||
selectedVersionId: props.modelCard.latestVersionId,
|
||||
selectedVersionSourceApp: props.modelCard.latestVersionSourceApp,
|
||||
selectedVersionUserId: props.modelCard.latestVersionUserId
|
||||
})
|
||||
if (props.modelCard.progress)
|
||||
await store.receiveModelCancel(props.modelCard.modelCardId)
|
||||
await store.receiveModel(props.modelCard.modelCardId, 'UpdateNotification')
|
||||
}
|
||||
|
||||
const expiredNotification = computed(() => {
|
||||
if (!props.modelCard.latestVersionId || props.modelCard.hasDismissedUpdateWarning)
|
||||
return
|
||||
if (props.modelCard.latestVersionId === props.modelCard.selectedVersionId) return
|
||||
const notification = {} as ModelCardNotification
|
||||
notification.dismissible = true
|
||||
notification.level = 'success'
|
||||
notification.text = 'Newer version available!'
|
||||
notification.cta = {
|
||||
name: 'Update',
|
||||
action: receiveLatestVersion
|
||||
}
|
||||
return notification
|
||||
})
|
||||
|
||||
const failRate = computed(() => {
|
||||
if (!props.modelCard.report) return 0
|
||||
return (
|
||||
(props.modelCard.report.filter((r) => r.status === 4).length /
|
||||
props.modelCard.report.length) *
|
||||
100
|
||||
)
|
||||
})
|
||||
|
||||
const receiveResultNotificationText = computed(() => {
|
||||
if (failRate.value > 80) {
|
||||
return 'Model loaded. Some objects have failed to convert!'
|
||||
}
|
||||
return 'Model loaded!'
|
||||
})
|
||||
|
||||
const receiveResultNotification = computed(() => {
|
||||
if (
|
||||
!props.modelCard.bakedObjectIds ||
|
||||
props.modelCard.displayReceiveComplete !== true
|
||||
)
|
||||
return
|
||||
|
||||
const notification = {} as ModelCardNotification
|
||||
notification.dismissible = true
|
||||
notification.level = 'success'
|
||||
notification.text = receiveResultNotificationText.value
|
||||
notification.report = props.modelCard.report
|
||||
notification.cta = {
|
||||
name: 'Highlight',
|
||||
action: () => {
|
||||
app.$baseBinding.highlightModel(props.modelCard.modelCardId)
|
||||
}
|
||||
}
|
||||
return notification
|
||||
})
|
||||
|
||||
const errorNotification = computed(() => {
|
||||
if (!props.modelCard.error) return
|
||||
const notification = {} as ModelCardNotification
|
||||
notification.dismissible = true
|
||||
notification.level = 'danger'
|
||||
notification.text = props.modelCard.error.errorMessage
|
||||
notification.report = props.modelCard.report
|
||||
return notification
|
||||
})
|
||||
|
||||
const { result: versionDetailsResult, refetch } = useQuery(
|
||||
versionDetailsQuery,
|
||||
() => ({
|
||||
projectId: props.modelCard.projectId,
|
||||
modelId: props.modelCard.modelId,
|
||||
versionId: props.modelCard.selectedVersionId
|
||||
}),
|
||||
() => ({
|
||||
clientId: projectAccount.value.accountInfo.id
|
||||
})
|
||||
)
|
||||
|
||||
const createdAgoUpdater = useInterval(10_000) // refresh the created ago, and latestversion etc. every 10s
|
||||
|
||||
const createdAgo = computed(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
createdAgoUpdater.value
|
||||
return dayjs(versionDetailsResult.value?.project.model.version.createdAt).from(
|
||||
dayjs()
|
||||
)
|
||||
})
|
||||
|
||||
const latestVersionCreatedAt = computed(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
createdAgoUpdater.value
|
||||
return dayjs(props.modelCard.latestVersionCreatedAt).from(dayjs())
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
refetch()
|
||||
})
|
||||
|
||||
// On initialisation, we check whether there was a never version created while we were offline. If so, flagging this dude as expired.
|
||||
watchOnce(versionDetailsResult, async (newVal) => {
|
||||
if (!newVal) return
|
||||
let patchObject = {}
|
||||
|
||||
if (
|
||||
newVal?.project.model.versions.items &&
|
||||
newVal?.project.model.versions.items.length !== 0 &&
|
||||
newVal?.project.model.versions.items[0].id !== props.modelCard.selectedVersionId
|
||||
) {
|
||||
patchObject = {
|
||||
latestVersionId: newVal?.project.model.versions.items[0].id,
|
||||
latestVersionCreatedAt: newVal?.project.model.versions.items[0].createdAt,
|
||||
latestVersionSourceApp: newVal?.project.model.versions.items[0].sourceApplication,
|
||||
latestVersionUserId: newVal?.project.model.versions.items[0].authorUser?.id,
|
||||
hasDismissedUpdateWarning: props.modelCard.hasSelectedOldVersion ? true : false
|
||||
}
|
||||
}
|
||||
|
||||
// Always update the card's project name and model name, if needed. Note, this is not needed for senders (senders do not need to create layers).
|
||||
await store.patchModel(props.modelCard.modelCardId, {
|
||||
...patchObject,
|
||||
projectName: newVal?.project.name as string,
|
||||
modelName: newVal?.project.model.name as string
|
||||
})
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,413 @@
|
||||
<template>
|
||||
<ModelCardBase
|
||||
ref="cardBase"
|
||||
: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">
|
||||
<FormButton
|
||||
v-tippy="'Edit what gets published'"
|
||||
:icon-left="Square3Stack3DIcon"
|
||||
size="sm"
|
||||
color="subtle"
|
||||
class="block text-foreground-2 hover:text-foreground overflow-hidden max-w-full !justify-start"
|
||||
:disabled="!!modelCard.progress || !props.canEdit || isSendSettingsMissing"
|
||||
full-width
|
||||
@click.stop="openFilterDialog = true"
|
||||
>
|
||||
<span class="font-bold">{{ modelCard.sendFilter?.name }}: </span>
|
||||
<span class="truncate">{{ modelCard.sendFilter?.summary }}</span>
|
||||
</FormButton>
|
||||
</div>
|
||||
|
||||
<CommonDialog
|
||||
v-model:open="openFilterDialog"
|
||||
:title="`Change filter`"
|
||||
fullscreen="none"
|
||||
>
|
||||
<FilterListSelect :filter="modelCard.sendFilter" @update:filter="updateFilter" />
|
||||
|
||||
<div class="mt-4 flex justify-end items-center space-x-2">
|
||||
<FormButton
|
||||
size="sm"
|
||||
color="outline"
|
||||
:disabled="isSaveDisabled"
|
||||
@click.stop="saveFilter()"
|
||||
>
|
||||
Save
|
||||
</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"
|
||||
/>
|
||||
<CommonModelNotification
|
||||
v-if="errorNotification"
|
||||
:notification="errorNotification"
|
||||
:report="modelCard.report"
|
||||
@dismiss="store.patchModel(modelCard.modelCardId, { error: undefined })"
|
||||
/>
|
||||
<CommonModelNotification
|
||||
v-if="latestVersionNotification"
|
||||
:notification="latestVersionNotification"
|
||||
:report="modelCard.report"
|
||||
@dismiss="
|
||||
store.patchModel(modelCard.modelCardId, {
|
||||
latestCreatedVersionId: undefined
|
||||
})
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
</ModelCardBase>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ref, 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'
|
||||
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, type DUIAccount } from '~/store/accounts'
|
||||
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<{
|
||||
modelCard: ISenderModelCard
|
||||
project: ProjectModelGroup
|
||||
canEdit: boolean
|
||||
}>()
|
||||
|
||||
const account = accountStore.accounts.find(
|
||||
(acc) => acc.accountInfo.id === props.project.accountId
|
||||
) as DUIAccount
|
||||
const clientId = account.accountInfo.id
|
||||
|
||||
const openFilterDialog = ref(false)
|
||||
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 })
|
||||
)
|
||||
|
||||
onWorkspacePlanUsageUpdated(() => {
|
||||
void checkPermissions()
|
||||
})
|
||||
|
||||
const sendOrCancel = () => {
|
||||
// check for progress first to allow cancelling even if permissions changed
|
||||
if (props.modelCard.progress) {
|
||||
store.sendModelCancel(props.modelCard.modelCardId)
|
||||
return
|
||||
}
|
||||
|
||||
if (!props.canEdit || !canCreateVersionPerm.value) {
|
||||
return
|
||||
}
|
||||
|
||||
store.sendModel(props.modelCard.modelCardId, 'ModelCardButton')
|
||||
hasSetVersionMessage.value = false
|
||||
}
|
||||
|
||||
const newFilter = ref<ISendFilter>()
|
||||
const updateFilter = (filter: ISendFilter) => {
|
||||
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.value.typeDiscriminator
|
||||
})
|
||||
|
||||
// do not reset idmap while creating a new one because it is managed by host app
|
||||
newFilter.value.idMap = props.modelCard.sendFilter?.idMap
|
||||
|
||||
await store.patchModel(props.modelCard.modelCardId, {
|
||||
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 { mutate } = provideApolloClient(account.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
|
||||
|
||||
const notification = {} as ModelCardNotification
|
||||
notification.dismissible = false
|
||||
notification.level = props.modelCard.progress ? 'info' : 'info'
|
||||
notification.text = props.modelCard.progress
|
||||
? 'Model changed while publishing'
|
||||
: 'Out of sync with application'
|
||||
|
||||
const ctaType = props.modelCard.progress ? 'Restart' : 'Update'
|
||||
notification.cta = {
|
||||
name: ctaType,
|
||||
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)
|
||||
}
|
||||
store.sendModel(props.modelCard.modelCardId, ctaType)
|
||||
}
|
||||
}
|
||||
return notification
|
||||
})
|
||||
|
||||
const errorNotification = computed(() => {
|
||||
if (!props.modelCard.error) return
|
||||
const notification = {} as ModelCardNotification
|
||||
notification.dismissible = props.modelCard.error.dismissible
|
||||
notification.level = 'danger'
|
||||
notification.text = props.modelCard.error.errorMessage
|
||||
notification.report = props.modelCard.report
|
||||
return notification
|
||||
})
|
||||
|
||||
const failRate = computed(() => {
|
||||
if (!props.modelCard.report) return 0
|
||||
return (
|
||||
(props.modelCard.report.filter((r) => r.status === 4).length /
|
||||
props.modelCard.report.length) *
|
||||
100
|
||||
)
|
||||
})
|
||||
|
||||
const sendResultNotificationText = computed(() => {
|
||||
if (failRate.value > 80) {
|
||||
return 'Version created. Some objects have failed to convert!'
|
||||
}
|
||||
return 'Version created!'
|
||||
})
|
||||
|
||||
const sendResultNotificationLevel = computed(() => {
|
||||
if (failRate.value > 80) {
|
||||
return 'warning'
|
||||
}
|
||||
return 'info'
|
||||
})
|
||||
|
||||
const latestVersionNotification = computed(() => {
|
||||
if (!props.modelCard.latestCreatedVersionId) return
|
||||
const notification = {} as ModelCardNotification
|
||||
notification.dismissible = true
|
||||
notification.level = sendResultNotificationLevel.value
|
||||
notification.text = sendResultNotificationText.value
|
||||
notification.report = props.modelCard.report
|
||||
|
||||
// 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',
|
||||
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>
|
||||
@@ -0,0 +1,83 @@
|
||||
<template>
|
||||
<div class="p-0">
|
||||
<button
|
||||
v-if="expandable"
|
||||
class="flex w-full items-center text-foreground-2 justify-between hover:foundation-3 rounded-md transition group mb-2"
|
||||
@click="showSettings = !showSettings"
|
||||
>
|
||||
<div class="flex items-center transition group-hover:text-primary h-8 min-w-0">
|
||||
<CommonIconsArrowFilled
|
||||
:class="`w-5 ${showSettings ? '' : '-rotate-90'} transition`"
|
||||
/>
|
||||
<div class="text-body-sm text-left select-none">Settings</div>
|
||||
</div>
|
||||
</button>
|
||||
<div v-show="showSettings" class="px-1">
|
||||
<FormJsonForm
|
||||
:schema="settingsJsonForms"
|
||||
:data="data"
|
||||
@change="onParamsFormChange"
|
||||
></FormJsonForm>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { CardSetting, CardSettingValue } from '~/lib/models/card/setting'
|
||||
import type { JsonFormsChangeEvent } from '@jsonforms/vue'
|
||||
import { cloneDeep, omit } from 'lodash-es'
|
||||
import type { JsonSchema } from '@jsonforms/core'
|
||||
// import { useHostAppStore } from '~/store/hostApp'
|
||||
|
||||
const props = defineProps<{
|
||||
settings?: CardSetting[]
|
||||
defaultSettings: CardSetting[]
|
||||
expandable: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ (e: 'update:settings', value: CardSetting[]): void }>()
|
||||
|
||||
// const store = useHostAppStore()
|
||||
|
||||
// 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 (settings.value === undefined) return {}
|
||||
const obj: JsonSchema = { type: 'object', properties: {} }
|
||||
settings.value.forEach((setting: CardSetting) => {
|
||||
const mappedSetting = omit({ ...setting, $id: setting.id }, ['id'])
|
||||
if (obj && obj.properties) {
|
||||
obj.properties[setting.id] = mappedSetting
|
||||
}
|
||||
})
|
||||
return obj
|
||||
})
|
||||
|
||||
type DataType = Record<string, unknown>
|
||||
const data = computed(() => {
|
||||
const settingValues = {} as DataType
|
||||
if (settings.value) {
|
||||
settings.value.forEach((setting) => {
|
||||
settingValues[setting.id as string] = setting.value
|
||||
})
|
||||
}
|
||||
return settingValues
|
||||
})
|
||||
|
||||
const onParamsFormChange = (e: JsonFormsChangeEvent) => {
|
||||
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', settings.value)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,237 @@
|
||||
<template>
|
||||
<CommonDialog
|
||||
v-model:open="showReceiveDialog"
|
||||
fullscreen="none"
|
||||
:title="title"
|
||||
:show-back-button="step !== 1"
|
||||
@back="step--"
|
||||
@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"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="step === 2 && selectedProject && selectedAccountId">
|
||||
<div>
|
||||
<WizardModelSelector
|
||||
:project="selectedProject"
|
||||
:account-id="selectedAccountId"
|
||||
:show-new-model="false"
|
||||
@next="selectModel"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="step === 3">
|
||||
<WizardVersionSelector
|
||||
v-if="selectedProject && selectedModel"
|
||||
:account-id="selectedAccountId"
|
||||
:project-id="selectedProject.id"
|
||||
:model-id="selectedModel.id"
|
||||
:selected-version-id="urlParsedVersionId"
|
||||
:workspace-slug="selectedWorkspace?.slug"
|
||||
:from-wizard="true"
|
||||
@next="selectVersionAndAddModel"
|
||||
@update:settings="handleUpdateSettings"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="urlParseError" class="p-2 text-danger">{{ urlParseError }}</div>
|
||||
</CommonDialog>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import type {
|
||||
ModelListModelItemFragment,
|
||||
ProjectListProjectItemFragment,
|
||||
VersionListItemFragment,
|
||||
WorkspaceListWorkspaceItemFragment
|
||||
} from '~/lib/common/generated/gql/graphql'
|
||||
import { useHostAppStore } from '~/store/hostApp'
|
||||
import { useAccountStore } from '~/store/accounts'
|
||||
import { ReceiverModelCard } from '~/lib/models/card/receiver'
|
||||
import { useMixpanel } from '~/lib/core/composables/mixpanel'
|
||||
import { 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 })
|
||||
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
const step = ref(1)
|
||||
|
||||
// Clears data if going backwards in the wizard
|
||||
watch(step, (newVal, oldVal) => {
|
||||
if (newVal > oldVal) return // exit fast on forward
|
||||
if (newVal === 1) {
|
||||
selectedProject.value = undefined
|
||||
selectedModel.value = undefined
|
||||
}
|
||||
if (newVal === 2) selectedModel.value = undefined
|
||||
})
|
||||
|
||||
const accountStore = useAccountStore()
|
||||
const { activeAccount } = storeToRefs(accountStore)
|
||||
|
||||
const selectedAccountId = ref<string>(activeAccount.value?.accountInfo.id as string)
|
||||
const selectedWorkspace = ref<WorkspaceListWorkspaceItemFragment>()
|
||||
const selectedProject = ref<ProjectListProjectItemFragment>()
|
||||
const selectedModel = ref<ModelListModelItemFragment>()
|
||||
const receieveSettings = ref<CardSetting[] | undefined>(undefined)
|
||||
const settingsWereChanged = ref(false)
|
||||
|
||||
const { tryParseUrl, urlParsedData, urlParseError } = useAddByUrl()
|
||||
const updateSearchText = (text: string | undefined) => {
|
||||
urlParseError.value = undefined
|
||||
if (!text) return
|
||||
tryParseUrl(text, 'receiver')
|
||||
}
|
||||
|
||||
const urlParsedVersionId = ref<string>()
|
||||
watch(urlParsedData, (newVal) => {
|
||||
if (!newVal) return
|
||||
selectProject(newVal.account?.accountInfo.id, newVal.project)
|
||||
selectModel(newVal.model)
|
||||
if (newVal.version) urlParsedVersionId.value = newVal.version.id
|
||||
})
|
||||
|
||||
watch(showReceiveDialog, (newVal) => {
|
||||
if (newVal) {
|
||||
urlParseError.value = undefined
|
||||
}
|
||||
})
|
||||
|
||||
const selectProject = (
|
||||
accountId: string,
|
||||
project: ProjectListProjectItemFragment,
|
||||
workspace?: WorkspaceListWorkspaceItemFragment
|
||||
) => {
|
||||
step.value++
|
||||
selectedAccountId.value = accountId
|
||||
selectedProject.value = project
|
||||
selectedWorkspace.value = workspace
|
||||
|
||||
void trackEvent('DUI3 Action', { name: 'Load Wizard', step: 'project selected' })
|
||||
}
|
||||
|
||||
const selectModel = (model: ModelListModelItemFragment) => {
|
||||
step.value++
|
||||
selectedModel.value = model
|
||||
void trackEvent('DUI3 Action', { name: 'Load Wizard', step: 'model selected' })
|
||||
}
|
||||
|
||||
const title = computed(() => {
|
||||
if (step.value === 1) return 'Select project'
|
||||
if (step.value === 2) return 'Select model'
|
||||
if (step.value === 3) return 'Select version'
|
||||
return ''
|
||||
})
|
||||
|
||||
const handleUpdateSettings = (settings: CardSetting[]) => {
|
||||
receieveSettings.value = settings
|
||||
settingsWereChanged.value = true
|
||||
}
|
||||
|
||||
// accountId, serverUrl, ModelListModelItemFragment, VersionListItemFragment
|
||||
const selectVersionAndAddModel = async (
|
||||
version: VersionListItemFragment,
|
||||
latestVersion: VersionListItemFragment
|
||||
) => {
|
||||
void trackEvent('DUI3 Action', {
|
||||
name: 'Load Wizard',
|
||||
step: 'version selected',
|
||||
hasSelectedLatestVersion: version.id === latestVersion.id
|
||||
})
|
||||
|
||||
const existingModel = hostAppStore.models.find(
|
||||
(m) =>
|
||||
m.modelId === selectedModel.value?.id &&
|
||||
m.typeDiscriminator === 'ReceiverModelCard'
|
||||
) as ReceiverModelCard
|
||||
|
||||
// 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')
|
||||
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
|
||||
}
|
||||
const selectedVersionSourceApp = getSlugFromHostAppNameAndVersion(
|
||||
version.sourceApplication as string
|
||||
)
|
||||
const latestVersionSourceApp = getSlugFromHostAppNameAndVersion(
|
||||
latestVersion.sourceApplication as string
|
||||
)
|
||||
|
||||
const modelCard = new ReceiverModelCard()
|
||||
modelCard.settings = receieveSettings.value
|
||||
modelCard.accountId = selectedAccountId.value
|
||||
modelCard.serverUrl = activeAccount.value.accountInfo.serverInfo.url
|
||||
|
||||
modelCard.projectId = selectedProject.value?.id as string
|
||||
modelCard.modelId = selectedModel.value?.id as string
|
||||
modelCard.workspaceId = selectedProject.value?.workspace?.id as string
|
||||
modelCard.workspaceSlug = selectedProject?.value?.workspace?.slug as string
|
||||
|
||||
modelCard.projectName = selectedProject.value?.name as string
|
||||
modelCard.modelName = selectedModel.value?.name as string
|
||||
|
||||
modelCard.selectedVersionId = version.id
|
||||
modelCard.selectedVersionSourceApp = selectedVersionSourceApp
|
||||
modelCard.selectedVersionUserId = version.authorUser?.id as string
|
||||
|
||||
modelCard.latestVersionId = latestVersion.id
|
||||
modelCard.latestVersionSourceApp = latestVersionSourceApp
|
||||
modelCard.latestVersionUserId = latestVersion.authorUser?.id as string
|
||||
|
||||
modelCard.hasDismissedUpdateWarning = true
|
||||
modelCard.hasSelectedOldVersion = version.id !== latestVersion.id
|
||||
|
||||
emit('close')
|
||||
await hostAppStore.addModel(modelCard)
|
||||
await hostAppStore.receiveModel(modelCard.modelCardId, 'Wizard')
|
||||
}
|
||||
|
||||
const hostAppStore = useHostAppStore()
|
||||
</script>
|
||||
@@ -0,0 +1,159 @@
|
||||
<template>
|
||||
<div>
|
||||
<slot name="activator" :toggle="toggleDialog">
|
||||
<FormButton
|
||||
v-tippy="'View report'"
|
||||
color="outline"
|
||||
:icon-left="
|
||||
summary.failedCount === 0 && summary.warningCount === 0
|
||||
? CheckCircleIcon
|
||||
: ExclamationCircleIcon
|
||||
"
|
||||
hide-text
|
||||
size="sm"
|
||||
@click.stop="toggleDialog()"
|
||||
/>
|
||||
</slot>
|
||||
<CommonDialog v-model:open="showReportDialog" :title="`Report`" fullscreen="none">
|
||||
<div class="text-body-2xs">
|
||||
{{ numberOfSuccess }} objects converted ok, {{ numberOfWarning }} warnings and
|
||||
{{ numberOfFailed }} errors.
|
||||
</div>
|
||||
<div class="flex mt-2 space-x-2 text-body-2xs">
|
||||
<span>Filter:</span>
|
||||
<button
|
||||
v-if="numberOfSuccess !== 0"
|
||||
class="flex items-center justify-center border-success px-1 pb-1 text-success leading-none"
|
||||
:class="successToggle ? 'border-b-2' : ''"
|
||||
@click="successToggle = !successToggle"
|
||||
>
|
||||
<CheckCircleIcon
|
||||
class="w-4 mr-1 stroke-green-500 text-green-500"
|
||||
></CheckCircleIcon>
|
||||
{{ numberOfSuccess }}
|
||||
</button>
|
||||
<button
|
||||
v-if="numberOfWarning !== 0"
|
||||
class="flex items-center justify-center border-warning px-1 pb-1 text-warning leading-none"
|
||||
:class="warningToggle ? 'border-b-2' : ''"
|
||||
@click="warningToggle = !warningToggle"
|
||||
>
|
||||
<ExclamationTriangleIcon
|
||||
class="w-4 mr-1 stroke-warning-500 text-warning-500"
|
||||
></ExclamationTriangleIcon>
|
||||
{{ numberOfWarning }}
|
||||
</button>
|
||||
<button
|
||||
v-if="numberOfFailed !== 0"
|
||||
class="flex items-center justify-center border-danger px-1 pb-1 text-danger leading-none"
|
||||
:class="failedToggle ? 'border-b-2' : ''"
|
||||
@click="failedToggle = !failedToggle"
|
||||
>
|
||||
<ExclamationCircleIcon
|
||||
class="w-4 mr-1 stroke-red-500 text-red-500"
|
||||
></ExclamationCircleIcon>
|
||||
{{ numberOfFailed }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col space-y-1 py-2">
|
||||
<ReportItem
|
||||
v-for="(item, index) in reportLimited"
|
||||
:key="index"
|
||||
:report-item="item"
|
||||
/>
|
||||
<div v-if="reportLimited.length === 0" class="text-body-xs text-foreground-2">
|
||||
No items found.
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="report.length > reportSlice">
|
||||
<FormButton size="sm" full-width color="outline" @click="reportSlice += 20">
|
||||
Show more
|
||||
</FormButton>
|
||||
</div>
|
||||
</CommonDialog>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ExclamationCircleIcon,
|
||||
ExclamationTriangleIcon,
|
||||
CheckCircleIcon
|
||||
} from '@heroicons/vue/20/solid'
|
||||
import type { ConversionResult } from '~~/lib/conversions/conversionResult'
|
||||
|
||||
const props = defineProps<{
|
||||
report: ConversionResult[]
|
||||
}>()
|
||||
|
||||
const showReportDialog = ref(false)
|
||||
|
||||
const successToggle = ref(true) // Status 1
|
||||
const warningToggle = ref(true) // Status 3
|
||||
const failedToggle = ref(true) // Status 4
|
||||
|
||||
const toggleDialog = () => {
|
||||
showReportDialog.value = !showReportDialog.value
|
||||
}
|
||||
|
||||
const reportSlice = ref(10)
|
||||
// Limit so we don't display 100k items at once and burn
|
||||
const reportLimited = computed(() => reportSorted.value.slice(0, reportSlice.value))
|
||||
// Sort to errors first
|
||||
const reportSorted = computed(() =>
|
||||
[...filteredReports.value].sort((a, b) => b.status - a.status)
|
||||
)
|
||||
// Filter according to toggles
|
||||
const filteredReports = computed(() => {
|
||||
return props.report.filter((report) => {
|
||||
if (successToggle.value && report.status === 1) {
|
||||
return true
|
||||
}
|
||||
if (failedToggle.value && report.status === 4) {
|
||||
return true
|
||||
}
|
||||
if (warningToggle.value && report.status === 3) {
|
||||
return true
|
||||
}
|
||||
// TODO: do more later!
|
||||
return false
|
||||
})
|
||||
})
|
||||
|
||||
const numberOfSuccess = computed(
|
||||
() => props.report.filter((r) => r.status === 1).length
|
||||
)
|
||||
|
||||
const numberOfWarning = computed(
|
||||
() => props.report.filter((r) => r.status === 3).length
|
||||
)
|
||||
|
||||
const numberOfFailed = computed(() => props.report.filter((r) => r.status === 4).length)
|
||||
|
||||
const summary = computed(() => {
|
||||
const failed = props.report.filter((item) => item.status === 4)
|
||||
const warning = props.report.filter((item) => item.status === 3)
|
||||
const ok = props.report.filter((item) => item.status === 1)
|
||||
|
||||
let hint = 'All objects converted ok'
|
||||
const isSuccess = failed.length === 0 && warning.length === 0
|
||||
if (!isSuccess) {
|
||||
if (failed.length !== 0 && warning.length !== 0) {
|
||||
// both fail and warning
|
||||
hint = `${failed.length} object(s) failed to convert, ${warning.length} object(s) converted with warning`
|
||||
} else if (failed.length !== 0 && warning.length === 0) {
|
||||
// only fail
|
||||
hint = `${failed.length} object(s) failed to convert`
|
||||
} else if (warning.length !== 0 && failed.length === 0) {
|
||||
// only warning
|
||||
hint = `${warning.length} object(s) converted with warning`
|
||||
}
|
||||
}
|
||||
return {
|
||||
failedCount: failed.length,
|
||||
warningCount: warning.length,
|
||||
okCount: ok.length,
|
||||
hint
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,135 @@
|
||||
<template>
|
||||
<button
|
||||
class="block rounded-lg p-1 transition hover:bg-primary-muted"
|
||||
@click="highlightObject"
|
||||
>
|
||||
<div class="text-foreground-2 flex items-center relative">
|
||||
<div class="mr-1 hover:cursor-pointer">
|
||||
<div v-if="reportItem.status === 1">
|
||||
<CheckCircleIcon class="w-4 stroke-green-500 text-green-500" />
|
||||
</div>
|
||||
<div v-else-if="reportItem.status === 3">
|
||||
<ExclamationTriangleIcon class="w-4 text-warning"></ExclamationTriangleIcon>
|
||||
</div>
|
||||
<div v-else>
|
||||
<ExclamationCircleIcon class="w-4 text-danger"></ExclamationCircleIcon>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-xs transition truncate">
|
||||
<span v-if="reportItem.status === 1">
|
||||
{{ reportItem.sourceType?.split('.').reverse()[0] }} >
|
||||
</span>
|
||||
|
||||
<span>
|
||||
{{
|
||||
reportItem.resultType
|
||||
? reportItem.resultType?.split('.').reverse()[0]
|
||||
: reportItem.error?.message
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
v-tippy="'Details'"
|
||||
class="block rounded-lg transition hover:bg-primary-muted ml-auto"
|
||||
@click.stop="toggleDetails"
|
||||
>
|
||||
<div v-if="!showDetails">
|
||||
<ChevronDownIcon class="w-4" />
|
||||
</div>
|
||||
<div v-else>
|
||||
<ChevronUpIcon class="w-4" />
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
v-if="reportItem.status !== 1 && !isSender"
|
||||
v-tippy="'See object on Web'"
|
||||
class="block rounded-lg transition hover:bg-primary-muted ml-1"
|
||||
@click.stop="openObjectOnWeb"
|
||||
>
|
||||
<ArrowTopRightOnSquareIcon class="w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</button>
|
||||
<div
|
||||
v-if="showDetails"
|
||||
class="text-xs text-foreground-2 ml-3 rounded-lg p-1 hover:bg-primary-muted hover:cursor-pointer"
|
||||
>
|
||||
<button
|
||||
v-tippy="'Copy to clipboard'"
|
||||
class="text-left w-full whitespace-pre-wrap break-all overflow-hidden"
|
||||
@click="copyToClipboard(details)"
|
||||
>
|
||||
{{ details }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ExclamationCircleIcon,
|
||||
ExclamationTriangleIcon,
|
||||
CheckCircleIcon,
|
||||
ChevronUpIcon,
|
||||
ChevronDownIcon,
|
||||
ArrowTopRightOnSquareIcon
|
||||
} from '@heroicons/vue/24/solid'
|
||||
import type { ConversionResult } from '~/lib/conversions/conversionResult'
|
||||
import { useAccountStore } from '~/store/accounts'
|
||||
import type { IModelCard } from '~/lib/models/card'
|
||||
import { useHostAppStore } from '~/store/hostApp'
|
||||
|
||||
const app = useNuxtApp()
|
||||
const hostAppStore = useHostAppStore()
|
||||
const accStore = useAccountStore()
|
||||
|
||||
const showDetails = ref<boolean>(false)
|
||||
|
||||
const props = defineProps<{
|
||||
reportItem: ConversionResult
|
||||
}>()
|
||||
|
||||
const cardBase = inject('cardBase') as IModelCard
|
||||
|
||||
const isSender = computed(() =>
|
||||
hostAppStore.models
|
||||
.find((m) => m.modelCardId === cardBase.modelCardId)
|
||||
?.typeDiscriminator.toLowerCase()
|
||||
.includes('sender')
|
||||
)
|
||||
|
||||
const acc = accStore.accounts.find((acc) => acc.accountInfo.id === cardBase.accountId)
|
||||
|
||||
const details = computed(() =>
|
||||
props.reportItem.error
|
||||
? props.reportItem.error.stackTrace
|
||||
: `${props.reportItem.sourceType} > ${props.reportItem.resultType}`
|
||||
)
|
||||
|
||||
const openObjectOnWeb = () => {
|
||||
// This is a POC implementation. Later we will highlight object(s) within the model. Currently it is done by 'Isolate' filter on viewer but there is no direct URL to achieve this.
|
||||
const url = `${acc?.accountInfo.serverInfo.url}/projects/${cardBase?.projectId}/models/${props.reportItem.sourceId}`
|
||||
app.$openUrl(url)
|
||||
}
|
||||
|
||||
const highlightObject = () => {
|
||||
// sender reports highlight in source app
|
||||
if (cardBase.typeDiscriminator.toLowerCase().includes('send')) {
|
||||
app.$baseBinding.highlightObjects([props.reportItem.sourceId])
|
||||
return
|
||||
}
|
||||
|
||||
// receive reports that are ok highliht in source app
|
||||
if (props.reportItem.status === 1 && props.reportItem.resultId) {
|
||||
app.$baseBinding.highlightObjects([props.reportItem.resultId])
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const copyToClipboard = (text: string) => {
|
||||
navigator.clipboard.writeText(text)
|
||||
}
|
||||
|
||||
const toggleDetails = () => {
|
||||
showDetails.value = !showDetails.value
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,36 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<FilterListSelect @update:filter="updateFilter" />
|
||||
<ModelSettings
|
||||
v-if="hasSendSettings"
|
||||
expandable
|
||||
:default-settings="(store.sendSettings as unknown as CardSetting[])"
|
||||
@update:settings="updateSettings"
|
||||
></ModelSettings>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import type { ISendFilter } from '~/lib/models/card/send'
|
||||
import { useHostAppStore } from '~/store/hostApp'
|
||||
import type { CardSetting } from '~/lib/models/card/setting'
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:filter', filter: ISendFilter): void
|
||||
(e: 'update:settings', settings: CardSetting[]): void
|
||||
}>()
|
||||
|
||||
const updateFilter = (filter: ISendFilter) => {
|
||||
// TODO: something like hostApp.validateSendFilter()
|
||||
// which should return a bool and a reason if invalid
|
||||
emit('update:filter', filter)
|
||||
}
|
||||
|
||||
const updateSettings = (settings: CardSetting[]) => {
|
||||
emit('update:settings', settings)
|
||||
}
|
||||
|
||||
const store = useHostAppStore()
|
||||
const hasSendSettings = computed(
|
||||
() => store.sendSettings && store.sendSettings?.length > 0
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<div class="p-0">
|
||||
<slot name="activator" :toggle="toggleDialog"></slot>
|
||||
<CommonDialog
|
||||
v-model:open="showSettingsDialog"
|
||||
:title="`Settings`"
|
||||
fullscreen="none"
|
||||
>
|
||||
<ModelSettings
|
||||
:expandable="false"
|
||||
:default-settings="(store.sendSettings as unknown as CardSetting[])"
|
||||
:settings="props.settings"
|
||||
@update:settings="updateSettings"
|
||||
></ModelSettings>
|
||||
<div class="mt-4 flex justify-end items-center space-x-2">
|
||||
<FormButton size="sm" color="outline" @click="showSettingsDialog = false">
|
||||
Cancel
|
||||
</FormButton>
|
||||
<FormButton size="sm" @click="saveSettings()">Save</FormButton>
|
||||
</div>
|
||||
</CommonDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useSettingsTracking } from '~/lib/core/composables/trackSettings'
|
||||
import { useHostAppStore } from '~/store/hostApp'
|
||||
import type { CardSetting } from '~/lib/models/card/setting'
|
||||
|
||||
const { trackSettingsChange } = useSettingsTracking()
|
||||
|
||||
const props = defineProps<{
|
||||
settings?: CardSetting[]
|
||||
modelCardId: string
|
||||
}>()
|
||||
|
||||
const store = useHostAppStore()
|
||||
|
||||
const showSettingsDialog = ref(false)
|
||||
|
||||
const toggleDialog = () => {
|
||||
showSettingsDialog.value = !showSettingsDialog.value
|
||||
}
|
||||
|
||||
let newSettings: CardSetting[]
|
||||
const updateSettings = (settings: CardSetting[]) => {
|
||||
newSettings = settings
|
||||
}
|
||||
|
||||
const saveSettings = async () => {
|
||||
trackSettingsChange(
|
||||
'Model Card Settings Updated',
|
||||
newSettings,
|
||||
store.sendSettings || []
|
||||
)
|
||||
|
||||
await store.patchModel(props.modelCardId, {
|
||||
settings: newSettings,
|
||||
expired: true
|
||||
})
|
||||
showSettingsDialog.value = false
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,275 @@
|
||||
<template>
|
||||
<CommonDialog
|
||||
v-model:open="showSendDialog"
|
||||
fullscreen="none"
|
||||
:title="title"
|
||||
:show-back-button="step !== 1"
|
||||
@back="step--"
|
||||
@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>
|
||||
<div v-if="step === 2 && selectedProject && selectedAccountId">
|
||||
<WizardModelSelector
|
||||
:project="selectedProject"
|
||||
:workspace-id="selectedProject.workspace?.id"
|
||||
:workspace-slug="selectedProject.workspace?.slug"
|
||||
:account-id="selectedAccountId"
|
||||
is-sender
|
||||
@next="selectModel"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="step === 3">
|
||||
<SendFiltersAndSettings
|
||||
v-model="filter"
|
||||
@update:filter="(f) => (filter = f)"
|
||||
@update:settings="
|
||||
(s) => {
|
||||
settings = s
|
||||
settingsWereChanged = true
|
||||
}
|
||||
"
|
||||
/>
|
||||
<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-danger">
|
||||
{{ urlParseError }}
|
||||
</div>
|
||||
</CommonDialog>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useSubscription } from '@vue/apollo-composable'
|
||||
import type {
|
||||
ModelListModelItemFragment,
|
||||
ProjectListProjectItemFragment
|
||||
} from '~/lib/common/generated/gql/graphql'
|
||||
import type { ISendFilter } from '~/lib/models/card/send'
|
||||
import { SenderModelCard } from '~/lib/models/card/send'
|
||||
import { useHostAppStore } from '~/store/hostApp'
|
||||
import { useAccountStore } from '~/store/accounts'
|
||||
import { 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 })
|
||||
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
const step = ref(1)
|
||||
const accountStore = useAccountStore()
|
||||
const { activeAccount } = storeToRefs(accountStore)
|
||||
|
||||
const selectedAccountId = ref<string>(activeAccount.value?.accountInfo.id as string)
|
||||
const selectedProject = ref<ProjectListProjectItemFragment>()
|
||||
const selectedModel = ref<ModelListModelItemFragment>()
|
||||
const filter = ref<ISendFilter | undefined>(undefined)
|
||||
const settings = ref<CardSetting[] | undefined>(undefined)
|
||||
const 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
|
||||
tryParseUrl(text, 'sender')
|
||||
}
|
||||
|
||||
watch(urlParsedData, (newVal) => {
|
||||
if (!newVal) return
|
||||
selectProject(newVal.account?.accountInfo.id, newVal.project)
|
||||
selectModel(newVal.model)
|
||||
})
|
||||
|
||||
watch(showSendDialog, (newVal) => {
|
||||
if (newVal) {
|
||||
urlParseError.value = undefined
|
||||
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
|
||||
selectedProject.value = project
|
||||
void trackEvent('DUI3 Action', { name: 'Publish Wizard', step: 'project selected' })
|
||||
}
|
||||
|
||||
const title = computed(() => {
|
||||
if (step.value === 1) return 'Select project'
|
||||
if (step.value === 2) return 'Select model'
|
||||
if (step.value === 3) return 'Select objects'
|
||||
return ''
|
||||
})
|
||||
|
||||
const selectModel = (model: ModelListModelItemFragment) => {
|
||||
step.value++
|
||||
selectedModel.value = model
|
||||
void trackEvent('DUI3 Action', { name: 'Publish Wizard', step: 'model selected' })
|
||||
}
|
||||
|
||||
// accountId, serverUrl, projectId, modelId, sendFilter, settings
|
||||
const addModel = async () => {
|
||||
void trackEvent('DUI3 Action', {
|
||||
name: 'Publish Wizard',
|
||||
step: 'objects selected',
|
||||
filter: filter.value?.typeDiscriminator
|
||||
})
|
||||
|
||||
const existingModel = hostAppStore.models.find(
|
||||
(m) =>
|
||||
m.modelId === selectedModel.value?.id &&
|
||||
m.typeDiscriminator.includes('SenderModelCard')
|
||||
) as SenderModelCard
|
||||
|
||||
// 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!
|
||||
await hostAppStore.patchModel(existingModel.modelCardId, {
|
||||
sendFilter: filter.value as ISendFilter,
|
||||
expired: false
|
||||
})
|
||||
void hostAppStore.sendModel(existingModel.modelCardId, 'Wizard')
|
||||
return
|
||||
}
|
||||
|
||||
const model = new SenderModelCard()
|
||||
model.accountId = selectedAccountId.value
|
||||
model.serverUrl = activeAccount.value?.accountInfo.serverInfo.url as string
|
||||
model.projectId = selectedProject.value?.id as string
|
||||
model.modelId = selectedModel.value?.id as string
|
||||
model.workspaceId = selectedProject.value?.workspace?.id as string
|
||||
model.workspaceSlug = selectedProject?.value?.workspace?.slug as string
|
||||
model.sendFilter = filter.value as ISendFilter
|
||||
model.sendFilter.idMap = {} // do not let it null from the beginning otherwise we will end up with null state on Revit...
|
||||
model.settings = settings.value
|
||||
model.expired = false
|
||||
|
||||
emit('close')
|
||||
await hostAppStore.addModel(model)
|
||||
void hostAppStore.sendModel(model.modelCardId, 'Wizard')
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<GlobalToastRenderer v-model:notification="hostAppStore.currentNotification" />
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { GlobalToastRenderer } from '@speckle/ui-components'
|
||||
import { useHostAppStore } from '~/store/hostApp'
|
||||
|
||||
const hostAppStore = useHostAppStore()
|
||||
</script>
|
||||
@@ -0,0 +1,340 @@
|
||||
<template>
|
||||
<div class="space-y-2">
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center space-x-2 justify-between">
|
||||
<FormTextInput
|
||||
v-model="searchText"
|
||||
:placeholder="
|
||||
totalCount === 0 ? 'New model name' : 'Search in ' + project.name
|
||||
"
|
||||
name="search"
|
||||
autocomplete="off"
|
||||
:show-clear="!!searchText"
|
||||
full-width
|
||||
color="foundation"
|
||||
/>
|
||||
<div
|
||||
v-if="isSender"
|
||||
v-tippy="
|
||||
canCreateModelResult?.project.permissions.canCreateModel.authorized
|
||||
? 'Create new model'
|
||||
: canCreateModelResult?.project.permissions.canCreateModel.message
|
||||
"
|
||||
>
|
||||
<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 />
|
||||
|
||||
<WizardListModelCard
|
||||
v-for="model in models"
|
||||
:key="model.id"
|
||||
:model="model"
|
||||
:token="token"
|
||||
@click="handleModelSelect(model)"
|
||||
/>
|
||||
|
||||
<CommonDialog
|
||||
v-model:open="showSelectionHasProblemsDialog"
|
||||
title="Warning"
|
||||
fullscreen="none"
|
||||
>
|
||||
<div class="mx-1">
|
||||
<p class="text-body-xs mb-2">You are about to overwrite this model.</p>
|
||||
<p
|
||||
v-if="hasNonZeroVersionsProblem"
|
||||
class="mb-2 text-body-3xs text-foreground-2"
|
||||
>
|
||||
The model you selected contains versions coming from
|
||||
<b>other files/apps</b>
|
||||
.
|
||||
</p>
|
||||
<p v-if="existingModelProblem" class="mb-2 text-body-3xs text-foreground-2">
|
||||
<b>{{ ` ${existingModelName}` }}</b>
|
||||
is already being used to
|
||||
<b>{{ isSender ? 'publish,' : 'load,' }}</b>
|
||||
you could consider using the existing one.
|
||||
</p>
|
||||
</div>
|
||||
<template #buttons>
|
||||
<FormButton
|
||||
full-width
|
||||
size="sm"
|
||||
text
|
||||
@click="showSelectionHasProblemsDialog = false"
|
||||
>
|
||||
Cancel
|
||||
</FormButton>
|
||||
<FormButton full-width size="sm" @click="confirmModelSelection()">
|
||||
Proceed
|
||||
</FormButton>
|
||||
</template>
|
||||
</CommonDialog>
|
||||
<FormButton
|
||||
v-if="
|
||||
models?.length === 0 &&
|
||||
!!searchText &&
|
||||
isSender &&
|
||||
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"
|
||||
@click="loadMore"
|
||||
>
|
||||
{{ hasReachedEnd ? 'No more models found' : 'Load older models' }}
|
||||
</FormButton>
|
||||
</div>
|
||||
</div>
|
||||
<CommonDialog
|
||||
v-if="isSender"
|
||||
v-model:open="showNewModelDialog"
|
||||
title="Create new model"
|
||||
fullscreen="none"
|
||||
>
|
||||
<form @submit="createNewModel(newModelName as string)">
|
||||
<FormTextInput
|
||||
v-model="newModelName"
|
||||
:rules="rules"
|
||||
:placeholder="hostAppStore.documentInfo?.name"
|
||||
name="name"
|
||||
color="foundation"
|
||||
:show-clear="!!newModelName"
|
||||
full-width
|
||||
autocomplete="off"
|
||||
size="lg"
|
||||
/>
|
||||
<div class="mt-4 flex justify-end items-center space-x-2 w-full">
|
||||
<FormButton size="sm" text @click="showNewModelDialog = false">
|
||||
Cancel
|
||||
</FormButton>
|
||||
<FormButton size="sm" submit :disabled="isCreatingModel || !newModelName">
|
||||
Create
|
||||
</FormButton>
|
||||
</div>
|
||||
</form>
|
||||
</CommonDialog>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { PlusIcon } from '@heroicons/vue/20/solid'
|
||||
import { provideApolloClient, useMutation, useQuery } from '@vue/apollo-composable'
|
||||
import type {
|
||||
ProjectListProjectItemFragment,
|
||||
ModelListModelItemFragment
|
||||
} from '~/lib/common/generated/gql/graphql'
|
||||
import { useModelNameValidationRules } from '~/lib/validation'
|
||||
import {
|
||||
canCreateModelInProjectQuery,
|
||||
createModelMutation,
|
||||
projectModelsQuery
|
||||
} from '~/lib/graphql/mutationsAndQueries'
|
||||
import type { DUIAccount } from '~/store/accounts'
|
||||
import { useAccountStore } from '~/store/accounts'
|
||||
import { useHostAppStore } from '~/store/hostApp'
|
||||
import { useMixpanel } from '~/lib/core/composables/mixpanel'
|
||||
|
||||
const { trackEvent } = useMixpanel()
|
||||
const hostAppStore = useHostAppStore()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'next', model: ModelListModelItemFragment): void
|
||||
}>()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
project: ProjectListProjectItemFragment
|
||||
workspaceId?: string
|
||||
workspaceSlug?: string
|
||||
accountId: string
|
||||
isSender?: boolean
|
||||
}>(),
|
||||
{ isSender: false }
|
||||
)
|
||||
|
||||
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>(hostAppStore.documentInfo?.name ?? 'unnamed model')
|
||||
|
||||
watch(searchText, () => (newModelName.value = searchText.value as string))
|
||||
|
||||
let selectedModel: ModelListModelItemFragment | undefined = undefined
|
||||
const existingModelProblem = ref(false)
|
||||
const existingModelName = ref<string | undefined>(undefined)
|
||||
const hasNonZeroVersionsProblem = ref(false)
|
||||
const handleModelSelect = (model: ModelListModelItemFragment) => {
|
||||
const existingModel = hostAppStore.models.find((m) => m.modelId === model.id)
|
||||
existingModelProblem.value = !!existingModel
|
||||
if (existingModelProblem.value) {
|
||||
existingModelName.value = model.name
|
||||
}
|
||||
hasNonZeroVersionsProblem.value = model.versions.totalCount !== 0 && props.isSender
|
||||
|
||||
if (!existingModelProblem.value && !hasNonZeroVersionsProblem.value) {
|
||||
return emit('next', model)
|
||||
}
|
||||
selectedModel = model
|
||||
showSelectionHasProblemsDialog.value = true
|
||||
}
|
||||
|
||||
const confirmModelSelection = () => {
|
||||
existingModelProblem.value = false
|
||||
hasNonZeroVersionsProblem.value = false
|
||||
emit('next', selectedModel as ModelListModelItemFragment)
|
||||
}
|
||||
|
||||
const rules = useModelNameValidationRules()
|
||||
|
||||
const handleModelCreated = (result: ModelListModelItemFragment) => {
|
||||
refetch() // Sorts the list with newly created project otherwise it will put the project at the bottom.
|
||||
emit('next', result)
|
||||
}
|
||||
|
||||
const isCreatingModel = ref(false)
|
||||
|
||||
const createNewModel = async (name: string) => {
|
||||
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
|
||||
|
||||
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.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)
|
||||
handleModelCreated(res?.data?.modelMutations.create)
|
||||
} else {
|
||||
let errorMessage = 'Undefined error'
|
||||
if (res?.errors && res?.errors.length !== 0) {
|
||||
errorMessage = res?.errors[0].message
|
||||
}
|
||||
|
||||
hostAppStore.setNotification({
|
||||
type: 1,
|
||||
title: 'Failed to create model',
|
||||
description: errorMessage
|
||||
})
|
||||
}
|
||||
isCreatingModel.value = false
|
||||
}
|
||||
|
||||
const { result: canCreateModelResult } = useQuery(
|
||||
canCreateModelInProjectQuery,
|
||||
() => ({ projectId: props.project.id }),
|
||||
() => ({
|
||||
clientId: props.accountId,
|
||||
fetchPolicy: 'network-only'
|
||||
})
|
||||
)
|
||||
|
||||
const {
|
||||
result: projectModelsResult,
|
||||
loading,
|
||||
fetchMore,
|
||||
refetch
|
||||
} = useQuery(
|
||||
projectModelsQuery,
|
||||
() => ({
|
||||
projectId: props.project.id,
|
||||
limit: 10,
|
||||
filter: {
|
||||
search: (searchText.value || '').trim() || null
|
||||
}
|
||||
}),
|
||||
() => ({ clientId: props.accountId, debounce: 500, fetchPolicy: 'cache-and-network' })
|
||||
)
|
||||
|
||||
const 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)
|
||||
|
||||
watch(projectModelsResult, (newVal) => {
|
||||
if (
|
||||
newVal &&
|
||||
newVal?.project.models.items.length >= newVal?.project.models.totalCount
|
||||
) {
|
||||
hasReachedEnd.value = true
|
||||
} else {
|
||||
hasReachedEnd.value = false
|
||||
}
|
||||
})
|
||||
|
||||
const loadMore = () => {
|
||||
fetchMore({
|
||||
variables: { cursor: projectModelsResult.value?.project.models.cursor },
|
||||
updateQuery: (previousResult, { fetchMoreResult }) => {
|
||||
if (!fetchMoreResult || fetchMoreResult.project.models.items.length === 0) {
|
||||
hasReachedEnd.value = true
|
||||
return previousResult
|
||||
}
|
||||
|
||||
if (
|
||||
previousResult.project.models.items.length +
|
||||
fetchMoreResult.project.models.items.length >=
|
||||
fetchMoreResult.project.models.totalCount
|
||||
) {
|
||||
hasReachedEnd.value = true
|
||||
}
|
||||
|
||||
return {
|
||||
project: {
|
||||
id: previousResult.project.id,
|
||||
__typename: previousResult.project.__typename,
|
||||
models: {
|
||||
__typename: previousResult.project.models.__typename,
|
||||
totalCount: previousResult.project.models.totalCount,
|
||||
cursor: fetchMoreResult.project.models.cursor,
|
||||
items: [
|
||||
...previousResult.project.models.items,
|
||||
...fetchMoreResult.project.models.items
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,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>
|
||||
@@ -0,0 +1,634 @@
|
||||
<template>
|
||||
<div class="space-y-2">
|
||||
<div class="space-y-2 relative">
|
||||
<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(
|
||||
`${activeAccount.accountInfo.serverInfo.url.replace(
|
||||
/\/$/,
|
||||
''
|
||||
)}/workspaces/actions/create`
|
||||
)
|
||||
"
|
||||
>
|
||||
<div class="min-w-0 truncate flex-grow">
|
||||
<span>{{ 'Create a workspace' }}</span>
|
||||
</div>
|
||||
<ArrowTopRightOnSquareIcon class="w-4" />
|
||||
</FormButton>
|
||||
</div>
|
||||
<WorkspaceMenu
|
||||
v-else-if="selectedWorkspace"
|
||||
:workspaces="workspaces"
|
||||
:current-selected-workspace-id="selectedWorkspace.id"
|
||||
@workspace:selected="(workspace: WorkspaceListWorkspaceItemFragment) => handleWorkspaceSelected(workspace)"
|
||||
>
|
||||
<template #activator="{ toggle }">
|
||||
<button
|
||||
v-tippy="'Click to change the workspace'"
|
||||
class="flex items-center w-full p-1 space-x-2 bg-foundation hover:bg-primary-muted rounded text-foreground border"
|
||||
@click="toggle()"
|
||||
>
|
||||
<WorkspaceAvatar
|
||||
:size="'xs'"
|
||||
:name="selectedWorkspace.name || ''"
|
||||
:logo="selectedWorkspace.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="shrink-0 pt-1 px-1">
|
||||
<AccountsMenu
|
||||
:current-selected-account-id="accountId"
|
||||
@select="(e) => selectAccount(e)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center space-x-1 justify-between">
|
||||
<FormTextInput
|
||||
v-model="searchText"
|
||||
placeholder="Search your projects"
|
||||
name="search"
|
||||
autocomplete="off"
|
||||
:show-clear="!!searchText"
|
||||
full-width
|
||||
color="foundation"
|
||||
/>
|
||||
<div class="flex justify-between items-center space-x-2">
|
||||
<template v-if="isSender">
|
||||
<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
|
||||
v-tippy="
|
||||
canCreateProject
|
||||
? 'Create new project'
|
||||
: canCreateProjectPermissionCheck?.message
|
||||
"
|
||||
>
|
||||
<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>
|
||||
</template>
|
||||
<div v-if="!workspacesEnabled || !workspaces" class="mt-1">
|
||||
<AccountsMenu
|
||||
:current-selected-account-id="accountId"
|
||||
@select="(e) => selectAccount(e)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<WizardPersonalProjectsWarning v-if="isPersonalProjectsAsWorkspace" />
|
||||
|
||||
<CommonLoadingBar v-if="loading || isCreatingProject" loading />
|
||||
</div>
|
||||
<div v-if="!urlParseError" class="grid grid-cols-1 gap-2 relative z-0">
|
||||
<WizardListProjectCard
|
||||
v-for="project in projects"
|
||||
:key="project.id"
|
||||
:project="project"
|
||||
:is-sender="isSender"
|
||||
@click="handleProjectCardClick(project)"
|
||||
/>
|
||||
<p v-if="projects?.length === 0 && !!searchText" class="text-sm">
|
||||
No projects found
|
||||
</p>
|
||||
<FormButton
|
||||
v-if="
|
||||
projects?.length === 0 &&
|
||||
!!searchText &&
|
||||
isSender &&
|
||||
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"
|
||||
@click="loadMore"
|
||||
>
|
||||
{{ hasReachedEnd ? 'No more projects found' : 'Load older projects' }}
|
||||
</FormButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ChevronDownIcon, ArrowTopRightOnSquareIcon } from '@heroicons/vue/24/outline'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { PlusIcon, 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'
|
||||
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()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(
|
||||
e: 'next',
|
||||
accountId: string,
|
||||
project: ProjectListProjectItemFragment,
|
||||
workspace?: WorkspaceListWorkspaceItemFragment // NOTE: this nullabilities will disappear whenever we are workspace only
|
||||
): void
|
||||
(e: 'search-text-update', text: string | undefined): void
|
||||
}>()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
isSender: boolean
|
||||
/**
|
||||
* For the send wizard - not allowing selecting projects we can't write to.
|
||||
*/
|
||||
disableNoWriteAccessProjects?: boolean
|
||||
urlParseError?: string
|
||||
}>(),
|
||||
{
|
||||
disableNoWriteAccessProjects: false
|
||||
}
|
||||
)
|
||||
|
||||
const searchText = ref<string>()
|
||||
const newProjectName = ref<string>()
|
||||
const accountStore = useAccountStore()
|
||||
const configStore = useConfigStore()
|
||||
const { activeAccount } = storeToRefs(accountStore)
|
||||
|
||||
const accountId = computed(() => activeAccount.value.accountInfo.id)
|
||||
|
||||
watch(searchText, () => {
|
||||
newProjectName.value = searchText.value
|
||||
emit('search-text-update', searchText.value)
|
||||
})
|
||||
|
||||
// TODO: this function is never triggered!! remove or evaluate
|
||||
const selectAccount = (account: DUIAccount) => {
|
||||
refetchServerInfo() // to be able to understand workspaces enabled or not
|
||||
refetchActiveWorkspace()
|
||||
refetchWorkspaces()
|
||||
void trackEvent('DUI3 Action', { name: 'Account Select' }, account.accountInfo.id)
|
||||
}
|
||||
|
||||
const handleProjectCreated = (result: ProjectListProjectItemFragment) => {
|
||||
refetch() // Sorts the list with newly created project otherwise it will put the project at the bottom.
|
||||
emit('next', accountId.value, result)
|
||||
}
|
||||
|
||||
const { result: serverInfoResult, refetch: refetchServerInfo } = useQuery(
|
||||
serverInfoQuery,
|
||||
() => ({}),
|
||||
() => ({
|
||||
clientId: accountId.value,
|
||||
debounce: 500,
|
||||
fetchPolicy: 'network-only'
|
||||
})
|
||||
)
|
||||
|
||||
const workspacesEnabled = computed(
|
||||
() => serverInfoResult.value?.serverInfo.workspaces.workspacesEnabled
|
||||
)
|
||||
|
||||
const { result: workspacesResult, refetch: refetchWorkspaces } = useQuery(
|
||||
workspacesListQuery,
|
||||
() => ({
|
||||
limit: 100
|
||||
}),
|
||||
() => ({
|
||||
clientId: accountId.value,
|
||||
debounce: 500,
|
||||
fetchPolicy: 'network-only'
|
||||
})
|
||||
)
|
||||
|
||||
const workspaces = computed(() => workspacesResult.value?.activeUser?.workspaces.items)
|
||||
|
||||
const { result: activeWorkspaceResult, refetch: refetchActiveWorkspace } = useQuery(
|
||||
activeWorkspaceQuery,
|
||||
() => ({}),
|
||||
() => ({
|
||||
clientId: accountId.value,
|
||||
debounce: 500,
|
||||
fetchPolicy: 'network-only'
|
||||
})
|
||||
)
|
||||
|
||||
const activeWorkspace = computed(() => {
|
||||
const userSelectedWorkspaceId = configStore.userSelectedWorkspaceId
|
||||
if (userSelectedWorkspaceId) {
|
||||
const previouslySelectedWorkspace = workspaces.value?.find(
|
||||
(w) => w.id === userSelectedWorkspaceId
|
||||
)
|
||||
if (previouslySelectedWorkspace) {
|
||||
return previouslySelectedWorkspace
|
||||
}
|
||||
}
|
||||
|
||||
const 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) => {
|
||||
if (newItems && newItems.length > 0) {
|
||||
selectedWorkspace.value = activeWorkspace.value ?? newItems[0]
|
||||
} else {
|
||||
selectedWorkspace.value = undefined
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const handleProjectCardClick = (project: ProjectListProjectItemFragment) => {
|
||||
if (
|
||||
props.isSender
|
||||
? project.permissions.canPublish.authorized
|
||||
: project.permissions.canLoad.authorized
|
||||
) {
|
||||
emit('next', accountId.value, project, selectedWorkspace.value)
|
||||
}
|
||||
}
|
||||
|
||||
const handleWorkspaceSelected = async (
|
||||
newSelectedWorkspace: WorkspaceListWorkspaceItemFragment
|
||||
) => {
|
||||
selectedWorkspace.value = newSelectedWorkspace
|
||||
const account = computed(() => {
|
||||
return accountStore.accounts.find(
|
||||
(acc) => acc.accountInfo.id === accountId.value
|
||||
) as DUIAccount
|
||||
})
|
||||
const { mutate } = provideApolloClient(account.value.client)(() =>
|
||||
useMutation(setActiveWorkspaceMutation)
|
||||
)
|
||||
try {
|
||||
await mutate({ slug: newSelectedWorkspace.slug })
|
||||
} catch (error) {
|
||||
// I dont believe we should throw toast for this, but good to be critical on console
|
||||
console.error(error)
|
||||
}
|
||||
|
||||
configStore.setUserSelectedWorkspace(newSelectedWorkspace.id)
|
||||
}
|
||||
|
||||
// This is a hack for people who don't have a workspace and have personal projects only.
|
||||
const timeoutWait = ref(false)
|
||||
|
||||
const filtersReady = computed(
|
||||
() => selectedWorkspace.value !== undefined || timeoutWait.value
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
setTimeout(() => {
|
||||
timeoutWait.value = true
|
||||
}, 1000)
|
||||
})
|
||||
|
||||
const {
|
||||
result: projectsResult,
|
||||
loading,
|
||||
fetchMore,
|
||||
refetch
|
||||
} = useQuery(
|
||||
projectsListQuery,
|
||||
() => ({
|
||||
limit: 10, // stupid hack, increased it since we do manual filter to be able to see more project, see below TODO note, once we have `personalOnly` filter, decrease back to 10
|
||||
filter: {
|
||||
search: (searchText.value || '').trim() || null,
|
||||
workspaceId: isPersonalProjectsAsWorkspace.value
|
||||
? null
|
||||
: selectedWorkspace.value?.id,
|
||||
includeImplicitAccess: true,
|
||||
personalOnly: isPersonalProjectsAsWorkspace.value
|
||||
}
|
||||
}),
|
||||
() => ({
|
||||
enabled: filtersReady.value,
|
||||
clientId: accountId.value,
|
||||
debounce: 500,
|
||||
fetchPolicy: 'network-only'
|
||||
})
|
||||
)
|
||||
|
||||
const projects = computed(() =>
|
||||
isPersonalProjectsAsWorkspace.value // TODO: we need to replace this logic with `personalOnly` filter when it is implemented into app.speckle.systems
|
||||
? projectsResult.value?.activeUser?.projects.items.filter(
|
||||
(i) => i.workspaceId === null
|
||||
)
|
||||
: projectsResult.value?.activeUser?.projects.items
|
||||
)
|
||||
const hasReachedEnd = ref(false)
|
||||
|
||||
watch(searchText, () => {
|
||||
hasReachedEnd.value = false
|
||||
})
|
||||
|
||||
watch(projectsResult, (newVal) => {
|
||||
if (
|
||||
newVal &&
|
||||
newVal.activeUser &&
|
||||
newVal?.activeUser?.projects.items.length >= newVal?.activeUser?.projects.totalCount
|
||||
) {
|
||||
hasReachedEnd.value = true
|
||||
} else {
|
||||
hasReachedEnd.value = false
|
||||
}
|
||||
})
|
||||
|
||||
const { 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 },
|
||||
updateQuery: (previousResult, { fetchMoreResult }) => {
|
||||
if (!fetchMoreResult || fetchMoreResult.activeUser?.projects.items.length === 0) {
|
||||
hasReachedEnd.value = true
|
||||
return previousResult
|
||||
}
|
||||
|
||||
if (!previousResult.activeUser || !fetchMoreResult.activeUser)
|
||||
return previousResult
|
||||
|
||||
return {
|
||||
activeUser: {
|
||||
id: previousResult.activeUser?.id,
|
||||
__typename: previousResult.activeUser?.__typename,
|
||||
projects: {
|
||||
__typename: previousResult.activeUser?.projects.__typename,
|
||||
cursor: fetchMoreResult?.activeUser?.projects.cursor,
|
||||
totalCount: fetchMoreResult?.activeUser?.projects.totalCount,
|
||||
items: [
|
||||
...previousResult.activeUser.projects.items,
|
||||
...fetchMoreResult.activeUser.projects.items
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,167 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
v-if="isLimited && workspaceSlug"
|
||||
class="flex items-center justify-between bg-foundation rounded-md border border-outline-3 p-1 space-x-2 text-xs"
|
||||
>
|
||||
<div class="ml-1">Upgrade to load older versions.</div>
|
||||
<FormButton
|
||||
size="sm"
|
||||
@click="$openUrl(`${serverUrl}/settings/workspaces/${workspaceSlug}/billing`)"
|
||||
>
|
||||
Upgrade
|
||||
</FormButton>
|
||||
</div>
|
||||
<div v-if="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"
|
||||
:key="version.id"
|
||||
:version="version"
|
||||
:index="index"
|
||||
:latest-version-id="latestVersion.id"
|
||||
:selected-version-id="selectedVersionId"
|
||||
:project-id="projectId"
|
||||
:from-wizard="fromWizard"
|
||||
:account-id="accountId"
|
||||
@click="$emit('next', version, latestVersion)"
|
||||
/>
|
||||
</div>
|
||||
<CommonLoadingBar v-if="loading" loading />
|
||||
<FormButton
|
||||
color="outline"
|
||||
full-width
|
||||
:disabled="hasReachedEnd"
|
||||
@click="loadMore"
|
||||
>
|
||||
{{ hasReachedEnd ? 'No older versions' : 'Show older versions' }}
|
||||
</FormButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { useQuery } from '@vue/apollo-composable'
|
||||
import { modelVersionsQuery } from '~/lib/graphql/mutationsAndQueries'
|
||||
import type { VersionListItemFragment } from '~/lib/common/generated/gql/graphql'
|
||||
import { useAccountStore } from '~/store/accounts'
|
||||
import type { CardSetting } from '~/lib/models/card/setting'
|
||||
import { useHostAppStore } from '~/store/hostApp'
|
||||
|
||||
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,
|
||||
fetchMore,
|
||||
refetch
|
||||
} = useQuery(
|
||||
modelVersionsQuery,
|
||||
() => {
|
||||
const payload = {
|
||||
projectId: props.projectId,
|
||||
modelId: props.modelId,
|
||||
limit: 6,
|
||||
filter: props.selectedVersionId
|
||||
? { priorityIds: [props.selectedVersionId] }
|
||||
: undefined
|
||||
}
|
||||
|
||||
return payload
|
||||
},
|
||||
() => ({ clientId: props.accountId, fetchPolicy: 'cache-and-network' })
|
||||
)
|
||||
|
||||
const versions = computed(() => modelVersionResults.value?.project.model.versions.items)
|
||||
|
||||
const isLimited = computed(
|
||||
() => versions.value?.filter((v) => v.referencedObject === null).length !== 0
|
||||
)
|
||||
|
||||
const hasReachedEnd = ref(false)
|
||||
|
||||
const latestVersion = computed(() => {
|
||||
if (!versions.value) return
|
||||
const sorted = [...versions.value].sort(
|
||||
(a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt)
|
||||
)
|
||||
return sorted[0]
|
||||
})
|
||||
|
||||
const loadMore = () => {
|
||||
fetchMore({
|
||||
variables: { cursor: modelVersionResults.value?.project.model.versions.cursor },
|
||||
updateQuery: (previousResult, { fetchMoreResult }) => {
|
||||
if (
|
||||
!fetchMoreResult ||
|
||||
fetchMoreResult.project.model.versions.items.length === 0
|
||||
) {
|
||||
hasReachedEnd.value = true
|
||||
return previousResult
|
||||
}
|
||||
return {
|
||||
project: {
|
||||
id: previousResult.project.id,
|
||||
__typename: previousResult.project.__typename,
|
||||
model: {
|
||||
id: previousResult.project.model.id,
|
||||
__typename: previousResult.project.model.__typename,
|
||||
versions: {
|
||||
__typename: previousResult.project.model.versions.__typename,
|
||||
totalCount: previousResult.project.model.versions.totalCount,
|
||||
cursor: fetchMoreResult?.project.model.versions.cursor,
|
||||
items: [
|
||||
...previousResult.project.model.versions.items,
|
||||
...fetchMoreResult.project.model.versions.items
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
refetch()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,104 @@
|
||||
<template>
|
||||
<button
|
||||
class="group text-left relative bg-foundation-2 rounded p-1 hover:text-primary hover:bg-primary-muted transition cursor-pointer hover:shadow-md"
|
||||
>
|
||||
<div class="flex items-center space-x-2 max-[275px]:space-x-0">
|
||||
<div class="max-[275px]:hidden">
|
||||
<div
|
||||
v-if="model.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="previewUrl"
|
||||
alt="preview image for model"
|
||||
class="h-12 w-12 object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="h-12 w-12 bg-blue-500/10 rounded flex items-center justify-center"
|
||||
>
|
||||
<CommonLoadingIcon />
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-w-0 w-full">
|
||||
<div class="text-body-3xs text-foreground-2 truncate" :title="model.name">
|
||||
{{ folderPath }}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-around space-x-2">
|
||||
<div class="text-heading-sm grow truncate text-ellipsis">
|
||||
{{ model.displayName }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-body-3xs text-foreground-2 truncate flex space-x-2">
|
||||
<div>updated {{ updatedAgo }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2 max-[275px]:hidden">
|
||||
<div class="px-1 text-xs flex items-center">
|
||||
<div>{{ model.versions.totalCount }}</div>
|
||||
<ClockIcon class="ml-1 h-3" />
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<SourceAppBadge v-if="sourceApp" :source-app="sourceApp" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import dayjs from 'dayjs'
|
||||
import { CubeTransparentIcon } from '@heroicons/vue/20/solid'
|
||||
import { ClockIcon } from '@heroicons/vue/24/outline'
|
||||
import type { SourceAppName } from '@speckle/shared'
|
||||
import { SourceApps } from '@speckle/shared'
|
||||
import type { ModelListModelItemFragment } from '~/lib/common/generated/gql/graphql'
|
||||
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(() => {
|
||||
const splitName = props.model.name.split('/')
|
||||
if (splitName.length === 1) return ' '
|
||||
const withoutLast = splitName.slice(0, -1)
|
||||
return withoutLast.join('/')
|
||||
})
|
||||
|
||||
const updatedAgo = computed(() => {
|
||||
return dayjs(props.model.updatedAt).from(dayjs())
|
||||
})
|
||||
|
||||
const 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]
|
||||
|
||||
return (
|
||||
SourceApps.find((sapp) =>
|
||||
version.sourceApplication?.toLowerCase()?.includes(sapp.searchKey.toLowerCase())
|
||||
) || {
|
||||
searchKey: '',
|
||||
name: version.sourceApplication as SourceAppName,
|
||||
short: version.sourceApplication?.substring(0, 3) as string,
|
||||
bgColor: '#000'
|
||||
}
|
||||
)
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<div
|
||||
v-tippy="cardTippy"
|
||||
:class="`group relative bg-foundation-2 rounded px-2 py-1 transition ${
|
||||
hasAccess
|
||||
? 'cursor-pointer hover:text-primary hover:bg-primary-muted hover:shadow-md'
|
||||
: 'cursor-not-allowed italic bg-neutral-500/5'
|
||||
} `"
|
||||
>
|
||||
<div
|
||||
:class="`text-heading-sm text-ellipsis truncate ${
|
||||
hasAccess ? '' : 'text-foreground-2'
|
||||
}`"
|
||||
>
|
||||
{{ project.name }}
|
||||
</div>
|
||||
<div class="text-body-3xs text-foreground-2">
|
||||
{{ projectRole }}, updated {{ updatedAgo }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import dayjs from 'dayjs'
|
||||
import type { ProjectListProjectItemFragment } from '~/lib/common/generated/gql/graphql'
|
||||
const props = defineProps<{
|
||||
project: ProjectListProjectItemFragment
|
||||
isSender: boolean
|
||||
}>()
|
||||
|
||||
const updatedAgo = computed(() => {
|
||||
return dayjs(props.project.updatedAt).from(dayjs())
|
||||
})
|
||||
|
||||
const cardTippy = computed(() => (!hasAccess.value ? disabledMessage.value : ''))
|
||||
|
||||
// Previously we were having hard coded messaging, web team will provide better messaging per permission here instaed common message
|
||||
const disabledMessage = computed(() =>
|
||||
props.isSender
|
||||
? props.project.permissions.canPublish.message
|
||||
: props.project.permissions.canLoad.message
|
||||
)
|
||||
|
||||
const hasAccess = computed(() =>
|
||||
props.isSender
|
||||
? props.project.permissions.canPublish.authorized
|
||||
: props.project.permissions.canLoad.authorized
|
||||
)
|
||||
|
||||
const projectRole = computed(() => {
|
||||
if (hasAccess.value) {
|
||||
return 'Can edit'
|
||||
}
|
||||
return 'Can view'
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,164 @@
|
||||
<template>
|
||||
<button
|
||||
:class="`relative block text-left shadow rounded-md bg-foundation-2 hover:bg-primary-muted overflow-hidden transition `"
|
||||
:disabled="(selectedVersionId === version.id && !fromWizard) || isLimited"
|
||||
>
|
||||
<UserAvatar
|
||||
v-tippy="`Authored by ${version.authorUser?.name}`"
|
||||
:user="{ avatar: version.authorUser?.avatar, name: version.authorUser?.name as string }"
|
||||
size="sm"
|
||||
class="absolute inset-1"
|
||||
/>
|
||||
<div v-if="isLimited">
|
||||
<div
|
||||
class="bg-foundation h-24 w-full flex-shrink-0 rounded-md border border-outline-3"
|
||||
:class="isLimited ? 'diagonal-stripes' : ''"
|
||||
>
|
||||
<div class="flex flex-col items-center justify-center space-y-2 w-full h-full">
|
||||
<div
|
||||
class="flex h-10 w-10 items-center justify-center rounded-md bg-foundation border border-outline-3"
|
||||
>
|
||||
<LockClosedIcon class="h-5 w-5 text-foreground-3 z-20" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="flex items-center justify-center w-full h-24">
|
||||
<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">
|
||||
<SourceAppBadge
|
||||
:source-app="
|
||||
SourceApps.find((sapp) =>
|
||||
version.sourceApplication?.toLowerCase()?.includes(sapp.searchKey.toLowerCase())
|
||||
) || {
|
||||
searchKey: '',
|
||||
name: version.sourceApplication as SourceAppName,
|
||||
short: version.sourceApplication?.substring(0, 3) as string,
|
||||
bgColor: '#000'
|
||||
}
|
||||
"
|
||||
/>
|
||||
<span class="text-body-2xs text-foreground-2 truncate">{{ createdAgo }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<CommonBadge
|
||||
v-if="latestVersionId === version.id && selectedVersionId !== latestVersionId"
|
||||
dot
|
||||
dot-icon-color-classes="animate-ping"
|
||||
class="absolute top-1 right-1 shadow"
|
||||
>
|
||||
Latest
|
||||
</CommonBadge>
|
||||
<CommonBadge
|
||||
v-if="selectedVersionId === version.id"
|
||||
dot
|
||||
color-classes="bg-foundation"
|
||||
class="absolute top-1 right-1 shadow"
|
||||
>
|
||||
Current
|
||||
</CommonBadge>
|
||||
<!-- Warning if obj is coming from the v2 side -->
|
||||
<!-- <div v-if="!objectVersion" class="bottom-0 left-0">
|
||||
<div
|
||||
class="text-body-2xs px-2 bg-blue-500/5 py-2 text-foreground-2 flex items-center space-x-1 justify-center"
|
||||
>
|
||||
<div>Compatibility warning:</div>
|
||||
<FormButton size="sm" text @click.stop="showCompatWarning = true">
|
||||
read more
|
||||
</FormButton>
|
||||
<CommonDialog
|
||||
v-model:open="showCompatWarning"
|
||||
title="Compatibility warning"
|
||||
fullscreen="none"
|
||||
>
|
||||
This version might not receive as expected.
|
||||
<br />
|
||||
<br />
|
||||
As we progress with the new Speckle, there are a few things that won’t work as
|
||||
expected. We recommend you send this model again using next connectors if
|
||||
available.
|
||||
<br />
|
||||
<br />
|
||||
We will do our best to convert, but, for example, Instances (Blocks), Render
|
||||
Materials, Parameters and others will not work from the previous version of
|
||||
the connectors.
|
||||
<div class="mt-4 flex justify-end items-center space-x-2">
|
||||
<FormButton size="sm" @click="showCompatWarning = false">
|
||||
Understood
|
||||
</FormButton>
|
||||
</div>
|
||||
</CommonDialog>
|
||||
</div>
|
||||
</div> -->
|
||||
</button>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { LockClosedIcon } from '@heroicons/vue/24/solid'
|
||||
import dayjs from 'dayjs'
|
||||
import type { SourceAppName } from '@speckle/shared'
|
||||
import { SourceApps } from '@speckle/shared'
|
||||
import type { VersionListItemFragment } from '~/lib/common/generated/gql/graphql'
|
||||
import { 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
|
||||
latestVersionId: string
|
||||
accountId: string
|
||||
projectId: string
|
||||
workspaceSlug?: string
|
||||
selectedVersionId?: string
|
||||
fromWizard?: boolean
|
||||
}>()
|
||||
|
||||
const createdAgo = computed(() => {
|
||||
return dayjs(props.version.createdAt).from(dayjs())
|
||||
})
|
||||
|
||||
const isLimited = computed(() => props.version.referencedObject === null)
|
||||
|
||||
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,
|
||||
// () => ({ projectId: props.projectId, objectId: props.referencedObjectId }),
|
||||
// () => ({ clientId: props.accountId })
|
||||
// )
|
||||
|
||||
// type Data = {
|
||||
// version?: number
|
||||
// }
|
||||
// const objectVersion = computed(() => {
|
||||
// const data = objectQueryResult.value?.project?.object?.data as Data | undefined
|
||||
// return data?.version
|
||||
// })
|
||||
|
||||
// const showCompatWarning = ref(false)
|
||||
</script>
|
||||
@@ -0,0 +1,35 @@
|
||||
<template>
|
||||
<div
|
||||
:class="[
|
||||
'flex shrink-0 overflow-hidden rounded-md border border-outline-2 bg-foundation-2',
|
||||
sizeClasses
|
||||
]"
|
||||
>
|
||||
<div
|
||||
class="h-full w-full bg-cover bg-center bg-no-repeat flex items-center justify-center"
|
||||
:style="logo ? { backgroundImage: `url('${logo}')` } : {}"
|
||||
>
|
||||
<span v-if="!logo" class="text-foreground-3 uppercase leading-none">
|
||||
{{ name[0] }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { MaybeNullOrUndefined } from '@speckle/shared'
|
||||
import { type UserAvatarSize, useAvatarSizeClasses } from '@speckle/ui-components'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
size?: UserAvatarSize
|
||||
logo: MaybeNullOrUndefined<string>
|
||||
name: string
|
||||
}>(),
|
||||
{
|
||||
size: 'base'
|
||||
}
|
||||
)
|
||||
|
||||
const { sizeClasses } = useAvatarSizeClasses({ props: toRefs(props) })
|
||||
</script>
|
||||
@@ -0,0 +1,54 @@
|
||||
<template>
|
||||
<button
|
||||
:class="`group block w-full p-1 text-left rounded-md items-center space-x-2 select-none group transition hover:bg-primary-muted hover:text-primary ${
|
||||
workspace.readOnly
|
||||
? 'text-danger bg-rose-500/10 cursor-not-allowed'
|
||||
: 'cursor-pointer'
|
||||
} ${
|
||||
currentSelectedWorkspaceId === workspace.id ? 'bg-blue-500/5 text-primary' : ''
|
||||
}`"
|
||||
:disabled="workspace.readOnly"
|
||||
@click="$emit('select', workspace)"
|
||||
>
|
||||
<div class="flex items-center space-x-2">
|
||||
<WorkspaceAvatar
|
||||
:size="'sm'"
|
||||
:name="workspace.name || ''"
|
||||
:logo="workspace.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
|
||||
currentSelectedWorkspaceId: string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
(e: 'select', workspace: WorkspaceListWorkspaceItemFragment): void
|
||||
}>()
|
||||
</script>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user