Compare commits

...

132 Commits

Author SHA1 Message Date
Björn Steinhagen 553d3c3d7c Merge branch 'main' into parameter-updater 2026-03-18 08:42:22 +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 82b9c3a545 fix(dui): assert workspaceId is non-null in issue query 2026-03-04 20:38:12 +02:00
Björn Steinhagen 04faa51c45 feat(dui): disable apply changes button for resolved issues 2026-03-04 20:25:34 +02:00
Björn Steinhagen 4fdf61bf1e feat: refactor to dedicated IParametersBinding 2026-03-04 20:13:32 +02:00
Björn Steinhagen d870fdcb30 Merge pull request #91 from specklesystems/bjorn/cnx-3133-dui-apply-changes-workflow
feat(dui): add "apply changes" ui workflow for parameter updater
2026-03-04 17:25:43 +02:00
Björn Steinhagen 0f7c902711 chore: merge main and resolve generated graphql conflicts 2026-03-04 17:24:52 +02:00
Björn Steinhagen 9ab8f311c8 chore: reverting 2026-03-04 16:46:59 +02:00
Björn Steinhagen c52caa0838 chore: new queries 2026-03-04 16:40:50 +02:00
Björn Steinhagen 889ec0e3be chore: resolving conflicts 2026-03-04 16:39:16 +02:00
Björn Steinhagen 80a6d5501b chore: conflicts on generated 2026-03-04 16:28:04 +02:00
Björn Steinhagen 92f6d61c5d chore(issues): remove my wip comments 2026-03-03 11:03:52 +02:00
Björn Steinhagen 244e4236a5 feat(issues): add apply changes workflow for parameter updater 2026-03-03 11:00:09 +02: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
127 changed files with 11185 additions and 3769 deletions
-1
View File
File diff suppressed because one or more lines are too long
+44
View File
@@ -0,0 +1,44 @@
name: Linting
on:
pull_request:
branches:
- main
jobs:
lint-and-build:
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22.14.0'
- name: Enable Corepack and Install Correct Yarn Version
run: |
corepack enable
corepack prepare yarn@$(jq -r .packageManager package.json | cut -d'@' -f2) --activate
yarn --version
- name: Cache node_modules
uses: actions/cache@v4
with:
path: |
**/node_modules
.yarn/cache
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
- name: Install Dependencies
run: yarn install --immutable
- name: Run Linter
run: yarn lint
- name: Run generate
run: yarn generate
+35
View File
@@ -0,0 +1,35 @@
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
+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
}
+16 -16
View File
@@ -1,18 +1,18 @@
{
// See https://go.microsoft.com/fwlink/?LinkId=827846 to learn about workspace recommendations.
// Extension identifier format: ${publisher}.${name}. Example: vscode.csharp
// See https://go.microsoft.com/fwlink/?LinkId=827846 to learn about workspace recommendations.
// Extension identifier format: ${publisher}.${name}. Example: vscode.csharp
// List of extensions which should be recommended for users of this workspace.
"recommendations": [
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"Vue.volar",
"bradlc.vscode-tailwindcss",
"stylelint.vscode-stylelint",
"cpylua.language-postcss",
"graphql.vscode-graphql",
"graphql.vscode-graphql-syntax"
],
// List of extensions recommended by VS Code that should not be recommended for users of this workspace.
"unwantedRecommendations": ["octref.vetur"]
}
// List of extensions which should be recommended for users of this workspace.
"recommendations": [
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"Vue.volar",
"bradlc.vscode-tailwindcss",
"stylelint.vscode-stylelint",
"cpylua.language-postcss",
"graphql.vscode-graphql",
"graphql.vscode-graphql-syntax"
],
// List of extensions recommended by VS Code that should not be recommended for users of this workspace.
"unwantedRecommendations": ["octref.vetur"]
}
+1 -1
View File
@@ -1 +1 @@
nodeLinker: node-modules
nodeLinker: node-modules
+6 -12
View File
@@ -1,6 +1,6 @@
# dui3
# Speckle Connectors DUI
DUIv3 is a Speckle interface embedded inside the desktop connectors that allows users to interact with them - sync streams, manage servers etc. It's built in Vue 3 with Nuxt 3 and only supports client side rendering.
DUI v3 is a Speckle interface embedded inside the desktop connectors that allows users to interact with them - sync streams, manage servers etc. It's built in Vue 3 with Nuxt 3 and only supports client side rendering.
Look at the [Nuxt 3 documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.
@@ -15,12 +15,12 @@ yarn install
And create an `.env` file from `.env.example`.
## Development Server
## Development
Start the development server on `http://localhost:3000`
Start the development server on `http://localhost:8082`
```bash
npm run dev
yarn dev
```
## Production
@@ -28,13 +28,7 @@ npm run dev
Build the application for production:
```bash
npm run build
```
Locally preview production build:
```bash
npm run preview
yarn build
```
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information...
+13 -4
View File
@@ -13,15 +13,20 @@
import { useMixpanel } from '~/lib/core/composables/mixpanel'
import { useConfigStore } from '~/store/config'
import { useAccountStore } from '~/store/accounts'
import { useHostAppStore } from '~/store/hostApp'
import { storeToRefs } from 'pinia'
import { logToSeq } from '~/lib/logger/composables/useLogger'
const uiConfigStore = useConfigStore()
const { isDarkTheme } = storeToRefs(uiConfigStore)
const hostAppStore = useHostAppStore()
const { connectorVersion, hostAppName, hostAppVersion } = storeToRefs(hostAppStore)
useHead({
// Title suffix
titleTemplate: (titleChunk) =>
titleChunk ? `${titleChunk as string} - Speckle DUIv3` : 'Speckle DUIv3',
title: computed(
() =>
`CNX: (hostApp: ${hostAppName.value}:v${hostAppVersion.value}),(version: ${connectorVersion.value})`
),
htmlAttrs: {
lang: 'en',
class: computed(() => (isDarkTheme.value ? `dark` : ``))
@@ -50,6 +55,10 @@ onMounted(() => {
uniqueEmails.add(email)
}
})
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { $intercom } = useNuxtApp() // needed her for initialisation
logToSeq('Information', 'DUI3 initialized')
})
</script>
<style></style>
+2 -1
View File
@@ -1,5 +1,6 @@
/* stylelint-disable selector-id-pattern */
@import '@speckle/ui-components/style.css';
@import url('@speckle/ui-components/style.css');
@tailwind base;
@tailwind components;
@tailwind utilities;
+1 -1
View File
@@ -1,7 +1,7 @@
import type { CodegenConfig } from '@graphql-codegen/cli'
const config: CodegenConfig = {
schema: 'http://127.0.0.1:3000/graphql',
schema: 'https://app.speckle.systems/graphql',
documents: ['{lib,components,layouts,pages,middleware}/**/*.{vue,js,ts}'],
ignoreNoDocuments: true, // for better experience with the watcher
generates: {
+1 -1
View File
@@ -60,7 +60,7 @@
<script setup lang="ts">
import type { DUIAccount } from '~~/store/accounts'
import { TrashIcon } from '@heroicons/vue/24/outline'
import { type BaseBridge } from '~/lib/bridge/base'
import type { BaseBridge } from '~/lib/bridge/base'
const { $accountBinding } = useNuxtApp()
+160
View File
@@ -0,0 +1,160 @@
<template>
<div class="flex flex-col space-y-2">
<div v-if="isDesktopServiceAvailable">
<div v-show="!isAddingAccount" class="text-foreground-2 space-y-2">
<FormButton
text
size="sm"
full-width
@click="showCustomServerInput = !showCustomServerInput"
>
{{ showCustomServerInput ? 'Use default server' : 'Set custom server url' }}
</FormButton>
<div v-if="showCustomServerInput">
<FormTextInput
v-model="customServerUrl"
name="name"
:show-label="false"
color="foundation"
autocomplete="off"
show-clear
@clear="showCustomServerInput = false"
/>
</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="startAccountAddFlow()">
Sign in (Legacy)
</FormButton>
</div>
</div>
<div v-show="isAddingAccount" class="text-foreground-2 mt-2 mb-4 space-y-2">
<div class="text-sm text-center">
Please check your browser: waiting for authorization to complete.
</div>
<div class="py-2"><CommonLoadingBar :loading="isAddingAccount" /></div>
<div v-if="showHelp" class="bg-blue-500/10 p-2 rounded-md space-y-2">
<div class="text-sm text-center">Having trouble?</div>
<FormButton size="sm" full-width @click="restartFlow()">Retry</FormButton>
<FormButton
text
size="sm"
full-width
@click="$openUrl('https://speckle.community')"
>
Get in touch with us
</FormButton>
</div>
</div>
</div>
<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 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 customServerUrl = ref<string | undefined>('https://app.speckle.systems')
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 = customServerUrl.value
? `http://localhost:29364/auth/add-account?serverUrl=${
new URL(customServerUrl.value).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)
}
const restartFlow = () => {
isAddingAccount.value = false
showHelp.value = false
}
onMounted(async () => {
isDesktopServiceAvailable.value = await pingDesktopService()
})
</script>
+24 -16
View File
@@ -39,18 +39,20 @@
title="Add a new account"
fullscreen="none"
>
<div>
<div v-if="isDesktopServiceAvailable">
<AccountsSignInFlow />
</div>
<div v-else class="flex flex-wrap justify-center space-x-4 max-width">
<FormButton text @click="$openUrl(`speckle://accounts`)">
Add account via Manager
</FormButton>
<FormButton text @click="accountStore.refreshAccounts()">
Refresh accounts
</FormButton>
</div>
<div class="flex flex-col space-y-2">
<AccountsSignInFlow v-if="!showLegacy" />
<AccountsLegacySignInFlow v-else @back-to-sign-in="showLegacy = false" />
<FormButton
v-if="!showLegacy"
text
full-width
size="sm"
class="text-xs"
@click="showLegacy = true"
>
Legacy Sign in
</FormButton>
</div>
</CommonDialog>
</div>
@@ -58,6 +60,7 @@
</CommonDialog>
</div>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { XMarkIcon } from '@heroicons/vue/20/solid'
@@ -68,7 +71,6 @@ import { useDesktopService } from '~/lib/core/composables/desktopService'
const { trackEvent } = useMixpanel()
const app = useNuxtApp()
const { $openUrl } = useNuxtApp()
const { pingDesktopService } = useDesktopService()
const props = withDefaults(
@@ -86,7 +88,7 @@ defineEmits<{
}>()
const showAddNewAccount = ref(false)
// const showAccountsDialog = ref(false)
const showLegacy = ref(false)
const showAccountsDialog = defineModel<boolean>('open', {
required: false,
@@ -95,7 +97,7 @@ const showAccountsDialog = defineModel<boolean>('open', {
const isDesktopServiceAvailable = ref(false) // this should be false default because there is a delay if /ping is not successful.
app.$baseBinding.on('documentChanged', () => {
app.$baseBinding?.on('documentChanged', () => {
showAccountsDialog.value = false
})
@@ -106,6 +108,13 @@ watch(showAccountsDialog, (newVal) => {
}
})
watch(showAddNewAccount, (newVal) => {
if (newVal) {
// reset the current/legacy state on every add account sub-dialog
showLegacy.value = false
}
})
const accountStore = useAccountStore()
const { accounts, activeAccount, userSelectedAccount, isLoading } =
storeToRefs(accountStore)
@@ -144,7 +153,6 @@ const user = computed(() => {
// acc = currentSelectedAccount
// }
// }
return {
name: activeAccount.value.accountInfo.userInfo.name,
avatar: activeAccount.value.accountInfo.userInfo.avatar
+35 -100
View File
@@ -1,116 +1,51 @@
<template>
<div>
<div v-show="!isAddingAccount" class="text-foreground-2 my-2 space-y-2">
<div v-if="showCustomServerInput">
<FormTextInput
v-model="customServerUrl"
name="name"
:show-label="false"
placeholder="https://app.speckle.systems"
color="foundation"
autocomplete="off"
show-clear
@clear="showCustomServerInput = false"
/>
</div>
<FormButton full-width @click="startAccountAddFlow()">Sign In</FormButton>
<FormButton
text
size="sm"
full-width
@click="showCustomServerInput = !showCustomServerInput"
>
{{ showCustomServerInput ? 'Use default server' : 'Set custom server url' }}
</FormButton>
<div class="flex flex-col space-y-2">
<FormButton
text
size="sm"
full-width
@click="showCustomServerInput = !showCustomServerInput"
>
{{ showCustomServerInput ? 'Use default server' : 'Set custom server url' }}
</FormButton>
<div v-if="showCustomServerInput">
<FormTextInput
v-model="customServerUrl"
name="name"
:show-label="false"
placeholder="https://app.speckle.systems"
color="foundation"
autocomplete="off"
show-clear
@clear="showCustomServerInput = false"
/>
</div>
<div v-show="isAddingAccount" class="text-foreground-2 mt-2 mb-4 space-y-2">
<div class="text-sm text-center">
Please check your browser: waiting for authorization to complete.
</div>
<div class="py-2"><CommonLoadingBar :loading="isAddingAccount" /></div>
<div v-if="showHelp" class="bg-blue-500/10 p-2 rounded-md space-y-2">
<div class="text-sm text-center">Having trouble?</div>
<FormButton size="sm" full-width @click="restartFlow()">Retry</FormButton>
<FormButton
text
size="sm"
full-width
@click="$openUrl('https://speckle.community')"
>
Get in touch with us
</FormButton>
</div>
</div>
<FormButton v-if="canAddAccount" full-width @click="logIn()">Sign in</FormButton>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useIntervalFn } from '@vueuse/core'
import { useAccountStore } from '~~/store/accounts'
import { useHostAppStore } from '~/store/hostApp'
import { ToastNotificationType } from '@speckle/ui-components'
import { useMixpanel } from '~/lib/core/composables/mixpanel'
import { useAuthManager } from '~/lib/authn/useAuthManager'
import type { BaseBridge } from '~/lib/bridge/base'
const accountStore = useAccountStore()
const hostApp = useHostAppStore()
const app = useNuxtApp()
const { trackEvent } = useMixpanel()
const customServerUrl = ref<string | undefined>(undefined)
const isAddingAccount = ref(false)
const showHelp = ref(false)
const customServerUrl = ref<string | undefined>('https://app.speckle.systems')
const showCustomServerInput = ref(false)
const accountCheckerIntervalFn = useIntervalFn(
async () => {
const previousAccountCount = accountStore.accounts.length
await accountStore.refreshAccounts()
const currentAccountCount = accountStore.accounts.length
if (previousAccountCount !== currentAccountCount) {
isAddingAccount.value = false
showCustomServerInput.value = false
accountCheckerIntervalFn.pause()
trackEvent('DUI Account Added')
}
},
1000,
{ immediate: false }
const { $accountBinding } = useNuxtApp()
const canAddAccount = ['AddAccount', 'addAccount'].some((name) =>
($accountBinding as unknown as BaseBridge).availableMethodNames.includes(name)
)
const startAccountAddFlow = () => {
isAddingAccount.value = true
accountCheckerIntervalFn.resume()
setTimeout(() => {
showHelp.value = true
}, 10_000)
const url = customServerUrl.value
? `http://localhost:29364/auth/add-account?serverUrl=${customServerUrl.value}`
: `http://localhost:29364/auth/add-account`
const { generateChallenge } = useAuthManager()
app.$openUrl(url)
// this is a annoying timeout that we cannot detect if user added same account or not.
setTimeout(() => {
if (isAddingAccount.value) {
isAddingAccount.value = false
showCustomServerInput.value = false
accountCheckerIntervalFn.pause()
// Note to Dim: not sure about toast
hostApp.setNotification({
title: 'Sign In',
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)
}
const restartFlow = () => {
isAddingAccount.value = false
showHelp.value = false
const logIn = () => {
const serverUrl = customServerUrl.value
? new URL(customServerUrl.value).origin
: 'https://app.speckle.systems'
const challenge = generateChallenge(serverUrl)
const authUrl = `${serverUrl}/authn/verify/sdui/${challenge}`
window.location.href = authUrl
}
</script>
-158
View File
@@ -1,158 +0,0 @@
<!-- NOT WILL BE USED SINCE WE ENABLE AUTOMATION CREATION FROM DUI3 -->
<template>
<div class="p-0">
<slot name="activator" :toggle="toggleDialog"></slot>
<CommonDialog
v-model:open="showAutomateDialog"
:title="`Settings`"
fullscreen="none"
>
<div v-if="hasFunctions">
<FormSelectBase
key="name"
v-model="selectedFunction"
clearable
label="Automate functions"
placeholder="Nothing selected"
name="Functions"
show-label
:items="functions"
mount-menu-on-body
>
<template #something-selected="{ value }">
<span>{{ value.name }}</span>
</template>
<template #option="{ item }">
<div class="flex items-center">
<span class="truncate">{{ item.name }}</span>
</div>
</template>
</FormSelectBase>
</div>
<div v-if="selectedFunction && finalParams && step === 0">
<FormJsonForm
ref="jsonForm"
:data="data"
:schema="finalParams"
class="space-y-4"
:validate-on-mount="false"
@change="handler"
/>
</div>
<div v-if="step === 1">
<FormTextInput
v-model="automationName"
name="automationName"
label="Automation name"
color="foundation"
show-label
help="Give your automation a name"
placeholder="Name"
show-required
validate-on-value-update
/>
</div>
<FormButton
v-if="selectedFunction && step === 0"
size="sm"
class="mt-4"
@click="step++"
>
Next
</FormButton>
<FormButton
v-if="selectedFunction && step === 1"
size="sm"
class="mt-4"
@click="createAutomationHandler"
>
Create
</FormButton>
</CommonDialog>
</div>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import type { AutomateFunctionItemFragment } from '~/lib/common/generated/gql/graphql'
import {
automateFunctionsQuery,
createAutomationMutation
} from '~/lib/graphql/mutationsAndQueries'
import { provideApolloClient, useMutation, useQuery } from '@vue/apollo-composable'
import { useAccountStore } from '~/store/accounts'
import type { ApolloError } from '@apollo/client/errors'
import { formatVersionParams } from '~/lib/common/helpers/jsonSchema'
import { useJsonFormsChangeHandler } from '~/lib/core/composables/jsonSchema'
const props = defineProps<{
projectId: string
modelId: string
}>()
const step = ref<number>(0)
const automationName = ref<string>('')
const accountStore = useAccountStore()
const { activeAccount } = storeToRefs(accountStore)
const accountId = computed(() => activeAccount.value?.accountInfo.id) // NOTE: none of the tokens here has read, write access to automate, only frontend tokens have. Keep in mind after first pass!
const selectedFunction = ref<AutomateFunctionItemFragment>()
const showAutomateDialog = ref(false)
const toggleDialog = () => {
showAutomateDialog.value = !showAutomateDialog.value
}
const { mutate } = provideApolloClient(activeAccount.value.client)(() =>
useMutation(createAutomationMutation)
)
const createAutomationHandler = async () => {
const _res = await mutate({
projectId: props.projectId,
input: { name: automationName.value, enabled: false }
})
showAutomateDialog.value = false
}
const { result: functionsResult, onError } = useQuery(
automateFunctionsQuery,
() => ({}),
() => ({ clientId: accountId.value, debounce: 500, fetchPolicy: 'network-only' })
)
onError((err: ApolloError) => {
console.warn(err.message)
})
const functions = computed(() => functionsResult.value?.automateFunctions.items)
const hasFunctions = computed(() => functions.value?.length !== 0)
const release = computed(() =>
selectedFunction.value?.releases.items.length
? selectedFunction.value?.releases.items[0]
: undefined
)
const finalParams = computed(() => formatVersionParams(release.value?.inputSchema))
const { handler } = useJsonFormsChangeHandler({
schema: finalParams
})
console.log(finalParams)
type DataType = Record<string, unknown>
const data = computed(() => {
const kvp = {} as DataType
if (finalParams.value) {
Object.entries(finalParams.value).forEach((k, _) => {
kvp[k as unknown as string] = undefined
})
}
return kvp
})
</script>
@@ -9,7 +9,13 @@
<Component :is="iconAndColor.icon" :class="`w-4 h-4 ${iconAndColor.color}`" />
</div>
<div :class="`text-xs ${iconAndColor.color}`">
{{ result.category }}: {{ result.objectIds.length }} affected elements
{{ result.category }}:
{{
'objectIds' in props.result
? props.result.objectIds.length
: props.result.objectAppIds.length
}}
affected elements
</div>
</div>
<div v-if="result.message" class="text-xs text-foreground-2 pl-5">
@@ -19,16 +25,13 @@
</div>
</template>
<script setup lang="ts">
import { useQuery } from '@vue/apollo-composable'
import {
XMarkIcon,
InformationCircleIcon,
ExclamationTriangleIcon
} from '@heroicons/vue/24/outline'
import type { Automate } from '@speckle/shared'
import { objectQuery } from '~/lib/graphql/mutationsAndQueries'
import type { IModelCard } from '~/lib/models/card'
import { useAccountStore } from '~/store/accounts'
type ObjectResult = Automate.AutomateTypes.ResultsSchema['values']['objectResults'][0]
@@ -37,41 +40,16 @@ const props = defineProps<{
result: ObjectResult
functionId?: string
}>()
const accStore = useAccountStore()
const app = useNuxtApp()
const projectAccount = computed(() =>
accStore.accountWithFallback(props.modelCard.accountId, props.modelCard.serverUrl)
)
const clientId = projectAccount.value.accountInfo.id
const applicationIds = ref<string[]>([])
type Data = {
applicationId?: string
}
// Loop over each objectId to run the query and collect application IDs
props.result.objectIds.forEach((objectId) => {
const { result: objectResult } = useQuery(
objectQuery,
() => ({
projectId: props.modelCard.projectId,
objectId
}),
() => ({ clientId })
)
watch(objectResult, (newValue) => {
const data = newValue?.project.object?.data as Data | undefined
const applicationId = data?.applicationId
if (applicationId && !applicationIds.value.includes(applicationId)) {
applicationIds.value.push(applicationId)
}
})
const applicationIds = computed(() => {
// Old schema ignore
if ('objectIds' in props.result) return []
return Object.values(props.result.objectAppIds).filter((id) => id !== null)
})
const handleClick = async () => {
if (applicationIds.value.length === 0) return
await app.$baseBinding.highlightObjects(applicationIds.value)
}
+1
View File
@@ -329,6 +329,7 @@ onUnmounted(() => {
html.dialog-open {
overflow: visible !important;
}
html.dialog-open body {
overflow: hidden !important;
}
+32 -4
View File
@@ -17,14 +17,27 @@
</div>
<div class="flex items-center group">
<FormButton
v-if="notification.cta"
v-if="notification.secondaryCta"
v-tippy="notification.secondaryCta.tooltipText"
size="sm"
:color="notification.level === 'info' ? 'outline' : notification.level"
color="outline"
full-width
@click.stop="notification.cta?.action"
class="mr-1"
@click.stop="notification.secondaryCta.action"
>
{{ notification.cta.name }}
{{ notification.secondaryCta.name }}
</FormButton>
<div v-if="notification.cta" v-tippy="notification.cta.tooltipText">
<FormButton
:disabled="notification.cta.disabled"
size="sm"
color="primary"
full-width
@click.stop="notification.cta?.action"
>
{{ notification.cta.name }}
</FormButton>
</div>
</div>
</div>
<div
@@ -59,6 +72,21 @@ if (props.notification.timeout) {
useTimeoutFn(() => emit('dismiss'), props.notification.timeout)
}
// const notificationButtonColor = (notificationLevel: ModelCardNotificationLevel) => {
// switch (notificationLevel) {
// case 'info':
// return 'outline'
// case 'danger':
// return 'danger'
// case 'success':
// return 'primary'
// case 'warning':
// return 'danger'
// default:
// return 'outline'
// }
// }
const textClassColor = computed(() => {
switch (props.notification.level) {
case 'danger':
+23 -10
View File
@@ -18,18 +18,24 @@
</div>
</div>
<div class="opacity-0 group-hover:opacity-100 transition flex">
<div
:class="
isPersonalProject ? '' : 'opacity-0 group-hover:opacity-100 transition flex'
"
>
<button
v-tippy="'Open project in browser'"
class="hover:text-primary flex items-center space-x-2 p-2"
v-tippy="projectNavigatorTippy"
class="hover:text-primary flex items-center space-x-2 p-2 relative animate-pulse"
>
<ArrowTopRightOnSquareIcon
class="w-4"
@click.stop="
$openUrl(projectUrl),
trackEvent('DUI3 Action', { name: 'Project View' }, project.accountId)
"
/>
<div class="relative w-4 h-4">
<ArrowTopRightOnSquareIcon
class="w-4 h-4"
@click.stop="
$openUrl(projectUrl),
trackEvent('DUI3 Action', { name: 'Project View' }, project.accountId)
"
/>
</div>
</button>
</div>
</button>
@@ -130,6 +136,13 @@ 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 { result: projectDetailsResult, refetch: refetchProjectDetails } = useQuery(
+8 -2
View File
@@ -1,13 +1,19 @@
<template>
<CommonAlert
v-if="!store.isConnectorUpToDate && !hasDismissedAlert"
v-if="
store.isDistributedBySpeckle &&
store.latestAvailableVersion &&
!store.isConnectorUpToDate &&
!hasDismissedAlert &&
!store.isUpdateNotificationDisabled
"
v-tippy="
'Version: ' + store.latestAvailableVersion?.Number + ', released ' + createdAgo
"
color="neutral"
size="xs"
hide-icon
class="mb-2 mt-1"
class="mt-1"
>
<template #description>
<div class="flex items-center">
+6 -2
View File
@@ -1,7 +1,6 @@
<template>
<!-- ONLY FOR TEST FOR NOW-->
<form class="flex flex-col space-y-4 form-json-form">
<span>Settings</span>
<FormJsonForm :schema="jsonSchema" @change="onParamsFormChange"></FormJsonForm>
</form>
</template>
@@ -21,6 +20,11 @@ const jsonSchema = {
type: 'string',
title: 'Favorite Color',
enum: ['red', 'green', 'blue']
},
multiSelect: {
type: 'array',
title: 'Multi Favorite Chars',
enum: ['a', 'b', 'c', 'd']
}
}
}
@@ -28,6 +32,6 @@ const jsonSchema = {
const paramsFormState = ref<JsonFormsChangeEvent>()
const onParamsFormChange = (e: JsonFormsChangeEvent) => {
paramsFormState.value = e
console.log(JSON.stringify(e))
console.log(e)
}
</script>
+7
View File
@@ -66,6 +66,13 @@
@update:filter="(filter : ISendFilter) => (selectedFilter = filter)"
/>
</div>
<!-- I dont like the way we use revit categories filter for archicad layers, this component need to be generalized if we have one more -->
<div v-else-if="selectedFilter.id === 'archicadLayers'">
<FilterRevitCategories
:filter="(selectedFilter as RevitCategoriesSendFilter)"
@update:filter="(filter : ISendFilter) => (selectedFilter = filter)"
/>
</div>
<!-- Below should have been implemented as sendFilterSelect as above, we can delete it later -->
<div v-else-if="selectedFilter.id === 'navisworksSavedSets'">
<FilterFormSelect
+1 -1
View File
@@ -8,7 +8,7 @@
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import type { IDirectSelectionSendFilter, ISendFilter } from 'lib/models/card/send'
import type { IDirectSelectionSendFilter, ISendFilter } from '~/lib/models/card/send'
import { useHostAppStore } from '~~/store/hostApp'
import { useSelectionStore } from '~~/store/selection'
+23 -1
View File
@@ -12,8 +12,11 @@
full-width
color="foundation"
/>
<FormButton color="outline" size="sm" @click="selectAllCategories">
{{ allSelected ? 'Deselect all' : 'Select all' }}
</FormButton>
</div>
<div class="flex space-y-1 flex-col">
<div class="flex space-y-1 flex-col max-h-48 simple-scrollbar overflow-auto">
<div
v-for="cat in selectedCategoriesObjects.sort((a, b) =>
a.name.localeCompare(b.name)
@@ -93,6 +96,25 @@ const searchResults = computed(() => {
const selectedCategories = ref<string[]>(props.filter.selectedCategories || [])
const selectAllCategories = () => {
if (allSelected.value) {
selectedCategories.value = []
return
}
availableCategories.value.forEach((cat) => {
const index = selectedCategories.value.indexOf(cat.id)
if (index !== -1) {
return
} else {
selectedCategories.value.push(cat.id)
}
})
}
const allSelected = computed(() => {
return availableCategories.value.length === selectedCategories.value.length
})
const selectOrUnselectCategory = (id: string) => {
const index = selectedCategories.value.indexOf(id)
if (index !== -1) {
@@ -0,0 +1,146 @@
<template>
<div>
<div class="text-foreground-2 text-body-2xs mb-1 pl-1">
{{ control.label }}
</div>
<!-- button next to component (like revit send categories) -->
<!-- min width to keep components "in-sync" at narrow sizes -->
<!-- size "sm" matches height of select all toggle -->
<div class="flex items-center space-x-2 min-w-72">
<FormSelectMulti
:model-value="modelValue"
:name="fieldName"
:rules="multiValidator"
:label="control.label"
:items="control.options"
class="flex-1 min-w-0"
clearable
:search="true"
:search-placeholder="'Search'"
:filter-predicate="searchFilterPredicate"
:help="control.description"
:allow-unset="false"
by="value"
button-style="tinted"
:validate-on-value-update="validateOnValueUpdate"
mount-menu-on-body
fixed-height
@update:model-value="handleChange"
>
<template #nothing-selected>
{{
appliedOptions['placeholder']
? appliedOptions['placeholder']
: 'Select values'
}}
</template>
<template #something-selected="{ value }">
<div ref="elementToWatchForChanges" class="flex items-center space-x-0.5">
<div ref="itemContainer" class="flex flex-wrap overflow-hidden space-x-0.5">
<div v-for="(item, i) in value" :key="item.value" class="text-foreground">
{{ item.label + (i < value.length - 1 ? ', ' : '') }}
</div>
</div>
<div v-if="hiddenSelectedItemCount > 0" class="text-foreground-2 normal">
+{{ hiddenSelectedItemCount }}
</div>
</div>
</template>
<template #option="{ item }">
<div class="flex items-center text-foreground-2 text-body-2xs">
<span class="truncate">{{ item.label }}</span>
</div>
</template>
</FormSelectMulti>
<!-- Select All / Deselect All button - positioned next to dropdown like Revit -->
<FormButton color="outline" class="min-w-28" size="base" @click="toggleSelectAll">
{{ allSelected ? 'Deselect all' : 'Select all' }}
</FormButton>
</div>
</div>
</template>
<script setup lang="ts">
import type { ControlElement } from '@jsonforms/core'
import { rendererProps, useJsonFormsEnumControl } from '@jsonforms/vue'
import type { Nullable } from '@speckle/shared'
import { useFormSelectChildInternals } from '@speckle/ui-components'
import type { GenericValidateFunction } from 'vee-validate'
import { useJsonRendererBaseSetup } from '~/lib/form/composables/jsonRenderers'
type OptionType = { value: string; label: string }
type ValueType = OptionType | OptionType[] | undefined
const emit = defineEmits<(e: 'update:modelValue', v: ValueType) => void>()
const props = defineProps({
...rendererProps<ControlElement>(),
// TODO: Doesn't appear that jsonforms properly supports multiple selection
multiple: {
type: Boolean,
default: true
},
controlOverrides: {
type: Object as PropType<Nullable<ReturnType<typeof useJsonFormsEnumControl>>>,
default: null
}
})
const searchFilterPredicate = (item: OptionType, search: string) =>
item.label.toLocaleLowerCase().includes(search.toLocaleLowerCase())
const elementToWatchForChanges = ref(null as Nullable<HTMLElement>)
const itemContainer = ref(null as Nullable<HTMLElement>)
const { hiddenSelectedItemCount, isArrayValue } =
useFormSelectChildInternals<OptionType>({
props: toRefs(props),
emit,
dynamicVisibility: { elementToWatchForChanges, itemContainer }
})
/* eslint-disable @typescript-eslint/no-explicit-any */
const multiValidator: GenericValidateFunction<any> = () => true // ignoring validation for multi enum since it is custom and jsonforms does not support it properly
const { handleChange, control, appliedOptions, fieldName, validateOnValueUpdate } =
useJsonRendererBaseSetup(props.controlOverrides || useJsonFormsEnumControl(props), {
onChangeValueConverter: (newVal: ValueType) => {
if (props.multiple && isArrayValue(newVal)) {
return newVal.map((v) => v.value)
} else if (newVal && !props.multiple && !isArrayValue(newVal)) {
return newVal.value
} else {
return undefined
}
}
})
const modelValue = computed(() => {
const val = control.value.data as OptionType[]
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
return control.value.options.filter((o) => val?.includes(o.value))
})
/**
* Computed property to check if all available options are selected.
*/
const allSelected = computed(() => {
const currentSelection = modelValue.value || []
const allOptions = control.value.options || []
return currentSelection.length === allOptions.length && allOptions.length > 0
})
/**
* Toggle between selecting all categories and clearing all selections.
*/
const toggleSelectAll = () => {
if (allSelected.value) {
// deselect all -> pass empty array
handleChange([])
} else {
// select all available options
const allOptions = control.value.options || []
handleChange(allOptions)
}
}
</script>
@@ -7,8 +7,8 @@
:label="control.label"
:placeholder="appliedOptions['placeholder']"
:help="control.description"
color="foundation"
show-label
size="lg"
:validate-on-value-update="validateOnValueUpdate"
@update:model-value="handleChange"
/>
+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>
+41 -4
View File
@@ -25,6 +25,7 @@
</div>
<div class="relative group flex items-center">
<FormButton
v-if="app.$receiveBinding"
v-tippy="'Load a model from Speckle into this file'"
color="outline"
size="sm"
@@ -48,14 +49,32 @@
>
<span class="">Update</span>
</FormButton> -->
<div class="text-[8px] text-foreground-disabled max-[150px]:hidden">
<div
class="text-[8px] text-foreground-disabled max-[150px]:hidden"
:class="{ 'mr-2': !hostAppStore.isDistributedBySpeckle }"
>
{{ hostAppStore.connectorVersion }}
</div>
<div
v-if="!hostAppStore.isDistributedBySpeckle && hostAppStore.hostAppName"
v-tippy="
`${hostAppStore.hostAppName
.charAt(0)
.toUpperCase()}${hostAppStore.hostAppName.slice(
1
)} connector is not distributed by Speckle.`
"
class="text-xs text-foreground-disabled max-[150px]:hidden mr-1"
>
<CommonBadge color="secondary">Partner</CommonBadge>
</div>
<HeaderButton
v-if="hostAppStore.isDistributedBySpeckle"
v-tippy="'Documentation and help'"
@click="
app.$openUrl(
`https://www.speckle.systems/connectors/${hostAppStore.hostAppName}?utm=dui`
`https://docs.speckle.systems/connectors/${hostAppStore.hostAppName}?utm=dui`
)
"
>
@@ -63,7 +82,11 @@
class="w-4 text-foreground-disabled group-hover:text-foreground-2"
/>
</HeaderButton>
<HeaderButton v-tippy="'Send us feedback'" @click="showFeedbackDialog = true">
<HeaderButton
v-if="hostAppStore.isDistributedBySpeckle"
v-tippy="'Send us feedback'"
@click="openFeedbackDialog()"
>
<ChatBubbleLeftIcon
class="w-4 text-foreground-disabled group-hover:text-foreground-2"
/>
@@ -95,8 +118,22 @@ const showFeedbackDialog = ref<boolean>(false)
const showSendDialog = ref<boolean>(false)
const showReceiveDialog = ref<boolean>(false)
app.$baseBinding.on('documentChanged', () => {
app.$baseBinding?.on('documentChanged', () => {
showSendDialog.value = false
showReceiveDialog.value = false
})
const { $intercom } = useNuxtApp()
const openFeedbackDialog = () => {
if (
(hostAppStore.hostAppName?.toLowerCase() === 'revit' &&
hostAppStore.hostAppVersion?.includes('2022')) ||
!hostAppStore.isDistributedBySpeckle
) {
showFeedbackDialog.value = true
} else {
$intercom.show()
}
}
</script>
+14
View File
@@ -37,6 +37,18 @@
</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="
@@ -109,6 +121,8 @@ import { XMarkIcon, Bars3Icon } from '@heroicons/vue/20/solid'
import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/vue'
import { useConfigStore } from '~/store/config'
const app = useNuxtApp()
const uiConfigStore = useConfigStore()
const { isDarkTheme, hasConfigBindings, isDevMode } = storeToRefs(uiConfigStore)
const { toggleTheme } = uiConfigStore
+30 -11
View File
@@ -5,21 +5,31 @@
>
Welcome to Speckle
</h1>
<div v-if="isDesktopServiceAvailable">
<AccountsSignInFlow />
<div v-if="isDesktopServiceAvailable || canAddAccount">
<AccountsSignInFlow v-if="!showLegacy" />
<AccountsLegacySignInFlow v-else @back-to-sign-in="showLegacy = false" />
<FormButton
v-if="!showLegacy"
text
full-width
size="sm"
class="text-xs"
@click="showLegacy = true"
>
Legacy Sign in
</FormButton>
</div>
<div v-else>
<div class="text-foreground-2 mt-2 mb-4">
Click the button below to sign into Speckle via Manager. This will allow you to
publish or load data.
To sign in and start using Speckle, you'll need the Desktop Service running.
This lightweight background service handles secure authentication.
</div>
<div class="text-foreground-2 text-sm mt-2 mb-4"></div>
<div class="flex flex-wrap justify-center space-y-2 max-width">
<FormButton full-width @click="$openUrl(`speckle://accounts`)">
Sign In
<div class="space-y-3">
<FormButton full-width @click="$openUrl('https://releases.speckle.systems')">
Download Desktop Service
</FormButton>
<div>
<div class="text-xs">Already done?</div>
<div class="text-center">
<div class="text-foreground-2 text-xs mb-2">Already installed?</div>
<FormButton
size="sm"
full-width
@@ -27,20 +37,29 @@
link
@click="accountStore.refreshAccounts()"
>
Click to refresh
Refresh to check again
</FormButton>
</div>
</div>
</div>
</LayoutPanel>
</template>
<script setup lang="ts">
import { useAccountStore } from '~~/store/accounts'
import { useDesktopService } from '~/lib/core/composables/desktopService'
import type { BaseBridge } from '~/lib/bridge/base'
const accountStore = useAccountStore()
const { pingDesktopService } = useDesktopService()
const { $accountBinding } = useNuxtApp()
const canAddAccount = ['AddAccount', 'addAccount'].some((name) =>
($accountBinding as unknown as BaseBridge).availableMethodNames.includes(name)
)
const showLegacy = ref(false)
const isDesktopServiceAvailable = ref(false) // this should be false default because there is a delay if /ping is not successful.
onMounted(async () => {
+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>
+34 -1
View File
@@ -5,6 +5,7 @@
:icon-left="Bars3Icon"
hide-text
size="sm"
:disabled="!!props.modelCard.progress"
@click.stop="openModelCardActionsDialog = true"
/>
<CommonDialog
@@ -32,6 +33,18 @@
</button>
</template>
</ReportBase>
<IssuesDialog
v-if="issues && issues.length > 0"
:model-card="modelCard"
:issues="issues"
>
<template #activator="{ toggle }">
<button class="action action-normal" @click="toggle()">
<div class="truncate max-[275px]:text-xs">Issues</div>
<div><Cog6ToothIcon class="w-5 h-5" /></div>
</button>
</template>
</IssuesDialog>
<button
v-for="item in items"
:key="item.name"
@@ -57,6 +70,10 @@ import {
} from '@heroicons/vue/24/outline'
import type { IModelCard } from '~/lib/models/card'
import { useMixpanel } from '~/lib/core/composables/mixpanel'
import { issuesListQuery } from '~/lib/issues/graphql/queries'
import { useAccountStore } from '~/store/accounts'
import { storeToRefs } from 'pinia'
import { useQuery } from '@vue/apollo-composable'
const { trackEvent } = useMixpanel()
@@ -73,7 +90,7 @@ const hasSettings = computed(() => {
})
const app = useNuxtApp()
app.$baseBinding.on('documentChanged', () => {
app.$baseBinding?.on('documentChanged', () => {
openModelCardActionsDialog.value = false
})
@@ -113,6 +130,22 @@ const items = [
}
}
]
const accountStore = useAccountStore()
const { activeAccount } = storeToRefs(accountStore)
const accountId = computed(() => activeAccount.value.accountInfo.id)
const { result: issuesResult } = useQuery(
issuesListQuery,
() => ({ projectId: props.modelCard.projectId }),
() => ({
clientId: accountId.value,
debounce: 500,
fetchPolicy: 'network-only'
})
)
const issues = computed(() => issuesResult?.value?.project.issues.items)
</script>
<style scoped lang="postcss">
.action {
+121 -20
View File
@@ -5,10 +5,12 @@
>
<div v-if="modelData" class="relative px-1 py-1">
<div class="relative flex items-center space-x-2 min-w-0">
<div class="text-foreground-2 mt-[2px] flex items-center -space-x-2 relative">
<div
v-tippy="buttonTooltip"
class="text-foreground-2 mt-[2px] flex items-center -space-x-2 relative"
>
<!-- CTA button -->
<FormButton
v-tippy="buttonTooltip"
color="outline"
:icon-left="
modelCard.progress
@@ -19,7 +21,9 @@
"
hide-text
class=""
:disabled="!canEdit"
:disabled="
(!canEdit || isSettingsMissing || ctaDisabled) && !modelCard.progress
"
@click.stop="$emit('manual-publish-or-load')"
></FormButton>
</div>
@@ -57,6 +61,32 @@
</button>
</template>
</AutomateResultDialog>
<!-- To test missing settings -->
<!-- <FormButton
v-if="!isSettingsMissing"
v-tippy="'Refresh settings are needed'"
color="subtle"
:icon-left="TrashIcon"
hide-text
size="sm"
@click="deleteSettings"
/> -->
<IssuesDialog
v-if="issues && issues.length > 0"
:model-card="modelCard"
:issues="issues"
>
<template #activator="{ toggle }">
<FormButton
v-tippy="'Issues'"
color="subtle"
:icon-left="MessageCircleMore"
hide-text
size="sm"
@click="toggle()"
/>
</template>
</IssuesDialog>
<FormButton
v-if="store.hostAppName !== 'navisworks' && store.hostAppName !== 'etabs'"
v-tippy="'Highlight'"
@@ -92,7 +122,24 @@
Fetching model data...
<CommonLoadingBar loading />
</div>
<div v-else class="px-1 py-1">Error loading data.</div>
<div
v-else
class="flex flex-row items-center px-2 pt-2 text-body-2xs text-foreground-2 truncate text-red-500"
>
<span class="ml-1.5">Error on loading model data.</span>
<div class="flex items-center justify-end grow">
<FormButton
v-tippy="'Remove model card'"
color="subtle"
:icon-left="TrashIcon"
hide-text
size="sm"
class="text-red-500"
@click.stop="removeModel"
/>
</div>
</div>
<!-- Slot to allow senders or receivers to hoist their own buttons/ui -->
<!-- class="px-2 h-0 group-hover:h-auto transition-all overflow-hidden" -->
@@ -152,18 +199,18 @@
>
<div
v-tippy="
`${latestCommentNotification.comment?.author.name} just left a
`${latestCommentNotification.comment?.author?.name} just left a
comment.`
"
class="flex items-center space-x-1"
>
<UserAvatarGroup
size="xs"
:users="[latestCommentNotification.comment?.author]"
:users="[latestCommentNotification.comment?.author as AvatarUserWithId]"
/>
<span class="line-clamp-1">
{{ latestCommentNotification.comment?.author.name }} just left a
comment.
{{ latestCommentNotification.comment?.author?.name }} just left a
comment on the issue.
</span>
</div>
<div>
@@ -207,18 +254,28 @@ import { useMixpanel } from '~/lib/core/composables/mixpanel'
import { useIntervalFn, useTimeoutFn } from '@vueuse/core'
import type { ProjectCommentsUpdatedMessage } from '~/lib/common/generated/gql/graphql'
import { useFunctionRunsStatusSummary } from '~/lib/automate/runStatus'
import { CursorArrowRaysIcon, XCircleIcon } from '@heroicons/vue/24/outline'
import { CursorArrowRaysIcon, XCircleIcon, TrashIcon } from '@heroicons/vue/24/outline'
import type { AvatarUserWithId } from '@speckle/ui-components'
import { issuesListQuery } from '~/lib/issues/graphql/queries'
import { MessageCircleMore } from 'lucide-vue-next'
const app = useNuxtApp()
const store = useHostAppStore()
const accStore = useAccountStore()
const { trackEvent } = useMixpanel()
const props = defineProps<{
modelCard: IModelCard
project: ProjectModelGroup
canEdit: boolean
}>()
const props = withDefaults(
defineProps<{
modelCard: IModelCard
project: ProjectModelGroup
canEdit: boolean
ctaDisabled?: boolean
ctaDisabledMessage?: string
}>(),
{
ctaDisabled: false
}
)
defineEmits<{
(e: 'manual-publish-or-load'): void
@@ -229,11 +286,9 @@ const isSender = computed(() => {
})
const buttonTooltip = computed(() => {
return props.modelCard.progress
? 'Cancel'
: isSender.value
? 'Publish model'
: 'Load selected version'
if (props.modelCard.progress) return 'Cancel'
if (props.ctaDisabled) return props.ctaDisabledMessage
return isSender.value ? 'Publish model' : 'Load selected version'
})
const projectAccount = computed(() =>
@@ -299,6 +354,25 @@ const summary = computed(() => {
})
})
const { result: issuesResult, refetch: refetchIssues } = useQuery(
issuesListQuery,
() => ({ projectId: props.modelCard.projectId }),
() => ({
clientId,
debounce: 500,
fetchPolicy: 'network-only'
})
)
const issues = computed(() =>
issuesResult?.value?.project.issues.items.filter(
(issue) =>
issue.status !== 'resolved' &&
issue.resourceIdString &&
(issue.resourceIdString as string).includes(props.modelCard.modelId)
)
)
provide<IModelCard>('cardBase', props.modelCard)
const highlightModel = () => {
@@ -320,6 +394,31 @@ const highlightModel = () => {
trackEvent('DUI3 Action', { name: 'Highlight Model' }, props.modelCard.accountId)
}
const isSettingsMissing = computed(() =>
isSender.value ? isSendSettingsMissing.value : isReceiveSettingsMissing.value
)
const isSendSettingsMissing = computed(
() =>
isSender.value &&
store.sendSettings &&
store.sendSettings.length > 0 &&
!props.modelCard.settings
)
const isReceiveSettingsMissing = computed(
() =>
!isSender.value &&
store.receiveSettings &&
store.receiveSettings.length > 0 &&
!props.modelCard.settings
)
// To test missing settings
// const deleteSettings = async () => {
// await store.patchModel(props.modelCard.modelCardId, { settings: undefined })
// }
const viewModel = () => {
// previously with DUI2, it was Stream View but actually it is "Version View" now. Also having conflict with old/new terminology.
trackEvent('DUI3 Action', { name: 'Version View' }, props.modelCard.accountId)
@@ -430,8 +529,10 @@ const { start: startCommentClearTimeout, stop: stopCommentClearTimeout } = useTi
)
onCommentResult((res) => {
latestCommentNotification.value = res.data?.projectCommentsUpdated
latestCommentNotification.value = res.data
?.projectCommentsUpdated as ProjectCommentsUpdatedMessage
startCommentClearTimeout()
refetchIssues()
})
const viewComment = () => {
-186
View File
@@ -1,186 +0,0 @@
<template>
<div class="p-0">
<slot name="activator" :toggle="toggleDialog"></slot>
<CommonDialog
v-model:open="showModelCreateDialog"
:title="canCreateModelInWorkspace ? `Create new model` : errorMessage?.title"
fullscreen="none"
>
<form v-if="canCreateModelInWorkspace" @submit="onSubmitCreateNewModel">
<div class="text-body-2xs mb-2 ml-1">Model name</div>
<FormTextInput
v-model="newModelName"
class="text-xs"
autocomplete="off"
name="name"
label="Model name"
color="foundation"
:show-clear="!!newModelName"
:placeholder="hostAppStore.documentInfo?.name"
:rules="[
ValidationHelpers.isRequired,
ValidationHelpers.isStringOfLength({ minLength: 3 })
]"
full-width
/>
<div class="mt-4 flex justify-end items-center space-x-2 w-full">
<FormButton size="sm" text @click="showModelCreateDialog = false">
Cancel
</FormButton>
<FormButton size="sm" submit :disabled="isCreatingModel">Create</FormButton>
</div>
</form>
<div v-else class="m-2">
{{ errorMessage?.description }}
<div class="flex mt-2 space-x-2 justify-end">
<FormButton size="sm" color="outline" @click="showModelCreateDialog = false">
Close
</FormButton>
<FormButton
v-if="errorMessage?.cta"
size="sm"
submit
@click="errorMessage?.cta?.action(), (showModelCreateDialog = false)"
>
{{ errorMessage?.cta?.name }}
</FormButton>
</div>
</div>
</CommonDialog>
</div>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { useMutation, provideApolloClient, useQuery } from '@vue/apollo-composable'
import type { ModelListModelItemFragment } from '~/lib/common/generated/gql/graphql'
import { useForm } from 'vee-validate'
import { ValidationHelpers } from '@speckle/ui-components'
import type { DUIAccount } from '~/store/accounts'
import { useAccountStore } from '~/store/accounts'
import { useMixpanel } from '~/lib/core/composables/mixpanel'
import { useHostAppStore } from '~/store/hostApp'
import {
canCreateModelInProjectQuery,
createModelMutation
} from '~/lib/graphql/mutationsAndQueries'
type WorkspacePermissionMessage = {
title: string
description: string
cta?: {
name: string
action: () => void
}
}
const { $openUrl } = useNuxtApp()
const showModelCreateDialog = ref(false)
const isCreatingModel = ref(false)
const props = defineProps<{
projectId: string
workspaceId?: string
workspaceSlug?: string
}>()
const emit = defineEmits<{
(e: 'model:created', model: ModelListModelItemFragment): void
}>()
const { trackEvent } = useMixpanel()
const accountStore = useAccountStore()
const hostAppStore = useHostAppStore()
const { activeAccount } = storeToRefs(accountStore)
const accountId = computed(() => activeAccount.value.accountInfo.id)
const newModelName = ref<string>()
const errorMessage = ref<WorkspacePermissionMessage>()
const toggleDialog = () => {
showModelCreateDialog.value = !showModelCreateDialog.value
}
const account = computed(() => {
return accountStore.accounts.find(
(acc) => acc.accountInfo.id === accountId.value
) as DUIAccount
})
const canCreateModelInWorkspace = ref<boolean>()
const { result: canCreateModelInWorkspaceResult } = useQuery(
canCreateModelInProjectQuery,
() => ({ projectId: props.projectId }),
() => ({
clientId: accountId.value,
debounce: 500,
fetchPolicy: 'network-only'
})
)
watch(canCreateModelInWorkspaceResult, (val) => {
if (val?.project.permissions.canCreateModel.code !== 'OK') {
switch (val?.project.permissions.canCreateModel.code) {
case 'WorkspaceLimitsReached':
errorMessage.value = {
title: 'Plan limit reached',
description:
'The model limit for this workspace has been reached. Upgrade the workspace plan to create or move more models.',
cta: {
name: 'Explore Plans',
action: () =>
$openUrl(
`${account.value.accountInfo.serverInfo.url}/settings/workspaces/${props.workspaceSlug}/billing`
)
}
}
break
// TODO: we should add more cases later according to `code`
default:
errorMessage.value = {
title: 'Workspace warning',
description: val?.project.permissions.canCreateModel.message ?? 'error'
}
break
}
canCreateModelInWorkspace.value = false
} else {
canCreateModelInWorkspace.value = true
}
})
const createNewModel = async (name: string) => {
isCreatingModel.value = true
void trackEvent('DUI3 Action', { name: 'Model Create' }, account.value.accountInfo.id)
const { mutate } = provideApolloClient(account.value.client)(() =>
useMutation(createModelMutation)
)
const res = await mutate({ input: { projectId: props.projectId, name } })
if (res?.data?.modelMutations.create) {
emit('model:created', res?.data?.modelMutations.create)
// refetch() // Sorts the list with newly created model otherwise it will put the model at the bottom.
// emit('next', res?.data?.modelMutations.create)
} else {
let errorMessage = 'Undefined error'
if (res?.errors && res?.errors.length !== 0) {
errorMessage = res?.errors[0].message
}
hostAppStore.setNotification({
type: 1,
title: 'Failed to create model',
description: errorMessage
})
}
isCreatingModel.value = false
}
const { handleSubmit } = useForm<{ name: string }>()
const onSubmitCreateNewModel = handleSubmit(() => {
void createNewModel(newModelName.value as string)
})
</script>
+41 -15
View File
@@ -19,7 +19,7 @@
color="subtle"
class="block text-foreground-2 hover:text-foreground overflow-hidden max-w-full !justify-start"
full-width
:disabled="!!modelCard.progress || noReadAccess"
:disabled="!!modelCard.progress || !canEdit || isReceiveSettingsMissing"
@click.stop="openVersionsDialog = true"
>
<span>
@@ -52,10 +52,16 @@
:model-id="modelCard.modelId"
:workspace-slug="modelCard.workspaceSlug"
:selected-version-id="modelCard.selectedVersionId"
:settings="modelCard.settings"
@next="handleVersionSelection"
@update:settings="handleUpdateSettings"
/>
</CommonDialog>
<template #states>
<CommonModelNotification
v-if="isReceiveSettingsMissing"
:notification="receiveSettingsMissingNotification"
/>
<CommonModelNotification
v-if="expiredNotification"
:notification="expiredNotification"
@@ -95,6 +101,7 @@ import type { VersionListItemFragment } from '~/lib/common/generated/gql/graphql
import { useMixpanel } from '~/lib/core/composables/mixpanel'
import { useInterval, watchOnce } from '@vueuse/core'
import { useAccountStore } from '~~/store/accounts'
import type { CardSetting } from '~/lib/models/card/setting'
const { trackEvent } = useMixpanel()
const app = useNuxtApp()
@@ -114,14 +121,44 @@ const projectAccount = computed(() =>
accountStore.accountWithFallback(props.project.accountId, props.project.serverUrl)
)
app.$baseBinding.on('documentChanged', () => {
app.$baseBinding?.on('documentChanged', () => {
openVersionsDialog.value = false
})
const isReceiveSettingsMissing = computed(
() =>
store.receiveSettings &&
store.receiveSettings.length > 0 &&
!props.modelCard.settings
)
const receiveSettingsMissingNotification = computed(() => {
const notification = {} as ModelCardNotification
notification.dismissible = false
notification.level = 'danger'
notification.text = 'Load settings are corrupted for some reason.'
notification.cta = {
name: 'Refresh',
action: async () => {
await store.patchModel(props.modelCard.modelCardId, {
settings: store.receiveSettings
})
}
}
return notification
})
const isExpired = computed(() => {
return props.modelCard.latestVersionId !== props.modelCard.selectedVersionId
})
const handleUpdateSettings = async (settings: CardSetting[]) => {
await store.patchModel(props.modelCard.modelCardId, {
settings
})
}
// Cancels any in progress receive AND load selected version
const handleVersionSelection = async (
selectedVersion: VersionListItemFragment,
@@ -178,7 +215,7 @@ const expiredNotification = computed(() => {
if (props.modelCard.latestVersionId === props.modelCard.selectedVersionId) return
const notification = {} as ModelCardNotification
notification.dismissible = true
notification.level = 'info'
notification.level = 'success'
notification.text = 'Newer version available!'
notification.cta = {
name: 'Update',
@@ -203,13 +240,6 @@ const receiveResultNotificationText = computed(() => {
return 'Model loaded!'
})
const receiveResultNotificationLevel = computed(() => {
if (failRate.value > 80) {
return 'warning'
}
return 'info'
})
const receiveResultNotification = computed(() => {
if (
!props.modelCard.bakedObjectIds ||
@@ -219,7 +249,7 @@ const receiveResultNotification = computed(() => {
const notification = {} as ModelCardNotification
notification.dismissible = true
notification.level = receiveResultNotificationLevel.value
notification.level = 'success'
notification.text = receiveResultNotificationText.value
notification.report = props.modelCard.report
notification.cta = {
@@ -269,10 +299,6 @@ const latestVersionCreatedAt = computed(() => {
return dayjs(props.modelCard.latestVersionCreatedAt).from(dayjs())
})
const noReadAccess = computed(() => {
return props.canEdit
})
onMounted(() => {
refetch()
})
+234 -19
View File
@@ -4,6 +4,8 @@
:model-card="modelCard"
:project="project"
:can-edit="canEdit"
:cta-disabled="ctaDisabled"
:cta-disabled-message="ctaDisabledMessage"
@manual-publish-or-load="sendOrCancel"
>
<div class="flex max-[275px]:w-full overflow-hidden my-2">
@@ -13,11 +15,10 @@
size="sm"
color="subtle"
class="block text-foreground-2 hover:text-foreground overflow-hidden max-w-full !justify-start"
:disabled="!!modelCard.progress || !props.canEdit"
:disabled="!!modelCard.progress || !props.canEdit || isSendSettingsMissing"
full-width
@click.stop="openFilterDialog = true"
>
<!-- Sending&nbsp; -->
<span class="font-bold">{{ modelCard.sendFilter?.name }}:&nbsp;</span>
<span class="truncate">{{ modelCard.sendFilter?.summary }}</span>
</FormButton>
@@ -31,17 +32,69 @@
<FilterListSelect :filter="modelCard.sendFilter" @update:filter="updateFilter" />
<div class="mt-4 flex justify-end items-center space-x-2">
<!-- TODO: Ux wise, users might want to just save the selection and publish it later. -->
<FormButton size="sm" color="outline" @click.stop="saveFilter()">
<FormButton
size="sm"
color="outline"
:disabled="isSaveDisabled"
@click.stop="saveFilter()"
>
Save
</FormButton>
<FormButton size="sm" @click.stop="saveFilterAndSend()">
Save & Publish
</FormButton>
<div v-tippy="!canCreateVersionPerm ? canCreateVersionMessage : ''">
<FormButton
size="sm"
:disabled="!canCreateVersionPerm || isSaveDisabled"
@click.stop="saveFilterAndSend()"
>
Save & Publish
</FormButton>
</div>
</div>
</CommonDialog>
<CommonDialog
v-model:open="showSetMessageDialog"
title="Version message"
fullscreen="none"
>
<form @submit="setVersionMessage(versionMessage as string)">
<div class="text-body-2xs mb-2 ml-1">
Describe your latest changes to help keep track of design intent.
</div>
<FormTextArea
v-model="versionMessage"
class="text-xs"
placeholder="Moved elements to prevent clash"
autocomplete="off"
name="name"
label="Version message"
color="foundation"
:show-clear="!!versionMessage"
:rules="[ValidationHelpers.isStringOfLength({ minLength: 3 })]"
full-width
/>
<CommonLoadingBar v-if="isUpdatingVersionMessage" loading />
<div class="mt-4 flex justify-end items-center space-x-2 w-full">
<FormButton size="sm" text @click="showSetMessageDialog = false">
Cancel
</FormButton>
<FormButton
size="sm"
submit
:disabled="
isUpdatingVersionMessage || !versionMessage || versionMessage.length < 3
"
>
Save
</FormButton>
</div>
</form>
</CommonDialog>
<template #states>
<CommonModelNotification
v-if="isSendSettingsMissing"
:notification="sendSettingsMissingNotification"
/>
<CommonModelNotification
v-if="expiredNotification"
:notification="expiredNotification"
@@ -66,7 +119,7 @@
</ModelCardBase>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ref, onMounted, computed } from 'vue'
import ModelCardBase from '~/components/model/CardBase.vue'
import { Square3Stack3DIcon } from '@heroicons/vue/20/solid'
import type { ModelCardNotification } from '~/lib/models/card/notification'
@@ -74,9 +127,23 @@ 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<{
@@ -85,46 +152,167 @@ const props = defineProps<{
canEdit: boolean
}>()
const store = useHostAppStore()
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', () => {
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 = () => {
if (!props.canEdit) {
// check for progress first to allow cancelling even if permissions changed
if (props.modelCard.progress) {
store.sendModelCancel(props.modelCard.modelCardId)
return
}
if (props.modelCard.progress) store.sendModelCancel(props.modelCard.modelCardId)
else store.sendModel(props.modelCard.modelCardId, 'ModelCardButton')
if (!props.canEdit || !canCreateVersionPerm.value) {
return
}
store.sendModel(props.modelCard.modelCardId, 'ModelCardButton')
hasSetVersionMessage.value = false
}
let newFilter: ISendFilter
const newFilter = ref<ISendFilter>()
const updateFilter = (filter: ISendFilter) => {
newFilter = filter
newFilter.value = filter
}
const isSaveDisabled = computed(() => {
const filterToCheck = newFilter.value || props.modelCard.sendFilter
return !store.validateSendFilter(filterToCheck).valid
})
const saveFilter = async () => {
if (!newFilter.value) return // Safety check
void trackEvent('DUI3 Action', {
name: 'Publish Card Filter Change',
filter: newFilter.typeDiscriminator
filter: newFilter.value.typeDiscriminator
})
// do not reset idmap while creating a new one because it is managed by host app
newFilter.idMap = props.modelCard.sendFilter?.idMap
newFilter.value.idMap = props.modelCard.sendFilter?.idMap
await store.patchModel(props.modelCard.modelCardId, {
sendFilter: newFilter,
sendFilter: newFilter.value,
expired: true
})
openFilterDialog.value = false
}
const showSetMessageDialog = ref(false)
const isUpdatingVersionMessage = ref(false)
const hasSetVersionMessage = ref(false)
const versionMessage = ref<string>()
const setVersionMessage = async (message: string) => {
if (!props.modelCard.latestCreatedVersionId) {
return
}
void trackEvent('DUI3 Action', {
name: 'Set version message'
})
isUpdatingVersionMessage.value = true
const { 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
@@ -138,7 +326,12 @@ const expiredNotification = computed(() => {
const ctaType = props.modelCard.progress ? 'Restart' : 'Update'
notification.cta = {
name: ctaType,
disabled: !canCreateVersionPerm.value,
tooltipText: !canCreateVersionPerm.value
? canCreateVersionMessage.value || 'Publish limit reached'
: undefined,
action: async () => {
hasSetVersionMessage.value = false
if (props.modelCard.progress) {
await store.sendModelCancel(props.modelCard.modelCardId)
}
@@ -188,11 +381,33 @@ const latestVersionNotification = computed(() => {
notification.level = sendResultNotificationLevel.value
notification.text = sendResultNotificationText.value
notification.report = props.modelCard.report
// NOTE: this prevents us displaying the set message button for non-updated
// connectors that send over the root object id over instead of the commit id
if (
props.modelCard.latestCreatedVersionId.length === 10 &&
!hasSetVersionMessage.value
) {
notification.secondaryCta = {
name: 'Set message',
tooltipText: 'Describe your changes',
action: () => {
showSetMessageDialog.value = true
versionMessage.value = ''
}
}
}
notification.cta = {
name: 'View version',
name: 'View',
tooltipText: 'Check your model in the browser!',
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-member-access
action: () => cardBase.value?.viewModel()
}
return notification
})
onMounted(() => {
void checkPermissions()
})
</script>
@@ -27,28 +27,29 @@ import type { CardSetting, CardSettingValue } from '~/lib/models/card/setting'
import type { JsonFormsChangeEvent } from '@jsonforms/vue'
import { cloneDeep, omit } from 'lodash-es'
import type { JsonSchema } from '@jsonforms/core'
import { useHostAppStore } from '~/store/hostApp'
// import { useHostAppStore } from '~/store/hostApp'
const props = defineProps<{
settings?: CardSetting[]
defaultSettings: CardSetting[]
expandable: boolean
}>()
const emit = defineEmits<{ (e: 'update:settings', value: CardSetting[]): void }>()
const store = useHostAppStore()
// const store = useHostAppStore()
const defaultSendSettings = computed(() => store.sendSettings)
const sendSettings = ref<CardSetting[] | undefined>(
cloneDeep(props.settings ?? defaultSendSettings.value) // need to prevent mutation!
// const defaultSendSettings = computed(() => store.sendSettings)
const settings = ref<CardSetting[] | undefined>(
cloneDeep(props.settings ?? props.defaultSettings) // need to prevent mutation!
)
const showSettings = ref(!props.expandable)
const settingsJsonForms = computed(() => {
if (sendSettings.value === undefined) return {}
if (settings.value === undefined) return {}
const obj: JsonSchema = { type: 'object', properties: {} }
sendSettings.value.forEach((setting: CardSetting) => {
settings.value.forEach((setting: CardSetting) => {
const mappedSetting = omit({ ...setting, $id: setting.id }, ['id'])
if (obj && obj.properties) {
obj.properties[setting.id] = mappedSetting
@@ -60,8 +61,8 @@ const settingsJsonForms = computed(() => {
type DataType = Record<string, unknown>
const data = computed(() => {
const settingValues = {} as DataType
if (sendSettings.value) {
sendSettings.value.forEach((setting) => {
if (settings.value) {
settings.value.forEach((setting) => {
settingValues[setting.id as string] = setting.value
})
}
@@ -69,14 +70,14 @@ const data = computed(() => {
})
const onParamsFormChange = (e: JsonFormsChangeEvent) => {
if (sendSettings.value === undefined) return
sendSettings.value?.forEach((setting) => {
if (settings.value === undefined) return
settings.value?.forEach((setting) => {
if (setting) {
if (setting.value !== (e.data as DataType)[setting.id]) {
setting.value = (e.data as DataType)[setting.id] as CardSettingValue
}
}
})
emit('update:settings', sendSettings.value)
emit('update:settings', settings.value)
}
</script>
-220
View File
@@ -1,220 +0,0 @@
<template>
<div class="p-0">
<slot name="activator" :toggle="toggleDialog"></slot>
<CommonDialog
v-model:open="showProjectCreateDialog"
:title="`Create new project`"
fullscreen="none"
>
<form @submit="onSubmitCreateNewProject">
<div class="text-body-2xs mb-2 ml-1">Project name</div>
<FormTextInput
v-model="newProjectName"
class="text-xs"
placeholder="A Beautiful Home, A Small Bridge..."
autocomplete="off"
name="name"
label="Project name"
color="foundation"
:show-clear="!!newProjectName"
:rules="[
ValidationHelpers.isRequired,
ValidationHelpers.isStringOfLength({ minLength: 3 })
]"
full-width
/>
<div class="mt-4 flex justify-end items-center space-x-2 w-full">
<FormButton size="sm" text @click="showProjectCreateDialog = false">
Cancel
</FormButton>
<FormButton
size="sm"
submit
:disabled="isCreatingProject || !canCreateProject"
>
Create
</FormButton>
</div>
</form>
</CommonDialog>
</div>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { useMutation, provideApolloClient, useQuery } from '@vue/apollo-composable'
import type { ProjectListProjectItemFragment } from '~/lib/common/generated/gql/graphql'
import {
canCreatePersonalProjectQuery,
canCreateProjectInWorkspaceQuery,
createProjectInWorkspaceMutation,
createProjectMutation
} from '~/lib/graphql/mutationsAndQueries'
import type { DUIAccount } from '~/store/accounts'
import { useAccountStore } from '~/store/accounts'
import { useMixpanel } from '~/lib/core/composables/mixpanel'
import { useHostAppStore } from '~/store/hostApp'
import { useForm } from 'vee-validate'
import { ValidationHelpers } from '@speckle/ui-components'
const showProjectCreateDialog = ref(false)
const isCreatingProject = ref(false)
const props = defineProps<{ workspaceId?: string }>()
const emit = defineEmits<{
(e: 'project:created', result: ProjectListProjectItemFragment): void
}>()
const { trackEvent } = useMixpanel()
const accountStore = useAccountStore()
const hostAppStore = useHostAppStore()
const { activeAccount } = storeToRefs(accountStore)
const accountId = computed(() => activeAccount.value.accountInfo.id)
const newProjectName = ref<string>()
const errorMessageForWorkspace = ref<string>()
const errorMessageForPersonalProject = ref<string>()
const toggleDialog = () => {
showProjectCreateDialog.value = !showProjectCreateDialog.value
}
const account = computed(() => {
return accountStore.accounts.find(
(acc) => acc.accountInfo.id === accountId.value
) as DUIAccount
})
const canCreateProject = computed(() =>
props.workspaceId === 'personalProject'
? canCreatePersonalProject.value
: canCreateProjectInWorkspace.value
)
const { result: canCreatePersonalProjectResult } = useQuery(
canCreatePersonalProjectQuery,
() => ({}),
() => ({
clientId: accountId.value,
debounce: 500,
fetchPolicy: 'network-only'
})
)
watch(canCreatePersonalProjectResult, (val) => {
if (val?.activeUser?.permissions.canCreatePersonalProject.code !== 'OK') {
errorMessageForPersonalProject.value =
val?.activeUser?.permissions.canCreatePersonalProject.message
}
})
const canCreatePersonalProject = computed(() => {
try {
return (
canCreatePersonalProjectResult.value?.activeUser?.permissions
.canCreatePersonalProject.code === 'OK'
)
} catch {
return true
}
})
const { result: canCreateProjectInWorkspaceResult } = useQuery(
canCreateProjectInWorkspaceQuery,
() => ({ workspaceId: props.workspaceId ?? 'null' }), // TODO: i do not know the potential cause here
() => ({
clientId: accountId.value,
debounce: 500,
fetchPolicy: 'network-only'
})
)
watch(canCreateProjectInWorkspaceResult, (val) => {
if (val?.workspace.permissions.canCreateProject.code !== 'OK') {
errorMessageForWorkspace.value = val?.workspace.permissions.canCreateProject.message
}
})
const canCreateProjectInWorkspace = computed(() => {
try {
return (
canCreateProjectInWorkspaceResult.value?.workspace.permissions.canCreateProject
.code === 'OK'
)
} catch {
return true
}
})
const { handleSubmit } = useForm<{ name: string }>()
const onSubmitCreateNewProject = handleSubmit(() => {
// TODO: Chat with Fabians
// This works, but if we use handleSubmit(args) > args.name -> it is undefined in Production on netlify, but works fine on local dev
void createNewProject(newProjectName.value as string)
})
const createNewProject = async (name: string) => {
isCreatingProject.value = true
if (props.workspaceId !== 'personalProject' && props.workspaceId !== undefined) {
createNewProjectInWorkspace(name)
isCreatingProject.value = false
return
}
void trackEvent(
'DUI3 Action',
{ name: 'Project Create', workspace: false },
account.value.accountInfo.id
)
const { mutate } = provideApolloClient(account.value.client)(() =>
useMutation(createProjectMutation)
)
const res = await mutate({ input: { name } })
if (res?.data?.projectMutations.create) {
emit('project:created', res?.data?.projectMutations.create)
} else {
let errorMessage = 'Undefined error'
if (res?.errors && res?.errors.length !== 0) {
errorMessage = res?.errors[0].message
}
hostAppStore.setNotification({
type: 1,
title: 'Failed to create project',
description: errorMessage
})
}
isCreatingProject.value = false
}
const createNewProjectInWorkspace = async (name: string) => {
void trackEvent(
'DUI3 Action',
{ name: 'Project Create', workspace: true },
account.value.accountInfo.id
)
const { mutate } = provideApolloClient(account.value.client)(() =>
useMutation(createProjectInWorkspaceMutation)
)
const res = await mutate({
input: { name, workspaceId: props.workspaceId as string }
})
if (res?.data?.workspaceMutations.projects.create) {
emit('project:created', res?.data?.workspaceMutations.projects.create)
} else {
let errorMessage = 'Undefined error'
if (res?.errors && res?.errors.length !== 0) {
errorMessage = res?.errors[0].message
}
hostAppStore.setNotification({
type: 1,
title: 'Failed to create project',
description: errorMessage
})
}
}
</script>
@@ -1,141 +0,0 @@
<template>
<div class="p-0">
<slot name="activator" :toggle="toggleDialog"></slot>
<CommonDialog
v-model:open="showProjectCreateDialog"
:title="`Create new project`"
fullscreen="none"
>
<form @submit="onSubmitCreateNewProject">
<div class="text-body-2xs mb-2 ml-1">Project name</div>
<FormTextInput
v-model="newProjectName"
class="text-xs"
placeholder="A Beautiful Home, A Small Bridge..."
autocomplete="off"
name="name"
label="Project name"
color="foundation"
:show-clear="!!newProjectName"
:rules="[
ValidationHelpers.isRequired,
ValidationHelpers.isStringOfLength({ minLength: 3 })
]"
full-width
/>
<div class="mt-4 flex justify-end items-center space-x-2 w-full">
<FormButton size="sm" text @click="showProjectCreateDialog = false">
Cancel
</FormButton>
<FormButton
size="sm"
submit
:disabled="isCreatingProject || !canCreatePersonalProject"
>
Create
</FormButton>
</div>
</form>
</CommonDialog>
</div>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { useMutation, provideApolloClient, useQuery } from '@vue/apollo-composable'
import type { ProjectListProjectItemFragment } from '~/lib/common/generated/gql/graphql'
import {
canCreatePersonalProjectQuery,
createProjectMutation
} from '~/lib/graphql/mutationsAndQueries'
import type { DUIAccount } from '~/store/accounts'
import { useAccountStore } from '~/store/accounts'
import { useMixpanel } from '~/lib/core/composables/mixpanel'
import { useHostAppStore } from '~/store/hostApp'
import { useForm } from 'vee-validate'
import { ValidationHelpers } from '@speckle/ui-components'
const showProjectCreateDialog = ref(false)
const isCreatingProject = ref(false)
const emit = defineEmits<{
(e: 'project:created', result: ProjectListProjectItemFragment): void
}>()
const { trackEvent } = useMixpanel()
const accountStore = useAccountStore()
const hostAppStore = useHostAppStore()
const { activeAccount } = storeToRefs(accountStore)
const accountId = computed(() => activeAccount.value.accountInfo.id)
const newProjectName = ref<string>()
const errorMessage = ref<string>()
const toggleDialog = () => {
showProjectCreateDialog.value = !showProjectCreateDialog.value
}
const account = computed(() => {
return accountStore.accounts.find(
(acc) => acc.accountInfo.id === accountId.value
) as DUIAccount
})
const canCreatePersonalProject = ref<boolean>(false)
const { result: canCreatePersonalProjectResult } = useQuery(
canCreatePersonalProjectQuery,
() => ({}),
() => ({
clientId: accountId.value,
debounce: 500,
fetchPolicy: 'network-only'
})
)
watch(canCreatePersonalProjectResult, (val) => {
if (val?.activeUser?.permissions.canCreatePersonalProject.code !== 'OK') {
errorMessage.value = val?.activeUser?.permissions.canCreatePersonalProject.message
canCreatePersonalProject.value = false
} else {
canCreatePersonalProject.value = true
}
})
const { handleSubmit } = useForm<{ name: string }>()
const onSubmitCreateNewProject = handleSubmit(() => {
// TODO: Chat with Fabians
// This works, but if we use handleSubmit(args) > args.name -> it is undefined in Production on netlify, but works fine on local dev
void createNewProject(newProjectName.value as string)
})
const createNewProject = async (name: string) => {
isCreatingProject.value = true
void trackEvent(
'DUI3 Action',
{ name: 'Project Create', workspace: false },
account.value.accountInfo.id
)
const { mutate } = provideApolloClient(account.value.client)(() =>
useMutation(createProjectMutation)
)
const res = await mutate({ input: { name } })
if (res?.data?.projectMutations.create) {
emit('project:created', res?.data?.projectMutations.create)
} else {
let errorMessage = 'Undefined error'
if (res?.errors && res?.errors.length !== 0) {
errorMessage = res?.errors[0].message
}
hostAppStore.setNotification({
type: 1,
title: 'Failed to create project',
description: errorMessage
})
}
isCreatingProject.value = false
}
</script>
@@ -1,198 +0,0 @@
<template>
<div class="p-0">
<slot name="activator" :toggle="toggleDialog"></slot>
<CommonDialog
v-model:open="showProjectCreateDialog"
:title="canCreateProjectInWorkspace ? `Create new project` : errorMessage?.title"
fullscreen="none"
>
<form v-if="canCreateProjectInWorkspace" @submit="onSubmitCreateNewProject">
<div class="text-body-2xs mb-2 ml-1">Project name</div>
<FormTextInput
v-model="newProjectName"
class="text-xs"
placeholder="A Beautiful Home, A Small Bridge..."
autocomplete="off"
name="name"
label="Project name"
color="foundation"
:show-clear="!!newProjectName"
:rules="[
ValidationHelpers.isRequired,
ValidationHelpers.isStringOfLength({ minLength: 3 })
]"
full-width
/>
<div class="mt-4 flex justify-end items-center space-x-2 w-full">
<FormButton
size="sm"
color="outline"
@click="showProjectCreateDialog = false"
>
Cancel
</FormButton>
<FormButton size="sm" submit :disabled="isCreatingProject">Create</FormButton>
</div>
</form>
<div v-else class="m-2">
{{ errorMessage?.description }}
<div class="flex mt-2 space-x-2 justify-end">
<FormButton
size="sm"
color="outline"
@click="showProjectCreateDialog = false"
>
Close
</FormButton>
<FormButton
v-if="errorMessage?.cta"
size="sm"
submit
@click="errorMessage?.cta?.action(), (showProjectCreateDialog = false)"
>
{{ errorMessage?.cta?.name }}
</FormButton>
</div>
</div>
</CommonDialog>
</div>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { useMutation, provideApolloClient, useQuery } from '@vue/apollo-composable'
import type {
ProjectListProjectItemFragment,
WorkspaceListWorkspaceItemFragment
} from '~/lib/common/generated/gql/graphql'
import {
canCreateProjectInWorkspaceQuery,
createProjectInWorkspaceMutation
} from '~/lib/graphql/mutationsAndQueries'
import type { DUIAccount } from '~/store/accounts'
import { useAccountStore } from '~/store/accounts'
import { useMixpanel } from '~/lib/core/composables/mixpanel'
import { useHostAppStore } from '~/store/hostApp'
import { useForm } from 'vee-validate'
import { ValidationHelpers } from '@speckle/ui-components'
type WorkspacePermissionMessage = {
title: string
description: string
cta?: {
name: string
action: () => void
}
}
const { $openUrl } = useNuxtApp()
const showProjectCreateDialog = ref(false)
const isCreatingProject = ref(false)
const props = defineProps<{ workspace?: WorkspaceListWorkspaceItemFragment }>()
const emit = defineEmits<{
(e: 'project:created', result: ProjectListProjectItemFragment): void
}>()
const { trackEvent } = useMixpanel()
const accountStore = useAccountStore()
const hostAppStore = useHostAppStore()
const { activeAccount } = storeToRefs(accountStore)
const accountId = computed(() => activeAccount.value.accountInfo.id)
const newProjectName = ref<string>()
const errorMessage = ref<WorkspacePermissionMessage>()
const toggleDialog = () => {
showProjectCreateDialog.value = !showProjectCreateDialog.value
}
const account = computed(() => {
return accountStore.accounts.find(
(acc) => acc.accountInfo.id === accountId.value
) as DUIAccount
})
const canCreateProjectInWorkspace = ref<boolean>()
const { result: canCreateProjectInWorkspaceResult } = useQuery(
canCreateProjectInWorkspaceQuery,
() => ({ workspaceId: props.workspace?.id ?? 'null' }), // TODO: i do not know the potential cause here
() => ({
clientId: accountId.value,
debounce: 500,
fetchPolicy: 'network-only'
})
)
watch(canCreateProjectInWorkspaceResult, (val) => {
if (val?.workspace.permissions.canCreateProject.code !== 'OK') {
switch (val?.workspace.permissions.canCreateProject.code) {
case 'WorkspaceLimitsReached':
errorMessage.value = {
title: 'Plan limit reached',
description:
'The project limit for this workspace has been reached. Upgrade the workspace plan to create or move more projects.',
cta: {
name: 'Explore Plans',
action: () =>
$openUrl(
`${account.value.accountInfo.serverInfo.url}/settings/workspaces/${props.workspace?.slug}/billing`
)
}
}
break
// TODO: we should add more cases later according to `code`
default:
errorMessage.value = {
title: 'Workspace warning',
description: val?.workspace.permissions.canCreateProject.message ?? 'error'
}
break
}
canCreateProjectInWorkspace.value = false
} else {
canCreateProjectInWorkspace.value = true
}
})
const { handleSubmit } = useForm<{ name: string }>()
const onSubmitCreateNewProject = handleSubmit(() => {
// TODO: Chat with Fabians
// This works, but if we use handleSubmit(args) > args.name -> it is undefined in Production on netlify, but works fine on local dev
void createNewProjectInWorkspace(newProjectName.value as string)
})
const createNewProjectInWorkspace = async (name: string) => {
isCreatingProject.value = true
void trackEvent(
'DUI3 Action',
{ name: 'Project Create', workspace: true },
account.value.accountInfo.id
)
const { mutate } = provideApolloClient(account.value.client)(() =>
useMutation(createProjectInWorkspaceMutation)
)
const res = await mutate({
input: { name, workspaceId: props.workspace?.id as string }
})
if (res?.data?.workspaceMutations.projects.create) {
emit('project:created', res?.data?.workspaceMutations.projects.create)
} else {
let errorMessage = 'Undefined error'
if (res?.errors && res?.errors.length !== 0) {
errorMessage = res?.errors[0].message
}
hostAppStore.setNotification({
type: 1,
title: 'Failed to create project',
description: errorMessage
})
}
isCreatingProject.value = false
}
</script>
+32 -2
View File
@@ -5,13 +5,19 @@
:title="title"
:show-back-button="step !== 1"
@back="step--"
@fully-closed="step = 1"
@fully-closed="
() => {
step = 1
settingsWereChanged = false
}
"
>
<div>
<div v-if="step === 1">
<WizardProjectSelector
:is-sender="false"
:show-new-project="false"
:url-parse-error="urlParseError"
@next="selectProject"
@search-text-update="updateSearchText"
/>
@@ -36,10 +42,11 @@
:workspace-slug="selectedWorkspace?.slug"
:from-wizard="true"
@next="selectVersionAndAddModel"
@update:settings="handleUpdateSettings"
/>
</div>
</div>
<div v-if="urlParseError" class="p-2 text-xs text-danger">{{ urlParseError }}</div>
<div v-if="urlParseError" class="p-2 text-danger">{{ urlParseError }}</div>
</CommonDialog>
</template>
<script setup lang="ts">
@@ -54,10 +61,13 @@ import { useHostAppStore } from '~/store/hostApp'
import { useAccountStore } from '~/store/accounts'
import { ReceiverModelCard } from '~/lib/models/card/receiver'
import { useMixpanel } from '~/lib/core/composables/mixpanel'
import { useSettingsTracking } from '~/lib/core/composables/trackSettings'
import { useAddByUrl } from '~/lib/core/composables/addByUrl'
import { getSlugFromHostAppNameAndVersion } from '~/lib/common/helpers/hostAppSlug'
import type { CardSetting } from '~/lib/models/card/setting'
const { trackEvent } = useMixpanel()
const { trackSettingsChange } = useSettingsTracking()
const showReceiveDialog = defineModel<boolean>('open', { default: false })
@@ -82,6 +92,8 @@ const selectedAccountId = ref<string>(activeAccount.value?.accountInfo.id as str
const selectedWorkspace = ref<WorkspaceListWorkspaceItemFragment>()
const selectedProject = ref<ProjectListProjectItemFragment>()
const selectedModel = ref<ModelListModelItemFragment>()
const receieveSettings = ref<CardSetting[] | undefined>(undefined)
const settingsWereChanged = ref(false)
const { tryParseUrl, urlParsedData, urlParseError } = useAddByUrl()
const updateSearchText = (text: string | undefined) => {
@@ -130,6 +142,11 @@ const title = computed(() => {
return ''
})
const handleUpdateSettings = (settings: CardSetting[]) => {
receieveSettings.value = settings
settingsWereChanged.value = true
}
// accountId, serverUrl, ModelListModelItemFragment, VersionListItemFragment
const selectVersionAndAddModel = async (
version: VersionListItemFragment,
@@ -147,6 +164,18 @@ const selectVersionAndAddModel = async (
m.typeDiscriminator === 'ReceiverModelCard'
) as ReceiverModelCard
// track settings only if user changed them on receive
// compare against existing model settings if it exists, otherwise compare against defaults
if (settingsWereChanged.value && receieveSettings.value) {
trackSettingsChange(
'Load Settings Changed',
receieveSettings.value,
existingModel?.settings || hostAppStore.receiveSettings || [],
selectedAccountId.value,
true
)
}
if (existingModel) {
emit('close')
// Patch the existing model card with new versions!
@@ -172,6 +201,7 @@ const selectVersionAndAddModel = async (
)
const modelCard = new ReceiverModelCard()
modelCard.settings = receieveSettings.value
modelCard.accountId = selectedAccountId.value
modelCard.serverUrl = activeAccount.value.accountInfo.serverInfo.url
+1 -1
View File
@@ -75,7 +75,7 @@ import {
} from '@heroicons/vue/24/solid'
import type { ConversionResult } from '~/lib/conversions/conversionResult'
import { useAccountStore } from '~/store/accounts'
import type { IModelCard } from 'lib/models/card'
import type { IModelCard } from '~/lib/models/card'
import { useHostAppStore } from '~/store/hostApp'
const app = useNuxtApp()
+3 -2
View File
@@ -1,11 +1,12 @@
<template>
<div class="space-y-4">
<FilterListSelect @update:filter="updateFilter" />
<SendSettings
<ModelSettings
v-if="hasSendSettings"
expandable
:default-settings="(store.sendSettings as unknown as CardSetting[])"
@update:settings="updateSettings"
></SendSettings>
></ModelSettings>
</div>
</template>
<script setup lang="ts">
+11 -7
View File
@@ -6,11 +6,12 @@
:title="`Settings`"
fullscreen="none"
>
<SendSettings
<ModelSettings
:expandable="false"
:default-settings="(store.sendSettings as unknown as CardSetting[])"
:settings="props.settings"
@update:settings="updateSettings"
></SendSettings>
></ModelSettings>
<div class="mt-4 flex justify-end items-center space-x-2">
<FormButton size="sm" color="outline" @click="showSettingsDialog = false">
Cancel
@@ -22,11 +23,11 @@
</template>
<script setup lang="ts">
import { useMixpanel } from '~/lib/core/composables/mixpanel'
import { useSettingsTracking } from '~/lib/core/composables/trackSettings'
import { useHostAppStore } from '~/store/hostApp'
import type { CardSetting } from '~/lib/models/card/setting'
const { trackEvent } = useMixpanel()
const { trackSettingsChange } = useSettingsTracking()
const props = defineProps<{
settings?: CardSetting[]
@@ -47,9 +48,12 @@ const updateSettings = (settings: CardSetting[]) => {
}
const saveSettings = async () => {
void trackEvent('DUI3 Action', {
name: 'Send Settings Updated'
})
trackSettingsChange(
'Model Card Settings Updated',
newSettings,
store.sendSettings || []
)
await store.patchModel(props.modelCardId, {
settings: newSettings,
expired: true
+128 -21
View File
@@ -5,17 +5,22 @@
:title="title"
:show-back-button="step !== 1"
@back="step--"
@fully-closed="step = 1"
@fully-closed="
() => {
step = 1
settingsWereChanged = false
}
"
>
<div v-if="step === 1">
<WizardProjectSelector
is-sender
disable-no-write-access-projects
:url-parse-error="urlParseError"
@next="selectProject"
@search-text-update="updateSearchText"
/>
</div>
<!-- Model selector wizard -->
<div v-if="step === 2 && selectedProject && selectedAccountId">
<WizardModelSelector
:project="selectedProject"
@@ -26,24 +31,36 @@
@next="selectModel"
/>
</div>
<!-- Version selector wizard -->
<div v-if="step === 3">
<SendFiltersAndSettings
v-model="filter"
@update:filter="(f) => (filter = f)"
@update:settings="(s) => (settings = s)"
@update:settings="
(s) => {
settings = s
settingsWereChanged = true
}
"
/>
<div class="mt-2">
<FormButton full-width @click="addModel">Publish</FormButton>
<div v-tippy="publishTooltipMessage" class="mt-2">
<FormButton
full-width
:disabled="isPublishDisabled"
:loading="isLoadingPermissions"
@click="addModel"
>
Publish
</FormButton>
</div>
</div>
<div v-if="urlParseError" class="p-2 text-xs text-danger">
<div v-if="urlParseError" class="p-2 text-danger">
{{ urlParseError }}
</div>
</CommonDialog>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { useSubscription } from '@vue/apollo-composable'
import type {
ModelListModelItemFragment,
ProjectListProjectItemFragment
@@ -52,11 +69,16 @@ import type { ISendFilter } from '~/lib/models/card/send'
import { SenderModelCard } from '~/lib/models/card/send'
import { useHostAppStore } from '~/store/hostApp'
import { useAccountStore } from '~/store/accounts'
import { useSelectionStore } from '~/store/selection'
import { useMixpanel } from '~/lib/core/composables/mixpanel'
import { useSettingsTracking } from '~/lib/core/composables/trackSettings'
import type { CardSetting } from '~/lib/models/card/setting'
import { useAddByUrl } from '~/lib/core/composables/addByUrl'
import { useCheckGraphql } from '~/lib/core/composables/useCheckGraphql'
import { workspacePlanUsageUpdatedSubscription } from '~/lib/workspaces/graphql/subscriptions'
const { trackEvent } = useMixpanel()
const { trackSettingsChange } = useSettingsTracking()
const showSendDialog = defineModel<boolean>('open', { default: false })
@@ -71,8 +93,32 @@ const selectedProject = ref<ProjectListProjectItemFragment>()
const selectedModel = ref<ModelListModelItemFragment>()
const filter = ref<ISendFilter | undefined>(undefined)
const settings = ref<CardSetting[] | undefined>(undefined)
const settingsWereChanged = ref(false)
const { tryParseUrl, urlParsedData, urlParseError } = useAddByUrl()
const { canCreateModelIngestion, canCreateVersion } = useCheckGraphql()
const canPublish = ref(false)
const publishLimitMessage = ref<string | undefined>(undefined)
const isLoadingPermissions = ref(false)
const hostAppStore = useHostAppStore()
const selectionStore = useSelectionStore()
const publishValidation = computed(() => hostAppStore.validateSendFilter(filter.value))
const isPublishDisabled = computed(() => {
return (
!canPublish.value || isLoadingPermissions.value || !publishValidation.value.valid
)
})
const publishTooltipMessage = computed(() => {
if (!publishValidation.value.valid) return publishValidation.value.reason
if (!canPublish.value && !isLoadingPermissions.value)
return publishLimitMessage.value || ''
return ''
})
const updateSearchText = (text: string | undefined) => {
urlParseError.value = undefined
if (!text) return
@@ -88,9 +134,72 @@ watch(urlParsedData, (newVal) => {
watch(showSendDialog, (newVal) => {
if (newVal) {
urlParseError.value = undefined
void selectionStore.refreshSelectionFromHostApp()
}
})
const checkPermissions = async () => {
if (!selectedProject.value || !selectedModel.value) return
isLoadingPermissions.value = true
try {
const res = await canCreateModelIngestion(
selectedProject.value.id,
selectedModel.value.id,
selectedAccountId.value
)
if (res.queryAvailable) {
canPublish.value = res.authorized
publishLimitMessage.value = res.message || undefined
} else {
// check legacy canCreateVersion in else block
const legacyRes = await canCreateVersion(
selectedProject.value.id,
selectedModel.value.id,
selectedAccountId.value
)
canPublish.value = legacyRes.authorized
publishLimitMessage.value = legacyRes.message || undefined
}
} finally {
isLoadingPermissions.value = false
}
}
watch(step, async (newVal, oldVal) => {
if (newVal > oldVal) {
if (newVal === 3) {
await checkPermissions()
}
return // exit fast on forward
}
if (newVal === 1) {
selectedProject.value = undefined
selectedModel.value = undefined
}
if (newVal === 2) selectedModel.value = undefined
})
const workspaceId = computed(() => selectedProject.value?.workspace?.id)
const { onResult: onUsageUpdate } = useSubscription(
workspacePlanUsageUpdatedSubscription,
() => ({
input: {
workspaceId: workspaceId.value || ''
}
}),
() => ({
enabled: !!workspaceId.value && step.value === 3,
clientId: selectedAccountId.value
})
)
onUsageUpdate(() => {
void checkPermissions()
})
const selectProject = (accountId: string, project: ProjectListProjectItemFragment) => {
step.value++
selectedAccountId.value = accountId
@@ -111,20 +220,6 @@ const selectModel = (model: ModelListModelItemFragment) => {
void trackEvent('DUI3 Action', { name: 'Publish Wizard', step: 'model selected' })
}
// Clears data if going backwards in the wizard
watch(step, (newVal, oldVal) => {
if (newVal > oldVal) {
return // exit fast on forward
}
if (newVal === 1) {
selectedProject.value = undefined
selectedModel.value = undefined
}
if (newVal === 2) selectedModel.value = undefined
})
const hostAppStore = useHostAppStore()
// accountId, serverUrl, projectId, modelId, sendFilter, settings
const addModel = async () => {
void trackEvent('DUI3 Action', {
@@ -138,6 +233,18 @@ const addModel = async () => {
m.modelId === selectedModel.value?.id &&
m.typeDiscriminator.includes('SenderModelCard')
) as SenderModelCard
// track settings only if user changed them
// compare against existing model card settings
if (settingsWereChanged.value && settings.value) {
trackSettingsChange(
'Publish Settings Changed',
settings.value,
existingModel?.settings || hostAppStore.sendSettings || [],
selectedAccountId.value,
true
)
}
if (existingModel) {
emit('close')
// Patch the existing model card with new send filter and non-expired state!
-163
View File
@@ -1,163 +0,0 @@
<template>
<div
:class="[
'text-foreground-on-primary flex shrink-0 items-center justify-center overflow-hidden rounded-full font-semibold uppercase transition',
sizeClasses,
bgClasses,
borderClasses,
hoverClasses,
activeClasses
]"
>
<slot>
<div
v-if="user?.avatar"
class="h-full w-full bg-cover bg-center bg-no-repeat"
:style="{ backgroundImage: `url('${user.avatar}')` }"
/>
<div
v-else-if="initials"
class="flex h-full w-full select-none items-center justify-center"
>
{{ initials }}
</div>
<div v-else><UserCircleIcon :class="iconClasses" /></div>
</slot>
</div>
</template>
<script setup lang="ts">
import { UserCircleIcon } from '@heroicons/vue/20/solid'
type UserAvatar = {
name: string
avatar?: string | null | undefined
}
type UserAvatarSize = 'xs' | 'sm' | 'base' | 'lg' | 'xl' | 'editable'
const props = withDefaults(
defineProps<{
user?: UserAvatar
size?: UserAvatarSize
hoverEffect?: boolean
active?: boolean
noBorder?: boolean
noBackground?: boolean
}>(),
{
user: undefined,
size: 'base',
hoverEffect: false
}
)
const initials = computed(() => {
if (!props.user?.name.length) return
const parts = props.user.name.split(' ')
const firstLetter = parts[0]?.[0] || ''
const secondLetter = parts[1]?.[0] || ''
if (props.size === 'sm' || props.size === 'xs') return firstLetter
return firstLetter + secondLetter
})
const borderClasses = computed(() => {
if (props.noBorder) return ''
return 'border-2 border-foundation'
})
const bgClasses = computed(() => {
if (props.noBackground) return ''
return 'bg-primary'
})
const hoverClasses = computed(() => {
if (props.hoverEffect)
return 'hover:border-primary focus:border-primary active:scale-95'
return ''
})
const activeClasses = computed(() => {
if (props.active) return 'border-primary'
return ''
})
const heightClasses = computed(() => {
const size = props.size
switch (size) {
case 'xs':
return 'h-5'
case 'sm':
return 'h-6'
case 'lg':
return 'h-10'
case 'xl':
return 'h-14'
case 'editable':
return 'h-60'
case 'base':
default:
return 'h-8'
}
})
const widthClasses = computed(() => {
const size = props.size
switch (size) {
case 'xs':
return 'w-5'
case 'sm':
return 'w-6'
case 'lg':
return 'w-10'
case 'xl':
return 'w-14'
case 'editable':
return 'w-60'
case 'base':
default:
return 'w-8'
}
})
const textClasses = computed(() => {
const size = props.size
switch (size) {
case 'xs':
return 'text-tiny'
case 'sm':
return 'text-xs'
case 'lg':
return 'text-md'
case 'xl':
return 'text-2xl'
case 'editable':
return 'h1'
case 'base':
default:
return 'text-sm'
}
})
const iconClasses = computed(() => {
const size = props.size
switch (size) {
case 'xs':
return 'w-3 h-3'
case 'sm':
return 'w-3 h-3'
case 'lg':
return 'w-5 h-5'
case 'xl':
return 'w-8 h-8'
case 'editable':
return 'w-20 h-20'
case 'base':
default:
return 'w-4 h-4'
}
})
const sizeClasses = computed(
() => `${widthClasses.value} ${heightClasses.value} ${textClasses.value}`
)
</script>
+71 -32
View File
@@ -13,22 +13,24 @@
full-width
color="foundation"
/>
<ModelCreateDialog
:project-id="project.id"
:workspace-id="workspaceId"
:workspace-slug="workspaceSlug"
@model:created="(result: ModelListModelItemFragment) => handleModelCreated(result)"
<div
v-tippy="
canCreateModelResult?.project.permissions.canCreateModel.authorized
? 'Create new model'
: canCreateModelResult?.project.permissions.canCreateModel.message
"
>
<template #activator="{ toggle }">
<button
v-tippy="'New model'"
class="p-1.5 bg-foundation hover:bg-primary-muted rounded text-foreground border"
@click="toggle()"
>
<PlusIcon class="w-4" />
</button>
</template>
</ModelCreateDialog>
<FormButton
color="outline"
:disabled="
!canCreateModelResult?.project.permissions.canCreateModel.authorized
"
:class="`p-1.5 bg-foundation hover:bg-primary-muted rounded text-foreground border`"
@click="showNewModelDialog = true"
>
<PlusIcon class="w-4 -mx-2" />
</FormButton>
</div>
</div>
<div class="relative grid grid-cols-1 gap-2">
<CommonLoadingBar v-if="loading" loading />
@@ -37,6 +39,7 @@
v-for="model in models"
:key="model.id"
:model="model"
:token="token"
@click="handleModelSelect(model)"
/>
@@ -77,6 +80,20 @@
</template>
</CommonDialog>
<FormButton
v-if="
models?.length === 0 &&
!!searchText &&
canCreateModelResult?.project.permissions.canCreateModel?.authorized
"
full-width
color="outline"
:disabled="isCreatingModel"
@click="createNewModel(searchText)"
>
Create "{{ searchText }}"
</FormButton>
<FormButton
v-else
color="outline"
full-width
:disabled="hasReachedEnd"
@@ -91,7 +108,7 @@
title="Create new model"
fullscreen="none"
>
<form @submit="onSubmit">
<form @submit="createNewModel(newModelName as string)">
<FormTextInput
v-model="newModelName"
:rules="rules"
@@ -107,7 +124,9 @@
<FormButton size="sm" text @click="showNewModelDialog = false">
Cancel
</FormButton>
<FormButton size="sm" submit :disabled="isCreatingModel">Create</FormButton>
<FormButton size="sm" submit :disabled="isCreatingModel || !newModelName">
Create
</FormButton>
</div>
</form>
</CommonDialog>
@@ -122,10 +141,10 @@ import type {
} from '~/lib/common/generated/gql/graphql'
import { useModelNameValidationRules } from '~/lib/validation'
import {
canCreateModelInProjectQuery,
createModelMutation,
projectModelsQuery
} from '~/lib/graphql/mutationsAndQueries'
import { useForm } from 'vee-validate'
import type { DUIAccount } from '~/store/accounts'
import { useAccountStore } from '~/store/accounts'
import { useHostAppStore } from '~/store/hostApp'
@@ -152,13 +171,20 @@ const props = withDefaults(
const accountStore = useAccountStore()
const account = computed(
() =>
accountStore.accounts.find(
(acc) => acc.accountInfo.id === props.accountId
) as DUIAccount
)
const showNewModelDialog = ref(false)
const showSelectionHasProblemsDialog = ref(false)
const searchText = ref<string>()
const newModelName = ref<string>()
const newModelName = ref<string>(hostAppStore.documentInfo?.name ?? 'unnamed model')
watch(searchText, () => (newModelName.value = searchText.value))
watch(searchText, () => (newModelName.value = searchText.value as string))
let selectedModel: ModelListModelItemFragment | undefined = undefined
const existingModelProblem = ref(false)
@@ -187,12 +213,6 @@ const confirmModelSelection = () => {
}
const rules = useModelNameValidationRules()
const { handleSubmit } = useForm<{ name: string }>()
const onSubmit = handleSubmit(() => {
// TODO: Chat with Fabians
// This works, but if we use handleSubmit(args) > args.name -> it is undefined in Production on netlify, but works fine on local dev
void createNewModel(newModelName.value as string)
})
const handleModelCreated = (result: ModelListModelItemFragment) => {
refetch() // Sorts the list with newly created project otherwise it will put the project at the bottom.
@@ -200,21 +220,30 @@ const handleModelCreated = (result: ModelListModelItemFragment) => {
}
const isCreatingModel = ref(false)
const createNewModel = async (name: string) => {
if (!canCreateModelResult.value?.project.permissions.canCreateModel.authorized) {
hostAppStore.setNotification({
type: 1,
title: 'Failed to create model',
description:
canCreateModelResult.value?.project.permissions.canCreateModel.message
})
return
}
isCreatingModel.value = true
const account = accountStore.accounts.find(
(acc) => acc.accountInfo.id === props.accountId
) as DUIAccount
void trackEvent('DUI3 Action', { name: 'Model Create' }, account.accountInfo.id)
void trackEvent('DUI3 Action', { name: 'Model Create' }, account.value.accountInfo.id)
const { mutate } = provideApolloClient(account.client)(() =>
const { mutate } = provideApolloClient(account.value.client)(() =>
useMutation(createModelMutation)
)
const res = await mutate({ input: { projectId: props.project.id, name } })
if (res?.data?.modelMutations.create) {
refetch() // Sorts the list with newly created model otherwise it will put the model at the bottom.
emit('next', res?.data?.modelMutations.create)
// emit('next', res?.data?.modelMutations.create)
handleModelCreated(res?.data?.modelMutations.create)
} else {
let errorMessage = 'Undefined error'
if (res?.errors && res?.errors.length !== 0) {
@@ -230,6 +259,15 @@ const createNewModel = async (name: string) => {
isCreatingModel.value = false
}
const { result: canCreateModelResult } = useQuery(
canCreateModelInProjectQuery,
() => ({ projectId: props.project.id }),
() => ({
clientId: props.accountId,
fetchPolicy: 'network-only'
})
)
const {
result: projectModelsResult,
loading,
@@ -247,6 +285,7 @@ const {
() => ({ clientId: props.accountId, debounce: 500, fetchPolicy: 'cache-and-network' })
)
const token = computed(() => account.value.accountInfo.token)
const models = computed(() => projectModelsResult.value?.project.models.items)
const totalCount = computed(() => projectModelsResult.value?.project.models.totalCount)
const hasReachedEnd = ref(false)
@@ -0,0 +1,48 @@
<template>
<div>
<div
class="px-3 py-1 rounded-md shadow transition overflow-hidden bg-foundation border-foundation-2 hover:shadow-md border-1 group"
>
<div class="flex flex-col sm:flex-row sm:gap-2 text-foreground">
<div class="flex flex-col gap-4">
<div class="text-body-xs">
<h1
class="mb-1 text-sm font-semibold w-full inline-block py-1 bg-clip-text"
>
Move your projects to a workspace
</h1>
<p class="mb-2">
<span class="text-xs">
Personal projects are being phased out. Move your projects to a
workspace to create new projects and models, invite new project
collaborators, and view comments and versions older than 7 days. By
January 1st 2026, all projects will be archived if not moved into a
workspace.
</span>
</p>
<FormButton
color="primary"
size="sm"
class="mb-2"
@click="
$openUrl(
`${accountStore.activeAccount.accountInfo.serverInfo.url}/projects`
)
"
>
Move projects
</FormButton>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useAccountStore } from '~/store/accounts'
const accountStore = useAccountStore()
const { $openUrl } = useNuxtApp()
</script>
+292 -60
View File
@@ -1,16 +1,20 @@
<template>
<div class="space-y-2">
<div class="space-y-2 relative">
<div
v-if="workspacesEnabled && workspaces"
class="flex items-center space-x-2 bg-foundation -mx-3 -mt-2 px-3 py-2 shadow-sm border-b"
>
<div v-if="workspacesEnabled && workspaces" class="flex items-center space-x-2">
<div class="flex-grow min-w-0">
<div v-if="workspaces.length === 0">
<FormButton
full-width
class="flex items-center"
@click="$openUrl('https://app.speckle.systems/workspaces/actions/create')"
@click="
$openUrl(
`${activeAccount.accountInfo.serverInfo.url.replace(
/\/$/,
''
)}/workspaces/actions/create`
)
"
>
<div class="min-w-0 truncate flex-grow">
<span>{{ 'Create a workspace' }}</span>
@@ -33,33 +37,35 @@
<WorkspaceAvatar
:size="'xs'"
:name="selectedWorkspace.name || ''"
:logo="selectedWorkspace.logo"
:logo="selectedWorkspace.logoUrl"
/>
<div class="min-w-0 truncate flex-grow text-left">
<span>{{ selectedWorkspace.name }}</span>
</div>
<button
v-if="selectedWorkspace.slug"
v-tippy="'Open workspace in browser'"
class="transition mr-1 opacity-70 hover:opacity-100"
@click.stop="
$openUrl(
`${accountStore.activeAccount.accountInfo.serverInfo.url}/workspaces/${selectedWorkspace.slug}`
)
"
>
<ArrowTopRightOnSquareIcon class="w-3.5" />
</button>
<ChevronDownIcon class="h-3 w-3 shrink-0" />
</button>
</template>
</WorkspaceMenu>
</div>
<div class="px-0.5 shrink-0">
<div class="shrink-0 pt-1 px-1">
<AccountsMenu
:current-selected-account-id="accountId"
@select="(e) => selectAccount(e)"
/>
</div>
</div>
<!-- we can message to user about the non-workspace scenario -->
<!-- <div v-if="workspaces && workspaces.length === 0">
<CommonAlert size="xs" :color="'warning'">
<template #description>
You are listing legacy personal projects which will be deprecated end of
2025. We suggest you to move your personal projects into a workspace before
then.
</template>
</CommonAlert>
</div> -->
<div class="space-y-2">
<div class="flex items-center space-x-1 justify-between">
<FormTextInput
@@ -72,36 +78,68 @@
color="foundation"
/>
<div class="flex justify-between items-center space-x-2">
<ProjectCreateWorkspaceDialog
v-if="selectedWorkspace && selectedWorkspace.id !== 'personalProject'"
:workspace="selectedWorkspace"
@project:created="(result : ProjectListProjectItemFragment) => handleProjectCreated(result)"
>
<template #activator="{ toggle }">
<button
v-tippy="'New project in workspace'"
class="p-1.5 bg-foundation hover:bg-primary-muted rounded text-foreground border"
@click="toggle()"
>
<PlusIcon class="w-4" />
</button>
</template>
</ProjectCreateWorkspaceDialog>
<!-- TODO: once we deprecate personal projects, else block is bye bye -->
<ProjectCreatePersonalDialog
<div v-if="canCreateProject" v-tippy="'Create new project'">
<FormButton
color="outline"
:disabled="!canCreateProject"
:class="`p-1.5 bg-foundation hover:bg-primary-muted rounded text-foreground border`"
@click="showProjectCreateDialog = true"
>
<PlusIcon class="w-4 -mx-2" />
</FormButton>
</div>
<div
v-else
@project:created="(result : ProjectListProjectItemFragment) => handleProjectCreated(result)"
v-tippy="
canCreateProject
? 'Create new project'
: canCreateProjectPermissionCheck?.message
"
>
<template #activator="{ toggle }">
<button
v-tippy="'New personal project'"
class="p-1.5 bg-foundation hover:bg-primary-muted rounded text-foreground border"
@click="toggle()"
>
<PlusIcon class="w-4" />
</button>
</template>
</ProjectCreatePersonalDialog>
<FormButton
color="primary"
:class="`p-1.5 bg-foundation rounded text-foreground border`"
@click="upgradePlanButtonAction"
>
<ArrowUpCircleIcon class="w-4 -mx-2" />
</FormButton>
</div>
<CommonDialog
v-model:open="showProjectCreateDialog"
:title="`Create new project`"
fullscreen="none"
>
<form @submit="createProject(newProjectName as string)">
<div class="text-body-2xs mb-2 ml-1">Project name</div>
<FormTextInput
v-model="newProjectName"
class="text-xs"
placeholder="A Beautiful Home, A Small Bridge..."
autocomplete="off"
name="name"
label="Project name"
color="foundation"
:show-clear="!!newProjectName"
:rules="[
ValidationHelpers.isRequired,
ValidationHelpers.isStringOfLength({ minLength: 3 })
]"
full-width
/>
<div class="mt-4 flex justify-end items-center space-x-2 w-full">
<FormButton size="sm" text @click="showProjectCreateDialog = false">
Cancel
</FormButton>
<FormButton
size="sm"
submit
:disabled="isCreatingProject || !newProjectName"
>
Create
</FormButton>
</div>
</form>
</CommonDialog>
<div v-if="!workspacesEnabled || !workspaces" class="mt-1">
<AccountsMenu
:current-selected-account-id="accountId"
@@ -110,9 +148,12 @@
</div>
</div>
</div>
<CommonLoadingBar v-if="loading" loading />
<WizardPersonalProjectsWarning v-if="isPersonalProjectsAsWorkspace" />
<CommonLoadingBar v-if="loading || isCreatingProject" loading />
</div>
<div class="grid grid-cols-1 gap-2 relative z-0">
<div v-if="!urlParseError" class="grid grid-cols-1 gap-2 relative z-0">
<WizardListProjectCard
v-for="project in projects"
:key="project.id"
@@ -120,7 +161,27 @@
:is-sender="isSender"
@click="handleProjectCardClick(project)"
/>
<p v-if="projects?.length === 0 && !!searchText" class="text-sm">
No projects found
</p>
<FormButton
v-if="
projects?.length === 0 &&
!!searchText &&
canCreateProjectPermissionCheck?.authorized
"
full-width
color="outline"
:disabled="isCreatingProject"
class="block truncate overflow-hidden"
@click="createProject(searchText)"
>
Create "{{
searchText.length > 10 ? searchText.substring(0, 10) + '...' : searchText
}}"
</FormButton>
<FormButton
v-else
full-width
:disabled="hasReachedEnd"
color="outline"
@@ -135,24 +196,30 @@
<script setup lang="ts">
import { ChevronDownIcon, ArrowTopRightOnSquareIcon } from '@heroicons/vue/24/outline'
import { storeToRefs } from 'pinia'
import { PlusIcon } from '@heroicons/vue/20/solid'
import { PlusIcon, ArrowUpCircleIcon } from '@heroicons/vue/20/solid'
import type { DUIAccount } from '~/store/accounts'
import { useAccountStore } from '~/store/accounts'
import {
activeWorkspaceQuery,
canCreatePersonalProjectQuery,
createProjectInWorkspaceMutation,
createProjectMutation,
projectsListQuery,
serverInfoQuery,
setActiveWorkspaceMutation,
workspacesListQuery
} from '~/lib/graphql/mutationsAndQueries'
import { useMutation, provideApolloClient, useQuery } from '@vue/apollo-composable'
import { ValidationHelpers } from '@speckle/ui-components'
import type {
ProjectListProjectItemFragment,
WorkspaceListWorkspaceItemFragment
} from 'lib/common/generated/gql/graphql'
} from '~/lib/common/generated/gql/graphql'
import { useMixpanel } from '~/lib/core/composables/mixpanel'
import { useConfigStore } from '~/store/config'
import { useHostAppStore } from '~/store/hostApp'
const hostAppStore = useHostAppStore()
const { trackEvent } = useMixpanel()
const { $openUrl } = useNuxtApp()
@@ -174,6 +241,7 @@ const props = withDefaults(
* For the send wizard - not allowing selecting projects we can't write to.
*/
disableNoWriteAccessProjects?: boolean
urlParseError?: string
}>(),
{
showNewProject: true,
@@ -210,7 +278,11 @@ const handleProjectCreated = (result: ProjectListProjectItemFragment) => {
const { result: serverInfoResult, refetch: refetchServerInfo } = useQuery(
serverInfoQuery,
() => ({}),
() => ({ clientId: accountId.value, debounce: 500, fetchPolicy: 'network-only' })
() => ({
clientId: accountId.value,
debounce: 500,
fetchPolicy: 'network-only'
})
)
const workspacesEnabled = computed(
@@ -222,7 +294,11 @@ const { result: workspacesResult, refetch: refetchWorkspaces } = useQuery(
() => ({
limit: 100
}),
() => ({ clientId: accountId.value, debounce: 500, fetchPolicy: 'network-only' })
() => ({
clientId: accountId.value,
debounce: 500,
fetchPolicy: 'network-only'
})
)
const workspaces = computed(() => workspacesResult.value?.activeUser?.workspaces.items)
@@ -230,7 +306,11 @@ const workspaces = computed(() => workspacesResult.value?.activeUser?.workspaces
const { result: activeWorkspaceResult, refetch: refetchActiveWorkspace } = useQuery(
activeWorkspaceQuery,
() => ({}),
() => ({ clientId: accountId.value, debounce: 500, fetchPolicy: 'network-only' })
() => ({
clientId: accountId.value,
debounce: 500,
fetchPolicy: 'network-only'
})
)
const activeWorkspace = computed(() => {
@@ -243,15 +323,29 @@ const activeWorkspace = computed(() => {
return previouslySelectedWorkspace
}
}
// fallback to activeWorkspace query result
return activeWorkspaceResult.value?.activeUser
const activeLimitedWorkspace = activeWorkspaceResult.value?.activeUser
?.activeWorkspace as WorkspaceListWorkspaceItemFragment
// fallback to activeWorkspace query result
if (activeLimitedWorkspace) {
const activeWorkspace = workspaces.value?.find(
(w) => w.id === activeLimitedWorkspace.id
)
if (activeWorkspace) return activeWorkspace
}
return workspaces.value?.[0] // fallback to first workspace if none is active
})
const selectedWorkspace = ref<WorkspaceListWorkspaceItemFragment | undefined>(
activeWorkspace.value
)
const isPersonalProjectsAsWorkspace = computed(
() => selectedWorkspace.value?.id === 'personalProject'
)
watch(
workspaces,
(newItems) => {
@@ -320,12 +414,11 @@ const {
limit: 10, // stupid hack, increased it since we do manual filter to be able to see more project, see below TODO note, once we have `personalOnly` filter, decrease back to 10
filter: {
search: (searchText.value || '').trim() || null,
workspaceId:
selectedWorkspace.value?.id === 'personalProject'
? null
: selectedWorkspace.value?.id,
workspaceId: isPersonalProjectsAsWorkspace.value
? null
: selectedWorkspace.value?.id,
includeImplicitAccess: true,
personalOnly: selectedWorkspace.value?.id === 'personalProject'
personalOnly: isPersonalProjectsAsWorkspace.value
}
}),
() => ({
@@ -337,7 +430,7 @@ const {
)
const projects = computed(() =>
selectedWorkspace.value?.id === 'personalProject' // TODO: we need to replace this logic with `personalOnly` filter when it is implemented into app.speckle.systems
isPersonalProjectsAsWorkspace.value // TODO: we need to replace this logic with `personalOnly` filter when it is implemented into app.speckle.systems
? projectsResult.value?.activeUser?.projects.items.filter(
(i) => i.workspaceId === null
)
@@ -361,6 +454,145 @@ watch(projectsResult, (newVal) => {
}
})
const { result: canCreatePersonalProjectResult } = useQuery(
canCreatePersonalProjectQuery,
{},
() => ({
clientId: accountId.value
})
)
const canCreateProject = computed(() => {
// If a workspace is selected, return that permission check
if (selectedWorkspace.value && selectedWorkspace.value.permissions) {
return selectedWorkspace.value.permissions.canCreateProject.authorized //as boolean
}
// Otherwise, check for personal projects
if (canCreatePersonalProjectResult) {
return canCreatePersonalProjectResult.value?.activeUser?.permissions
.canCreatePersonalProject.authorized
}
// To be always safe, default to false
return false
})
const canCreateProjectPermissionCheck = computed(() => {
if (selectedWorkspace.value && selectedWorkspace.value.permissions) {
return selectedWorkspace.value.permissions.canCreateProject
}
if (canCreatePersonalProjectResult) {
return canCreatePersonalProjectResult.value?.activeUser?.permissions
.canCreatePersonalProject
}
return null
})
const isCreatingProject = ref(false)
const showProjectCreateDialog = ref(false)
const createProject = (name: string) => {
if (
canCreateProjectPermissionCheck.value &&
!canCreateProjectPermissionCheck.value.authorized
) {
hostAppStore.setNotification({
type: 1,
title: 'Failed to create project',
description: canCreateProjectPermissionCheck.value.message as string
})
return
}
if (isPersonalProjectsAsWorkspace.value || !selectedWorkspace.value) {
return void createNewPersonalProject(name)
} else {
return void createNewWorkspaceProject(name)
}
}
const account = computed(() => {
return accountStore.accounts.find(
(acc) => acc.accountInfo.id === accountId.value
) as DUIAccount
})
const createNewWorkspaceProject = async (name: string) => {
isCreatingProject.value = true
void trackEvent(
'DUI3 Action',
{ name: 'Project Create', workspace: true },
accountId.value
)
const { mutate, onError } = provideApolloClient(account.value.client)(() =>
useMutation(createProjectInWorkspaceMutation)
)
onError((err) => {
hostAppStore.setNotification({
type: 1,
title: 'Failed to create project',
description: err.cause?.message ?? err.message ?? 'Unknown error'
})
})
const res = await mutate({
input: { name, workspaceId: selectedWorkspace.value?.id as string }
})
if (res?.data?.workspaceMutations.projects.create) {
handleProjectCreated(res?.data?.workspaceMutations.projects.create)
}
isCreatingProject.value = false
}
const createNewPersonalProject = async (name: string) => {
isCreatingProject.value = true
void trackEvent(
'DUI3 Action',
{ name: 'Project Create', workspace: false },
account.value.accountInfo.id
)
const { mutate, onError } = provideApolloClient(account.value.client)(() =>
useMutation(createProjectMutation)
)
onError((err) => {
hostAppStore.setNotification({
type: 1,
title: 'Failed to create project',
description: err.cause?.message ?? err.message ?? 'Unknown error'
})
})
const res = await mutate({ input: { name } })
if (res?.data?.projectMutations.create) {
return handleProjectCreated(res?.data?.projectMutations.create)
}
isCreatingProject.value = false
}
const upgradePlanButtonAction = () => {
if (!canCreateProjectPermissionCheck.value) return
if (canCreateProjectPermissionCheck.value.code === 'WorkspaceNoEditorSeat') {
// open url to workspace/settings/users
$openUrl(
`${account.value.accountInfo.serverInfo.url}/settings/workspaces/${selectedWorkspace.value?.slug}/members`
)
return
}
if (canCreateProjectPermissionCheck.value.code === 'WorkspaceLimitsReached') {
// open url to workspace/billing
$openUrl(
`${account.value.accountInfo.serverInfo.url}/settings/workspaces/${selectedWorkspace.value?.slug}/billing`
)
return
}
}
const loadMore = () => {
fetchMore({
variables: { cursor: projectsResult.value?.activeUser?.projects.cursor },
+25 -1
View File
@@ -13,6 +13,17 @@
Upgrade
</FormButton>
</div>
<div v-if="hasReceiveSettings">
<ModelSettings
class="mb-2"
expandable
:settings="settings"
:default-settings="(store.receiveSettings as unknown as CardSetting[])"
@update:settings="updateSettings"
></ModelSettings>
<hr />
</div>
<div v-if="latestVersion" class="grid grid-cols-2 gap-3 max-[275px]:grid-cols-1">
<WizardListVersionCard
v-for="(version, index) in versions"
@@ -44,27 +55,40 @@ import { useQuery } from '@vue/apollo-composable'
import { modelVersionsQuery } from '~/lib/graphql/mutationsAndQueries'
import type { VersionListItemFragment } from '~/lib/common/generated/gql/graphql'
import { useAccountStore } from '~/store/accounts'
import type { CardSetting } from '~/lib/models/card/setting'
import { useHostAppStore } from '~/store/hostApp'
defineEmits<{
const emit = defineEmits<{
(
e: 'next',
version: VersionListItemFragment,
latestVersion: VersionListItemFragment
): void
(e: 'update:settings', settings: CardSetting[]): void
}>()
const props = defineProps<{
accountId: string
projectId: string
modelId: string
settings?: CardSetting[]
selectedVersionId?: string
workspaceSlug?: string
fromWizard?: boolean
}>()
const store = useHostAppStore()
const accountStore = useAccountStore()
const serverUrl = computed(() => accountStore.activeAccount.accountInfo.serverInfo.url)
const hasReceiveSettings = computed(
() => store.receiveSettings && store.receiveSettings.length > 0
)
const updateSettings = (settings: CardSetting[]) => {
emit('update:settings', settings)
}
const {
result: modelVersionResults,
loading,
-85
View File
@@ -1,85 +0,0 @@
<template>
<div>
<div class="text-body-2xs mb-2 ml-1">Project workspace</div>
<FormSelectBase
key="name"
v-model="selectedWorkspace"
clearable
label="Workspaces"
placeholder="Nothing selected"
name="Workspaces"
:items="workspaces"
:disabled-item-predicate="userCantCreateWorkspace"
mount-menu-on-body
>
<template #something-selected="{ value }">
<span>{{ value.name }}</span>
</template>
<template #option="{ item }">
<div
v-tippy="{
content: item.readOnly
? 'This workspace is read-only.'
: item.role === 'workspace:guest'
? 'You do not have write access on this workspace.'
: undefined,
disabled: !(item.readOnly || item.role === 'workspace:guest')
}"
class="flex items-center"
>
<span class="truncate">{{ item.name }}</span>
</div>
</template>
</FormSelectBase>
<div
v-if="selectedWorkspace"
class="text-body-sm caption rounded p-2 bg-blue-500/10 my-2"
>
Project will be created in the selected workspace.
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useQuery } from '@vue/apollo-composable'
import { workspacesListQuery } from '~/lib/graphql/mutationsAndQueries'
import type { WorkspaceListWorkspaceItemFragment } from 'lib/common/generated/gql/graphql'
import { storeToRefs } from 'pinia'
import { useAccountStore } from '~/store/accounts'
const emit = defineEmits<{
(
e: 'update:selectedWorkspace',
value: WorkspaceListWorkspaceItemFragment | undefined
): void
}>()
const accountStore = useAccountStore()
const { activeAccount } = storeToRefs(accountStore)
const accountId = computed(() => activeAccount.value.accountInfo.id)
const searchText = ref<string>()
const { result: workspacesResult } = useQuery(
workspacesListQuery,
() => ({
limit: 5,
filter: {
search: (searchText.value || '').trim() || null
}
}),
() => ({ clientId: accountId.value, debounce: 500, fetchPolicy: 'network-only' })
)
const workspaces = computed(() => workspacesResult.value?.activeUser?.workspaces.items)
const selectedWorkspace = ref<WorkspaceListWorkspaceItemFragment>()
watch(selectedWorkspace, (newVal) => {
emit('update:selectedWorkspace', newVal)
})
// Utility function to check if the user cannot create a workspace
const userCantCreateWorkspace = (item: WorkspaceListWorkspaceItemFragment) =>
(!!item?.role && item.role === 'workspace:guest') || !!item.readOnly
</script>
+21 -3
View File
@@ -4,9 +4,15 @@
>
<div class="flex items-center space-x-2 max-[275px]:space-x-0">
<div class="max-[275px]:hidden">
<div v-if="model.previewUrl" class="h-12 w-12">
<div
v-if="model.versions.totalCount === 0"
class="h-12 w-12 bg-blue-500/10 rounded flex items-center justify-center"
>
<CubeTransparentIcon class="w-5 h-5 text-foreground-2" />
</div>
<div v-else-if="previewUrl" class="h-12 w-12">
<img
:src="model.previewUrl"
:src="previewUrl"
alt="preview image for model"
class="h-12 w-12 object-cover"
/>
@@ -15,7 +21,7 @@
v-else
class="h-12 w-12 bg-blue-500/10 rounded flex items-center justify-center"
>
<CubeTransparentIcon class="w-5 h-5 text-foreground-2" />
<CommonLoadingIcon />
</div>
</div>
<div class="min-w-0 w-full">
@@ -52,9 +58,16 @@ import { ClockIcon } from '@heroicons/vue/24/outline'
import type { SourceAppName } from '@speckle/shared'
import { SourceApps } from '@speckle/shared'
import type { ModelListModelItemFragment } from '~/lib/common/generated/gql/graphql'
import { computedAsync } from '@vueuse/core'
import { usePreviewUrl } from '~/lib/core/composables/previewUrl'
const props = defineProps<{
model: ModelListModelItemFragment
/**
* Token to retrieve preview url
* @note by convention we pass around `accountId` but it doesn't make sense to get token for every model card. more efficient with this way.
*/
token: string
}>()
const folderPath = computed(() => {
@@ -68,6 +81,11 @@ const updatedAgo = computed(() => {
return dayjs(props.model.updatedAt).from(dayjs())
})
const previewUrl = computedAsync(async () => {
if (props.model.previewUrl === null) return
return await usePreviewUrl(props.token, props.model.previewUrl)
})
const sourceApp = computed(() => {
if (props.model.versions.items.length === 0) return
const version = props.model.versions.items[0]
+27 -2
View File
@@ -5,7 +5,7 @@
>
<UserAvatar
v-tippy="`Authored by ${version.authorUser?.name}`"
:user="version.authorUser"
:user="{ avatar: version.authorUser?.avatar, name: version.authorUser?.name as string }"
size="sm"
class="absolute inset-1"
/>
@@ -24,7 +24,15 @@
</div>
</div>
<div v-else class="flex items-center justify-center w-full h-24">
<img :src="version.previewUrl" alt="version preview" />
<div v-if="previewUrl">
<img :src="previewUrl" alt="preview image for version" />
</div>
<div
v-else
class="h-12 w-12 bg-blue-500/10 rounded flex items-center justify-center"
>
<CommonLoadingIcon />
</div>
</div>
<div class="p-1.5 border-t dark:border-gray-700">
<div class="flex space-x-2 items-center min-w-0">
@@ -100,9 +108,14 @@ import dayjs from 'dayjs'
import type { SourceAppName } from '@speckle/shared'
import { SourceApps } from '@speckle/shared'
import type { VersionListItemFragment } from '~/lib/common/generated/gql/graphql'
import { useAccountStore, type DUIAccount } from '~/store/accounts'
import { computedAsync } from '@vueuse/core'
import { usePreviewUrl } from '~/lib/core/composables/previewUrl'
// import { objectQuery } from '~/lib/graphql/mutationsAndQueries'
// import { useQuery } from '@vue/apollo-composable'
const accountStore = useAccountStore()
const props = defineProps<{
version: VersionListItemFragment
index: number
@@ -120,6 +133,18 @@ const createdAgo = computed(() => {
const isLimited = computed(() => props.version.referencedObject === null)
const token = computed(() => {
const account = accountStore.accounts.find(
(acc) => acc.accountInfo.id === props.accountId
) as DUIAccount
return account.accountInfo.token
})
const previewUrl = computedAsync(async () => {
if (props.version.previewUrl === null) return
return await usePreviewUrl(token.value, props.version.previewUrl)
})
// NOTE!!!: This logic somehow caused regression on versionList fetchMore, but we do not know exactly why yet.
// const { result: objectQueryResult } = useQuery(
// objectQuery,
+17 -1
View File
@@ -14,18 +14,34 @@
<WorkspaceAvatar
:size="'sm'"
:name="workspace.name || ''"
:logo="workspace.logo"
:logo="workspace.logoUrl"
/>
<div class="min-w-0 grow">
<div class="truncate overflow-hidden min-w-0 flex items-center space-x-2">
<span>{{ workspace.name }}</span>
</div>
</div>
<button
v-if="workspace.slug"
v-tippy="'Open workspace in browser'"
class="hidden transition mr-1 opacity-70 group-hover:block"
@click.stop="
$openUrl(
`${accountStore.activeAccount.accountInfo.serverInfo.url}/workspaces/${workspace.slug}`
)
"
>
<ArrowTopRightOnSquareIcon class="w-3.5" />
</button>
</div>
</button>
</template>
<script setup lang="ts">
import type { WorkspaceListWorkspaceItemFragment } from '~/lib/common/generated/gql/graphql'
import { ArrowTopRightOnSquareIcon } from '@heroicons/vue/24/outline'
import { useAccountStore } from '~/store/accounts'
const accountStore = useAccountStore()
defineProps<{
workspace: WorkspaceListWorkspaceItemFragment
+2 -8
View File
@@ -14,7 +14,7 @@
@select="
$emit('workspace:selected', workspace), (showWorkspaceSelectorDialog = false)
"
></WorkspaceListItem>
/>
</CommonDialog>
</div>
</template>
@@ -33,13 +33,7 @@ defineEmits<{
(e: 'workspace:selected', result: WorkspaceListWorkspaceItemFragment): void
}>()
const workspacesWithPersonalProjects = computed(() => [
...props.workspaces,
{
id: 'personalProject',
name: 'Personal Projects'
} as WorkspaceListWorkspaceItemFragment
])
const workspacesWithPersonalProjects = computed(() => [...props.workspaces])
const toggleDialog = () => {
showWorkspaceSelectorDialog.value = !showWorkspaceSelectorDialog.value
+1 -1
View File
@@ -1,4 +1,4 @@
HOST=0.0.0.0
HOST=127.0.0.1
PORT=8082
NUXT_PUBLIC_MIXPANEL_TOKEN_ID=acd87c5a50b56df91a795e999812a3a4
+53 -7
View File
@@ -1,7 +1,19 @@
import { omit } from 'lodash-es'
import { baseConfigs, globals, getESMDirname } from '../../eslint.config.mjs'
import withNuxt from './.nuxt/eslint.config.mjs'
import pluginVueA11y from 'eslint-plugin-vuejs-accessibility'
import globals from 'globals'
import { fileURLToPath } from 'url'
import { dirname } from 'path'
import prettierConfig from 'eslint-config-prettier'
import js from '@eslint/js'
/**
* Feed in import.meta.url in your .mjs module to get the equivalent of __dirname
* @param {string} importMetaUrl
*/
export const getESMDirname = (importMetaUrl) => {
return dirname(fileURLToPath(importMetaUrl))
}
const configs = await withNuxt([
{
@@ -62,6 +74,7 @@ const configs = await withNuxt([
'@typescript-eslint/require-await': 'error',
'no-undef': 'off',
'@typescript-eslint/no-empty-object-type': 'off', // too restrictive
'@typescript-eslint/unified-signatures': 'off', // DX sucks in vue event definitions
'@typescript-eslint/no-dynamic-delete': 'off', // too restrictive
'@typescript-eslint/restrict-template-expressions': 'off', // too restrictive
@@ -78,10 +91,7 @@ const configs = await withNuxt([
{
files: ['**/*.vue'],
rules: {
'vue/component-tags-order': [
'error',
{ order: ['docs', 'template', 'script', 'style'] }
],
'vue/block-order': ['error', { order: ['docs', 'template', 'script', 'style'] }],
'vue/require-default-prop': 'off',
'vue/multi-word-component-names': 'off',
'vue/component-name-in-template-casing': [
@@ -116,10 +126,46 @@ const configs = await withNuxt([
'./lib/common/generated/**/*',
'storybook-static',
'.nuxt/**',
'.output/**'
'.output/**',
'**/dist/**',
'**/dist-*/**',
'**/public/**',
'**/events.json',
'**/generated/**/*'
]
},
...baseConfigs
{
files: ['**/*.mjs'],
languageOptions: {
sourceType: 'module'
}
},
{
files: ['**/*.cjs'],
languageOptions: {
sourceType: 'commonjs'
}
},
{
files: ['**/*.{js,mjs,cjs}', '**/.*.{js,mjs,cjs}'],
...js.configs.recommended
},
prettierConfig,
{
rules: {
camelcase: [
1,
{
properties: 'always'
}
],
'no-var': 'error',
'no-alert': 'error',
eqeqeq: 'error',
'prefer-const': 'warn',
'object-shorthand': 'warn'
}
}
])
export default configs
+15 -2
View File
@@ -8,7 +8,7 @@
v-if="hasNoModelCards"
class="px-3 text-body-3xs text-foreground-2 justify-center bg-red-200/1 py-2 flex items-center w-full space-x-2"
>
<span>Version {{ hostApp.connectorVersion }}</span>
<span>Version {{ hostApp.connectorVersion || 'dev' }}</span>
<FormButton
size="sm"
text
@@ -19,6 +19,17 @@
>
Toggle theme
</FormButton>
<FormButton
v-if="hostApp.hostAppName?.toLowerCase() === 'revit'"
size="sm"
text
color="subtle"
:icon-right="WrenchScrewdriverIcon"
hide-text
@click="app.$showDevTools()"
>
Open dev tools
</FormButton>
</div>
</div>
</template>
@@ -27,7 +38,9 @@
import { storeToRefs } from 'pinia'
import { useHostAppStore } from '~/store/hostApp'
import { useConfigStore } from '~/store/config'
import { MoonIcon, SunIcon } from '@heroicons/vue/24/outline'
import { MoonIcon, SunIcon, WrenchScrewdriverIcon } from '@heroicons/vue/24/outline'
const app = useNuxtApp()
const uiConfigStore = useConfigStore()
const { isDarkTheme } = storeToRefs(uiConfigStore)
-136
View File
@@ -1,136 +0,0 @@
import { ApolloClient, gql } from '@apollo/client/core'
import { ApolloClients } from '@vue/apollo-composable'
import type { ComputedRef, Ref } from 'vue'
import type { Account } from '~/lib/bindings/definitions/IBasicConnectorBinding'
import { resolveClientConfig } from '~/lib/core/configs/apollo'
export type DUIAccount = {
/** account info coming from the host app */
accountInfo: Account
/** the graphql client; a bit superflous */
client?: ApolloClient<unknown>
/** whether an intial serverinfo query succeeded. */
isValid: boolean
}
export type DUIAccountsState = {
accounts: Ref<DUIAccount[]>
validAccounts: ComputedRef<DUIAccount[]>
refreshAccounts: () => Promise<void>
defaultAccount: ComputedRef<DUIAccount | undefined>
loading: Ref<boolean>
}
const AccountsInjectionKey = 'DUI_ACCOUNTS_STATE'
/**
* Use this composable to set up the account bindings and graphql clients at the top of the app.
* TODO: Properly handle cases when user was not connected to the internet,
* and then actually got connected.
*/
export function useAccountsSetup(): DUIAccountsState {
const app = useNuxtApp()
const $baseBinding = app.$baseBinding
const accounts = ref<DUIAccount[]>([])
const apolloClients = {} as Record<string, ApolloClient<unknown>>
// Tries to connect to the accounts and sets their is valid prop to false if fails.
const testAccounts = async (accs: DUIAccount[]) => {
const accountTestQuery = gql`
query AcccountTestQuery {
serverInfo {
version
name
company
}
}
`
for (const acc of accs) {
if (!acc.client) continue
try {
await acc.client.query({ query: accountTestQuery })
acc.isValid = true
} catch {
// TODO: properly dispose and kill this client. It's unclear how to do it.
acc.isValid = false
// NOTE: we do not want to delete the client, as we might want to "refresh" in
// case the user was not connected to the interweb.
// acc.client.disableNetworkFetches = true
// acc.client.stop()
// delete acc.client
}
}
}
const loading = ref(false)
// Matches local accounts coming from the host app to app state.
const refreshAccounts = async () => {
loading.value = true
const accs = await $baseBinding.getAccounts()
// We create a whole new list of accounts that will replace the old list. This way we ensure we drop
// out of scope old accounts that not exist anymore (TODO: test), and we don't need to do complex diffing.
const newAccs = [] as DUIAccount[]
for (const acc of accs) {
const existing = accounts.value.find((a) => a.accountInfo.id === acc.id)
if (existing) {
newAccs.push(existing as DUIAccount)
continue
}
const client = new ApolloClient(
resolveClientConfig({
httpEndpoint: new URL('/graphql', acc.serverInfo.url).href,
authToken: () => acc.token
})
)
apolloClients[acc.id] = client
newAccs.push({
accountInfo: acc,
client,
isValid: true
})
}
// We test accounts here so we try to prevent the app from querying/using invalid accounts.
await testAccounts(newAccs)
// Once we have tested the new accounts, finally set them.
accounts.value = newAccs
loading.value = false
}
void refreshAccounts() // Promise that we do not want to await (convention with void)
const defaultAccount = computed(() =>
accounts.value.find((acc) => acc.accountInfo.isDefault)
)
const validAccounts = computed(() => {
return accounts.value.filter((a) => a.isValid)
})
const accState = {
accounts,
defaultAccount,
validAccounts,
refreshAccounts,
loading
}
app.vueApp.provide(ApolloClients, apolloClients)
provide(AccountsInjectionKey, accState)
return accState // as DUIAccountsState
}
/**
* Use this composable to access the users' local accounts and their corresponding graphql client.
*/
export function useInjectedAccounts(): DUIAccountsState {
const state = inject(AccountsInjectionKey) as DUIAccountsState
return state
}
+29
View File
@@ -0,0 +1,29 @@
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
const CHALLENGE_KEY = 'speckle_challenge'
const CHALLENGE_URL_KEY = 'speckle_url_challenge'
export function useAuthManager() {
const generateChallenge = (url: string): string => {
let result = ''
for (let i = 0; i < 12; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length))
}
localStorage.setItem(CHALLENGE_KEY, result) // <-- persist it
localStorage.setItem(CHALLENGE_URL_KEY, url)
return result
}
const getChallenge = (): string | null => {
return localStorage.getItem(CHALLENGE_KEY)
}
const getChallengeUrl = (): string | null => {
return localStorage.getItem(CHALLENGE_URL_KEY)
}
return {
getChallenge,
getChallengeUrl,
generateChallenge
}
}
+49 -1
View File
@@ -1,9 +1,13 @@
import type { IBinding, IBindingSharedEvents } from 'lib/bindings/definitions/IBinding'
import type {
IBinding,
IBindingSharedEvents
} from '~/lib/bindings/definitions/IBinding'
export const IAccountBindingKey = 'accountsBinding'
export interface IAccountBinding extends IBinding<IAccountBindingEvents> {
getAccounts: () => Promise<Account[]>
addAccount: (accountId: string, account: Account) => Promise<void>
removeAccount: (accountId: string) => Promise<void>
}
@@ -12,6 +16,7 @@ export type Account = {
id: string
isDefault: boolean
token: string
refreshToken: string
serverInfo: {
name: string
url: string
@@ -28,3 +33,46 @@ export type Account = {
}
export interface IAccountBindingEvents extends IBindingSharedEvents {}
export class MockedAccountBinding implements IAccountBinding {
public async getAccounts() {
const config = useRuntimeConfig()
return (await [
{
id: 'whatever',
isDefault: true,
token: config.public.speckleToken,
serverInfo: {
name: 'test',
url: config.public.speckleUrl,
frontend2: true
},
userInfo: {
id: 'whatever',
avatar: 'whatever',
email: ''
}
}
]) as Account[]
}
public async addAccount(accountId: string, account: Account) {
return await console.log('no way dude', accountId, account)
}
public async removeAccount(accountId: string) {
return await console.log('no way dude', accountId)
}
public async showDevTools() {
await console.log('No way dude')
}
public async openUrl(url: string) {
await window.open(url)
}
public on() {
return
}
}
@@ -26,6 +26,7 @@ export interface IBasicConnectorBinding
highlightObjects: (objectIds: string[]) => Promise<void>
removeModel: (model: IModelCard) => Promise<void>
removeModels: (models: IModelCard[]) => Promise<void>
updateParameters: (payload: string) => Promise<void>
}
export interface IBasicConnectorBindingHostEvents
@@ -57,3 +58,69 @@ export type ToastAction = {
url: string
name: string
}
export class MockedBaseBinding implements IBasicConnectorBinding {
public async getSourceApplicationName() {
return await 'headless'
}
public async getSourceApplicationVersion() {
return await 'dev'
}
public async getConnectorVersion() {
return await 'dev'
}
public async getDocumentInfo() {
return (await {
id: 'whatever',
name: 'test',
location: 'whocares'
}) as DocumentInfo
}
public async getDocumentState() {
return (await { models: [] }) as DocumentModelStore
}
public async addModel(_model: IModelCard) {
await console.log('no way dude')
}
public async removeModel(_model: IModelCard) {
await console.log('no way dude')
}
public async removeModels(_models: IModelCard[]) {
await console.log('no way dude')
}
public async updateModel(_model: IModelCard) {
await console.log('no way dude')
}
public async highlightModel(_modelCardId: string) {
await console.log('no way dude')
}
public async highlightObjects(_objectIds: string[]) {
await console.log('no way dude')
}
public async updateParameters(payload: string) {
await console.log('Mock: updateParameters called with payload:', payload)
}
public async showDevTools() {
await console.log('No way dude')
}
public async openUrl(url: string) {
await window.open(url)
}
public on() {
return
}
}
+54 -2
View File
@@ -1,4 +1,3 @@
import { BaseBridge } from '~/lib/bridge/base'
import type {
IBinding,
IBindingSharedEvents
@@ -15,6 +14,7 @@ export const IConfigBindingKey = 'configBinding'
export interface IConfigBinding extends IBinding<IConfigBindingEvents> {
getIsDevMode: () => Promise<boolean>
getConfig: () => Promise<ConnectorConfig>
getGlobalConfig: () => Promise<GlobalConfig>
updateConfig: (config: ConnectorConfig) => void
setUserSelectedAccountId: (accountId: string) => void
getUserSelectedAccountId: () => Promise<AccountsConfig>
@@ -25,6 +25,10 @@ export interface IConfigBinding extends IBinding<IConfigBindingEvents> {
export interface IConfigBindingEvents extends IBindingSharedEvents {}
export type GlobalConfig = {
isUpdateNotificationDisabled: boolean
}
export type ConnectorConfig = {
darkTheme: boolean
}
@@ -38,4 +42,52 @@ export type WorkspacesConfig = {
}
// Useless, but will do for now :)
export class MockedConfigBinding extends BaseBridge {}
export class MockedConfigBinding implements IConfigBinding {
public async getIsDevMode() {
return await true
}
public async getConfig() {
return await { darkTheme: false }
}
public async getGlobalConfig() {
return await { isUpdateNotificationDisabled: true }
}
public async updateConfig() {
return await console.log('')
}
public async setUserSelectedAccountId(accountId: string) {
return await console.log(accountId)
}
public async setUserSelectedWorkspaceId(workspaceId: string) {
return await console.log(workspaceId)
}
public async getAccountsConfig() {
return (await { userSelectedAccountId: 'whatever' }) as AccountsConfig
}
public async getWorkspacesConfig() {
return (await { userSelectedWorkspaceId: 'whatever' }) as WorkspacesConfig
}
public async getUserSelectedAccountId() {
return (await { userSelectedAccountId: 'whatever' }) as AccountsConfig
}
public async showDevTools() {
await console.log('No way dude')
}
public async openUrl(url: string) {
await window.open(url)
}
public on() {
return
}
}
@@ -0,0 +1,3 @@
export interface IParametersBinding {
update: (payload: string) => Promise<void>
}
+27 -1
View File
@@ -1,4 +1,4 @@
import type { ConversionResult } from 'lib/conversions/conversionResult'
import type { ConversionResult } from '~/lib/conversions/conversionResult'
import type { IModelCardSharedEvents } from '~/lib/models/card'
import type { CardSetting } from '~/lib/models/card/setting'
import type {
@@ -24,3 +24,29 @@ export interface IReceiveBindingEvents
conversionResults: ConversionResult[]
}) => void
}
export class MockedReceiveBinding implements IReceiveBinding {
public async getReceiveSettings() {
return await []
}
public async receive(_modelCardId: string) {
return await console.log('no way dude')
}
public async cancelReceive(_modelCardId: string) {
return await console.log('no way dude')
}
public async showDevTools() {
await console.log('No way dude')
}
public async openUrl(url: string) {
await window.open(url)
}
public on() {
return
}
}
@@ -0,0 +1,158 @@
import type {
IBinding,
IBindingSharedEvents
} from '~/lib/bindings/definitions/IBinding'
export const IRevitMapperBindingKey = 'revitMapperBinding'
export interface IRevitMapperBinding extends IBinding<IMapperBindingEvents> {
// Gets list of defined layers in doc
getAvailableLayers: () => Promise<LayerOption[]>
// Object methods
assignObjectsToCategory: (objectIds: string[], categoryValue: string) => Promise<void>
clearObjectsCategoryAssignment: (objectIds: string[]) => Promise<void>
clearAllObjectsCategoryAssignments: () => Promise<void>
getCurrentObjectsMappings: () => Promise<CategoryMapping[]>
getCategoryMappingsForObjects: (objectIds: string[]) => Promise<string[]>
// Layer methods
assignLayerToCategory: (layerIds: string[], categoryValue: string) => Promise<void>
clearLayerCategoryAssignment: (layerIds: string[]) => Promise<void>
clearAllLayerCategoryAssignments: () => Promise<void>
getCurrentLayerMappings: () => Promise<LayerCategoryMapping[]>
getEffectiveObjectsForLayerMapping: (
layerIds: string[],
categoryValue: string
) => Promise<string[]>
getCategoryMappingsForLayers: (layerIds: string[]) => Promise<string[]>
}
export interface IMapperBindingEvents extends IBindingSharedEvents {
mappingsChanged: (mappings: CategoryMapping[]) => void
layersChanged: (layers: LayerOption[]) => void
}
export interface Category {
value: string
label: string
}
export interface CategoryMapping {
categoryValue: string
categoryLabel: string
objectIds: string[]
objectCount: number
}
export interface LayerCategoryMapping {
categoryValue: string
categoryLabel: string
layerIds: string[]
layerNames: string[]
layerCount: number
}
export interface LayerOption {
id: string
name: string
}
// Mock implementation for dev/testing
export class MockedMapperBinding implements IRevitMapperBinding {
private mockMappings: CategoryMapping[] = []
public assignObjectsToCategory(
objectIds: string[],
categoryValue: string
): Promise<void> {
console.log('Mock: Assigning objects to category', { objectIds, categoryValue })
return Promise.resolve()
}
public getAvailableLayers(): Promise<LayerOption[]> {
return Promise.resolve([
{ id: 'layer1', name: 'Ground Floor' },
{ id: 'layer2', name: 'Ground Floor/Walls' },
{ id: 'layer3', name: 'Ground Floor/Walls/Interior' },
{ id: 'layer4', name: 'Second Floor' }
])
}
public clearObjectsCategoryAssignment(objectIds: string[]): Promise<void> {
console.log('Mock: Clearing category assignment', { objectIds })
return Promise.resolve()
}
public clearAllObjectsCategoryAssignments(): Promise<void> {
console.log('Mock: Clearing all assignments')
this.mockMappings = []
return Promise.resolve()
}
public getCurrentObjectsMappings(): Promise<CategoryMapping[]> {
return Promise.resolve(this.mockMappings)
}
public assignLayerToCategory(
layerIds: string[],
categoryValue: string
): Promise<void> {
console.log('Mock: Assigning layers to category', { layerIds, categoryValue })
return Promise.resolve()
}
public clearLayerCategoryAssignment(layerIds: string[]): Promise<void> {
console.log('Mock: Clearing layer category assignment', { layerIds })
return Promise.resolve()
}
public clearAllLayerCategoryAssignments(): Promise<void> {
console.log('Mock: Clearing all layer assignments')
return Promise.resolve()
}
public getCurrentLayerMappings(): Promise<LayerCategoryMapping[]> {
return Promise.resolve([])
}
public getEffectiveObjectsForLayerMapping(
layerIds: string[],
categoryValue: string
): Promise<string[]> {
console.log('Mock: Getting effective objects for layer mapping', {
layerIds,
categoryValue
})
return Promise.resolve(['obj1', 'obj2', 'obj3'])
}
public getCategoryMappingsForObjects(objectIds: string[]): Promise<string[]> {
console.log('Mock: Getting category mappings for objects', { objectIds })
// Mock returning some categories for testing
return Promise.resolve(
objectIds.length > 1 ? ['OST_Walls', 'OST_Doors'] : ['OST_Walls']
)
}
public getCategoryMappingsForLayers(layerIds: string[]): Promise<string[]> {
console.log('Mock: Getting category mappings for layers', { layerIds })
return Promise.resolve(
layerIds.length > 1 ? ['OST_Floors', 'OST_Ceilings'] : ['OST_Floors']
)
}
public showDevTools(): Promise<void> {
console.log('Braaaaa, no way!')
return Promise.resolve()
}
public openUrl(url: string): Promise<void> {
window.open(url)
return Promise.resolve()
}
public on() {
// Mock event handler
}
}
@@ -17,3 +17,24 @@ export type SelectionInfo = {
summary?: string
selectedObjectIds: string[]
}
export class MockedSelectionBinding implements ISelectionBinding {
public async getSelection() {
return (await {
summary: '2 objects selected over mock binding',
selectedObjectIds: ['1', '2', '3']
}) as SelectionInfo
}
public async showDevTools() {
await console.log('No way dude')
}
public async openUrl(url: string) {
await window.open(url)
}
public on() {
return
}
}
+32 -1
View File
@@ -5,7 +5,7 @@ import type {
} from '~~/lib/bindings/definitions/IBinding'
import type { CardSetting } from '~/lib/models/card/setting'
import type { IModelCardSharedEvents } from '~/lib/models/card'
import type { ConversionResult } from 'lib/conversions/conversionResult'
import type { ConversionResult } from '~/lib/conversions/conversionResult'
import type { CreateVersionArgs } from '~/lib/bridge/server'
export const ISendBindingKey = 'sendBinding'
@@ -26,6 +26,7 @@ export interface ISendBindingEvents
modelCardId: string
versionId: string
sendConversionResults: ConversionResult[]
ingestionId?: string
}) => void
setIdMap: (args: {
modelCardId: string
@@ -38,3 +39,33 @@ export interface ISendBindingEvents
triggerCancel: (modelCardId: string) => void
triggerCreateVersion: (args: CreateVersionArgs) => void
}
export class MockedSendBinding implements ISendBinding {
public async getSendFilters() {
return await []
}
public async getSendSettings() {
return await []
}
public async send(_modelCardId: string) {
return await console.log('no way dude')
}
public async cancelSend(_modelCardId: string) {
return await console.log('no way dude')
}
public async showDevTools() {
await console.log('No way dude')
}
public async openUrl(url: string) {
await window.open(url)
}
public on() {
return
}
}
+17 -5
View File
@@ -1,6 +1,4 @@
/* eslint-disable @typescript-eslint/require-await */
import { BaseBridge } from '~~/lib/bridge/base'
import type {
IBinding,
IBindingSharedEvents
@@ -38,9 +36,11 @@ export type ComplexType = {
count: number
}
export class MockedTestBinding extends BaseBridge {
export class MockedTestBinding implements ITestBinding {
public async sayHi(name: string, count: number, sayHelloNotHi: boolean) {
return `Hello from mocked bindings. Args: name = ${name}, count = ${count}, sayHelloNotHi = ${sayHelloNotHi.toString()}.`
return [
`Hello from mocked bindings. Args: name = ${name}, count = ${count}, sayHelloNotHi = ${sayHelloNotHi.toString()}.`
]
}
public async goAway() {
@@ -56,6 +56,18 @@ export class MockedTestBinding extends BaseBridge {
}
public async triggerEvent(eventName: string) {
return eventName
return console.log(eventName)
}
public async showDevTools() {
await console.log('No way dude')
}
public async openUrl(url: string) {
await window.open(url)
}
public on() {
return
}
}
-156
View File
@@ -1,156 +0,0 @@
import { ArchicadBridge } from '~/lib/bridge/server'
import { BaseBridge } from '~/lib/bridge/base'
import type { IRawBridge } from '~/lib/bridge/definitions'
/**
* A generic bridge class for Webivew2 or CefSharp.
*/
export class GenericBridge extends BaseBridge {
private bridge: IRawBridge
private archicadBridge: ArchicadBridge | undefined
private requests = {} as Record<
string,
{
methodName: string
resolve: (value: unknown) => void
reject: (reason: string | Error) => void
rejectTimerId: number
}
>
// TOTHINK: as this is a fast timeout, it forces us for long await methods in .net to return results via events. Kind-of not cool, and i'd be in favour of bumping it to "endless", or remove it altogether
// An example is the send or receive operations: they can take fucking long :D
private TIMEOUT_MS = 1000 * 60 // 60 sec
constructor(object: IRawBridge, isArchicadBridge: boolean = false) {
super()
this.bridge = object
if (isArchicadBridge) {
this.archicadBridge = new ArchicadBridge(this.emitter)
}
}
public async create(): Promise<boolean> {
// NOTE: GetMethods is a call to the .NET side.
try {
this.availableMethodNames = await this.bridge.GetBindingsMethodNames()
} catch {
console.warn(`Failed to get method names from binding.`)
return false
}
// NOTE: hoisting original calls as lowerCasedMethodNames, but using the UpperCasedName for the .NET call
// This allows us to follow js convetions and keep .NET ones too (eg. bindings.sayHi('') => public string SayHi(string name) {}
for (const methodName of this.availableMethodNames) {
const lowercasedMethodName = lowercaseMethodName(methodName)
const hoistTarget = this as unknown as Record<string, object>
hoistTarget[lowercasedMethodName] = (...args: unknown[]) =>
this.runMethod(methodName, args)
}
return true
}
private async emitResponseReady(eventName: string, requestId: string) {
this.registerPromise(eventName, requestId)
const data = await this.bridge.GetCallResult(requestId)
const request = this.requests[requestId]
try {
const parsedData = data ? (JSON.parse(data) as Record<string, unknown>) : null
if (parsedData === null) {
throw new Error(`Data is not parsed successfuly on ${eventName}`)
}
if (this.archicadBridge) {
this.archicadBridge.emit(eventName, parsedData, this.runMethod.bind(this))
} else {
this.emitter.emit(eventName, parsedData)
}
request.resolve(parsedData)
} catch (e) {
console.error(e)
request.reject(e as Error)
} finally {
window.clearTimeout(request.rejectTimerId)
delete this.requests[requestId]
}
}
async runMethod(
methodName: string,
args: unknown[],
shouldTimeout: boolean = true
): Promise<unknown> {
const requestId = (Math.random() + 1).toString(36).substring(2) + '_' + methodName
const preserializedArgs = args.map((a) => JSON.stringify(a))
this.bridge.RunMethod(methodName, requestId, JSON.stringify(preserializedArgs))
return this.registerPromise(methodName, requestId, shouldTimeout)
}
private async registerPromise(
methodName: string,
requestId: string,
shouldTimeout: boolean = true
) {
return new Promise((resolve, reject) => {
this.requests[requestId] = {
methodName,
resolve,
reject,
rejectTimerId: window.setTimeout(
() => {
reject(
`.NET response timed out for call to ${methodName} - did not receive anything back in good time (${this.TIMEOUT_MS}ms).`
)
delete this.requests[requestId]
},
shouldTimeout ? this.TIMEOUT_MS : 3600000
)
}
})
}
private async responseReady(requestId: string) {
if (!this.requests[requestId])
throw new Error(
`.NET Bridge found no request to resolve with the id of ${requestId}. Something is weird!`
)
const request = this.requests[requestId]
const data = await this.bridge.GetCallResult(requestId)
try {
const parsedData = data ? (JSON.parse(data) as Record<string, unknown>) : null // TODO: check if data is undefined
// eslint-disable-next-line no-prototype-builtins
if (parsedData && parsedData.hasOwnProperty('error')) {
console.error(data)
this.emitter.emit('errorOnResponse', data)
throw new Error(
`Failed to run ${requestId}. The host app error is logged above.`
)
}
request.resolve(parsedData)
} catch (e) {
console.error(e)
request.reject(e as Error)
} finally {
window.clearTimeout(request.rejectTimerId)
delete this.requests[requestId]
}
}
public showDevTools() {
this.bridge.ShowDevTools()
}
public openUrl(url: string) {
this.bridge.OpenUrl(url)
}
}
const lowercaseMethodName = (name: string) =>
name.charAt(0).toLowerCase() + name.slice(1)
+105 -19
View File
@@ -1,30 +1,47 @@
import { ArchicadBridge } from '~/lib/bridge/server'
import { BaseBridge } from '~/lib/bridge/base'
import type { IRawBridge } from '~/lib/bridge/definitions'
/**
* A generic bridge class for Webivew2 or CefSharp.
*/
export class GenericBridge extends BaseBridge {
private bridge: IRawBridge
private archicadBridge: ArchicadBridge | undefined
private requests = {} as Record<
string,
{
methodName: string
resolve: (value: unknown) => void
reject: (reason: string | Error) => void
rejectTimerId: number
}
>
// TOTHINK: as this is a fast timeout, it forces us for long await methods in .net to return results via events. Kind-of not cool, and i'd be in favour of bumping it to "endless", or remove it altogether
// An example is the send or receive operations: they can take fucking long :D
private TIMEOUT_MS = 1000 * 60 // 60 sec
constructor(object: IRawBridge) {
constructor(object: IRawBridge, isArchicadBridge: boolean = false) {
super()
this.bridge = object
if (isArchicadBridge) {
this.archicadBridge = new ArchicadBridge(this.emitter)
}
}
public async create(): Promise<boolean> {
// NOTE: GetMethods is a call to the .NET side.
let availableMethodNames = [] as string[]
try {
availableMethodNames = await this.bridge.GetBindingsMethodNames()
this.availableMethodNames = await this.bridge.GetBindingsMethodNames()
} catch {
console.warn(`Failed to get method names.`)
console.warn(`Failed to get method names from binding.`)
return false
}
// NOTE: hoisting original calls as lowerCasedMethodNames, but using the UpperCasedName for the .NET call
// This allows us to follow js convetions and keep .NET ones too (eg. bindings.sayHi('') => public string SayHi(string name) {}
for (const methodName of availableMethodNames) {
for (const methodName of this.availableMethodNames) {
const lowercasedMethodName = lowercaseMethodName(methodName)
const hoistTarget = this as unknown as Record<string, object>
hoistTarget[lowercasedMethodName] = (...args: unknown[]) =>
@@ -34,27 +51,96 @@ export class GenericBridge extends BaseBridge {
return true
}
private async runMethod(methodName: string, args: unknown[]): Promise<unknown> {
private async emitResponseReady(eventName: string, requestId: string) {
this.registerPromise(eventName, requestId)
const data = await this.bridge.GetCallResult(requestId)
const request = this.requests[requestId]
try {
const parsedData = data ? (JSON.parse(data) as Record<string, unknown>) : null
if (parsedData === null) {
throw new Error(`Data is not parsed successfuly on ${eventName}`)
}
if (this.archicadBridge) {
this.archicadBridge.emit(eventName, parsedData, this.runMethod.bind(this))
} else {
this.emitter.emit(eventName, parsedData)
}
request.resolve(parsedData)
} catch (e) {
console.error(e)
request.reject(e as Error)
} finally {
window.clearTimeout(request.rejectTimerId)
delete this.requests[requestId]
}
}
async runMethod(
methodName: string,
args: unknown[],
shouldTimeout: boolean = true
): Promise<unknown> {
const requestId = (Math.random() + 1).toString(36).substring(2) + '_' + methodName
const preserializedArgs = args.map((a) => JSON.stringify(a))
// NOTE: RunMethod is a call to the .NET side.
const result = await this.bridge.RunMethod(
methodName,
JSON.stringify(preserializedArgs)
)
this.bridge.RunMethod(methodName, requestId, JSON.stringify(preserializedArgs))
const parsed = result ? (JSON.parse(result) as Record<string, unknown>) : null
return this.registerPromise(methodName, requestId, shouldTimeout)
}
if (parsed && parsed['error']) {
console.error(parsed)
private async registerPromise(
methodName: string,
requestId: string,
shouldTimeout: boolean = true
) {
return new Promise((resolve, reject) => {
this.requests[requestId] = {
methodName,
resolve,
reject,
rejectTimerId: window.setTimeout(
() => {
reject(
`.NET response timed out for call to ${methodName} - did not receive anything back in good time (${this.TIMEOUT_MS}ms).`
)
delete this.requests[requestId]
},
shouldTimeout ? this.TIMEOUT_MS : 3600000
)
}
})
}
private async responseReady(requestId: string) {
if (!this.requests[requestId])
throw new Error(
`Failed to run ${methodName} with args ${JSON.stringify(
args
)}. The host app error is logged above.`
`.NET Bridge found no request to resolve with the id of ${requestId}. Something is weird!`
)
}
return parsed
const request = this.requests[requestId]
const data = await this.bridge.GetCallResult(requestId)
try {
const parsedData = data ? (JSON.parse(data) as Record<string, unknown>) : null // TODO: check if data is undefined
// eslint-disable-next-line no-prototype-builtins
if (parsedData && parsedData.hasOwnProperty('error')) {
console.error(data)
this.emitter.emit('errorOnResponse', data)
throw new Error(
`Failed to run ${requestId}. The host app error is logged above.`
)
}
request.resolve(parsedData)
} catch (e) {
console.error(e)
request.reject(e as Error)
} finally {
window.clearTimeout(request.rejectTimerId)
delete this.requests[requestId]
}
}
public showDevTools() {
+73 -19
View File
@@ -16,6 +16,9 @@ import type { Emitter } from 'nanoevents'
import { useDesktopService } from '~/lib/core/composables/desktopService'
import type { ToastNotification } from '@speckle/ui-components'
import { ToastNotificationType } from '@speckle/ui-components'
import { useModelIngestion } from '../ingestion/composables/useModelIngestion'
import type { ISenderModelCard } from '../models/card/send'
import { useCheckGraphql } from '~/lib/core/composables/useCheckGraphql'
export type SendBatchViaBrowserArgs = {
modelCardId: string
@@ -189,7 +192,7 @@ export class ArchicadBridge {
const body: ArchicadReceiveRequest = {
accountId: eventPayload.accountId,
projectId: eventPayload.projectId,
referencedObject: result.data.project.model.version.referencedObject,
referencedObject: result.data.project.model.version.referencedObject as string,
xmlConverterPath: eventPayload.xmlConverterPath
}
@@ -272,7 +275,7 @@ export class ArchicadBridge {
serverUrl: account?.accountInfo.serverInfo.url as string,
token: account?.accountInfo.token as string,
streamId: eventPayload.projectId,
objectId: result.data.project.model.version.referencedObject
objectId: result.data.project.model.version.referencedObject as string
})
const updateProgress = (e: {
@@ -313,7 +316,7 @@ export class ArchicadBridge {
})
// CONVERSION WILL START AFTER THAT
await runMethod('afterGetObjects', args as unknown as unknown[])
await runMethod('afterGetObjects', args as unknown as unknown[], false)
}
private queuedPromises = {} as Record<string, Promise<Response>[]>
@@ -373,7 +376,7 @@ export class ArchicadBridge {
this.queuedPromises[modelCardId] = []
console.log(`🚀 Upload is completed in ${(performance.now() - start) / 1000} s!`)
const args = [eventPayload.modelCardId, referencedObjectId]
await runMethod('afterSendObjects', args as unknown as unknown[])
await runMethod('afterSendObjects', args as unknown as unknown[], false)
}
}
@@ -466,24 +469,75 @@ export class ArchicadBridge {
}
private async createVersion(args: CreateVersionArgs) {
const accountStore = useAccountStore()
const { accounts } = storeToRefs(accountStore)
const account = accounts.value.find((acc) => acc.accountInfo.id === args.accountId)
const hostAppStore = useHostAppStore()
const { completeIngestionWithVersion } = useModelIngestion()
const { canCreateModelIngestion } = useCheckGraphql()
const createVersion = provideApolloClient((account as DUIAccount).client)(() =>
useMutation(createVersionMutation)
const modelCard = hostAppStore.models.find(
(model) => model.modelCardId === args.modelCardId
) as ISenderModelCard
const canCreateIngestion = await canCreateModelIngestion(
modelCard.projectId,
modelCard.modelId,
modelCard.accountId
)
const hostAppStore = useHostAppStore()
const result = await createVersion.mutate({
input: {
modelId: args.modelId,
objectId: args.referencedObjectId,
sourceApplication: hostAppStore.hostAppName,
projectId: args.projectId
if (canCreateIngestion.queryAvailable) {
const ingestionId = hostAppStore.activeIngestions[args.modelCardId]
if (!ingestionId) {
hostAppStore.setNotification({
type: ToastNotificationType.Danger,
title: 'Ingestion Failed',
description: 'Ingestion ID not found to create version.'
})
throw new Error(`Ingestion failed: Ingestion ID not found to create version.`)
}
})
return result?.data?.versionMutations?.create?.id
const res = await completeIngestionWithVersion(
modelCard,
ingestionId,
args.referencedObjectId
)
if (res?.statusData.__typename === 'ModelIngestionSuccessStatus') {
return res?.statusData.versionId
}
if (res?.statusData.__typename === 'ModelIngestionFailedStatus') {
const errorReason = res?.statusData.errorReason || 'Unknown error'
hostAppStore.setNotification({
type: ToastNotificationType.Danger,
title: 'Ingestion Failed',
description: errorReason
})
throw new Error(`Ingestion failed: ${errorReason}.`)
}
hostAppStore.setNotification({
type: ToastNotificationType.Danger,
title: 'Ingestion Error',
description: 'Ingestion status does not match expected types.'
})
throw new Error(
`Ingestion status does not match with the expected types as success or failure.`
)
} else {
const accountStore = useAccountStore()
const account = accountStore.getAccountClient(args.accountId)
const { mutate } = provideApolloClient(account)(() =>
useMutation(createVersionMutation)
)
const result = await mutate({
input: {
modelId: args.modelId,
objectId: args.referencedObjectId,
sourceApplication: args.sourceApplication || 'Archicad',
projectId: args.projectId
}
})
return result?.data?.versionMutations?.create?.id
}
}
}
+81 -27
View File
@@ -19,6 +19,9 @@ import type {
ReceiveViaBrowserArgs,
CreateVersionArgs
} from '~/lib/bridge/server'
import { useModelIngestion } from '../ingestion/composables/useModelIngestion'
import type { ISenderModelCard } from '../models/card/send'
import { useCheckGraphql } from '~/lib/core/composables/useCheckGraphql'
declare let sketchup: {
exec: (data: Record<string, unknown>) => void
@@ -86,7 +89,7 @@ export class SketchupBridge extends BaseBridge {
}
// NOTE: Overriden emit as we do not need to parse the data back - the Sketchup bridge already parses it for us.
emit(eventName: string, payload: string): void {
override emit(eventName: string, payload: string): void {
const eventPayload = payload as unknown as Record<string, unknown>
if (eventName === 'sendViaBrowser')
@@ -124,18 +127,20 @@ export class SketchupBridge extends BaseBridge {
serverUrl: account?.accountInfo.serverInfo.url as string,
token: account?.accountInfo.token as string,
streamId: eventPayload.projectId,
objectId: result.data.project.model.version.referencedObject
objectId: result.data.project.model.version.referencedObject as string
})
const updateProgress = (e: {
const updateProgress = (_: {
stage: ProgressStage
current: number
total: number
}) => {
const progress = e.current / e.total
// TODO: replace object loader with loader 2, for now progress is not return total and it end up with infinity
// const progress = e.current / e.total
hostAppStore.handleModelProgressEvents({
modelCardId: eventPayload.modelCardId,
progress: { status: 'Downloading', progress }
progress: { status: 'Downloading' }
})
}
@@ -295,40 +300,89 @@ export class SketchupBridge extends BaseBridge {
sourceApplication: 'sketchup',
message: message || 'send from sketchup'
}
const versionId = await this.createVersion(args)
const hostAppStore = useHostAppStore()
// TODO: Alignment needed
hostAppStore.setModelSendResult({
modelCardId: args.modelCardId,
versionId: versionId as string,
sendConversionResults
})
try {
const versionId = await this.createVersion(args)
hostAppStore.setModelSendResult({
modelCardId: args.modelCardId,
versionId: versionId as string,
sendConversionResults
})
} catch (err) {
hostAppStore.setHostAppError({
message: (err as Error).message || 'Unknown error occurred',
error: (err as Error).toString(),
stackTrace: (err as Error).stack || ''
})
}
}
public async createVersion(args: CreateVersionArgs) {
const accountStore = useAccountStore()
const hostAppStore = useHostAppStore()
const accountStore = useAccountStore()
const { accounts } = storeToRefs(accountStore)
const account = accounts.value.find((acc) => acc.accountInfo.id === args.accountId)
const { completeIngestionWithVersion } = useModelIngestion()
const createVersion = provideApolloClient((account as DUIAccount).client)(() =>
useMutation(createVersionMutation)
const modelCard = hostAppStore.models.find(
(model) => model.modelCardId === args.modelCardId
)
// sketchup versions are provided as 2 digit. i.e. 22, 23, 24
// we are safe with this string concatanation for 77 years
const hostAppName = `SketchUp 20${hostAppStore.hostAppVersion}`
if (!modelCard) {
throw new Error('Model card not found') // ctor
}
const result = await createVersion.mutate({
input: {
modelId: args.modelId,
objectId: args.referencedObjectId,
sourceApplication: hostAppName,
projectId: args.projectId
const { canCreateModelIngestion } = useCheckGraphql()
const canCreateIngestion = await canCreateModelIngestion(
modelCard.projectId,
modelCard.modelId,
modelCard.accountId
)
if (canCreateIngestion.queryAvailable) {
const ingestionId = hostAppStore.activeIngestions[args.modelCardId]
if (!ingestionId) {
throw new Error(`Ingestion failed: Ingestion ID not found to create version.`)
}
})
return result?.data?.versionMutations?.create?.id
const res = await completeIngestionWithVersion(
modelCard as ISenderModelCard,
ingestionId,
args.referencedObjectId
)
if (res?.statusData.__typename === 'ModelIngestionSuccessStatus') {
return res?.statusData.versionId
}
if (res?.statusData.__typename === 'ModelIngestionFailedStatus') {
throw new Error(
`Ingestion failed: ${res?.statusData.errorReason || 'Unknown error'}.`
)
}
throw new Error(
`Ingestion status does not match with the expected types as success or failure.`
)
} else {
// for the self hosters that does not have available graphql for ingestions
const createVersion = provideApolloClient((account as DUIAccount).client)(() =>
useMutation(createVersionMutation)
)
// sketchup versions are provided as 2 digit. i.e. 22, 23, 24
// we are safe with this string concatanation for 77 years
const hostAppName = `SketchUp 20${hostAppStore.hostAppVersion}`
const result = await createVersion.mutate({
input: {
modelId: args.modelId,
objectId: args.referencedObjectId,
sourceApplication: hostAppName,
projectId: args.projectId
}
})
return result?.data?.versionMutations?.create?.id
}
}
public async create(): Promise<boolean> {
+136 -16
View File
@@ -11,27 +11,84 @@ import type { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-
* 3. It does not support dead code elimination, so it will add unused operations.
*
* Therefore it is highly recommended to use the babel or swc plugin for production.
* Learn more about it here: https://the-guild.dev/graphql/codegen/plugins/presets/preset-client#reducing-bundle-size
*/
const documents = {
"\n query AcccountTestQuery {\n serverInfo {\n version\n name\n company\n }\n }\n ": types.AcccountTestQueryDocument,
"\n mutation SetActiveWorkspaceMutation($slug: String) {\n activeUserMutations {\n setActiveWorkspace(slug: $slug)\n }\n }\n": types.SetActiveWorkspaceMutationDocument,
type Documents = {
"\n mutation SetActiveWorkspaceMutation($slug: String) {\n activeUserMutations {\n setActiveWorkspace(slug: $slug) {\n id\n }\n }\n }\n": typeof types.SetActiveWorkspaceMutationDocument,
"\n mutation VersionMutations($input: CreateVersionInput!) {\n versionMutations {\n create(input: $input) {\n id\n }\n }\n }\n": typeof types.VersionMutationsDocument,
"\n mutation Update($input: UpdateVersionInput!) {\n versionMutations {\n update(input: $input) {\n id\n }\n }\n }\n": typeof types.UpdateDocument,
"\n mutation MarkReceivedVersion($input: MarkReceivedVersionInput!) {\n versionMutations {\n markReceived(input: $input)\n }\n }\n": typeof types.MarkReceivedVersionDocument,
"\n mutation CreateModel($input: CreateModelInput!) {\n modelMutations {\n create(input: $input) {\n ...ModelListModelItem\n }\n }\n }\n": typeof types.CreateModelDocument,
"\n mutation CreateProject($input: ProjectCreateInput) {\n projectMutations {\n create(input: $input) {\n ...ProjectListProjectItem\n }\n }\n }\n": typeof types.CreateProjectDocument,
"\n mutation CreateProjectInWorkspace($input: WorkspaceProjectCreateInput!) {\n workspaceMutations {\n projects {\n create(input: $input) {\n ...ProjectListProjectItem\n }\n }\n }\n }\n": typeof types.CreateProjectInWorkspaceDocument,
"\n mutation StreamAccessRequestCreate($input: String!) {\n streamAccessRequestCreate(streamId: $input) {\n id\n }\n }\n": typeof types.StreamAccessRequestCreateDocument,
"\n fragment WorkspaceListWorkspaceItem on Workspace {\n id\n slug\n name\n description\n createdAt\n updatedAt\n logoUrl\n role\n readOnly\n permissions {\n canCreateProject {\n authorized\n code\n message\n }\n }\n }\n": typeof types.WorkspaceListWorkspaceItemFragmentDoc,
"\n fragment AutomateFunctionItem on AutomateFunction {\n name\n isFeatured\n id\n creator {\n name\n }\n releases {\n items {\n inputSchema\n }\n }\n }\n": typeof types.AutomateFunctionItemFragmentDoc,
"\n mutation CreateAutomation($projectId: ID!, $input: ProjectAutomationCreateInput!) {\n projectMutations {\n automationMutations(projectId: $projectId) {\n create(input: $input) {\n id\n name\n }\n }\n }\n }\n": typeof types.CreateAutomationDocument,
"\n fragment AutomateFunctionRunItem on AutomateFunctionRun {\n id\n status\n statusMessage\n results\n contextView\n function {\n id\n name\n logo\n }\n }\n": typeof types.AutomateFunctionRunItemFragmentDoc,
"\n fragment AutomationRunItem on AutomateRun {\n id\n status\n automation {\n id\n name\n }\n functionRuns {\n ...AutomateFunctionRunItem\n }\n }\n": typeof types.AutomationRunItemFragmentDoc,
"\n query AutomationStatus($projectId: String!, $modelId: String!) {\n project(id: $projectId) {\n model(id: $modelId) {\n automationsStatus {\n id\n status\n automationRuns {\n ...AutomationRunItem\n }\n }\n }\n }\n }\n": typeof types.AutomationStatusDocument,
"\n query WorkspaceListQuery(\n $limit: Int!\n $filter: UserWorkspacesFilter\n $cursor: String\n ) {\n activeUser {\n id\n workspaces(limit: $limit, filter: $filter, cursor: $cursor) {\n totalCount\n cursor\n items {\n ...WorkspaceListWorkspaceItem\n }\n }\n }\n }\n": typeof types.WorkspaceListQueryDocument,
"\n query ActiveUser {\n activeUser {\n role\n id\n name\n }\n }\n": typeof types.ActiveUserDocument,
"\n query CanCreatePersonalProject {\n activeUser {\n permissions {\n canCreatePersonalProject {\n authorized\n code\n message\n payload\n }\n }\n }\n }\n": typeof types.CanCreatePersonalProjectDocument,
"\n query CanCreateProjectInWorkspace($workspaceId: String!) {\n workspace(id: $workspaceId) {\n permissions {\n canCreateProject {\n authorized\n code\n message\n payload\n }\n }\n }\n }\n": typeof types.CanCreateProjectInWorkspaceDocument,
"\n query CanCreateModelInProject($projectId: String!) {\n project(id: $projectId) {\n permissions {\n canCreateModel {\n authorized\n code\n message\n }\n }\n }\n }\n": typeof types.CanCreateModelInProjectDocument,
"\n query CanCreateVersion($projectId: String!, $modelId: String!) {\n project(id: $projectId) {\n model(id: $modelId) {\n permissions {\n canCreateVersion {\n authorized\n code\n message\n errorMessage\n }\n }\n }\n }\n }\n": typeof types.CanCreateVersionDocument,
"\n query ActiveWorkspace {\n activeUser {\n activeWorkspace {\n id\n name\n }\n }\n }\n": typeof types.ActiveWorkspaceDocument,
"\n fragment ProjectListProjectItem on Project {\n id\n name\n role\n updatedAt\n workspaceId\n workspace {\n id\n name\n slug\n role\n }\n models {\n totalCount\n }\n permissions {\n canLoad {\n authorized\n code\n message\n }\n canPublish {\n authorized\n code\n message\n }\n }\n }\n": typeof types.ProjectListProjectItemFragmentDoc,
"\n query ProjectListQuery($limit: Int!, $filter: UserProjectsFilter, $cursor: String) {\n activeUser {\n id\n projects(limit: $limit, filter: $filter, cursor: $cursor) {\n totalCount\n cursor\n items {\n ...ProjectListProjectItem\n }\n }\n }\n }\n": typeof types.ProjectListQueryDocument,
"\n fragment ModelListModelItem on Model {\n displayName\n name\n id\n previewUrl\n updatedAt\n versions(limit: 1) {\n totalCount\n items {\n ...VersionListItem\n }\n }\n }\n": typeof types.ModelListModelItemFragmentDoc,
"\n query ProjectModels(\n $projectId: String!\n $cursor: String\n $limit: Int!\n $filter: ProjectModelsFilter\n ) {\n project(id: $projectId) {\n id\n models(cursor: $cursor, limit: $limit, filter: $filter) {\n totalCount\n cursor\n items {\n ...ModelListModelItem\n }\n }\n }\n }\n": typeof types.ProjectModelsDocument,
"\n fragment VersionListItem on Version {\n id\n referencedObject\n message\n sourceApplication\n authorUser {\n avatar\n id\n name\n }\n createdAt\n previewUrl\n }\n": typeof types.VersionListItemFragmentDoc,
"\n query ModelVersions(\n $modelId: String!\n $projectId: String!\n $limit: Int!\n $cursor: String\n $filter: ModelVersionsFilter\n ) {\n project(id: $projectId) {\n id\n model(id: $modelId) {\n id\n versions(limit: $limit, cursor: $cursor, filter: $filter) {\n totalCount\n cursor\n items {\n ...VersionListItem\n }\n }\n }\n }\n }\n": typeof types.ModelVersionsDocument,
"\n query ObjectQuery($projectId: String!, $objectId: String!) {\n project(id: $projectId) {\n object(id: $objectId) {\n id\n data\n }\n }\n }\n": typeof types.ObjectQueryDocument,
"\n query ProjectAddByUrlQueryWithVersion(\n $projectId: String!\n $modelId: String!\n $versionId: String!\n ) {\n project(id: $projectId) {\n ...ProjectListProjectItem\n model(id: $modelId) {\n ...ModelListModelItem\n version(id: $versionId) {\n ...VersionListItem\n }\n }\n }\n }\n": typeof types.ProjectAddByUrlQueryWithVersionDocument,
"\n query ProjectAddByUrlQueryWithoutVersion($projectId: String!, $modelId: String!) {\n project(id: $projectId) {\n ...ProjectListProjectItem\n model(id: $modelId) {\n ...ModelListModelItem\n }\n }\n }\n": typeof types.ProjectAddByUrlQueryWithoutVersionDocument,
"\n query ProjectDetails($projectId: String!) {\n project(id: $projectId) {\n id\n role\n name\n workspace {\n name\n slug\n readOnly\n role\n }\n team {\n user {\n avatar\n id\n name\n }\n }\n visibility\n permissions {\n canLoad {\n authorized\n code\n message\n }\n canPublish {\n authorized\n code\n message\n }\n }\n }\n }\n": typeof types.ProjectDetailsDocument,
"\n query AutomateFunctions($workspaceId: String!) {\n workspace(id: $workspaceId) {\n automateFunctions {\n items {\n ...AutomateFunctionItem\n }\n }\n }\n }\n": typeof types.AutomateFunctionsDocument,
"\n query ModelDetails($modelId: String!, $projectId: String!) {\n project(id: $projectId) {\n id\n name\n model(id: $modelId) {\n id\n displayName\n name\n versions {\n totalCount\n items {\n id\n }\n }\n author {\n id\n name\n avatar\n }\n }\n }\n }\n": typeof types.ModelDetailsDocument,
"\n query VersionDetails($projectId: String!, $versionId: String!, $modelId: String!) {\n project(id: $projectId) {\n id\n name\n model(id: $modelId) {\n id\n name\n versions(limit: 1) {\n items {\n id\n createdAt\n sourceApplication\n authorUser {\n id\n }\n }\n }\n version(id: $versionId) {\n id\n referencedObject\n message\n sourceApplication\n createdAt\n previewUrl\n }\n }\n }\n }\n": typeof types.VersionDetailsDocument,
"\n query ServerInfo {\n serverInfo {\n workspaces {\n workspacesEnabled\n }\n }\n }\n": typeof types.ServerInfoDocument,
"\n subscription OnProjectVersionsUpdate($projectId: String!) {\n projectVersionsUpdated(id: $projectId) {\n id\n type\n version {\n id\n createdAt\n message\n sourceApplication\n authorUser {\n id\n name\n avatar\n }\n model {\n id\n name\n displayName\n }\n }\n }\n }\n": typeof types.OnProjectVersionsUpdateDocument,
"\n subscription ProjectTriggeredAutomationsStatusUpdated($projectId: String!) {\n projectTriggeredAutomationsStatusUpdated(projectId: $projectId) {\n type\n version {\n id\n }\n model {\n id\n }\n project {\n id\n }\n run {\n ...AutomationRunItem\n }\n }\n }\n": typeof types.ProjectTriggeredAutomationsStatusUpdatedDocument,
"\n subscription OnUserProjectsUpdated {\n userProjectsUpdated {\n id\n project {\n id\n visibility\n team {\n id\n role\n }\n }\n }\n }\n": typeof types.OnUserProjectsUpdatedDocument,
"\n subscription ProjectUpdated($projectId: String!) {\n projectUpdated(id: $projectId) {\n id\n project {\n visibility\n }\n }\n }\n": typeof types.ProjectUpdatedDocument,
"\n subscription ModelViewingSubscription($target: ViewerUpdateTrackingTarget!) {\n viewerUserActivityBroadcasted(target: $target) {\n userName\n userId\n sessionId\n user {\n name\n id\n avatar\n }\n status\n }\n }\n": typeof types.ModelViewingSubscriptionDocument,
"\n subscription ProjectCommentsUpdated($target: ViewerUpdateTrackingTarget!) {\n projectCommentsUpdated(target: $target) {\n comment {\n author {\n avatar\n id\n name\n }\n id\n hasParent\n parent {\n id\n }\n }\n type\n }\n }\n": typeof types.ProjectCommentsUpdatedDocument,
"\n mutation CreateModelIngestion($input: ModelIngestionCreateInput!) {\n projectMutations {\n modelIngestionMutations {\n create(input: $input) {\n id\n }\n }\n }\n }\n": typeof types.CreateModelIngestionDocument,
"\n mutation UpdateModelIngestionProgress($input: ModelIngestionUpdateInput!) {\n projectMutations {\n modelIngestionMutations {\n updateProgress(input: $input) {\n id\n }\n }\n }\n }\n": typeof types.UpdateModelIngestionProgressDocument,
"\n mutation CompleteModelIngestionWithVersion($input: ModelIngestionSuccessInput!) {\n projectMutations {\n modelIngestionMutations {\n completeWithVersion(input: $input) {\n id\n statusData {\n __typename\n ... on ModelIngestionProcessingStatus {\n status\n progressMessage\n progress\n }\n ... on ModelIngestionSuccessStatus {\n status\n versionId\n }\n ... on ModelIngestionFailedStatus {\n errorStacktrace\n errorReason\n status\n }\n ... on ModelIngestionCancelledStatus {\n cancellationMessage\n status\n }\n ... on ModelIngestionQueuedStatus {\n progressMessage\n status\n }\n }\n }\n }\n }\n }\n": typeof types.CompleteModelIngestionWithVersionDocument,
"\n mutation FailModelIngestionWithError($input: ModelIngestionFailedInput!) {\n projectMutations {\n modelIngestionMutations {\n failWithError(input: $input) {\n id\n }\n }\n }\n }\n": typeof types.FailModelIngestionWithErrorDocument,
"\n mutation FailModelIngestionWithCancel($input: ModelIngestionCancelledInput!) {\n projectMutations {\n modelIngestionMutations {\n failWithCancel(input: $input) {\n id\n }\n }\n }\n }\n": typeof types.FailModelIngestionWithCancelDocument,
"\n query CanCreateIngestion($modelId: String!, $projectId: String!) {\n project(id: $projectId) {\n model(id: $modelId) {\n permissions {\n canCreateIngestion {\n authorized\n code\n message\n }\n }\n }\n }\n }\n": typeof types.CanCreateIngestionDocument,
"\n subscription ProjectModelIngestionUpdated(\n $input: ProjectModelIngestionSubscriptionInput!\n ) {\n projectModelIngestionUpdated(input: $input) {\n type\n modelIngestion {\n id\n statusData {\n __typename\n ... on ModelIngestionSuccessStatus {\n status\n versionId\n }\n ... on ModelIngestionProcessingStatus {\n status\n progressMessage\n progress\n }\n ... on ModelIngestionFailedStatus {\n status\n errorReason\n }\n ... on ModelIngestionCancelledStatus {\n status\n cancellationMessage\n }\n ... on ModelIngestionQueuedStatus {\n status\n progressMessage\n }\n }\n }\n }\n }\n": typeof types.ProjectModelIngestionUpdatedDocument,
"\n fragment IssuesItem on Issue {\n id\n status\n title\n priority\n viewerState\n identifier\n resourceIdString\n activities(input: { limit: 1, sortDirection: asc }) {\n totalCount\n items {\n actor {\n id\n user {\n name\n id\n avatar\n }\n }\n eventType\n createdAt\n }\n }\n replies {\n totalCount\n items {\n id\n author {\n id\n user {\n name\n id\n avatar\n }\n }\n createdAt\n description {\n doc\n }\n }\n }\n description {\n doc\n }\n labels {\n hexColor\n id\n name\n }\n author {\n id\n user {\n id\n name\n avatar\n }\n }\n dueDate\n assignee {\n id\n user {\n id\n avatar\n name\n }\n }\n }\n": typeof types.IssuesItemFragmentDoc,
"\n query IssuesList($projectId: String!) {\n project(id: $projectId) {\n id\n issues {\n totalCount\n items {\n ...IssuesItem\n }\n }\n }\n }\n": typeof types.IssuesListDocument,
"\n query IssueResourceMetaSearch(\n $workspaceId: String!\n $resourceType: ResourceMetaType!\n $resourceId: String!\n $projectId: String\n $metaType: String\n ) {\n resourceMetaSearch(\n workspaceId: $workspaceId\n resourceType: $resourceType\n resourceId: $resourceId\n projectId: $projectId\n metaType: $metaType\n ) {\n data\n }\n }\n": typeof types.IssueResourceMetaSearchDocument,
"\n subscription WorkspacePlanUsageUpdated($input: WorkspacePlanUsageSubscriptionInput!) {\n workspacePlanUsageUpdated(input: $input)\n }\n": typeof types.WorkspacePlanUsageUpdatedDocument,
};
const documents: Documents = {
"\n mutation SetActiveWorkspaceMutation($slug: String) {\n activeUserMutations {\n setActiveWorkspace(slug: $slug) {\n id\n }\n }\n }\n": types.SetActiveWorkspaceMutationDocument,
"\n mutation VersionMutations($input: CreateVersionInput!) {\n versionMutations {\n create(input: $input) {\n id\n }\n }\n }\n": types.VersionMutationsDocument,
"\n mutation Update($input: UpdateVersionInput!) {\n versionMutations {\n update(input: $input) {\n id\n }\n }\n }\n": types.UpdateDocument,
"\n mutation MarkReceivedVersion($input: MarkReceivedVersionInput!) {\n versionMutations {\n markReceived(input: $input)\n }\n }\n": types.MarkReceivedVersionDocument,
"\n mutation CreateModel($input: CreateModelInput!) {\n modelMutations {\n create(input: $input) {\n ...ModelListModelItem\n }\n }\n }\n": types.CreateModelDocument,
"\n mutation CreateProject($input: ProjectCreateInput) {\n projectMutations {\n create(input: $input) {\n ...ProjectListProjectItem\n }\n }\n }\n": types.CreateProjectDocument,
"\n mutation CreateProjectInWorkspace($input: WorkspaceProjectCreateInput!) {\n workspaceMutations {\n projects {\n create(input: $input) {\n ...ProjectListProjectItem\n }\n }\n }\n }\n": types.CreateProjectInWorkspaceDocument,
"\n mutation StreamAccessRequestCreate($input: String!) {\n streamAccessRequestCreate(streamId: $input) {\n id\n }\n }\n": types.StreamAccessRequestCreateDocument,
"\n fragment WorkspaceListWorkspaceItem on Workspace {\n id\n slug\n name\n description\n createdAt\n updatedAt\n logo\n role\n readOnly\n }\n": types.WorkspaceListWorkspaceItemFragmentDoc,
"\n fragment WorkspaceListWorkspaceItem on Workspace {\n id\n slug\n name\n description\n createdAt\n updatedAt\n logoUrl\n role\n readOnly\n permissions {\n canCreateProject {\n authorized\n code\n message\n }\n }\n }\n": types.WorkspaceListWorkspaceItemFragmentDoc,
"\n fragment AutomateFunctionItem on AutomateFunction {\n name\n isFeatured\n id\n creator {\n name\n }\n releases {\n items {\n inputSchema\n }\n }\n }\n": types.AutomateFunctionItemFragmentDoc,
"\n mutation CreateAutomation($projectId: ID!, $input: ProjectAutomationCreateInput!) {\n projectMutations {\n automationMutations(projectId: $projectId) {\n create(input: $input) {\n id\n name\n }\n }\n }\n }\n": types.CreateAutomationDocument,
"\n fragment AutomateFunctionRunItem on AutomateFunctionRun {\n id\n status\n statusMessage\n results\n contextView\n function {\n id\n name\n logo\n }\n }\n": types.AutomateFunctionRunItemFragmentDoc,
"\n fragment AutomationRunItem on AutomateRun {\n id\n status\n automation {\n id\n name\n }\n functionRuns {\n ...AutomateFunctionRunItem\n }\n }\n": types.AutomationRunItemFragmentDoc,
"\n query AutomationStatus($projectId: String!, $modelId: String!) {\n project(id: $projectId) {\n model(id: $modelId) {\n automationsStatus {\n id\n status\n automationRuns {\n ...AutomationRunItem\n }\n }\n }\n }\n }\n": types.AutomationStatusDocument,
"\n query WorkspaceListQuery(\n $limit: Int!\n $filter: UserWorkspacesFilter\n $cursor: String\n ) {\n activeUser {\n id\n workspaces(limit: $limit, filter: $filter, cursor: $cursor) {\n totalCount\n cursor\n items {\n ...WorkspaceListWorkspaceItem\n }\n }\n }\n }\n": types.WorkspaceListQueryDocument,
"\n query ActiveUser {\n activeUser {\n role\n id\n name\n }\n }\n": types.ActiveUserDocument,
"\n query CanCreatePersonalProject {\n activeUser {\n permissions {\n canCreatePersonalProject {\n authorized\n code\n message\n payload\n }\n }\n }\n }\n": types.CanCreatePersonalProjectDocument,
"\n query CanCreateProjectInWorkspace($workspaceId: String!) {\n workspace(id: $workspaceId) {\n permissions {\n canCreateProject {\n authorized\n code\n message\n payload\n }\n }\n }\n }\n": types.CanCreateProjectInWorkspaceDocument,
"\n query CanCreateModelInProject($projectId: String!) {\n project(id: $projectId) {\n permissions {\n canCreateModel {\n authorized\n code\n message\n }\n }\n }\n }\n": types.CanCreateModelInProjectDocument,
"\n query ActiveWorkspace {\n activeUser {\n activeWorkspace {\n ...WorkspaceListWorkspaceItem\n }\n }\n }\n": types.ActiveWorkspaceDocument,
"\n query CanCreateVersion($projectId: String!, $modelId: String!) {\n project(id: $projectId) {\n model(id: $modelId) {\n permissions {\n canCreateVersion {\n authorized\n code\n message\n errorMessage\n }\n }\n }\n }\n }\n": types.CanCreateVersionDocument,
"\n query ActiveWorkspace {\n activeUser {\n activeWorkspace {\n id\n name\n }\n }\n }\n": types.ActiveWorkspaceDocument,
"\n fragment ProjectListProjectItem on Project {\n id\n name\n role\n updatedAt\n workspaceId\n workspace {\n id\n name\n slug\n role\n }\n models {\n totalCount\n }\n permissions {\n canLoad {\n authorized\n code\n message\n }\n canPublish {\n authorized\n code\n message\n }\n }\n }\n": types.ProjectListProjectItemFragmentDoc,
"\n query ProjectListQuery($limit: Int!, $filter: UserProjectsFilter, $cursor: String) {\n activeUser {\n id\n projects(limit: $limit, filter: $filter, cursor: $cursor) {\n totalCount\n cursor\n items {\n ...ProjectListProjectItem\n }\n }\n }\n }\n": types.ProjectListQueryDocument,
"\n fragment ModelListModelItem on Model {\n displayName\n name\n id\n previewUrl\n updatedAt\n versions(limit: 1) {\n totalCount\n items {\n ...VersionListItem\n }\n }\n }\n": types.ModelListModelItemFragmentDoc,
@@ -42,7 +99,7 @@ const documents = {
"\n query ProjectAddByUrlQueryWithVersion(\n $projectId: String!\n $modelId: String!\n $versionId: String!\n ) {\n project(id: $projectId) {\n ...ProjectListProjectItem\n model(id: $modelId) {\n ...ModelListModelItem\n version(id: $versionId) {\n ...VersionListItem\n }\n }\n }\n }\n": types.ProjectAddByUrlQueryWithVersionDocument,
"\n query ProjectAddByUrlQueryWithoutVersion($projectId: String!, $modelId: String!) {\n project(id: $projectId) {\n ...ProjectListProjectItem\n model(id: $modelId) {\n ...ModelListModelItem\n }\n }\n }\n": types.ProjectAddByUrlQueryWithoutVersionDocument,
"\n query ProjectDetails($projectId: String!) {\n project(id: $projectId) {\n id\n role\n name\n workspace {\n name\n slug\n readOnly\n role\n }\n team {\n user {\n avatar\n id\n name\n }\n }\n visibility\n permissions {\n canLoad {\n authorized\n code\n message\n }\n canPublish {\n authorized\n code\n message\n }\n }\n }\n }\n": types.ProjectDetailsDocument,
"\n query AutomateFunctions {\n automateFunctions {\n items {\n ...AutomateFunctionItem\n }\n }\n }\n": types.AutomateFunctionsDocument,
"\n query AutomateFunctions($workspaceId: String!) {\n workspace(id: $workspaceId) {\n automateFunctions {\n items {\n ...AutomateFunctionItem\n }\n }\n }\n }\n": types.AutomateFunctionsDocument,
"\n query ModelDetails($modelId: String!, $projectId: String!) {\n project(id: $projectId) {\n id\n name\n model(id: $modelId) {\n id\n displayName\n name\n versions {\n totalCount\n items {\n id\n }\n }\n author {\n id\n name\n avatar\n }\n }\n }\n }\n": types.ModelDetailsDocument,
"\n query VersionDetails($projectId: String!, $versionId: String!, $modelId: String!) {\n project(id: $projectId) {\n id\n name\n model(id: $modelId) {\n id\n name\n versions(limit: 1) {\n items {\n id\n createdAt\n sourceApplication\n authorUser {\n id\n }\n }\n }\n version(id: $versionId) {\n id\n referencedObject\n message\n sourceApplication\n createdAt\n previewUrl\n }\n }\n }\n }\n": types.VersionDetailsDocument,
"\n query ServerInfo {\n serverInfo {\n workspaces {\n workspacesEnabled\n }\n }\n }\n": types.ServerInfoDocument,
@@ -50,8 +107,19 @@ const documents = {
"\n subscription ProjectTriggeredAutomationsStatusUpdated($projectId: String!) {\n projectTriggeredAutomationsStatusUpdated(projectId: $projectId) {\n type\n version {\n id\n }\n model {\n id\n }\n project {\n id\n }\n run {\n ...AutomationRunItem\n }\n }\n }\n": types.ProjectTriggeredAutomationsStatusUpdatedDocument,
"\n subscription OnUserProjectsUpdated {\n userProjectsUpdated {\n id\n project {\n id\n visibility\n team {\n id\n role\n }\n }\n }\n }\n": types.OnUserProjectsUpdatedDocument,
"\n subscription ProjectUpdated($projectId: String!) {\n projectUpdated(id: $projectId) {\n id\n project {\n visibility\n }\n }\n }\n": types.ProjectUpdatedDocument,
"\n subscription Subscription($target: ViewerUpdateTrackingTarget!) {\n viewerUserActivityBroadcasted(target: $target) {\n userName\n userId\n sessionId\n user {\n name\n id\n avatar\n }\n status\n }\n }\n": types.SubscriptionDocument,
"\n subscription ModelViewingSubscription($target: ViewerUpdateTrackingTarget!) {\n viewerUserActivityBroadcasted(target: $target) {\n userName\n userId\n sessionId\n user {\n name\n id\n avatar\n }\n status\n }\n }\n": types.ModelViewingSubscriptionDocument,
"\n subscription ProjectCommentsUpdated($target: ViewerUpdateTrackingTarget!) {\n projectCommentsUpdated(target: $target) {\n comment {\n author {\n avatar\n id\n name\n }\n id\n hasParent\n parent {\n id\n }\n }\n type\n }\n }\n": types.ProjectCommentsUpdatedDocument,
"\n mutation CreateModelIngestion($input: ModelIngestionCreateInput!) {\n projectMutations {\n modelIngestionMutations {\n create(input: $input) {\n id\n }\n }\n }\n }\n": types.CreateModelIngestionDocument,
"\n mutation UpdateModelIngestionProgress($input: ModelIngestionUpdateInput!) {\n projectMutations {\n modelIngestionMutations {\n updateProgress(input: $input) {\n id\n }\n }\n }\n }\n": types.UpdateModelIngestionProgressDocument,
"\n mutation CompleteModelIngestionWithVersion($input: ModelIngestionSuccessInput!) {\n projectMutations {\n modelIngestionMutations {\n completeWithVersion(input: $input) {\n id\n statusData {\n __typename\n ... on ModelIngestionProcessingStatus {\n status\n progressMessage\n progress\n }\n ... on ModelIngestionSuccessStatus {\n status\n versionId\n }\n ... on ModelIngestionFailedStatus {\n errorStacktrace\n errorReason\n status\n }\n ... on ModelIngestionCancelledStatus {\n cancellationMessage\n status\n }\n ... on ModelIngestionQueuedStatus {\n progressMessage\n status\n }\n }\n }\n }\n }\n }\n": types.CompleteModelIngestionWithVersionDocument,
"\n mutation FailModelIngestionWithError($input: ModelIngestionFailedInput!) {\n projectMutations {\n modelIngestionMutations {\n failWithError(input: $input) {\n id\n }\n }\n }\n }\n": types.FailModelIngestionWithErrorDocument,
"\n mutation FailModelIngestionWithCancel($input: ModelIngestionCancelledInput!) {\n projectMutations {\n modelIngestionMutations {\n failWithCancel(input: $input) {\n id\n }\n }\n }\n }\n": types.FailModelIngestionWithCancelDocument,
"\n query CanCreateIngestion($modelId: String!, $projectId: String!) {\n project(id: $projectId) {\n model(id: $modelId) {\n permissions {\n canCreateIngestion {\n authorized\n code\n message\n }\n }\n }\n }\n }\n": types.CanCreateIngestionDocument,
"\n subscription ProjectModelIngestionUpdated(\n $input: ProjectModelIngestionSubscriptionInput!\n ) {\n projectModelIngestionUpdated(input: $input) {\n type\n modelIngestion {\n id\n statusData {\n __typename\n ... on ModelIngestionSuccessStatus {\n status\n versionId\n }\n ... on ModelIngestionProcessingStatus {\n status\n progressMessage\n progress\n }\n ... on ModelIngestionFailedStatus {\n status\n errorReason\n }\n ... on ModelIngestionCancelledStatus {\n status\n cancellationMessage\n }\n ... on ModelIngestionQueuedStatus {\n status\n progressMessage\n }\n }\n }\n }\n }\n": types.ProjectModelIngestionUpdatedDocument,
"\n fragment IssuesItem on Issue {\n id\n status\n title\n priority\n viewerState\n identifier\n resourceIdString\n activities(input: { limit: 1, sortDirection: asc }) {\n totalCount\n items {\n actor {\n id\n user {\n name\n id\n avatar\n }\n }\n eventType\n createdAt\n }\n }\n replies {\n totalCount\n items {\n id\n author {\n id\n user {\n name\n id\n avatar\n }\n }\n createdAt\n description {\n doc\n }\n }\n }\n description {\n doc\n }\n labels {\n hexColor\n id\n name\n }\n author {\n id\n user {\n id\n name\n avatar\n }\n }\n dueDate\n assignee {\n id\n user {\n id\n avatar\n name\n }\n }\n }\n": types.IssuesItemFragmentDoc,
"\n query IssuesList($projectId: String!) {\n project(id: $projectId) {\n id\n issues {\n totalCount\n items {\n ...IssuesItem\n }\n }\n }\n }\n": types.IssuesListDocument,
"\n query IssueResourceMetaSearch(\n $workspaceId: String!\n $resourceType: ResourceMetaType!\n $resourceId: String!\n $projectId: String\n $metaType: String\n ) {\n resourceMetaSearch(\n workspaceId: $workspaceId\n resourceType: $resourceType\n resourceId: $resourceId\n projectId: $projectId\n metaType: $metaType\n ) {\n data\n }\n }\n": types.IssueResourceMetaSearchDocument,
"\n subscription WorkspacePlanUsageUpdated($input: WorkspacePlanUsageSubscriptionInput!) {\n workspacePlanUsageUpdated(input: $input)\n }\n": types.WorkspacePlanUsageUpdatedDocument,
};
/**
@@ -71,15 +139,15 @@ export function graphql(source: string): unknown;
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n query AcccountTestQuery {\n serverInfo {\n version\n name\n company\n }\n }\n "): (typeof documents)["\n query AcccountTestQuery {\n serverInfo {\n version\n name\n company\n }\n }\n "];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n mutation SetActiveWorkspaceMutation($slug: String) {\n activeUserMutations {\n setActiveWorkspace(slug: $slug)\n }\n }\n"): (typeof documents)["\n mutation SetActiveWorkspaceMutation($slug: String) {\n activeUserMutations {\n setActiveWorkspace(slug: $slug)\n }\n }\n"];
export function graphql(source: "\n mutation SetActiveWorkspaceMutation($slug: String) {\n activeUserMutations {\n setActiveWorkspace(slug: $slug) {\n id\n }\n }\n }\n"): (typeof documents)["\n mutation SetActiveWorkspaceMutation($slug: String) {\n activeUserMutations {\n setActiveWorkspace(slug: $slug) {\n id\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n mutation VersionMutations($input: CreateVersionInput!) {\n versionMutations {\n create(input: $input) {\n id\n }\n }\n }\n"): (typeof documents)["\n mutation VersionMutations($input: CreateVersionInput!) {\n versionMutations {\n create(input: $input) {\n id\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n mutation Update($input: UpdateVersionInput!) {\n versionMutations {\n update(input: $input) {\n id\n }\n }\n }\n"): (typeof documents)["\n mutation Update($input: UpdateVersionInput!) {\n versionMutations {\n update(input: $input) {\n id\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -103,7 +171,7 @@ export function graphql(source: "\n mutation StreamAccessRequestCreate($input:
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n fragment WorkspaceListWorkspaceItem on Workspace {\n id\n slug\n name\n description\n createdAt\n updatedAt\n logo\n role\n readOnly\n }\n"): (typeof documents)["\n fragment WorkspaceListWorkspaceItem on Workspace {\n id\n slug\n name\n description\n createdAt\n updatedAt\n logo\n role\n readOnly\n }\n"];
export function graphql(source: "\n fragment WorkspaceListWorkspaceItem on Workspace {\n id\n slug\n name\n description\n createdAt\n updatedAt\n logoUrl\n role\n readOnly\n permissions {\n canCreateProject {\n authorized\n code\n message\n }\n }\n }\n"): (typeof documents)["\n fragment WorkspaceListWorkspaceItem on Workspace {\n id\n slug\n name\n description\n createdAt\n updatedAt\n logoUrl\n role\n readOnly\n permissions {\n canCreateProject {\n authorized\n code\n message\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -128,6 +196,10 @@ export function graphql(source: "\n query AutomationStatus($projectId: String!,
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n query WorkspaceListQuery(\n $limit: Int!\n $filter: UserWorkspacesFilter\n $cursor: String\n ) {\n activeUser {\n id\n workspaces(limit: $limit, filter: $filter, cursor: $cursor) {\n totalCount\n cursor\n items {\n ...WorkspaceListWorkspaceItem\n }\n }\n }\n }\n"): (typeof documents)["\n query WorkspaceListQuery(\n $limit: Int!\n $filter: UserWorkspacesFilter\n $cursor: String\n ) {\n activeUser {\n id\n workspaces(limit: $limit, filter: $filter, cursor: $cursor) {\n totalCount\n cursor\n items {\n ...WorkspaceListWorkspaceItem\n }\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n query ActiveUser {\n activeUser {\n role\n id\n name\n }\n }\n"): (typeof documents)["\n query ActiveUser {\n activeUser {\n role\n id\n name\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -143,7 +215,11 @@ export function graphql(source: "\n query CanCreateModelInProject($projectId: S
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n query ActiveWorkspace {\n activeUser {\n activeWorkspace {\n ...WorkspaceListWorkspaceItem\n }\n }\n }\n"): (typeof documents)["\n query ActiveWorkspace {\n activeUser {\n activeWorkspace {\n ...WorkspaceListWorkspaceItem\n }\n }\n }\n"];
export function graphql(source: "\n query CanCreateVersion($projectId: String!, $modelId: String!) {\n project(id: $projectId) {\n model(id: $modelId) {\n permissions {\n canCreateVersion {\n authorized\n code\n message\n errorMessage\n }\n }\n }\n }\n }\n"): (typeof documents)["\n query CanCreateVersion($projectId: String!, $modelId: String!) {\n project(id: $projectId) {\n model(id: $modelId) {\n permissions {\n canCreateVersion {\n authorized\n code\n message\n errorMessage\n }\n }\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n query ActiveWorkspace {\n activeUser {\n activeWorkspace {\n id\n name\n }\n }\n }\n"): (typeof documents)["\n query ActiveWorkspace {\n activeUser {\n activeWorkspace {\n id\n name\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -187,7 +263,7 @@ export function graphql(source: "\n query ProjectDetails($projectId: String!) {
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n query AutomateFunctions {\n automateFunctions {\n items {\n ...AutomateFunctionItem\n }\n }\n }\n"): (typeof documents)["\n query AutomateFunctions {\n automateFunctions {\n items {\n ...AutomateFunctionItem\n }\n }\n }\n"];
export function graphql(source: "\n query AutomateFunctions($workspaceId: String!) {\n workspace(id: $workspaceId) {\n automateFunctions {\n items {\n ...AutomateFunctionItem\n }\n }\n }\n }\n"): (typeof documents)["\n query AutomateFunctions($workspaceId: String!) {\n workspace(id: $workspaceId) {\n automateFunctions {\n items {\n ...AutomateFunctionItem\n }\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -219,11 +295,55 @@ export function graphql(source: "\n subscription ProjectUpdated($projectId: Str
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n subscription Subscription($target: ViewerUpdateTrackingTarget!) {\n viewerUserActivityBroadcasted(target: $target) {\n userName\n userId\n sessionId\n user {\n name\n id\n avatar\n }\n status\n }\n }\n"): (typeof documents)["\n subscription Subscription($target: ViewerUpdateTrackingTarget!) {\n viewerUserActivityBroadcasted(target: $target) {\n userName\n userId\n sessionId\n user {\n name\n id\n avatar\n }\n status\n }\n }\n"];
export function graphql(source: "\n subscription ModelViewingSubscription($target: ViewerUpdateTrackingTarget!) {\n viewerUserActivityBroadcasted(target: $target) {\n userName\n userId\n sessionId\n user {\n name\n id\n avatar\n }\n status\n }\n }\n"): (typeof documents)["\n subscription ModelViewingSubscription($target: ViewerUpdateTrackingTarget!) {\n viewerUserActivityBroadcasted(target: $target) {\n userName\n userId\n sessionId\n user {\n name\n id\n avatar\n }\n status\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n subscription ProjectCommentsUpdated($target: ViewerUpdateTrackingTarget!) {\n projectCommentsUpdated(target: $target) {\n comment {\n author {\n avatar\n id\n name\n }\n id\n hasParent\n parent {\n id\n }\n }\n type\n }\n }\n"): (typeof documents)["\n subscription ProjectCommentsUpdated($target: ViewerUpdateTrackingTarget!) {\n projectCommentsUpdated(target: $target) {\n comment {\n author {\n avatar\n id\n name\n }\n id\n hasParent\n parent {\n id\n }\n }\n type\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n mutation CreateModelIngestion($input: ModelIngestionCreateInput!) {\n projectMutations {\n modelIngestionMutations {\n create(input: $input) {\n id\n }\n }\n }\n }\n"): (typeof documents)["\n mutation CreateModelIngestion($input: ModelIngestionCreateInput!) {\n projectMutations {\n modelIngestionMutations {\n create(input: $input) {\n id\n }\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n mutation UpdateModelIngestionProgress($input: ModelIngestionUpdateInput!) {\n projectMutations {\n modelIngestionMutations {\n updateProgress(input: $input) {\n id\n }\n }\n }\n }\n"): (typeof documents)["\n mutation UpdateModelIngestionProgress($input: ModelIngestionUpdateInput!) {\n projectMutations {\n modelIngestionMutations {\n updateProgress(input: $input) {\n id\n }\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n mutation CompleteModelIngestionWithVersion($input: ModelIngestionSuccessInput!) {\n projectMutations {\n modelIngestionMutations {\n completeWithVersion(input: $input) {\n id\n statusData {\n __typename\n ... on ModelIngestionProcessingStatus {\n status\n progressMessage\n progress\n }\n ... on ModelIngestionSuccessStatus {\n status\n versionId\n }\n ... on ModelIngestionFailedStatus {\n errorStacktrace\n errorReason\n status\n }\n ... on ModelIngestionCancelledStatus {\n cancellationMessage\n status\n }\n ... on ModelIngestionQueuedStatus {\n progressMessage\n status\n }\n }\n }\n }\n }\n }\n"): (typeof documents)["\n mutation CompleteModelIngestionWithVersion($input: ModelIngestionSuccessInput!) {\n projectMutations {\n modelIngestionMutations {\n completeWithVersion(input: $input) {\n id\n statusData {\n __typename\n ... on ModelIngestionProcessingStatus {\n status\n progressMessage\n progress\n }\n ... on ModelIngestionSuccessStatus {\n status\n versionId\n }\n ... on ModelIngestionFailedStatus {\n errorStacktrace\n errorReason\n status\n }\n ... on ModelIngestionCancelledStatus {\n cancellationMessage\n status\n }\n ... on ModelIngestionQueuedStatus {\n progressMessage\n status\n }\n }\n }\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n mutation FailModelIngestionWithError($input: ModelIngestionFailedInput!) {\n projectMutations {\n modelIngestionMutations {\n failWithError(input: $input) {\n id\n }\n }\n }\n }\n"): (typeof documents)["\n mutation FailModelIngestionWithError($input: ModelIngestionFailedInput!) {\n projectMutations {\n modelIngestionMutations {\n failWithError(input: $input) {\n id\n }\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n mutation FailModelIngestionWithCancel($input: ModelIngestionCancelledInput!) {\n projectMutations {\n modelIngestionMutations {\n failWithCancel(input: $input) {\n id\n }\n }\n }\n }\n"): (typeof documents)["\n mutation FailModelIngestionWithCancel($input: ModelIngestionCancelledInput!) {\n projectMutations {\n modelIngestionMutations {\n failWithCancel(input: $input) {\n id\n }\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n query CanCreateIngestion($modelId: String!, $projectId: String!) {\n project(id: $projectId) {\n model(id: $modelId) {\n permissions {\n canCreateIngestion {\n authorized\n code\n message\n }\n }\n }\n }\n }\n"): (typeof documents)["\n query CanCreateIngestion($modelId: String!, $projectId: String!) {\n project(id: $projectId) {\n model(id: $modelId) {\n permissions {\n canCreateIngestion {\n authorized\n code\n message\n }\n }\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n subscription ProjectModelIngestionUpdated(\n $input: ProjectModelIngestionSubscriptionInput!\n ) {\n projectModelIngestionUpdated(input: $input) {\n type\n modelIngestion {\n id\n statusData {\n __typename\n ... on ModelIngestionSuccessStatus {\n status\n versionId\n }\n ... on ModelIngestionProcessingStatus {\n status\n progressMessage\n progress\n }\n ... on ModelIngestionFailedStatus {\n status\n errorReason\n }\n ... on ModelIngestionCancelledStatus {\n status\n cancellationMessage\n }\n ... on ModelIngestionQueuedStatus {\n status\n progressMessage\n }\n }\n }\n }\n }\n"): (typeof documents)["\n subscription ProjectModelIngestionUpdated(\n $input: ProjectModelIngestionSubscriptionInput!\n ) {\n projectModelIngestionUpdated(input: $input) {\n type\n modelIngestion {\n id\n statusData {\n __typename\n ... on ModelIngestionSuccessStatus {\n status\n versionId\n }\n ... on ModelIngestionProcessingStatus {\n status\n progressMessage\n progress\n }\n ... on ModelIngestionFailedStatus {\n status\n errorReason\n }\n ... on ModelIngestionCancelledStatus {\n status\n cancellationMessage\n }\n ... on ModelIngestionQueuedStatus {\n status\n progressMessage\n }\n }\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n fragment IssuesItem on Issue {\n id\n status\n title\n priority\n viewerState\n identifier\n resourceIdString\n activities(input: { limit: 1, sortDirection: asc }) {\n totalCount\n items {\n actor {\n id\n user {\n name\n id\n avatar\n }\n }\n eventType\n createdAt\n }\n }\n replies {\n totalCount\n items {\n id\n author {\n id\n user {\n name\n id\n avatar\n }\n }\n createdAt\n description {\n doc\n }\n }\n }\n description {\n doc\n }\n labels {\n hexColor\n id\n name\n }\n author {\n id\n user {\n id\n name\n avatar\n }\n }\n dueDate\n assignee {\n id\n user {\n id\n avatar\n name\n }\n }\n }\n"): (typeof documents)["\n fragment IssuesItem on Issue {\n id\n status\n title\n priority\n viewerState\n identifier\n resourceIdString\n activities(input: { limit: 1, sortDirection: asc }) {\n totalCount\n items {\n actor {\n id\n user {\n name\n id\n avatar\n }\n }\n eventType\n createdAt\n }\n }\n replies {\n totalCount\n items {\n id\n author {\n id\n user {\n name\n id\n avatar\n }\n }\n createdAt\n description {\n doc\n }\n }\n }\n description {\n doc\n }\n labels {\n hexColor\n id\n name\n }\n author {\n id\n user {\n id\n name\n avatar\n }\n }\n dueDate\n assignee {\n id\n user {\n id\n avatar\n name\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n query IssuesList($projectId: String!) {\n project(id: $projectId) {\n id\n issues {\n totalCount\n items {\n ...IssuesItem\n }\n }\n }\n }\n"): (typeof documents)["\n query IssuesList($projectId: String!) {\n project(id: $projectId) {\n id\n issues {\n totalCount\n items {\n ...IssuesItem\n }\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n query IssueResourceMetaSearch(\n $workspaceId: String!\n $resourceType: ResourceMetaType!\n $resourceId: String!\n $projectId: String\n $metaType: String\n ) {\n resourceMetaSearch(\n workspaceId: $workspaceId\n resourceType: $resourceType\n resourceId: $resourceId\n projectId: $projectId\n metaType: $metaType\n ) {\n data\n }\n }\n"): (typeof documents)["\n query IssueResourceMetaSearch(\n $workspaceId: String!\n $resourceType: ResourceMetaType!\n $resourceId: String!\n $projectId: String\n $metaType: String\n ) {\n resourceMetaSearch(\n workspaceId: $workspaceId\n resourceType: $resourceType\n resourceId: $resourceId\n projectId: $projectId\n metaType: $metaType\n ) {\n data\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n subscription WorkspacePlanUsageUpdated($input: WorkspacePlanUsageSubscriptionInput!) {\n workspacePlanUsageUpdated(input: $input)\n }\n"): (typeof documents)["\n subscription WorkspacePlanUsageUpdated($input: WorkspacePlanUsageSubscriptionInput!) {\n workspacePlanUsageUpdated(input: $input)\n }\n"];
export function graphql(source: string) {
return (documents as any)[source] ?? {};
File diff suppressed because one or more lines are too long
+4 -2
View File
@@ -7,7 +7,8 @@ import type {
} from '~/lib/common/generated/gql/graphql'
import {
projectAddByUrlQueryWithoutVersion,
projectAddByUrlQueryWithVersion
projectAddByUrlQueryWithVersion,
userInfoAndServerRoleQuery
} from '~/lib/graphql/mutationsAndQueries'
import { omit } from 'lodash-es'
import { useDebounceFn } from '@vueuse/core'
@@ -60,6 +61,7 @@ export function useAddByUrl() {
const { projectId, modelId, versionId } = params
const apollo = (acc as DUIAccount).client
const userInfoRes = await apollo.query({ query: userInfoAndServerRoleQuery })
let project: ProjectListProjectItemFragment | undefined = undefined,
model: ModelListModelItemFragment | undefined = undefined,
@@ -113,7 +115,7 @@ export function useAddByUrl() {
? project.permissions.canPublish.authorized
: project.permissions.canLoad.authorized
if (!hasAccess) {
if (!hasAccess && userInfoRes.data.activeUser?.role !== 'server:admin') {
urlParseError.value = errorMessage
return
}
+13 -7
View File
@@ -6,16 +6,17 @@ interface CustomProperties {
[key: string]: object | string | boolean | number | undefined | null
}
// Cached email and server
// Cached email, server, and userId
const lastEmail: Ref<string | undefined> = ref(undefined)
const lastServer: Ref<string | undefined> = ref(undefined)
const lastUserId: Ref<string | undefined> = ref(undefined)
/**
* Get Mixpanel functions
* In DUI3, quite likely to change distinct id of the track operation since we can trigger repetitive calls that belongs to different account.
* Also we have some operations that explicitly not belong to any account, i.e. first "Send" or "Load" click,
* with this case we use default account on manager to get "email" and "server" and cache them for later anonymous track.
* In each call we update "lastEmail" and "lastServer" for the following potential anonymous tracks.
* with this case we use default account on manager to get "email", "server", and "userId" and cache them for later anonymous track.
* In each call we update "lastEmail", "lastServer", and "lastUserId" for the following potential anonymous tracks.
*/
export function useMixpanel() {
const hostApp = useHostAppStore()
@@ -42,11 +43,13 @@ export function useMixpanel() {
const account = accounts.find((a) => a.accountInfo.id === accountId)
lastEmail.value = account?.accountInfo.userInfo.email
lastServer.value = account?.accountInfo.serverInfo.url
lastUserId.value = account?.accountInfo.userInfo.id
} else {
// do not set if they cached already
if (lastEmail.value === undefined || lastServer.value === undefined) {
lastEmail.value = activeAccount.accountInfo.userInfo.email
lastServer.value = activeAccount.accountInfo.serverInfo.url
lastUserId.value = activeAccount.accountInfo.userInfo.id
}
}
@@ -62,9 +65,9 @@ export function useMixpanel() {
}
const hashedEmail =
'@' + md5(lastEmail.value.toLowerCase() as string).toUpperCase()
const hashedServer = md5(
new URL(lastServer.value).hostname.toLowerCase() as string
).toUpperCase()
const serverUrl = new URL(lastServer.value)
const serverHostname = serverUrl.hostname.toLowerCase()
const hashedServer = md5(serverHostname).toUpperCase()
// Get os info from userAgent text
// taken from original mixpanel implementation
@@ -84,6 +87,8 @@ export function useMixpanel() {
distinct_id: hashedEmail,
// eslint-disable-next-line camelcase
server_id: hashedServer,
// eslint-disable-next-line camelcase
server_domain: serverHostname,
token: mixpanelTokenId as string,
type: isAction ? 'action' : undefined,
hostApp: hostApp.hostAppName,
@@ -91,7 +96,8 @@ export function useMixpanel() {
ui: 'dui3', // Not sure about this but we need to put something to distiguish some events, like "Send", "Receive", alternatively we can have "SendDUI3" not sure!
// eslint-disable-next-line camelcase
core_version: hostApp.connectorVersion,
email: lastEmail,
email: lastEmail.value,
userId: lastUserId.value,
...customProperties
}
+17
View File
@@ -0,0 +1,17 @@
/**
* @param previewUrl url that server returns but does not return the corresponding image if the project is private
* @param token auth token to get proper image over url
*/
export async function usePreviewUrl(
token: string,
previewUrl?: string
): Promise<string | undefined> {
if (!previewUrl) return previewUrl
const res = await fetch(previewUrl, {
headers: { Authorization: `Bearer ${token}` }
})
if (!res.ok) return previewUrl //
const blob = await res.blob()
return URL.createObjectURL(blob)
}
+42
View File
@@ -0,0 +1,42 @@
import { useMixpanel } from '~/lib/core/composables/mixpanel'
import type { CardSetting } from '~/lib/models/card/setting'
export function useSettingsTracking() {
const { trackEvent } = useMixpanel()
function trackSettingsChange(
eventName: string,
settings: CardSetting[],
defaultSettings: CardSetting[],
accountId?: string,
requireChanges: boolean = false
) {
// building dynamic properties
// since this can change based on HostApp
const settingProperties: Record<string, string | boolean | number> = {
name: eventName
}
let hasAnyChange = false
settings.forEach((setting) => {
const defaultSetting = defaultSettings.find((s) => s.id === setting.id)
if (defaultSetting) {
const isDefault = setting.value === defaultSetting.value
if (!isDefault) {
hasAnyChange = true
}
// if user selects default, just use 'default'
settingProperties['setting_' + setting.id] = isDefault
? `${setting.value} (default)`
: setting.value
}
})
// only track if user changed a setting
if (!requireChanges || hasAnyChange) {
void trackEvent('DUI3 Action', settingProperties, accountId)
}
}
return { trackSettingsChange }
}
+40 -38
View File
@@ -1,8 +1,17 @@
import type { ToastNotification } from '@speckle/ui-components'
import { ToastNotificationType } from '@speckle/ui-components'
import { useConfigStore } from '~/store/config'
import { useHostAppStore } from '~/store/hostApp'
export class UpdateError extends Error {
constructor(message: string) {
super(message)
this.name = 'FetchError'
// Required when extending Error in TypeScript
Object.setPrototypeOf(this, new.target.prototype)
}
}
type Versions = {
Versions: Version[]
}
@@ -18,31 +27,13 @@ export type Version = {
export function useUpdateConnector() {
const hostApp = useHostAppStore()
const config = useConfigStore()
const { $openUrl } = useNuxtApp()
const versions = ref<Version[]>([])
const latestAvailableVersion = ref<Version | null>(null)
const isUpToDate = computed(
() => hostApp.connectorVersion === latestAvailableVersion.value?.Number
)
async function checkUpdate() {
try {
await getVersions()
if (!isUpToDate.value && !config.isDevMode) {
const notification: ToastNotification = {
type: ToastNotificationType.Success,
title: `New connector update available`,
description: latestAvailableVersion.value?.Number.replace('+0', ''), // TODO: currently versions end with "+0" Alan will have a look
cta: {
title: `Update`,
onClick: () => downloadLatestVersion()
}
}
hostApp.setNotification(notification)
}
} catch (e) {
console.error(e)
const notification: ToastNotification = {
@@ -54,28 +45,39 @@ export function useUpdateConnector() {
}
async function getVersions() {
const response = await fetch(
`https://releases.speckle.dev/manager2/feeds/${hostApp.hostAppName?.toLowerCase()}-v3.json`,
{
method: 'GET'
try {
// End point to get list of versions that deployed by Speckle's pipeline
const response = await fetch(
`https://releases.speckle.dev/manager2/feeds/${hostApp.hostAppName?.toLowerCase()}-v3.json`,
{
method: 'GET'
}
)
if (!response.ok) {
// It is the only way to understand the connector is distributed by Speckle or not.
throw new UpdateError('Failed to fetch versions')
}
)
if (!response.ok) {
throw new Error('Failed to fetch versions')
const data = (await response.json()) as unknown as Versions
const sortedVersions = data.Versions.sort(function (a: Version, b: Version) {
return new Date(b.Date).getTime() - new Date(a.Date).getTime()
})
versions.value = sortedVersions
latestAvailableVersion.value = sortedVersions[0]
hostApp.setLatestAvailableVersion(sortedVersions[0])
} catch (err) {
if (err instanceof TypeError && err.message === 'Failed to fetch') {
// When user has network issue in between, actually it is not so likely because regardless user need network to be able to render netlify page
throw new Error('Network error')
} else if (err instanceof UpdateError) {
// We set the flag to use it in relavant places, hide some documentation related buttons etc..
hostApp.setIsDistributedBySpeckle(false)
} else {
// Rest of the possibilites that we trigger toast
throw new Error('Unknown error occurred')
}
}
const data = (await response.json()) as unknown as Versions
const sortedVersions = data.Versions.sort(function (a: Version, b: Version) {
return new Date(b.Date).getTime() - new Date(a.Date).getTime()
})
versions.value = sortedVersions
latestAvailableVersion.value = sortedVersions[0]
hostApp.setLatestAvailableVersion(sortedVersions[0])
}
function downloadLatestVersion() {
$openUrl(latestAvailableVersion.value?.Url as string)
}
return { checkUpdate }
+71
View File
@@ -0,0 +1,71 @@
import { canCreateVersionQuery } from '~/lib/graphql/mutationsAndQueries'
import { canCreateModelIngestionQuery } from '~/lib/ingestion/graphql/queries'
import { useAccountStore } from '~/store/accounts'
// use this composable whenever we need to check against available graphqls over servers
export function useCheckGraphql() {
/**
* Checks the ingestions available for the server,
* if available, returns with respond by appending `queryAvailable = true`
* otherwise, returns fake result object with `queryAvailable = false`
*/
const canCreateModelIngestion = async (
projectId: string,
modelId: string,
accountId: string
) => {
const accountsStore = useAccountStore()
const client = accountsStore.getAccountClient(accountId)
try {
const result = await client.query({
query: canCreateModelIngestionQuery,
variables: {
projectId,
modelId
},
fetchPolicy: 'network-only'
})
return {
...result.data.project.model.permissions.canCreateIngestion,
queryAvailable: true
}
} catch {
return { queryAvailable: false, authorized: false, message: undefined }
}
}
/**
* Checks if user can create a version for the given model.
* Used to validate before starting a publish operation.
*/
const canCreateVersion = async (
projectId: string,
modelId: string,
accountId: string
) => {
const accountsStore = useAccountStore()
const client = accountsStore.getAccountClient(accountId)
try {
const result = await client.query({
query: canCreateVersionQuery,
variables: {
projectId,
modelId
},
fetchPolicy: 'network-only'
})
return result.data.project.model.permissions.canCreateVersion
} catch (error) {
// If we can't check, allow the attempt - server will reject if not allowed
console.error('Failed to check canCreateVersion:', error)
return { authorized: true, message: null }
}
}
return {
canCreateVersion,
canCreateModelIngestion
}
}
+22 -2
View File
@@ -1,6 +1,7 @@
import type { JsonFormsRendererRegistryEntry } from '@jsonforms/core'
import {
and,
hasType,
isBooleanControl,
isDateControl,
isDateTimeControl,
@@ -11,12 +12,15 @@ import {
isOneOfEnumControl,
isStringControl,
isTimeControl,
rankWith
rankWith,
schemaMatches,
uiTypeIs
} from '@jsonforms/core'
import { vanillaRenderers } from '@jsonforms/vue-vanilla'
import BooleanControlRenderer from '~/components/form/json/BooleanControlRenderer.vue'
import DateControlRenderer from '~/components/form/json/DateControlRenderer.vue'
import DateTimeControlRenderer from '~/components/form/json/DateTimeControlRenderer.vue'
import MultiEnumControlRenderer from '~/components/form/json/MultiEnumControlRenderer.vue'
import EnumControlRenderer from '~/components/form/json/EnumControlRenderer.vue'
import EnumOneOfControlRenderer from '~/components/form/json/EnumOneOfControlRenderer.vue'
import IntegerControlRenderer from '~/components/form/json/IntegerControlRenderer.vue'
@@ -75,6 +79,21 @@ export const timeControlRenderer: JsonFormsRendererRegistryEntry = {
tester: rankWith(4, isTimeControl)
}
export const multiEnumControlRenderer: JsonFormsRendererRegistryEntry = {
renderer: MultiEnumControlRenderer as unknown,
tester: rankWith(
6,
and(
uiTypeIs('Control'),
and(
schemaMatches(
(schema) => hasType(schema, 'array') && !Array.isArray(schema.items)
)
)
)
)
}
export const renderers: JsonFormsRendererRegistryEntry[] = markRaw([
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
...vanillaRenderers,
@@ -87,5 +106,6 @@ export const renderers: JsonFormsRendererRegistryEntry[] = markRaw([
numberControlRenderer,
dateControlRenderer,
dateTimeControlRenderer,
timeControlRenderer
timeControlRenderer,
multiEnumControlRenderer
])
+57 -8
View File
@@ -3,7 +3,9 @@ import { graphql } from '~~/lib/common/generated/gql'
export const setActiveWorkspaceMutation = graphql(`
mutation SetActiveWorkspaceMutation($slug: String) {
activeUserMutations {
setActiveWorkspace(slug: $slug)
setActiveWorkspace(slug: $slug) {
id
}
}
}
`)
@@ -18,6 +20,16 @@ export const createVersionMutation = graphql(`
}
`)
export const setVersionMessageMutation = graphql(`
mutation Update($input: UpdateVersionInput!) {
versionMutations {
update(input: $input) {
id
}
}
}
`)
export const markReceivedVersionMutation = graphql(`
mutation MarkReceivedVersion($input: MarkReceivedVersionInput!) {
versionMutations {
@@ -74,9 +86,16 @@ export const workspaceListFragment = graphql(`
description
createdAt
updatedAt
logo
logoUrl
role
readOnly
permissions {
canCreateProject {
authorized
code
message
}
}
}
`)
@@ -173,6 +192,16 @@ export const workspacesListQuery = graphql(`
}
`)
export const userInfoAndServerRoleQuery = graphql(`
query ActiveUser {
activeUser {
role
id
name
}
}
`)
export const canCreatePersonalProjectQuery = graphql(`
query CanCreatePersonalProject {
activeUser {
@@ -217,11 +246,29 @@ export const canCreateModelInProjectQuery = graphql(`
}
`)
export const canCreateVersionQuery = graphql(`
query CanCreateVersion($projectId: String!, $modelId: String!) {
project(id: $projectId) {
model(id: $modelId) {
permissions {
canCreateVersion {
authorized
code
message
errorMessage
}
}
}
}
}
`)
export const activeWorkspaceQuery = graphql(`
query ActiveWorkspace {
activeUser {
activeWorkspace {
...WorkspaceListWorkspaceItem
id
name
}
}
}
@@ -426,10 +473,12 @@ export const projectDetailsQuery = graphql(`
`)
export const automateFunctionsQuery = graphql(`
query AutomateFunctions {
automateFunctions {
items {
...AutomateFunctionItem
query AutomateFunctions($workspaceId: String!) {
workspace(id: $workspaceId) {
automateFunctions {
items {
...AutomateFunctionItem
}
}
}
}
@@ -574,7 +623,7 @@ export const projectUpdatedSubscription = graphql(`
`)
export const modelViewingSubscription = graphql(`
subscription Subscription($target: ViewerUpdateTrackingTarget!) {
subscription ModelViewingSubscription($target: ViewerUpdateTrackingTarget!) {
viewerUserActivityBroadcasted(target: $target) {
userName
userId
@@ -0,0 +1,319 @@
import {
provideApolloClient,
useMutation,
useSubscription
} from '@vue/apollo-composable'
import { useAccountStore } from '~/store/accounts'
import { useHostAppStore } from '~/store/hostApp'
import {
completeModelIngestionWithVersion,
createModelIngestion,
updateModelIngestionProgress,
failModelIngestionWithError,
failModelIngestionWithCancel
} from '../graphql/mutations'
import { projectModelIngestionUpdatedSubscription } from '../graphql/subscriptions'
import type {
SourceDataInput,
ProjectModelIngestionUpdatedSubscription
} from '~~/lib/common/generated/gql/graphql'
import type { ISenderModelCard } from '~/lib/models/card/send'
import { storeToRefs } from 'pinia'
import { ToastNotificationType } from '@speckle/ui-components'
/**
* New way of creating versions.
* It is essential for server to track limits on versions.
* The flow is as follows:
* 0. Check if the user has enough limits to create a new version (this is handled outside of this composable)
* 1. Start a new ingestion
* 2. Update the ingestion with the new data when connector throws progress via 'setModelProgress' event
* 3. Complete the version with the root object id that passed by connector or server/sketchup bridges in JS
*/
export const useModelIngestion = () => {
const store = useHostAppStore()
const accountStore = useAccountStore()
const startIngestion = async (
senderModelCard: ISenderModelCard,
progressMessage: string,
sourceData: SourceDataInput
) => {
const { activeIngestions } = storeToRefs(store)
const client = accountStore.getAccountClient(senderModelCard.accountId)
const { mutate } = provideApolloClient(client)(() =>
useMutation(createModelIngestion)
)
const res = await mutate({
input: {
projectId: senderModelCard.projectId,
modelId: senderModelCard.modelId,
progressMessage,
sourceData,
maxIdleTimeoutSeconds: 7200 // 2h
}
})
if (res?.errors?.length) {
const msg = res.errors[0].message
store.setNotification({
type: ToastNotificationType.Danger,
title: 'Ingestion Error',
description: msg
})
throw new Error(msg)
}
const ingestionId = res?.data?.projectMutations.modelIngestionMutations.create.id
if (ingestionId) {
activeIngestions.value[senderModelCard.modelCardId] = ingestionId
}
return res?.data?.projectMutations.modelIngestionMutations.create
}
const updateIngestion = async (
senderModelCard: ISenderModelCard,
ingestionId: string,
progressMessage: string,
progress?: number
) => {
const client = accountStore.getAccountClient(senderModelCard.accountId)
const { mutate } = provideApolloClient(client)(() =>
useMutation(updateModelIngestionProgress)
)
const res = await mutate({
input: {
projectId: senderModelCard.projectId,
ingestionId,
progressMessage,
progress
}
})
if (res?.errors?.length) {
const msg = res.errors[0].message
store.setNotification({
type: ToastNotificationType.Danger,
title: 'Ingestion Error',
description: msg
})
throw new Error(msg)
}
return res?.data?.projectMutations.modelIngestionMutations.updateProgress
}
const failIngestion = async (
senderModelCard: ISenderModelCard,
ingestionId: string,
errorReason: string,
errorStacktrace?: string
) => {
const client = accountStore.getAccountClient(senderModelCard.accountId)
const { mutate } = provideApolloClient(client)(() =>
useMutation(failModelIngestionWithError)
)
const res = await mutate({
input: {
projectId: senderModelCard.projectId,
ingestionId,
errorReason,
errorStacktrace
}
})
if (res?.errors?.length) {
const msg = res.errors[0].message
store.setNotification({
type: ToastNotificationType.Danger,
title: 'Ingestion Error',
description: msg
})
throw new Error(msg)
}
const { activeIngestions } = storeToRefs(store)
// clean the failed ingestion
activeIngestions.value = Object.fromEntries(
Object.entries(activeIngestions.value).filter(
([key]) => key !== senderModelCard.modelCardId
)
)
}
const cancelIngestion = async (
senderModelCard: ISenderModelCard,
ingestionId: string,
cancellationMessage: string = 'Cancelled by user'
) => {
const client = accountStore.getAccountClient(senderModelCard.accountId)
const { mutate } = provideApolloClient(client)(() =>
useMutation(failModelIngestionWithCancel)
)
const res = await mutate({
input: {
projectId: senderModelCard.projectId,
ingestionId,
cancellationMessage
}
})
if (res?.errors?.length) {
const msg = res.errors[0].message
store.setNotification({
type: ToastNotificationType.Danger,
title: 'Ingestion Error',
description: msg
})
throw new Error(msg)
}
const { activeIngestions } = storeToRefs(store)
// clean the cancelled ingestion
activeIngestions.value = Object.fromEntries(
Object.entries(activeIngestions.value).filter(
([key]) => key !== senderModelCard.modelCardId
)
)
}
const completeIngestionWithVersion = async (
senderModelCard: ISenderModelCard,
ingestionId: string,
rootObjectId: string
) => {
const client = accountStore.getAccountClient(senderModelCard.accountId)
const { mutate } = provideApolloClient(client)(() =>
useMutation(completeModelIngestionWithVersion)
)
const res = await mutate({
input: {
projectId: senderModelCard.projectId,
ingestionId,
rootObjectId
}
})
if (res?.errors?.length) {
const msg = res.errors[0].message
store.setNotification({
type: ToastNotificationType.Danger,
title: 'Ingestion Error',
description: msg
})
throw new Error(msg)
}
const { activeIngestions } = storeToRefs(store)
// clean the completed ingestion
activeIngestions.value = Object.fromEntries(
Object.entries(activeIngestions.value).filter(
([key]) => key !== senderModelCard.modelCardId
)
)
return res?.data?.projectMutations.modelIngestionMutations.completeWithVersion
}
// Tracks active ingestion subscriptions so they can be stopped on cancel or terminal state
const activeSubscriptions: Record<string, () => void> = {}
/**
* Subscribes to ingestion status updates for a given ingestionId.
* Used when the connector (.NET SDK) handles the ingestion and passes the ingestionId
* back to the DUI via setModelSendResult. The DUI then subscribes to track
* the server-side processing state until a terminal status is reached.
*
* Manages model card state directly: updates progress, sets versionId on success,
* sets error on failure, and clears progress on terminal states.
*/
const subscribeToIngestion = (
senderModelCard: ISenderModelCard,
ingestionId: string
) => {
const client = accountStore.getAccountClient(senderModelCard.accountId)
senderModelCard.progress = { status: 'Remote processing...' }
const { onResult, onError, stop } = provideApolloClient(client)(() =>
useSubscription(projectModelIngestionUpdatedSubscription, () => ({
input: {
projectId: senderModelCard.projectId,
ingestionReference: { ingestionId }
}
}))
)
activeSubscriptions[senderModelCard.modelCardId] = stop
onResult((result) => {
const data = result.data as ProjectModelIngestionUpdatedSubscription | undefined
const statusData = data?.projectModelIngestionUpdated?.modelIngestion?.statusData
if (!statusData) return
switch (statusData.__typename) {
case 'ModelIngestionSuccessStatus':
senderModelCard.latestCreatedVersionId = statusData.versionId
senderModelCard.progress = undefined
unsubscribeFromIngestion(senderModelCard.modelCardId)
break
case 'ModelIngestionProcessingStatus':
senderModelCard.progress = {
status: statusData.progressMessage,
progress: statusData.progress ?? undefined
}
break
case 'ModelIngestionFailedStatus':
senderModelCard.error = {
errorMessage: statusData.errorReason,
dismissible: true
}
senderModelCard.progress = undefined
unsubscribeFromIngestion(senderModelCard.modelCardId)
break
case 'ModelIngestionCancelledStatus':
senderModelCard.progress = undefined
unsubscribeFromIngestion(senderModelCard.modelCardId)
break
case 'ModelIngestionQueuedStatus':
senderModelCard.progress = {
status: statusData.progressMessage
}
break
}
})
onError((err) => {
console.error('Ingestion subscription error:', err)
unsubscribeFromIngestion(senderModelCard.modelCardId)
})
}
const unsubscribeFromIngestion = (modelCardId: string) => {
const stop = activeSubscriptions[modelCardId]
if (stop) {
stop()
delete activeSubscriptions[modelCardId]
}
}
return {
startIngestion,
updateIngestion,
failIngestion,
cancelIngestion,
completeIngestionWithVersion,
subscribeToIngestion,
unsubscribeFromIngestion
}
}
+86
View File
@@ -0,0 +1,86 @@
import { graphql } from '~~/lib/common/generated/gql'
export const createModelIngestion = graphql(`
mutation CreateModelIngestion($input: ModelIngestionCreateInput!) {
projectMutations {
modelIngestionMutations {
create(input: $input) {
id
}
}
}
}
`)
export const updateModelIngestionProgress = graphql(`
mutation UpdateModelIngestionProgress($input: ModelIngestionUpdateInput!) {
projectMutations {
modelIngestionMutations {
updateProgress(input: $input) {
id
}
}
}
}
`)
export const completeModelIngestionWithVersion = graphql(`
mutation CompleteModelIngestionWithVersion($input: ModelIngestionSuccessInput!) {
projectMutations {
modelIngestionMutations {
completeWithVersion(input: $input) {
id
statusData {
__typename
... on ModelIngestionProcessingStatus {
status
progressMessage
progress
}
... on ModelIngestionSuccessStatus {
status
versionId
}
... on ModelIngestionFailedStatus {
errorStacktrace
errorReason
status
}
... on ModelIngestionCancelledStatus {
cancellationMessage
status
}
... on ModelIngestionQueuedStatus {
progressMessage
status
}
}
}
}
}
}
`)
export const failModelIngestionWithError = graphql(`
mutation FailModelIngestionWithError($input: ModelIngestionFailedInput!) {
projectMutations {
modelIngestionMutations {
failWithError(input: $input) {
id
}
}
}
}
`)
export const failModelIngestionWithCancel = graphql(`
mutation FailModelIngestionWithCancel($input: ModelIngestionCancelledInput!) {
projectMutations {
modelIngestionMutations {
failWithCancel(input: $input) {
id
}
}
}
}
`)
+17
View File
@@ -0,0 +1,17 @@
import { graphql } from '~~/lib/common/generated/gql'
export const canCreateModelIngestionQuery = graphql(`
query CanCreateIngestion($modelId: String!, $projectId: String!) {
project(id: $projectId) {
model(id: $modelId) {
permissions {
canCreateIngestion {
authorized
code
message
}
}
}
}
}
`)
+38
View File
@@ -0,0 +1,38 @@
import { graphql } from '~~/lib/common/generated/gql'
export const projectModelIngestionUpdatedSubscription = graphql(`
subscription ProjectModelIngestionUpdated(
$input: ProjectModelIngestionSubscriptionInput!
) {
projectModelIngestionUpdated(input: $input) {
type
modelIngestion {
id
statusData {
__typename
... on ModelIngestionSuccessStatus {
status
versionId
}
... on ModelIngestionProcessingStatus {
status
progressMessage
progress
}
... on ModelIngestionFailedStatus {
status
errorReason
}
... on ModelIngestionCancelledStatus {
status
cancellationMessage
}
... on ModelIngestionQueuedStatus {
status
progressMessage
}
}
}
}
}
`)
+71
View File
@@ -0,0 +1,71 @@
import { graphql } from '~~/lib/common/generated/gql'
export const issueFragment = graphql(`
fragment IssuesItem on Issue {
id
status
title
priority
viewerState
identifier
resourceIdString
activities(input: { limit: 1, sortDirection: asc }) {
totalCount
items {
actor {
id
user {
name
id
avatar
}
}
eventType
createdAt
}
}
replies {
totalCount
items {
id
author {
id
user {
name
id
avatar
}
}
createdAt
description {
doc
}
}
}
description {
doc
}
labels {
hexColor
id
name
}
author {
id
user {
id
name
avatar
}
}
dueDate
assignee {
id
user {
id
avatar
name
}
}
}
`)

Some files were not shown because too many files have changed in this diff Show More