Compare commits

...

130 Commits

Author SHA1 Message Date
Mucahit Bilal GOKER a38a699123 fix: hide create project/model buttons on receive flow (#101)
Release / get-version (push) Has been cancelled
Release / lint (push) Has been cancelled
Release / build (push) Has been cancelled
* Hide "Create Project/Model" Buttons in Load Flow

* simplify

* add isSender to modelselector

* chore: run linting on ModelSelector

---------

Co-authored-by: Oğuzhan Koral <45078678+oguzhankoral@users.noreply.github.com>
Co-authored-by: Björn Steinhagen <steinhagen.bjoern@gmail.com>
2026-04-15 19:45:21 +02:00
Björn Steinhagen 2ca577fe60 fix: show inaccessible state for project collapsible when account is missing (#103)
* fix(dui): show remove option for inaccessible project groups

* fix: show inaccessible state for project collapsible when account is missing

* chore: reverting previous unrelated changes
2026-04-10 10:45:16 +02:00
Iain Sproat c37235381f feat(deployment): package as Docker image & Helm Chart (#98)
* feat(deployment): package as Docker image & Helm Chart

* remove erroneous permission request

* fix corepack issue

* fix prettier

* deployment testing of helm chart with ctlptl, tilt & kind

* fix linting

* remove need for license to be mounted

* ensure consistency in naming

* incorporate copilot comments

* fix CI pipeline

* fix

* incorporate copilot review comments

* include MIXPANEL environment variable

* remove single quotes from NODE_ENV ARG

---------

Co-authored-by: Oğuzhan Koral <45078678+oguzhankoral@users.noreply.github.com>
2026-04-10 11:42:14 +03:00
Oğuzhan Koral 8e2f507286 fix: version check on dev env in connectors (#102)
* fix: version check on dev env in connectors

* chore: bump version
2026-04-08 12:18:30 +03:00
Björn Steinhagen 9d3a623fe6 feat(dui): add disable cache toggle to main menu (#99)
* feat(dui): adds disable cache setting

* fix(dui): excludes non-sharp dui connectors manually with slug check

* chore(dui): adds todo

* feat(dui): adds version check to isDisableCacheSupported
2026-04-02 11:34:32 +02:00
Björn Steinhagen 8fc81b0b4e fix(dui): stale load settings to existing model card (#94)
Co-authored-by: Oğuzhan Koral <45078678+oguzhankoral@users.noreply.github.com>
2026-03-27 19:29:33 +03:00
Björn Steinhagen 6f2f599b1b fix: redirect to workspace on sso session error (#97) 2026-03-27 19:15:54 +03:00
Oğuzhan Koral a69de13f16 feat: refactor auth flow and enable exchange token flow (#95)
* feat: refactor auth flow and enable exchange token flow

* fix: do not cache to local storage for exchange token

* chore: remove logging

* chore: lint

* feat: pkce alignment with oauth endpoint

* feat: default log in via accountBinding.authenticateAccount if available

* feat: do not show legacy sign in if connectors has accountBinding.authenticateAccount flow

* fix: base64url safe
2026-03-25 17:21:07 +03:00
Björn Steinhagen d2b0d35119 feat: parameter updater (#92)
* feat(issues): add apply changes workflow for parameter updater

* chore(issues): remove my wip comments

* chore: conflicts on generated

* chore: resolving conflicts

* chore: new queries

* chore: reverting

* feat: refactor to dedicated IParametersBinding

* feat(dui): disable apply changes button for resolved issues

* fix(dui): assert workspaceId is non-null in issue query
2026-03-18 12:55:25 +02:00
Björn Steinhagen b026659460 refactor: upsell message (#88)
* chore: upsell message

* fix: upgrade cta

---------

Co-authored-by: Oğuzhan Koral <45078678+oguzhankoral@users.noreply.github.com>
Co-authored-by: oguzhankoral <oguzhankoral@gmail.com>
2026-03-06 11:40:14 +03:00
Oğuzhan Koral 009cc77bab fix: correct url for create workspace action (#93) 2026-03-06 10:52:46 +03:00
Björn Steinhagen a8b802b7e3 fix(dui): prevents empty publish selection state
fix(dui): prevents empty publish selection state
2026-03-03 08:29:40 +02:00
Björn Steinhagen 6fc3df4a0d refactor: centralized filter validation and generalized 'empty selection' checks 2026-03-02 15:34:36 +02:00
Björn Steinhagen f47f19c02d Merge branch 'main' into bjorn/cnx-3125-prevent-publishing-without-a-valid-selection 2026-02-25 14:42:35 +02:00
Oğuzhan Koral 85f806368a feat: handle model card state according to given ingestion id (#89)
* feat: handle model card state according to given ingestion id

* chore: linting
2026-02-25 14:00:59 +03:00
Björn Steinhagen 35ddce1f90 fix(dui): prevents invalidate selection filter across not just selection 2026-02-25 11:01:30 +02:00
Björn Steinhagen a37b3389d6 fix(dui): prevents empty publish selection state 2026-02-25 10:23:19 +02:00
Björn Steinhagen ed4aa92ce1 fix: disable deletion of model card while ops are happening (#87)
* chore: battling git

* fix: logic to card base for sender and receiver fix
2026-02-16 11:50:28 +03:00
Björn Steinhagen 60f3bed254 feat: loading state on publish wizard
feat: loading state on publish button
2026-02-03 14:16:20 +02:00
Björn 2f412df64a feat: loading state on publish button 2026-02-03 14:11:37 +02:00
Oğuzhan Koral c7e0929eca feat: new business model changes (#85)
* feat: initial can create version implementation on model card

* feat: disable model card CTAs for send

* feat: initial model ingestion tests

* fix: apply ingestion send to all CTAs

* feat: sketchup bridge

* feat: centeralize the start ingestion logic in host app store

* fix: sketchup is handling via model ingestion

* chore: cosmetics

* feat(ingestion): add failWithError and failWithCancel GraphQL mutations

* feat(ingestion): add failIngestion and cancelIngestion methods to useModelIngestion composable

* feat(ingestion): handle ingestion failure and cancellation in hostAppStore

* fix: reviewers comments

* fix: don't know where the f that came from

* refactor(ingestion): remove unused statusData and fix lint errors

* feat(wizard): add canCreateVersion permission check to publish wizard

* TODOs

* feat(permissions): add 1s polling for canCreateVersion to reflect workspace limit changes

* fix(tooltip): undefined doesnt refresh v-tippy

* fix(wizard): too much ctrl z lol

* refactor(permissions): check canCreateVersion on action instead of polling

* feat(hostApp): adds fallback for model ingestion on older servers

* fix: ingestion available check and rock'n roll

* feat: workspace plan updated subscription boilerplate

* fix: bump the timeout to 2h

* feat: handle version limits in publish flows via subscription

* feat: align Archicad and Vectorworks with new ingestion flow

* chore: onMounted at end of file

* fix: logic and ui adjustments

* fix: refactoring and permissions

* refactor: ingestionStatus renamed to activeIngestions

* fix: error handling and notifications

* fix: global error handling

* chore: general alignment and clean up

* fix(vectorworks): now uses capital V

* chore: revert codegen

---------

Co-authored-by: Björn Steinhagen <88777268+bjoernsteinhagen@users.noreply.github.com>
Co-authored-by: Björn Steinhagen <steinhagen.bjoern@gmail.com>
2026-02-03 14:43:16 +03:00
Oğuzhan Koral eef0a59719 feat: disable intercom for non speckle distributions + partner badge (#84)
* feat: disable intercom for non speckle distributions + partner badge

* no logging
2026-01-16 18:00:49 +03:00
Dogukan Karatas 19f306756c fix: handle network connectivity in DUI (#80)
* error handler

* top-level handling

* internet check

* pass other network errors

---------

Co-authored-by: Oğuzhan Koral <45078678+oguzhankoral@users.noreply.github.com>
2026-01-12 17:51:47 +03:00
Oğuzhan Koral 305b100d34 feat: remove personal projects (#82) 2026-01-08 12:56:31 +03:00
Oğuzhan Koral f2cc0d55e3 fix: workspace avatars (#81)
* fix: workspace avatars

* get rid of from old logo prop
2026-01-06 16:21:53 +03:00
Oğuzhan Koral fdfef1d496 feat: issues (#77)
* WIP

* feat: readonly issues in connectors

* fix created at on replies

* filter out by resourceStringId

* show label name if just one

* generate gql

* linting

* linting
2025-12-10 18:01:13 +03:00
Oğuzhan Koral 5174af78cc fix: remove completed state for workspaces (#78)
* fix: remove completed state for workspaces

* remove experimental create automation dialog
2025-12-03 18:45:04 +03:00
Oğuzhan Koral ede6e99440 feat: new auth is default, desktop service is legacy and fallback (#76)
* feat: new auth is default, desktop service is legacy and fallback

* cleanup

* css

* rename login to signin

* better buttons

* default value instead placeholder
2025-11-25 23:04:40 +03:00
Oğuzhan Koral 9c708c64a0 fix: account by url should default to active one first (#75) 2025-11-11 18:31:21 +03:00
Oğuzhan Koral 41e635c8ef store url in cache (#74) 2025-10-27 16:53:35 +03:00
Oğuzhan Koral 095ccf114d feat: auth in dui (#71)
* feat: auth in dui

* feat: enable auth with registered app

* feat: handle exceptions
2025-10-27 15:31:56 +03:00
Dogukan Karatas a95fd9bdfe adds the server_domain (#62) 2025-10-16 16:23:01 +03:00
Dogukan Karatas bc665a008c userId is added to properties (#61) 2025-10-16 16:12:03 +03:00
Oğuzhan Koral 00a6a66ee0 fix: confusion on CTA and dry messaging (#70)
* fix: confusion on CTA and dry messaging

* make bjorn happy again
2025-10-15 10:39:04 +03:00
Oğuzhan Koral b0157af3c8 fix: find workspace from active limited workspace (#69) 2025-10-15 10:17:13 +03:00
Oğuzhan Koral 9b065bf921 fix(sketchup): disable progress update for now till replacing with objectloader2 (#68) 2025-10-15 10:16:45 +03:00
Dogukan Karatas 99ebd403c7 feat: track settings change on mixpanel events (#65)
* adds properties to update settings

* adds settings track on publish

* added track on publish/receive

* renaming

* fix some types

* introduced a helper function

* created a separate composable

* updated the comparing
2025-10-09 23:51:15 +03:00
Björn Steinhagen a166b86657 fix: hide update alert for non-distributed connectors (#63)
* fix: hide update alert for non-distributed connectors

* chore: formatting
2025-10-09 09:36:11 +03:00
Björn Steinhagen 185ba0f50a fix: replace Manager references with Desktop Service download link (#64)
* fix: replace Manager references with Desktop Service download link

* chore: restores old commented-out code

* fix: redirect to releases

---------

Co-authored-by: Oğuzhan Koral <45078678+oguzhankoral@users.noreply.github.com>
2025-10-09 09:27:47 +03:00
Oğuzhan Koral 49cabaa1bc Feat: archicad layers (#66)
* ugly workaround

* ugly workaround for archicadLayers

* comment
2025-10-08 17:09:19 +03:00
Björn Steinhagen 11b6d5254e feat(etabs): improve MultiEnumControlRenderer UX for analysis result export (#60)
* fix(ui): add scrolling support to MultiEnumControlRenderer dropdown

* feat(ui): add select all/deselect all functionality to MultiEnumControlRenderer

* fix(ui): prevent jumpiness and dropdown misalignment

* fix: not generous enough on the width

* fix: heigh alignment and comments
2025-09-19 15:07:04 +03:00
Björn Steinhagen aa5d59ba5b chore: mapper terminology (#59) 2025-09-05 16:22:44 +01:00
Oğuzhan Koral 1cdd5c89a4 check against null which was failing on production only (#58) 2025-08-27 10:37:16 +03:00
Oğuzhan Koral 047dbff259 Fix unnecessary is in check (#57) 2025-08-26 19:51:42 +03:00
Oğuzhan Koral ffff7366c3 Feat: disable update prompt in connectors (#56)
* Do not check for updates if it explicitly disabled by someone

* fix order of ops

* remove unused function

* check function in binding is implemented

* remove console logging

* sort logic finally

* fix mocked binding
2025-08-26 19:15:31 +03:00
Björn Steinhagen 4ecd6fbee9 fix(dui): align active workspace with recent GraphQL API changes (#55)
* feat: add `isProjectsActive` parameter to `setActiveWorkspace` mutation

* feat: pass `isProjectsActive` parameter to `setActiveWorkspace` mutation

* fix: align GraphQL queries with `LimitedWorkspace` schema

* fix: handle LimitedWorkspace type in activeWorkspace logic

* chore: regenerate GraphQL types after schema alignment
2025-08-25 14:16:45 +03:00
Oğuzhan Koral 54039daa32 fix: mixpanel email (#54) 2025-08-19 16:15:50 +01:00
Oğuzhan Koral b7e347f3f0 Chore: disable intercom for externals (#53)
* switches mapper terminology to category assignment

* linting

* chore: disable intercom for external developers

---------

Co-authored-by: Claire Kuang <kuang.claire@gmail.com>
2025-08-18 22:20:27 +01:00
Claire Kuang c8f85c3874 fix(mapper): switches mapper terminology to category assignment (#52)
* switches mapper terminology to category assignment

* linting

---------

Co-authored-by: oguzhankoral <oguzhankoral@gmail.com>
2025-08-18 14:14:39 +01:00
Björn Steinhagen 85405d10dd chore: separate header for mapper (#51) 2025-08-18 11:08:01 +01:00
Björn Steinhagen d8fdc2c3c5 fix(mapper): preserve mapping mode state on navigation (#49)
* feat: poc

- needs cleaning
- just me, hacking

* refactor: cleaning

* chore: update available categories

* fix: remember previous mode

* fix: clear search string after mapping

* feat: add Mixpanel tracking to revit mapper interactions (#50)

* feat: add Mixpanel tracking to revit mapper interactions

* fix: pr comments

* fix: just mode

* chore(interop-lite): rename event name prop

---------

Co-authored-by: oguzhankoral <oguzhankoral@gmail.com>

* revit mapper store

* WIP

* Fix form select base placeholder on select

* refactor: convention, not composable

* fix: deselecting objects through mapped mode

* fix: eslinting ?

* chore: remove console log

---------

Co-authored-by: oguzhankoral <oguzhankoral@gmail.com>
2025-08-15 18:05:18 +03:00
Björn Steinhagen 034d8645c6 feat(ui): show existing category mappings in revit mapper dropdown (#48)
* feat: poc

- needs cleaning
- just me, hacking

* refactor: cleaning

* chore: update available categories

* feat: add Mixpanel tracking to revit mapper interactions (#50)

* feat: add Mixpanel tracking to revit mapper interactions

* fix: pr comments

* fix: just mode

* chore(interop-lite): rename event name prop

---------

Co-authored-by: oguzhankoral <oguzhankoral@gmail.com>

* revit mapper store

* WIP

* Fix form select base placeholder on select

* refactor: convention, not composable

* fix: deselecting objects through mapped mode

* fix: eslinting ?

* chore: remove console log

---------

Co-authored-by: oguzhankoral <oguzhankoral@gmail.com>
2025-08-15 17:53:07 +03:00
Björn Steinhagen d797a65fab fix: pre-selected objects (#47) 2025-08-14 15:28:45 +03:00
Björn Steinhagen 028c9d2ac1 feat(dui): layer mapping for revit integration in interop lite (#44)
* feat: update mapper binding interface for layer support and renamed methods

* fix: add missing layer mock methods to `IRevitMapperBinding`

* feat: adds mode toggle

* feat: layer dropdown

* feat: hierarchical layer object highlighting and simple mappings mgmt

* fix: multi instead of base

* fix: refresh layer list on doc switch

* fix: formatting

* feat: added `Select All` button and updated event handling

* fix: event handling

* refactor: components to make mapper more maintainable

* chore: rename button to Assign Revit Categories

* refactor: hardcoded list now in dui

* fix: pr comments

* fix: redundant div

* refactor: remove redundant Props interfaces in mapper components

* refactor: group conditional buttons in mapper

* fix: auto import not working?

* fix: jokes i was being dumb

* chore(revit-mapper): css

---------

Co-authored-by: oguzhankoral <oguzhankoral@gmail.com>
2025-08-14 15:01:29 +03:00
Oğuzhan Koral b2695e77f5 fix(settings): warn user and force push them to refresh (#43) 2025-08-07 11:30:18 +03:00
Björn Steinhagen 669afe81cf feat(rhino): add revit mapper UI for category assignment (#41)
* feat: basic structure

* feat: categories

* feat: selection filter

* chore: mock categories

* feat: second iteration

* docs: comments

* feat: create mapper binding interface

* feat: register bindings

* feat: add Revit Integration button

conditionally based on the presence or absence of binding

* fix: tooltip

* fix: missing method and interface for `getAvailableCategories`

* fix: remove hardcoded categories

* chore: categories from connector

* chore: remaining methods

* chore: remove unused method

* fix: removing duplicate interfaces

* chore: cleanups

* fix: add DocumentModelStore dependency for event handling

* fix: linting

* fix: dropdown

* fix: again, linting

* chore: don't need the double label

* fix: missing label

* chore: small tweaks

* chore: name

* chore(revit-mapper): css

* chore(revit-mapper): correct routing

* fix(revit-mapper): revit integration buttons

---------

Co-authored-by: oguzhankoral <oguzhankoral@gmail.com>
2025-08-06 14:55:44 +03:00
Oğuzhan Koral 48bb180899 feat: remove button for deleted models (#40) 2025-08-01 20:06:58 +03:00
Oğuzhan Koral 3b4aa93858 Feat: mocked bindings and logging to seq (#39)
* mocked bindings and logging to seq

* test deploy

* test deploy

* test deploy

* connectorless state

* remove logs

* remove more logs

* add flags to globalThus

* log with /api/events/raw

* log error link on prod over local account

* handle test query to distinguish self hosters

* throw again

* log again...

* sa and ra

* error policy non none

* attach server url to logs

* Add host app version

* rename name to slug

* remove useless re throw

* fix confusion on versions
2025-07-23 15:51:09 +01:00
Oğuzhan Koral 4ebf702ab2 Feat: receive settings (#35)
* Receive settings for POC for now

* Patch the model after settings change
2025-06-27 12:38:47 +03:00
Oğuzhan Koral 6e6bd423a0 multi selectable card setting (#38) 2025-06-25 19:30:30 +03:00
Oğuzhan Koral 57ef9685b6 Pass URL origin to auth flow (#37) 2025-06-24 18:19:18 +03:00
Oğuzhan Koral 2ff5849739 Handle connectors that not deployed by Speckle (#36) 2025-06-19 20:07:36 +03:00
Oğuzhan Koral e55c0ca7dd Change the messaging for personal projects (#34) 2025-06-17 12:57:35 +03:00
Dimitrie Stefanescu 8ef79f7a7c Merge pull request #33 from specklesystems/oguzhan/hide-receive-button-on-navbar
Fix: hide receive button according to binding
2025-06-17 10:35:19 +01:00
oguzhankoral 79951f7cf7 Hide receive button according to binding 2025-06-17 12:22:23 +03:00
Dimitrie Stefanescu cf70ddc79b fix: don't throw on zero affected elements on automate results (#32) 2025-06-09 15:58:01 +03:00
Dimitrie Stefanescu 4e16813c75 Merge pull request #31 from specklesystems/dim/correctly-respect-server-roles
feat: correctly respects server roles when adding by url
2025-06-05 14:28:04 +01:00
Dimitrie Stefanescu ee4e7576ad feat: correctly respects server roles 2025-06-05 14:22:52 +01:00
Oğuzhan Koral 3ba11c983b Feat: open workspace and server in web buttons (#30)
* Open workspace and server buttons

* Do not have navigator for personal projects
2025-06-04 19:53:52 +03:00
Dimitrie Stefanescu d92dcf5342 Merge pull request #29 from specklesystems/dimitrie/cnx-1968-update-dui-and-appspsecklesystems-doc-links
feat: points docs to docs.speckle.systems
2025-06-03 13:42:05 +01:00
Dimitrie Stefanescu 55afedab68 feat: points docs to docs.speckle.systems 2025-06-03 12:58:24 +01:00
Dimitrie Stefanescu a8d948ec71 Merge pull request #25 from specklesystems/intercom
Adds intercom
2025-06-03 10:30:38 +01:00
Dimitrie Stefanescu 65a9d3e485 Merge branch 'main' into intercom 2025-06-03 09:30:43 +01:00
Oğuzhan Koral be631746b9 Fix explore plans (#28) 2025-06-02 16:25:00 +03:00
Dimitrie Stefanescu 20c43f2108 feat: adds last guards 2025-05-30 17:59:53 +01:00
Dimitrie Stefanescu af0de85ef7 chore: comment 2025-05-30 17:38:02 +01:00
Dimitrie Stefanescu 7c54845a05 removes runtime config 2025-05-30 17:37:14 +01:00
Oğuzhan Koral cbec244443 Fix: exclude incomplete workspaces (#26)
* Exclude incomplete workspaces

* get rid of from computed value
2025-05-30 19:36:52 +03:00
Dimitrie Stefanescu a2a9ab1f4b another try 2025-05-30 17:36:35 +01:00
Dimitrie Stefanescu 2f87c34272 reverts bad change 2025-05-30 17:34:30 +01:00
Dimitrie Stefanescu 7fde35e639 feat: maybe fix 2025-05-30 17:27:54 +01:00
Dimitrie Stefanescu 97765d84ca fix: maybe prod fix 2025-05-30 16:56:21 +01:00
Dimitrie Stefanescu 9d15be73ad Merge branch 'intercom' of https://github.com/specklesystems/speckle-connectors-dui into intercom 2025-05-30 16:52:00 +01:00
Dimitrie Stefanescu 45f763a2ce feat: makes intercom plugin client side only 2025-05-30 16:48:46 +01:00
Oğuzhan Koral 3c6bda7af9 Merge branch 'main' into intercom 2025-05-30 17:52:00 +03:00
Dimitrie Stefanescu 007794dae2 chore: just a comment 2025-05-30 15:24:41 +01:00
Dimitrie Stefanescu f8912338cb chore: unfies watch logic on active account 2025-05-30 15:11:42 +01:00
Dimitrie Stefanescu f932fa46a3 feat: stylign + account handling changes for intercom, when no accounts are present 2025-05-30 15:07:32 +01:00
Oğuzhan Koral 292d2bf0bb Feat: Handle new automate schema (#24)
* Handle new automate schema

* Get rid of from old schema for automate
2025-05-30 17:04:22 +03:00
Dimitrie Stefanescu 43340d9b52 feat: maybe fix for revit 2022 2025-05-30 14:44:06 +01:00
Dimitrie Stefanescu 4898a9e2e9 chore: console.log etc cleanup 2025-05-30 14:25:52 +01:00
Dimitrie Stefanescu 19b982e2e3 feat: adds intercom. wip, revit 2022 seems to not like it 2025-05-30 14:14:48 +01:00
Dimitrie Stefanescu e3cf896c14 Merge pull request #23 from specklesystems/dim/url-fixes
feat: removes buttons if there's a faulty url parsed
2025-05-27 17:42:35 +01:00
Dimitrie Stefanescu 34d855212f Merge branch 'main' into dim/url-fixes 2025-05-27 17:30:31 +01:00
Dimitrie Stefanescu 8d159547d4 feat: removes buttons if there's a faulty url parsed 2025-05-27 17:28:37 +01:00
Dimitrie Stefanescu 0c3ee8b38f Merge pull request #22 from specklesystems/dimitrie/cnx-1781-in-the-revit-connector-add-the-option-to-select-all-the
feat: adds select/deselect all button in revit categories filter
2025-05-26 14:26:26 +01:00
Dimitrie Stefanescu c51f282644 feat: adds select/deselect all button in revit categories filter 2025-05-26 13:48:31 +01:00
Dimitrie Stefanescu 1faea0aec2 Merge pull request #21 from specklesystems/dimitrie/cnx-598-version-message
Allows to set a version message post send
2025-05-26 11:55:45 +01:00
Dimitrie Stefanescu 5e97acf4c0 chore: ogu should be happy now 2025-05-26 11:45:03 +01:00
Dimitrie Stefanescu 9c7413d630 chore: linting errs 2025-05-26 10:43:36 +01:00
Dimitrie Stefanescu 89acd7ca80 chore: comment on the hack 2025-05-26 10:40:30 +01:00
Dimitrie Stefanescu 2e51a2fb3c feat: adds mp 2025-05-26 10:37:07 +01:00
Dimitrie Stefanescu 644754262c feat: wraps up set version message 2025-05-26 10:36:54 +01:00
Dimitrie Stefanescu b28129c30e feat: sets message. todo: fix hack when needing to query for latest version 2025-05-22 21:39:00 +01:00
Dimitrie Stefanescu 305ad36cac feat: wip 2025-05-22 20:09:33 +01:00
Dimitrie Stefanescu 554d0ee478 Merge pull request #20 from specklesystems/dim/doc-changed-event-removal
feat: removes doc changed event
2025-05-22 10:09:29 +01:00
Dimitrie Stefanescu 33fd9c65e3 feat: removes doc changed event 2025-05-22 10:01:52 +01:00
Dimitrie Stefanescu ebaee49fe8 Merge pull request #17 from specklesystems/dimitrie/cnx-1859-bring-back-search-and-create-over-project-and-model
feat: reorganises project creation
2025-05-22 09:21:04 +01:00
Dimitrie Stefanescu b877c1d321 feat: ogu is now happy 2025-05-21 19:05:08 +01:00
Dimitrie Stefanescu be4dc87b3e Merge branch 'main' into dimitrie/cnx-1859-bring-back-search-and-create-over-project-and-model 2025-05-21 19:02:20 +01:00
Dimitrie Stefanescu 94ddc486aa chore: cleanup 2025-05-21 18:56:49 +01:00
Dimitrie Stefanescu ac5984d184 feat: prefills model name, disables create button if empty name, makes notificaiton buttons primary 2025-05-21 18:56:10 +01:00
Dimitrie Stefanescu 8dae170592 feat: brings back model creation from search & cleanup 2025-05-21 18:38:10 +01:00
Dimitrie Stefanescu d6f371c7d2 chore: removes console.logs 2025-05-21 18:13:04 +01:00
Dimitrie Stefanescu cbf9e8d578 fix: handles correctly servers with no workspaces 2025-05-21 18:04:26 +01:00
Dimitrie Stefanescu 6f10519e6f chore: cleanup 2025-05-21 17:37:35 +01:00
Dimitrie Stefanescu 590b8b8872 feat: upgrade actions 2025-05-21 17:34:51 +01:00
Dimitrie Stefanescu 2514b6c606 chore: removes unused comp 2025-05-21 17:22:52 +01:00
Dimitrie Stefanescu af37112a5f feat: reorganises project creation 2025-05-21 17:20:24 +01:00
Oğuzhan Koral d224b33bc8 Remove toast for update (#11) 2025-05-21 00:16:21 +03:00
Adam Hathcock 377cfc2f65 Feat(preview): get preview image over rest api (#8)
* pass token to the model card to allow it to be used when loading a preview

* fix lint conditons

* composable for preview url and reuse in version and model cards

* remove unused import

* handle no version scenario

* remove console log

---------

Co-authored-by: oguzhankoral <oguzhankoral@gmail.com>
2025-05-20 20:53:52 +03:00
Dimitrie Stefanescu fdd13170f5 Merge pull request #9 from specklesystems/dim/codegen-without-local-server
feat: enables running the gql codegen without having a local server running
2025-05-20 18:20:10 +01:00
Dimitrie Stefanescu e70310654b Merge pull request #10 from specklesystems/dimitrie/cnx-1656-default-model-name-input-doesnt-work
fix: in the model create dialog, always defaults to either a model name
2025-05-20 18:19:50 +01:00
Dimitrie Stefanescu 8bb14d3389 fix: in the model create dialog, always defaults to either a model name 2025-05-20 18:01:01 +01:00
Dimitrie Stefanescu 93e4762b6a feat: enables running the gql codegen without having a local server running 2025-05-20 17:47:10 +01:00
Oğuzhan Koral 21a4bd4076 Feat: personal projects messaging (#7)
* Remember the personal projects choose

* Remove unused workspace selector

* Messaging about personal projects

* Pushy on move personal project into workspace
2025-05-16 18:18:25 +03:00
Oğuzhan Koral 82c95aab58 Remove read access remaining from old logic (#6) 2025-05-15 16:05:05 +03:00
Oğuzhan Koral fe77ede49e feat: introduce CI linting & fix various issues (#5)
* introduce CI checks

* fixx

* add caching

* fixes

* wip

* server bridge linting

* No lint errors

* fix paths on lint:prettier

* make files pretty again

* fix stylelint

* fix lock

---------

Co-authored-by: Kristaps Fabians Geikins <fabis94@live.com>
2025-05-14 10:05:51 +03:00
Oğuzhan Koral f70915f485 Update README.md (#4) 2025-05-13 16:29:38 +03:00
Kristaps Fabians Geikins f2d7493c2a chore: move package over from speckle-server (#2)
* stuff copied over, but aint workin

* various fixes

* vscode settings

* trigger deploy

* trigger deploy
2025-05-13 16:18:45 +03:00
209 changed files with 45881 additions and 2 deletions
+41
View File
@@ -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/
+8
View File
@@ -0,0 +1,8 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 2
indent_style = space
insert_final_newline = true
+4
View File
@@ -0,0 +1,4 @@
/.yarn/** linguist-vendored
/.yarn/releases/* binary
/.yarn/plugins/**/* binary
/.pnp.* binary linguist-generated
+48
View File
@@ -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
+63
View File
@@ -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
+35
View File
@@ -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
+39
View File
@@ -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
+41
View File
@@ -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
View File
@@ -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
+39
View File
@@ -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
View File
@@ -0,0 +1,11 @@
{
"trailingComma": "none",
"tabWidth": 2,
"semi": false,
"endOfLine": "auto",
"bracketSpacing": true,
"vueIndentScriptAndStyle": false,
"htmlWhitespaceSensitivity": "ignore",
"printWidth": 88,
"singleQuote": true
}
+1
View File
@@ -0,0 +1 @@
dist/
+18
View File
@@ -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"]
}
+61
View File
@@ -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
}
+1
View File
@@ -0,0 +1 @@
nodeLinker: node-modules
+34 -2
View File
@@ -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...
+64
View File
@@ -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>
+31
View File
@@ -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
View File
@@ -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>
+93
View File
@@ -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>
+132
View File
@@ -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>
+177
View File
@@ -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>
+53
View File
@@ -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>
+39
View File
@@ -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>
+60
View File
@@ -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>
+134
View File
@@ -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>
+27
View File
@@ -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>
+336
View File
@@ -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>
+104
View File
@@ -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>
+277
View File
@@ -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>
+46
View File
@@ -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>
+14
View File
@@ -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>
+56
View File
@@ -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>
+37
View File
@@ -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>
+33
View File
@@ -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>
+114
View File
@@ -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>
+133
View File
@@ -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>
+43
View File
@@ -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>
+83
View File
@@ -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>
+146
View File
@@ -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>
+58
View File
@@ -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>
+79
View File
@@ -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>
+17
View File
@@ -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>
+18
View File
@@ -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>
+35
View File
@@ -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>
+7
View File
@@ -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>
+25
View File
@@ -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>
+139
View File
@@ -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>
+46
View File
@@ -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>
+33
View File
@@ -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>
+178
View File
@@ -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>
+97
View File
@@ -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>
+72
View File
@@ -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>
+51
View File
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
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>
+77
View File
@@ -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>
+142
View File
@@ -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>
+39
View File
@@ -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>
+34
View File
@@ -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>
+60
View File
@@ -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>
+195
View File
@@ -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>
&nbsp;created this issue &middot;
{{ 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>
&nbsp;replied &middot;
{{ 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>
+49
View File
@@ -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>
+82
View File
@@ -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>
+48
View File
@@ -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>
+15
View File
@@ -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>
+162
View File
@@ -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>
+588
View File
@@ -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>
+332
View File
@@ -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>
&nbsp;from&nbsp;
<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>
+413
View File
@@ -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 }}:&nbsp;</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>
+83
View File
@@ -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>
+237
View File
@@ -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>
+159
View File
@@ -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>
+135
View File
@@ -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>
+36
View File
@@ -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>
+63
View File
@@ -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>
+275
View File
@@ -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>
+9
View File
@@ -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>
+340
View File
@@ -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>
+634
View File
@@ -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>
+167
View File
@@ -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>
+104
View File
@@ -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>
+55
View File
@@ -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>
+164
View File
@@ -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 wont 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>
+35
View File
@@ -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>
+54
View File
@@ -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