Gergo/automate backend module (#2267)
* Starting on gergo/newAutomateModule * regenerated gql * minor gql optimizations * various fixes for project tabs * WIP featured functions * composable for ez debounced inputs * composable for ez debounced inputs * WIP fn card * TS error fixes * WIP cardd * WIP function cards * empty state done * WIP runs table * runs table looks good * run info dialog works * minor run dialog improvement * feat: add automate module with feature flag * added fautomate feature flag to fe2 as well * link to beta implemented * minor adjustment * enabling dev to skip migrations * Merge branch 'main' into fabians/fe2-automate-integration * tabs set up for single automation page * refactored editable title/description * models title fix * update title mutation * title done * WIP function card * feat: feature flags implementation * WIP runs * feat: feature flags feedback * Revert "feat: feature flags feedback" This reverts commit 139065bcbf967af207c2e98896ff3aae8ff2fdb0. * Revert "feat: feature flags implementation" This reverts commit 0614debb330ab092e96c71b7eccfaa8b4a280a4f. * minor row fix * core automation page done * wip automation fn settings * function settings dialog * apollo dev tools fix * feat: automation run trigger logic * functions page * WIP function page * fix FE2 lint issue * testing library borked, just skip interactive tests * tests fix * enabling automate module for testing * disabling module in test env * WIP fn page * parameters demo dialog * added markdown rendering * finished single function page * faked markdown * pkg json fix * pkg json fix * updated schema for triggers * more schema adjustments * adjusted FE to support triggers * added model select to automate edit * fixed up runs dialog & status icon * migrated viewer to new components * updated automate panel to fit designs in viewer * cleaning up old shit * mocks fix * fn logo size fix * runs table status fix * feat: automate module, automation creation and trigger * test: fix automate module tests * test: fixt automate module tests * feat: create function flow * linting fixes * test fix? * functions page fixes * WIP automation wizard * parameters step done * WIP details step * automation wizard done for the most part * triggering automation * enabled switch * create automation from fns page * create automation from fn page * details validation update * disable capability for switch * edit fn done * functions empty state * various empty states * minor adjustment * various minor fixes * automation status dialog responsivity * status icon responsivity fixes * viewer panel * empty state adjustments * fns page responsivitiy * fn page core responsivity * automation wizard responsivity * fn wizard responsity fixes * minor fix ups * fixed up existing backend stuff * fixing eslint hopefully * tryna fix eslint * automate code validation mechanism added * minor GQL schema change * maybethis fixes eslint? * more eslint debugging * fix cross-env missing * tryna fix eslint memory issues * ci test fix * error improvements * migrations for fn tables * Improve empty state * Update button copy in edit function dialog And remove unused icon * Refine function page design * WIP function create * fn creation tests added * Fix enable switch label text on Automation page * Update design of function card * Change tag to beta * Fix selected ring not being rounded * Minor copy changes here and there * Add border and header column bg to Table component * Update styling of Automations tab * Update styling of individual Automation page * Remove icon from button * fn update w/ tests * fn release creation w/ tests * fixing tests * GH auth endpoint * minor cleanup * WIP reporting function statuses * automation update/delete w/ tests * WIP automation revision & trigger tests * revision creation tests done * trigger tests * function run reporting works * report status tests WIP * run status update tests done * auth code handshake tests * a couple of FE2 fixes * WIP function retrieval queries & tests * WIP automation queries * removed all functions stuff * implemented fn queries * all kinds of queries & resolvers done * more queries * automations query * automation status resolution core algo * FE2 fixes * fixed up mocks * fix(fe2): disallow loading automations if non-owner * chore: circleci extension config change * fixing some benjamins changes * hydration mismatch fix * fixed tests * preview service fix? * env flag fix * more form validation improvements * proper automation status run ordering * featured mock fix * meta data fixed * introduce outdated label * log streaming mock moved to serverside * encryption in create for FE * fix: integration work * core encryption stuff implemented * fixing tests & linting * improved revision input validation * automation create works * automations status fix * fixed automation run queries * minor cleanup * implemented log streaming * properly handing redacted props in update rev flow * implemented subscriptions backend * WIP subscriptions FE implementation * subscriptions work? * feat: add docker compose based reverse proxy for the server stack * revert: restore docker compose ingress dockerfile * chore: disable automate module feature flag by default * fix: move nginx ingress file to the right place * Implement `automateFunctionRunStatusReport` (#2262) * untested implementation * no more errors * no more errors * lint * add all statuses to `AutomationRunStatusOrder` * fix: status reporting now works * park in the right place, grapple with tests * update tests * use correct run ids, adjust tests --------- Co-authored-by: Gergő Jedlicska <gergo@jedlicska.com> * fix: make tab selection robust by using dynamic ID lookup * tests: fix authz module tests * fix: frontend TS issue * ci: add automate encryption keys path value * Fix ts build errors from ui-components changes * fix: frontend automation status colors * gergo/automateBackendModule (#2202) * feat: automation run trigger logic * feat: automate module, automation creation and trigger * test: fix automate module tests * test: fixt automate module tests * minor fix ups * fixed up existing backend stuff * fixing eslint hopefully * tryna fix eslint * automate code validation mechanism added * minor GQL schema change * maybethis fixes eslint? * more eslint debugging * fix cross-env missing * tryna fix eslint memory issues * ci test fix * error improvements * migrations for fn tables * WIP function create * fn creation tests added * fn update w/ tests * fn release creation w/ tests * fixing tests * GH auth endpoint * minor cleanup * WIP reporting function statuses * automation update/delete w/ tests * WIP automation revision & trigger tests * revision creation tests done * trigger tests * function run reporting works * report status tests WIP * run status update tests done * auth code handshake tests * a couple of FE2 fixes * WIP function retrieval queries & tests * WIP automation queries * removed all functions stuff * implemented fn queries * all kinds of queries & resolvers done * more queries * automations query * automation status resolution core algo * FE2 fixes * fixed up mocks * fix(fe2): disallow loading automations if non-owner * fixing some benjamins changes * hydration mismatch fix * fixed tests * preview service fix? * env flag fix * more form validation improvements * proper automation status run ordering * featured mock fix * feat(fe2): promo banner support (#2242) * Initial Commit * Updated Breakpoints * WIP * Fix typing * WIP - broken image * Updates * Hide banners if none defined * Remove test banner * Hardcoded image from assets * Add type to promoBanners * Updates from CR * Add expiry dates to banners * meta data fixed * introduce outdated label * log streaming mock moved to serverside * Viewer API Improvements (#2072) * Fix some monstrous bugs with index buffer shuffling now that we've changed our approach a bit.~ * Finished with the new material management approach for mesh batches. SelectionExtension now uses this approach and also considers existing material opacities when setting select and hover materials * Updated LineBatch to work with the new material management approach * Implemented the required draw range related changes to the point batch * Text batche now work with the new material management * SpeckleLineMaterial and SpecklePointsMaterial are now SpeckleMaterial as well. Had to rename two properties of SpeckleMaterial due to some typescript named property clash thing, but nothing really changed * Removed eslint-disable clauses in materials where they were no longer needed. Removed unused imports and overrides * Added the RTE define for some materials by default. It can still be overriden if users want to * Stencil outlines is now an toggle-able option for any SpeckleMaterial. Restricted to meshes * Implemented setting desired material for all geometry types via RenderMaterial and DisplayStyle data. SpeckleRenderer now has three overloaded setMaterial function. One for a material instance, one for a filer material and one for RenderData&DisplayStyle. Moved material hash related functionality from NodeRendeView into Materials * Added MaterialOptions which can be used when setting materials based on RenderMaterial/DisplayStyle to toggle various material features like stencilOutlines, pointSize. SelectionExtension now uses data material to set materials, and things are so much more simpler and nicer * Added public method for setting seletion extension options * After some profiling, realized three.js was doing a lot of pointless work each frame so now we're caching materials created from RenderMaterial/DisplayStyle to avoid this. Perf is nice and sharp now * addRenderTreeAsync is now a generator. Handled automatic zooming on viewer loading. Disabled section tool by default * Centralized RTE and shadow RTE buffer in an extended webglrenderer. This avoids re-computing rte data for each material over and over. Also, rte data is now centralized and available to materials * SpeckleMeshes now use a cached material clone as their batch material like any other material they use * Cleaned up Materils. Updated the debug show batches function use the new material manipulation system. So much easier now * Real time measurement exist now as a separate extension. Existing functionality preserved, besides one or two small additions. Renamed the MEASUREMENTS object layer to OVERLAY for a more generic usage. SelectionExtension can now be enabled/disabled. Added an additional overload to the setCameraView method in ICameraController which takes a box3 as target to focus the camera on * Removed viewer related events from input and replaced them everywhere with the proper input event types where required * Fixed two issues with the shadowcatcher. One was a regression introduced after we centralized the RTE data. The other was a super old one one and was essentially causing the shadowcatcher to generate the contact shadows incorrectly because the correct transform textures were not bound (this is three.js being a pain) * WIP on the filtering extension * FilteringExtension is done. Kept the same implementation * Added filter reset function * Removed uniform texture and batch count binding from each material's OnBeforeRender function. Additionallit our material override function from SpeckleMesh now uses our fast copy instead of three's copy function for materials. This decreased CPU overhead each frame by 20+% and also eliminated the ugly call to SpeckleMesh's function for updating the material with the transform texture and batch object count from each material's render callback which was not supposed to be there * Update RenderingStats to measure CPU render time per frame. * Added early and late update functions for extensions. First gets called before the core's update, and the latter afterwards * All speckle materials now use the centralized RTE data. Updated the fasts copy method to copy userdata defined uniforms where needed * Added explosion extension. Additional cleanup * Dirty transforms are marked on a per batch object basis, whenever their transforms gets changed. This automates the transform texture update execution, so we no longer need to manually mark entire batches as dirty * Added getObjects which returns all batch objects in the scene and getObjects which takes an rv an returns only the batch object associated with that rv. Both of these methods are availale on the renderer * Added setters for position, rotiation and scale in batch object * Diff extension is complete. We stuck with creating material instances and using those for coloring since it ensures maximum draw call efficiency. Fixed an issue in MeshBatch where transparent draw groups were not always sorted at the end of the group list leading to incorect opauque object selection during the depth pass * RV batch materials are obtained via the viewer-core API based on the RV itslef. This removed the need to get all batch materials for the differ * Small cleanup * Removed all circular dependencies besides one, like I predicted. The final one will dissapaear on it's own in the near future when we'll gracefull make DataTree obsolete. As a note, the circular dependencies were very shallow, reffering to enums/interfaces/statics declared in specific files. There was no real circular dependency on a class level * Removed last circular dependency just for the sake of completion * SpeckleRendere now has a clipping volume which is used internally to reject picks outside of it, and it's also exposed to the outside world to be used however * Implemented the SpeckleLoader along with it's abstract supertype. Data loading is now done through this loader which handles tree population with raw data as well as render view data. * Working minimal obj loader * Added total node counting and displaying * Viewer's load object now takes a loader of any speckle loader base class and uses that to load, instead of taking urls and tokens. This allows for any kind of loader to work with the vieweer's load function. Moved indexing of obj geometry to the obj geometry convertor. Loaders now take the target world tree instance instead of a viewer instance * Loaders can now load from string and array buffer data where implemented. ObjLoader can now load from a string payload. Sandbox can now load obj files from the UI using a file picker * alex/API2.0-core * Started on #1673. Fixed an issue with walkAsync where the recursive generator would waste too much time idle. * Solution for #1673. Replaced the old async pausing approach with a better version that has true variable wait time, and does not add additional dead wait time. Render tree building is now several times faster * Cut down some more on load time by using a lookup table for determined speckle types. For a very large number of objects, getting the actual speckle type was quite slow. For our reference stream with 1kk objects we cut down around 2 seconds of load time * BoxFromObjects now returns the correctly transformed aabb * Implemented optimisations for batch building step from the loading process. Reduced the step's time by around 50% * Implemented a NodeMap which allows us to search for nodes very very fast. * Added a dynamic pause in the loader which stops the converter from blocking. Paramater object types are not added as nodes anymore. Callback from converter is not without arguments * Replaced some internal walks with the newer and much faster id finding approach * Disable object shallow cloning acrss the speckle converter * Fixed the issues with block instances and revit instances caused by not duplicating speckle objects in the converter * getObjectPropertis is now async and slightly improved the execution time of the flatten function which it uses * Set caching to true by default * Fix for display style hashing * Implemented legacy viewer as a wrapper around the old viewer API * Started testing FE1 with API 2.0. Fixed some legacy issues. Also the camera controller extension now exposes it's underlying controls object for the sake of not messing around with unwatend changes in FE1 * Updated FE1 with API2.0 selection changes * Fixing selection bugs * The viewer now ignores duplicate id nodes * Fixes to object properties population, camera zooming and adding subtrees in core * Fixed some filtering issues. Added UpdateFlags to the viewer's requestRender function * Fixed an issue where section boxes were incorrectly added to the URL * Objects with no id are not given nodes into the tree * Updates to FE2 for API2.0. Also fixed a really really obscure bug in viewer-core where a material an incorrect material was set when reseting the batch to the default material * We now store separate node maps for each model loaded. Each node now holds it's subtree id (as a small number for memory considerations) * Render request after updating the visual diff * Fixed some missing update calls on shadows * Reverted FE1 changes and pinned the viewer library to a specific (latest) version pre-API2.0 * We're adding a viewer node for loaded models as subtree parent. This is because we're no longer spoofing ids and the model parent id needs to be preserved * Fixed an issue where clicking on a comment bubble made the pipeline use accumulation improperly, leading to dark blight * Null check for setMaterial. Fixed another case of dark blight. Hack required by frontend * Fixed the issue with filtering state not propagating in the FE * Updated selection event changes * Fixed an issue where an undefined subtree id would yield an incorrect render tree upon requesting it * Fixed an issue where undefined nodes were returned as valid when searching of ids * NodeRenderView now holds it's subtree id along the speckle id. This allows for precise node matching when looking for specific nodes * Fixed an issue with BlockInstances not instancing underlying meshes from breps * Some fixes to diffing. Some older than API2.0, some new. Render views now have guid composed of their id and subtreeid. * Update node render view id with guid where needed * Unload function now checks for requested resource to unload before trying to unload it * Check for the existence of a batch before applying draw ranges. Inthe FE, extension can temporarily keep dead rvs in their state when switching between streams leading to errors * Fixed an issue for filtering where subtree roots would cause incorrect rv additions to the ghosted rv lists * Unified block instance and revit instance conversion implementation * Separted instanced from non-instanced rvs. Working on InstancedMeshBatch * WIP on instanced types * Implemented visual for box draging * BoxSelection extension beautified and documented along with some small but welcomed changes to the viewer-core * Viewer's getExtension now looks in the prototype chain before returning an undefined extension * Added the extended Selection extension here for possible later use * First iteration on instancing. General idea works. Still WIP on several fronts * Fixed issues related to incorrect transform being calculated for instanced geometry. Fixed an issue with incorrect bounds being calculated * Fixed a few issues with instanced vs non instanced render view gathering * Disabled box selection extension * Fixed some linter errors * Disabled a lint 'error' * Fixed issues with depth rendering and instanced objects and fixed draw range visibility for instanced batches in a minimalistic way * Updated the measurements extension with the visiblity option * Restructed a bit our implementation for the acceleration structure because now the BLAS needs to aggregate an instance of three-mesh-bvh not extend it. So that instanced batch objects can share a single bvh instance -> no redundancy * WIP * Revert "WIP" This reverts commit 20d4bbf6210b0d37b956cc5b41fdb06f29845b4f. * More WIP on trying to make instanced geometry TAS and BAS work properly * Both Tas and Bas intersection testing seem to working fine. Now we need to implemented draw grouping for instanced mesh batches * Added draw group management to InstancedMeshBatch. It works in the same manner as for non instanced batches, but the offset value refers to instances not triangles. I believe it could be simplified, but I'd like to get it up and working first * Draw groups need to hold the index and count from the instanced buffer attribute * Added array shuffling to the instanced mesh batch using the same approach we used for the non instanced one. * Instanced batches now dynamically add InstancedMesh objects based on draw groups and a computed transform buffer * Applying draw range updates for instanced batches now works. Still some issues to handle. It aint much, but it's honest work * Disabled some more RTE until setting visibility for draw ranges is finished * Moved getting opaque, transparent and stencil draw ranges out from Batcher and on to a per-batch basis. Now instanced batches correctly apply draw ranges and get their opaque, transparent and stecil ranges * Fixed an issue with setting the visibility ranges for instanced mesh batches * Instanced attributes for instanced meshes now no longer allocate * Shadow depth material for instances is now set in the speckle renderer * R-enabled RTE globally. Made instancing work with RTE. Made instancing work with both RTE and shadowmapping. * Fixed an issue with materials building up in the mesh batch's cache incorrectly * Implemented gradient indexs attributes for instanced batches. Thismeans, any color ramp based material like the ones we use for filtering now works * Changed the way compound ids are created for instances, so that less memory is required. i.e chrome is not crashing anymore on particular streams * Implemented reseting the draw ranges for instanced mesh batches. We no longer double buffer the gradient index buffer. We just create a new one when shuffling, populate it, then copy it over at the end. We're still double buffering the transforms buffer since that one is larger and we might not want to allocate it each time we shuffle * Removed references of draw groups with ids since we're not doing that anymore. Fixed an issue with mesh batches where the material cache would keep piling materials incorrectly * Several issues with selective transformations on instanced batches fixed. * Added default null materials for instanced meshes both with and without vertex colors * Got rid of the patched InstancedMesh because it was ridiculously slow. Instead we're now computing the scene box using our acceleration structures where available and three.s boundingbox where not available * Minor, yet big regression fix * Fixed regression * Exported some extra types * WIP on instanced balancing * Instanced objects under a certain threshold now get batched together as regular mesh batches * Forgot to update the rvs aabb * Unified instanced and non instanced batch creation. Instances which do not qualify for instanced batching anre now mixed together with the rest of the non instanced batched objects * Added some timing information to instanced batches * Fixed an issue with zooming in on objects not working in the selection extension. Fixed an issue with object picking failing due to fp precision for objects right at the edge of the clipping volume. * Removed logs * Update stream moving via UI * Removed the priority argument from loadObject since it's not needed anymore * Updated LegacyViewer * Fixed an issue with API 2.0 where the legacy form of transforms with only an array of values as the matrix would not work * Updated FE1 viewer package to latest before API 2.0 * Disabled selection when measurement mode is on * Updated ibl params updating * Fixed an issue with measurement text not showing up * Logs * lockfile * Made DataTree obsolete. Removed unused 'input' property from viewer * Fixed circular dependencies * Removed DebugViewer, other small changes * Small changes * Small fixes * Added id to NodeData * Removed unused bounds property in rendertree * Notes to future self + ExtendedExtension is now exported * Removed an unused function from renderer. Removed the parent argumetn from getRenderViewerForNode... since it was legacy and has no meaning anymore. * Removed the instance type check in getRenderViewesForNode method. Should not be needed anymore and it was bad to begin with anyway * Removed some test code * Small changes * Removed pointless bounds calculation. Added note * Added return types * Ingestigating large group operations * Nested nodes from TreeNode are now optional. Small update to node render view. * #1818 Remove the concept of speckle data existing behind a 'data' field * Removed unneeded property * made arguments options in transformTRS * getBoundingBox from acceleration structures have an optional argument now * Several Batch methods no longer take variable numbe of arguments, but arrays now * Added note * Note * Removed unused things * Note and made the raycaster protected in intersections * Added some typings * Replaceds some functions with accessors. Added typings. Renamed stuff * Removed some unused properties. * Typings for measurements * Small typing changes * Fixed some more compile errors * Sources from 'batching' folder are now strict compile compliant * Sources from 'materials' folder are now strict compile compliant * Sources from 'extensions' folder are now strict compile compliant * Sources from 'tree' folder are now strict compile compliant * Viewer interface, implementation, legacy implementation and the exports now comply with the strict compilation flag. Also added the new tsconfig * Sources from 'objects' folder now comply to the strict compilation rules * Sources from 'pipeline' now satisfy the requirements for strict compilation * Sources from 'loaders' folder are now compiling with strict. That was so much fun * Sources from 'query' folder are now compiling with strict * Another round of correction triggered by previous changes * Update the declaration file in the object loader to contain a member that needs to be public * SpeckleRenderer along with the rest of the surces from the root 'modules' folder are not compiled with strict * Completely deprecated DataTree. Updated the dependencies with @types/underscore. Fixed remaining compilation issues * Fixed a failing build on the CI regarding a timeout id * Fixed compile errors from sandbox * Another fix * All EventEmiter child classes now have mapped event handler argument types, so that when attaching to a specific event, the provided handler has the correct types for it's arguments. Implicitly, got rid of the the unknown types in all event handlers. * Disabled verbatimModuleSyntax because it was messing things up. Fixed an issue with an improper import * Fixed frontend-2 linting errors. Also added all the event payload maps to the viewer export * Some good additions but mostly typescript catering * Some more typescript catering, but also something useful. The intersect function is now overloaded so that when you specify only the ObjectLayers.STREAM_CONTENT_MESH object layer in it's layers argument it will always return a ExtendedMeshIntersection which guarantees to have a batchObject, face and it's object is of type SpeckleMesh | SpeckleInstancedMesh. Generally you mostly raycast against meshes and getting a three.js intersection object which has all it's fileds optional led to some very useless defined checks. With this we can avoid all of them * Continued from yesterday, finished with the changes in intersections. Added MeshIntersection which is returned by all bvh intersections. This eliminates the need to check for face, faceIndex or index on intersection results from intersection meshes. Groups from MeshBatch and InstancedMeshBatch are now always DrawRanges(they always were) * Mostly catering to typescript * Removed underscore and all unused dependancies. Fixed remaining lint and build errors * Minor changes * Added the no-non-null assertion rule to the sandbox * getExtension never returns null, but rather throws an exception if requested extension does not exist. Added hasExtension for (theoretical) situations where you want to check for extension existence but don't want to go by try/catch with getExtension. Fixed remaining lint/build errors * getBatches now returns explicit batch types based on the geometry type that you provide * Adding the lockfile * undoing unnecessary FE2 changes * Merged viewer/fe SpeckleObject types * Fixed two small issues * Minor linting issues --------- Co-authored-by: Kristaps Fabians Geikins <fabians@speckle.systems> * encryption in create for FE * fix: integration work * bugfix(fe2): Fix conditioning around posting comments in viewer (#2246) * Test fix * Update * Testing * Updates from testing * various fixes --------- Co-authored-by: Kristaps Fabians Geikins <fabians@speckle.systems> * core encryption stuff implemented * fixing tests & linting * improved revision input validation * automation create works * fix(server): avoid removing verified email from user (#2249) * fix(server): avoid removing verified email from user * fix(server): fix test and use secure domain * fix(server): remove redundant test * automations status fix * fixed automation run queries * minor cleanup * implemented log streaming * properly handing redacted props in update rev flow * implemented subscriptions backend * feat(fe2): move settings to tab on projects page (#2207) * Add settings tab. Update style of component * Structuring of files/components * Updates to TexInput * Add RadioGroup * Last FE work * FE Updates * Webhooks Settings Tab * Styling updates to webhooks * Title/Description Update * General Page done * Collaborators WIP * Styling updates * Add custom message to updateProject * Radio Group Same Height * Styling updates to radio group. Disabled state * Updates pre demo * Updates to icons & post demo changes * Major Updates * Unsaved Changes Dialog * Routing WIP * Remove StatsBlock * Auto update discussions on Private * Routing/Redirects * New input style * Invite Dialog * Fix mobile radiogroup * Mobile Improvements * Fix console warning * Fix build * Disabled States * Fix console * Unhide webhooks * Updates from testing * Responsive fixes * Alignment fixes * Fix textarea mobile height * Updates to GraphQL Fragments * Fix disabled state * WIP Arrows for scroll * Update PageTabs - broken * Fix to PageTabs * PageTab fix initial scroll * Hide Scrollbar * Better underline method * Fix mobile initial underline * Webhooks Empty State * Fix input border * Fix empty state * Input Styling updates * Remove mobile smaller text * Update disabled state for disabled items * Updates disabled sates on Settings Block * Fix build. Disable Invite * Fixes to invite permissions * Disable role select when invite is disabled * Small alignment fix * Fix webhooks empty state * cleaning up unnecessary vue files * story improvement * Remove DisabledMessage prop * Fix disabled prop on Button * Move team to Leave Fragment * Remove unused Disabled Message props * Add limit to graphql query * Updates to BlockDiscussions * add formatTriggers function to webhooks * Remove md from button. Improved switch * Update RadioGroup.stories.ts * Update RadioGroup to use defineModel * Various styling and copy updates - More concise and accurate copy - More readable - Works better on mobile * Updates to Invite Dialog * Custom success Message * Update slot names * Remove md in TextInput. Set h-8 to default * Changes from call with fabs * Replace isOwner with composable * Set SettingsBlock icon as optional * Comments from PR * Updates from PR * Final Tidy Ups * Fix Title/Description * Fix spacing issue on Webhooks page * Update borders and colors to align with Automate Makes the same changes that I recently made to the unreleased Automate tab * WEB-869 * Improve styling of radiogroup component Better borders, hover effect, bigger checkmark icon, more subtle active background color, same across breakpoints. * Adjust border styling of RadioGroup component * Improve circle around checkmark in RadioGroup * Split Tabs into 2 components * Restyle overflow arrows * Adjust gap and remove icons from vertical nav Too many icons on the screen got distracting. * Ensure active item visible * Increase gap on vertical nav * Update copy for Access and Discussions settings * Input Tidyup * WEB-877 update-collaborators-block * Update inputs to new style * Fix webhooks button hover state * Fix comment copy Appears in the access settings before embedding a model * Remove hover shadow from search input on Dashboard * Small change from Benjamin * Change collaborators permissions copy * Comments from PR #1 * Comments from PR #2 * Fix condition for EditableHeader * Updates from CR --------- Co-authored-by: Kristaps Fabians Geikins <fabis94@live.com> Co-authored-by: Benjamin Ottensten <benjamin.ottensten@gmail.com> * WIP subscriptions FE implementation * Use showbutton prop instead of useCheckViewerCommentingAccess (#2251) * subscriptions work? * Reverted project access copy to something like before (#2254) * bugfix(ui-components): Scroll jumps to top on Dialog open (#2250) * Updates to dialog component * Fix build issues * Fix build * Use shortened header in Scene Explorer (#2256) * feat(fe2) - Replace CommonEditableTitleDescription with new CommonTitleDescription (#2253) * Make project & model headers non-editable * Fixes from PR * Add placeholder for description * Only show manage button when user canEdit (#2260) * feat: add docker compose based reverse proxy for the server stack * revert: restore docker compose ingress dockerfile * chore: disable automate module feature flag by default * fix: move nginx ingress file to the right place * feat(fe2): Hide settings tab for logged out users (#2261) * Hide settings for logged out users * Hide settings tab for non-logged in users * Add middleware to settings to login * Add middleware * Update to webhooks middleware * Updates to middleware * Changes to middleware * Update comments * bugfix(fe2): Coloring function disappears if parameter title is too long (#2255) * Add truncation to filter name * Use shortened model name in scene explorer * Revert "Use shortened model name in scene explorer" This reverts commit b86e1d8577ba1009462f68fb45840b8b1f66ec1a. * Remove gap * Implement `automateFunctionRunStatusReport` (#2262) * untested implementation * no more errors * no more errors * lint * add all statuses to `AutomationRunStatusOrder` * fix: status reporting now works * park in the right place, grapple with tests * update tests * use correct run ids, adjust tests --------- Co-authored-by: Gergő Jedlicska <gergo@jedlicska.com> * fix: make tab selection robust by using dynamic ID lookup * tests: fix authz module tests * fix: frontend TS issue * ci: add automate encryption keys path value * Fix ts build errors from ui-components changes * fix: frontend automation status colors * add handling for all enum cases in useRunStatusMetadata * Fix merge issue --------- Co-authored-by: Kristaps Fabians Geikins <fabis94@live.com> Co-authored-by: andrewwallacespeckle <139135120+andrewwallacespeckle@users.noreply.github.com> Co-authored-by: Alexandru Popovici <alexandrupopoviciioan@gmail.com> Co-authored-by: Kristaps Fabians Geikins <fabians@speckle.systems> Co-authored-by: Alessandro Magionami <alessandro.magionami@gmail.com> Co-authored-by: Benjamin Ottensten <benjamin.ottensten@gmail.com> Co-authored-by: Chuck Driesler <cdriesler.iv@gmail.com> * fix: runStatus label merge error --------- Co-authored-by: Kristaps Fabians Geikins <fabis94@live.com> Co-authored-by: Benjamin Ottensten <benjamin.ottensten@gmail.com> Co-authored-by: Chuck Driesler <cdriesler.iv@gmail.com> Co-authored-by: andrewwallacespeckle <139135120+andrewwallacespeckle@users.noreply.github.com> Co-authored-by: Alexandru Popovici <alexandrupopoviciioan@gmail.com> Co-authored-by: Kristaps Fabians Geikins <fabians@speckle.systems> Co-authored-by: Alessandro Magionami <alessandro.magionami@gmail.com>
This commit is contained in:
@@ -348,7 +348,7 @@ jobs:
|
||||
type: string
|
||||
docker:
|
||||
- image: speckle/pre-commit-runner:latest
|
||||
resource_class: medium
|
||||
resource_class: large
|
||||
working_directory: *work-dir
|
||||
steps:
|
||||
- checkout
|
||||
@@ -416,6 +416,8 @@ jobs:
|
||||
S3_CREATE_BUCKET: 'true'
|
||||
REDIS_URL: 'redis://127.0.0.1:6379'
|
||||
S3_REGION: '' # optional, defaults to 'us-east-1'
|
||||
FF_AUTOMATE_MODULE_ENABLED: 'true'
|
||||
AUTOMATE_ENCRYPTION_KEYS_PATH: 'test/assets/automate/encryptionKeys.json'
|
||||
steps:
|
||||
- checkout
|
||||
- restore_cache:
|
||||
|
||||
@@ -6,7 +6,10 @@ if [ -n "$CI" ]
|
||||
then
|
||||
echo "running eslint"
|
||||
yarn lint
|
||||
echo "...eslint done"
|
||||
echo "running prettier"
|
||||
yarn prettier:check
|
||||
echo "...prettier done"
|
||||
else
|
||||
# shellcheck disable=SC1090
|
||||
. "$(dirname "$0")/_/husky.sh"
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
services:
|
||||
nginx:
|
||||
restart: always
|
||||
image: nginx:1-alpine
|
||||
ports:
|
||||
- 8080:8080
|
||||
volumes:
|
||||
- ./utils/docker-compose-ingress/nginx/default.conf:/etc/nginx/conf.d/default.conf
|
||||
network_mode: host
|
||||
+2
-1
@@ -13,7 +13,7 @@
|
||||
"build:public": "yarn workspaces foreach -ptv --no-private run build",
|
||||
"build:tailwind-deps": "yarn workspaces foreach -iv -j unlimited --include '{@speckle/shared,@speckle/tailwind-theme,@speckle/ui-components}' run build",
|
||||
"ensure:tailwind-deps": "node ./utils/ensure-tailwind-deps.mjs",
|
||||
"lint": "eslint . --ext .js,.ts,.vue --max-warnings=0",
|
||||
"lint": "node --max-old-space-size=4096 ./node_modules/eslint/bin/eslint.js . --ext .js,.ts,.vue --max-warnings=0",
|
||||
"helm:readme:generate": "./utils/helm/update-schema-json.sh",
|
||||
"prettier:check": "prettier --check .",
|
||||
"prettier:fix": "prettier --write .",
|
||||
@@ -45,6 +45,7 @@
|
||||
"@types/eslint": "^8.4.1",
|
||||
"@types/lockfile": "^1.0.2",
|
||||
"commitizen": "^4.2.5",
|
||||
"cross-env": "^7.0.3",
|
||||
"cz-conventional-changelog": "^3.3.0",
|
||||
"eslint": "^8.11.0",
|
||||
"eslint-config-prettier": "^8.5.0",
|
||||
|
||||
@@ -36,6 +36,11 @@ NUXT_PUBLIC_DATADOG_ENV=
|
||||
# Debug core web vitals in the console
|
||||
NUXT_PUBLIC_DEBUG_CORE_WEB_VITALS=false
|
||||
|
||||
# Enable Speckle Automate functionality
|
||||
NUXT_PUBLIC_ENABLE_AUTOMATE_MODULE=false
|
||||
NUXT_PUBLIC_AUTOMATE_GH_CLIENT_ID=Iv1.79a1df48749f11b4
|
||||
AUTOMATE_GH_CLIENT_SECRET=5bb28a6397204edf259f3d40cf36afc6a95c3998
|
||||
|
||||
# Survicate
|
||||
NUXT_PUBLIC_SURVICATE_WORKSPACE_KEY=
|
||||
|
||||
|
||||
-1
@@ -5,7 +5,6 @@
|
||||
"stylelint.validate": ["css", "scss", "vue", "postcss"],
|
||||
"stylelint.enable": true,
|
||||
"stylelint.configFile": "${workspaceFolder}/stylelint.config.js",
|
||||
"volar.completion.preferredTagNameCase": "pascal",
|
||||
"javascript.suggest.autoImports": true,
|
||||
"typescript.suggest.autoImports": true,
|
||||
"typescript.preferences.importModuleSpecifier": "non-relative",
|
||||
|
||||
@@ -0,0 +1,403 @@
|
||||
<template>
|
||||
<LayoutDialog
|
||||
v-model:open="open"
|
||||
max-width="lg"
|
||||
title="Create Automation"
|
||||
:buttons-wrapper-classes="buttonsWrapperClasses"
|
||||
:buttons="buttons"
|
||||
:on-submit="onDialogSubmit"
|
||||
prevent-close-on-click-outside
|
||||
>
|
||||
<div class="flex flex-col gap-11">
|
||||
<CommonStepsNumber
|
||||
v-if="shouldShowStepsWidget"
|
||||
v-model="stepsWidgetModel"
|
||||
:steps="stepsWidgetSteps"
|
||||
:go-vertical-below="TailwindBreakpoints.sm"
|
||||
non-interactive
|
||||
/>
|
||||
<AutomateAutomationCreateDialogSelectFunctionStep
|
||||
v-if="enumStep === AutomationCreateSteps.SelectFunction"
|
||||
v-model:selected-function="selectedFunction"
|
||||
:preselected-function="validatedPreselectedFunction"
|
||||
/>
|
||||
<AutomateAutomationCreateDialogFunctionParametersStep
|
||||
v-else-if="
|
||||
enumStep === AutomationCreateSteps.FunctionParameters && selectedFunction
|
||||
"
|
||||
ref="parametersStep"
|
||||
v-model:parameters="functionParameters"
|
||||
v-model:has-errors="hasParameterErrors"
|
||||
:fn="selectedFunction"
|
||||
/>
|
||||
<AutomateAutomationCreateDialogAutomationDetailsStep
|
||||
v-else-if="enumStep === AutomationCreateSteps.AutomationDetails"
|
||||
v-model:project="selectedProject"
|
||||
v-model:model="selectedModel"
|
||||
v-model:automation-name="automationName"
|
||||
:preselected-project="preselectedProject"
|
||||
/>
|
||||
<AutomateAutomationCreateDialogDoneStep
|
||||
v-else-if="
|
||||
enumStep === AutomationCreateSteps.Done && automationId && selectedFunction
|
||||
"
|
||||
:automation-id="automationId"
|
||||
:function-name="selectedFunction.name"
|
||||
/>
|
||||
</div>
|
||||
</LayoutDialog>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { useEnumSteps, useEnumStepsWidgetSetup } from '~/lib/form/composables/steps'
|
||||
import {
|
||||
CommonStepsNumber,
|
||||
TailwindBreakpoints,
|
||||
type LayoutDialogButton
|
||||
} from '@speckle/ui-components'
|
||||
import {
|
||||
ArrowRightIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon
|
||||
} from '@heroicons/vue/24/outline'
|
||||
import { graphql } from '~/lib/common/generated/gql'
|
||||
import { Automate, type Optional } from '@speckle/shared'
|
||||
import type { CreateAutomationSelectableFunction } from '~/lib/automate/helpers/automations'
|
||||
import {
|
||||
AutomateRunTriggerType,
|
||||
type FormSelectModels_ModelFragment,
|
||||
type FormSelectProjects_ProjectFragment
|
||||
} from '~/lib/common/generated/gql/graphql'
|
||||
import { useForm } from 'vee-validate'
|
||||
import {
|
||||
useCreateAutomation,
|
||||
useCreateAutomationRevision,
|
||||
useUpdateAutomation
|
||||
} from '~/lib/projects/composables/automationManagement'
|
||||
import { formatJsonFormSchemaInputs } from '~/lib/automate/helpers/jsonSchema'
|
||||
import { projectAutomationRoute } from '~/lib/common/helpers/route'
|
||||
import {
|
||||
useAutomationInputEncryptor,
|
||||
type AutomationInputEncryptor
|
||||
} from '~/lib/automate/composables/automations'
|
||||
|
||||
enum AutomationCreateSteps {
|
||||
SelectFunction,
|
||||
FunctionParameters,
|
||||
AutomationDetails,
|
||||
Done
|
||||
}
|
||||
|
||||
type DetailsFormValues = {
|
||||
project: FormSelectProjects_ProjectFragment
|
||||
model: FormSelectModels_ModelFragment
|
||||
automationName: string
|
||||
}
|
||||
|
||||
graphql(`
|
||||
fragment AutomateAutomationCreateDialog_AutomateFunction on AutomateFunction {
|
||||
id
|
||||
...AutomationsFunctionsCard_AutomateFunction
|
||||
...AutomateAutomationCreateDialogFunctionParametersStep_AutomateFunction
|
||||
}
|
||||
`)
|
||||
|
||||
const props = defineProps<{
|
||||
preselectedFunction?: Optional<CreateAutomationSelectableFunction>
|
||||
preselectedProject?: Optional<FormSelectProjects_ProjectFragment>
|
||||
}>()
|
||||
const open = defineModel<boolean>('open', { required: true })
|
||||
const { handleSubmit: handleDetailsSubmit } = useForm<DetailsFormValues>()
|
||||
|
||||
const stepsOrder = computed(() => [
|
||||
AutomationCreateSteps.SelectFunction,
|
||||
AutomationCreateSteps.FunctionParameters,
|
||||
AutomationCreateSteps.AutomationDetails,
|
||||
AutomationCreateSteps.Done
|
||||
])
|
||||
|
||||
const stepsWidgetData = computed(() => [
|
||||
{
|
||||
step: AutomationCreateSteps.SelectFunction,
|
||||
title: 'Select Function'
|
||||
},
|
||||
{
|
||||
step: AutomationCreateSteps.FunctionParameters,
|
||||
title: 'Set Parameters'
|
||||
},
|
||||
{
|
||||
step: AutomationCreateSteps.AutomationDetails,
|
||||
title: 'Add Details'
|
||||
}
|
||||
])
|
||||
|
||||
const inputEncryption = useAutomationInputEncryptor({ ensureWhen: open })
|
||||
const logger = useLogger()
|
||||
const updateAutomation = useUpdateAutomation()
|
||||
const createAutomation = useCreateAutomation()
|
||||
const createRevision = useCreateAutomationRevision()
|
||||
const { enumStep, step } = useEnumSteps({ order: stepsOrder })
|
||||
const {
|
||||
items: stepsWidgetSteps,
|
||||
model: stepsWidgetModel,
|
||||
shouldShowWidget: shouldShowStepsWidget
|
||||
} = useEnumStepsWidgetSetup({ enumStep, widgetStepsMap: stepsWidgetData })
|
||||
|
||||
const parametersStep = ref<{ submit: () => Promise<void> }>()
|
||||
|
||||
const creationLoading = ref(false)
|
||||
const automationId = ref<string>()
|
||||
const automationName = ref<string>()
|
||||
const selectedProject = ref<FormSelectProjects_ProjectFragment>()
|
||||
const selectedModel = ref<FormSelectModels_ModelFragment>()
|
||||
const selectedFunction = ref<Optional<CreateAutomationSelectableFunction>>()
|
||||
const functionParameters = ref<Record<string, unknown>>()
|
||||
const hasParameterErrors = ref(false)
|
||||
|
||||
const buttons = computed((): LayoutDialogButton[] => {
|
||||
switch (enumStep.value) {
|
||||
case AutomationCreateSteps.SelectFunction:
|
||||
return [
|
||||
{
|
||||
id: 'selectFnNext',
|
||||
text: 'Next',
|
||||
props: {
|
||||
iconRight: ChevronRightIcon,
|
||||
disabled: !selectedFunction.value
|
||||
},
|
||||
onClick: () => {
|
||||
step.value++
|
||||
}
|
||||
}
|
||||
]
|
||||
case AutomationCreateSteps.FunctionParameters:
|
||||
return [
|
||||
{
|
||||
id: 'fnParamsPrev',
|
||||
text: 'Previous',
|
||||
props: {
|
||||
color: 'secondary',
|
||||
iconLeft: ChevronLeftIcon,
|
||||
textColor: 'primary'
|
||||
},
|
||||
onClick: () => step.value--
|
||||
},
|
||||
{
|
||||
id: 'fnParamsNext',
|
||||
text: 'Next',
|
||||
props: {
|
||||
iconRight: ChevronRightIcon,
|
||||
disabled: hasParameterErrors.value
|
||||
},
|
||||
submit: true
|
||||
}
|
||||
]
|
||||
case AutomationCreateSteps.AutomationDetails:
|
||||
return [
|
||||
{
|
||||
id: 'detailsPrev',
|
||||
text: 'Previous',
|
||||
props: {
|
||||
color: 'secondary',
|
||||
iconLeft: ChevronLeftIcon,
|
||||
textColor: 'primary'
|
||||
},
|
||||
onClick: () => step.value--
|
||||
},
|
||||
{
|
||||
id: 'detailsCreate',
|
||||
text: 'Create',
|
||||
submit: true,
|
||||
disabled: creationLoading.value
|
||||
}
|
||||
]
|
||||
case AutomationCreateSteps.Done:
|
||||
return [
|
||||
{
|
||||
id: 'doneClose',
|
||||
text: 'Close',
|
||||
props: {
|
||||
color: 'secondary',
|
||||
fullWidth: true
|
||||
},
|
||||
onClick: () => (open.value = false)
|
||||
},
|
||||
{
|
||||
id: 'doneGoToAutomation',
|
||||
text: 'Go to Automation',
|
||||
props: {
|
||||
iconRight: ArrowRightIcon,
|
||||
fullWidth: true,
|
||||
to:
|
||||
selectedProject.value && automationId.value
|
||||
? projectAutomationRoute(selectedProject.value.id, automationId.value)
|
||||
: undefined
|
||||
}
|
||||
}
|
||||
]
|
||||
default:
|
||||
return []
|
||||
}
|
||||
})
|
||||
|
||||
const buttonsWrapperClasses = computed(() => {
|
||||
switch (enumStep.value) {
|
||||
case AutomationCreateSteps.SelectFunction:
|
||||
return 'justify-end'
|
||||
case AutomationCreateSteps.Done:
|
||||
return 'flex-col sm:flex-row sm:justify-between'
|
||||
default:
|
||||
return 'justify-between'
|
||||
}
|
||||
})
|
||||
|
||||
const validatedPreselectedFunction = computed(() => {
|
||||
if (!(props.preselectedFunction?.releases.items || []).length) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return props.preselectedFunction
|
||||
})
|
||||
|
||||
const reset = () => {
|
||||
step.value = 0
|
||||
selectedFunction.value = undefined
|
||||
functionParameters.value = undefined
|
||||
hasParameterErrors.value = false
|
||||
selectedProject.value = undefined
|
||||
selectedModel.value = undefined
|
||||
automationName.value = undefined
|
||||
automationId.value = undefined
|
||||
}
|
||||
|
||||
const onDetailsSubmit = handleDetailsSubmit(async () => {
|
||||
const fn = selectedFunction.value
|
||||
const fnRelease = selectedFunction.value?.releases.items[0]
|
||||
const project = selectedProject.value
|
||||
const model = selectedModel.value
|
||||
const parameters = functionParameters.value
|
||||
const name = automationName.value
|
||||
|
||||
if (!fn || !project || !model || !name?.length || !fnRelease) {
|
||||
logger.error('Missing required data', {
|
||||
fn,
|
||||
project,
|
||||
model,
|
||||
parameters,
|
||||
name,
|
||||
fnRelease
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
creationLoading.value = true
|
||||
let aId: Optional<string> = undefined
|
||||
let automationEncrypt: Optional<AutomationInputEncryptor> = undefined
|
||||
try {
|
||||
const createRes = await createAutomation({
|
||||
projectId: project.id,
|
||||
input: {
|
||||
name,
|
||||
enabled: false
|
||||
}
|
||||
})
|
||||
aId = automationId.value = createRes?.id
|
||||
if (!aId) {
|
||||
logger.error('Failed to create automation', { createRes })
|
||||
return
|
||||
}
|
||||
|
||||
automationEncrypt = await inputEncryption.forAutomation({
|
||||
automationId: aId,
|
||||
projectId: project.id
|
||||
})
|
||||
|
||||
const cleanParams =
|
||||
formatJsonFormSchemaInputs(parameters, fnRelease.inputSchema) || null
|
||||
const encryptedParams = automationEncrypt.encryptInputs({
|
||||
inputs: cleanParams
|
||||
})
|
||||
|
||||
const revisionRes = await createRevision(
|
||||
{
|
||||
projectId: project.id,
|
||||
input: {
|
||||
automationId: aId,
|
||||
functions: [
|
||||
{
|
||||
functionReleaseId: fnRelease.id,
|
||||
functionId: fn.id,
|
||||
parameters: encryptedParams
|
||||
}
|
||||
],
|
||||
triggerDefinitions: <Automate.AutomateTypes.TriggerDefinitionsSchema>{
|
||||
version: Automate.AutomateTypes.TRIGGER_DEFINITIONS_SCHEMA_VERSION,
|
||||
definitions: [
|
||||
{
|
||||
type: AutomateRunTriggerType.VersionCreated,
|
||||
modelId: model.id
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{ hideSuccessToast: true }
|
||||
)
|
||||
|
||||
if (!revisionRes?.id) {
|
||||
logger.error('Failed to create revision', { revisionRes })
|
||||
return
|
||||
}
|
||||
|
||||
// Enable
|
||||
await updateAutomation({
|
||||
projectId: project.id,
|
||||
input: {
|
||||
id: aId,
|
||||
enabled: true
|
||||
}
|
||||
})
|
||||
|
||||
step.value++
|
||||
} finally {
|
||||
creationLoading.value = false
|
||||
automationEncrypt?.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
const onDialogSubmit = async (e: SubmitEvent) => {
|
||||
if (enumStep.value === AutomationCreateSteps.AutomationDetails) {
|
||||
await onDetailsSubmit(e)
|
||||
} else if (enumStep.value === AutomationCreateSteps.FunctionParameters) {
|
||||
await parametersStep.value?.submit()
|
||||
if (!hasParameterErrors.value) {
|
||||
step.value++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
open,
|
||||
(newVal, oldVal) => {
|
||||
if (newVal && !oldVal) {
|
||||
reset()
|
||||
|
||||
if (validatedPreselectedFunction.value) {
|
||||
selectedFunction.value = validatedPreselectedFunction.value
|
||||
enumStep.value = AutomationCreateSteps.FunctionParameters
|
||||
}
|
||||
|
||||
if (props.preselectedProject) {
|
||||
selectedProject.value = props.preselectedProject
|
||||
}
|
||||
}
|
||||
},
|
||||
{ flush: 'sync' }
|
||||
)
|
||||
|
||||
watch(selectedFunction, (newVal, oldVal) => {
|
||||
if (newVal?.id !== oldVal?.id) {
|
||||
// Reset params
|
||||
functionParameters.value = undefined
|
||||
}
|
||||
})
|
||||
</script>
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-2">
|
||||
<FormSelectProjects
|
||||
v-if="!preselectedProject"
|
||||
v-model="project"
|
||||
label="Speckle Project"
|
||||
show-label
|
||||
help="Choose the project where your target model is located."
|
||||
show-required
|
||||
button-style="tinted"
|
||||
mount-menu-on-body
|
||||
:rules="projectRules"
|
||||
:allow-unset="false"
|
||||
validate-on-value-update
|
||||
/>
|
||||
<FormSelectModels
|
||||
v-if="project?.id"
|
||||
v-model="model"
|
||||
:project-id="project.id"
|
||||
label="Speckle Model"
|
||||
show-label
|
||||
help="Choose the model you want to run your automation on."
|
||||
show-required
|
||||
button-style="tinted"
|
||||
mount-menu-on-body
|
||||
:rules="modelRules"
|
||||
:allow-unset="false"
|
||||
validate-on-value-update
|
||||
/>
|
||||
<FormTextInput
|
||||
v-model="automationName"
|
||||
name="automationName"
|
||||
label="Name"
|
||||
show-label
|
||||
help="Give your automation a name."
|
||||
placeholder="Automation Name"
|
||||
:rules="nameRules"
|
||||
show-required
|
||||
validate-on-value-update
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import type { Optional } from '@speckle/shared'
|
||||
import { FormTextInput, ValidationHelpers } from '@speckle/ui-components'
|
||||
import type {
|
||||
FormSelectModels_ModelFragment,
|
||||
FormSelectProjects_ProjectFragment
|
||||
} from '~/lib/common/generated/gql/graphql'
|
||||
|
||||
const props = defineProps<{
|
||||
preselectedProject?: Optional<FormSelectProjects_ProjectFragment>
|
||||
}>()
|
||||
const project = defineModel<Optional<FormSelectProjects_ProjectFragment>>('project', {
|
||||
required: true
|
||||
})
|
||||
const model = defineModel<Optional<FormSelectModels_ModelFragment>>('model', {
|
||||
required: true
|
||||
})
|
||||
const automationName = defineModel<Optional<string>>('automationName', {
|
||||
required: true
|
||||
})
|
||||
|
||||
const projectRules = computed(() => [ValidationHelpers.isRequired])
|
||||
const modelRules = projectRules
|
||||
const nameRules = computed(() => [
|
||||
ValidationHelpers.isRequired,
|
||||
ValidationHelpers.isStringOfLength({ maxLength: 150 })
|
||||
])
|
||||
|
||||
watch(
|
||||
() => props.preselectedProject,
|
||||
(newVal) => {
|
||||
if (newVal) {
|
||||
project.value = newVal
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,21 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="flex items-center justify-center gap-0.5">
|
||||
<CheckCircleIcon class="h-9 w-9 text-success" />
|
||||
<span class="h4 font-bold">Success</span>
|
||||
</div>
|
||||
<div class="label-light text-center">
|
||||
Your automation using
|
||||
<strong>"{{ functionName }}"</strong>
|
||||
has been created
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { CheckCircleIcon } from '@heroicons/vue/24/solid'
|
||||
|
||||
defineProps<{
|
||||
automationId: string
|
||||
functionName: string
|
||||
}>()
|
||||
</script>
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
<template>
|
||||
<div>
|
||||
<template v-if="finalParams">
|
||||
<FormJsonForm
|
||||
ref="jsonForm"
|
||||
v-model:data="parameters"
|
||||
:schema="finalParams"
|
||||
class="space-y-4"
|
||||
:validate-on-mount="false"
|
||||
@change="handler"
|
||||
/>
|
||||
</template>
|
||||
<CommonAlert v-else color="info">
|
||||
<template #title>
|
||||
No parameters defined for the selected function release
|
||||
</template>
|
||||
</CommonAlert>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import type { JsonFormsChangeEvent } from '@jsonforms/vue'
|
||||
import type { Optional } from '@speckle/shared'
|
||||
import { useJsonFormsChangeHandler } from '~/lib/automate/composables/jsonSchema'
|
||||
import { formatVersionParams } from '~/lib/automate/helpers/jsonSchema'
|
||||
import { graphql } from '~/lib/common/generated/gql'
|
||||
import type { AutomateAutomationCreateDialogFunctionParametersStep_AutomateFunctionFragment } from '~/lib/common/generated/gql/graphql'
|
||||
|
||||
graphql(`
|
||||
fragment AutomateAutomationCreateDialogFunctionParametersStep_AutomateFunction on AutomateFunction {
|
||||
id
|
||||
releases(limit: 1) {
|
||||
items {
|
||||
id
|
||||
inputSchema
|
||||
}
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
const props = defineProps<{
|
||||
fn: AutomateAutomationCreateDialogFunctionParametersStep_AutomateFunctionFragment
|
||||
}>()
|
||||
|
||||
const jsonForm = ref<{ triggerChange: () => Promise<Optional<JsonFormsChangeEvent>> }>()
|
||||
|
||||
const finalParams = computed(() => formatVersionParams(release.value?.inputSchema))
|
||||
const { handler, hasErrors: hasJsonFormErrors } = useJsonFormsChangeHandler({
|
||||
schema: finalParams
|
||||
})
|
||||
|
||||
const parameters = defineModel<Optional<Record<string, unknown>>>('parameters', {
|
||||
required: true
|
||||
})
|
||||
const hasErrors = defineModel<boolean>('hasErrors', { required: true })
|
||||
|
||||
const release = computed(() =>
|
||||
props.fn.releases.items.length ? props.fn.releases.items[0] : undefined
|
||||
)
|
||||
|
||||
const submit = async () => {
|
||||
await jsonForm.value?.triggerChange()
|
||||
}
|
||||
|
||||
// watch(
|
||||
// release,
|
||||
// () => {
|
||||
// if (finalParams.value) {
|
||||
// parameters.value = {}
|
||||
// }
|
||||
// },
|
||||
// { immediate: true }
|
||||
// )
|
||||
|
||||
watch(
|
||||
hasJsonFormErrors,
|
||||
(value) => {
|
||||
hasErrors.value = value
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
defineExpose({ submit })
|
||||
</script>
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
<template>
|
||||
<div>
|
||||
<FormTextInput
|
||||
name="search"
|
||||
placeholder="Search Functions"
|
||||
show-clear
|
||||
:model-value="bind.modelValue.value"
|
||||
full-width
|
||||
v-on="on"
|
||||
/>
|
||||
<div class="mt-2">
|
||||
<CommonLoadingBar :loading="loading" />
|
||||
<template v-if="!loading">
|
||||
<AutomateFunctionCardView v-if="queryItems?.length" small-view>
|
||||
<AutomateFunctionCard
|
||||
v-for="fn in items"
|
||||
:key="fn.id"
|
||||
:fn="fn"
|
||||
external-more-info
|
||||
:selected="selectedFunction && selectedFunction?.id === fn.id"
|
||||
@use="() => (selectedFunction = fn)"
|
||||
/>
|
||||
</AutomateFunctionCardView>
|
||||
<CommonGenericEmptyState
|
||||
v-else
|
||||
:search="!!search"
|
||||
@clear-search="search = ''"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { useDebouncedTextInput } from '@speckle/ui-components'
|
||||
import { useQueryLoading, useQuery } from '@vue/apollo-composable'
|
||||
import { graphql } from '~/lib/common/generated/gql'
|
||||
import type { CreateAutomationSelectableFunction } from '~/lib/automate/helpers/automations'
|
||||
import type { Optional } from '@speckle/shared'
|
||||
|
||||
// TODO: Pagination
|
||||
|
||||
const searchQuery = graphql(`
|
||||
query AutomationCreateDialogFunctionsSearch($search: String) {
|
||||
automateFunctions(limit: 21, filter: { search: $search }) {
|
||||
items {
|
||||
id
|
||||
...AutomateAutomationCreateDialog_AutomateFunction
|
||||
}
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
const props = defineProps<{
|
||||
preselectedFunction: Optional<CreateAutomationSelectableFunction>
|
||||
}>()
|
||||
const selectedFunction = defineModel<Optional<CreateAutomationSelectableFunction>>(
|
||||
'selectedFunction',
|
||||
{
|
||||
required: true
|
||||
}
|
||||
)
|
||||
const { on, bind, value: search } = useDebouncedTextInput()
|
||||
const loading = useQueryLoading()
|
||||
const { result } = useQuery(searchQuery, () => ({
|
||||
search: search.value?.length ? search.value : null
|
||||
}))
|
||||
|
||||
const queryItems = computed(() => result.value?.automateFunctions.items)
|
||||
const items = computed(() => {
|
||||
const baseItems = (queryItems.value || []).slice()
|
||||
const preselectedFn = props.preselectedFunction
|
||||
|
||||
if (!preselectedFn || baseItems.find((fn) => fn.id === preselectedFn.id)) {
|
||||
return baseItems
|
||||
}
|
||||
|
||||
return [preselectedFn, ...baseItems]
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.preselectedFunction,
|
||||
(newVal) => {
|
||||
if (newVal && !selectedFunction.value) {
|
||||
selectedFunction.value = newVal
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,128 @@
|
||||
<template>
|
||||
<Component
|
||||
:is="noButtons ? NuxtLink : 'div'"
|
||||
:class="classes"
|
||||
:to="noButtons ? automationFunctionRoute(fn.id) : undefined"
|
||||
:external="externalMoreInfo"
|
||||
:target="externalMoreInfo ? '_blank' : undefined"
|
||||
>
|
||||
<div
|
||||
class="px-4 py-4 flex flex-col gap-3 rounded-lg border border-outline-3 bg-foundation relative"
|
||||
>
|
||||
<div class="flex gap-3 items-center" :class="{ 'w-4/5': hasLabel }">
|
||||
<AutomateFunctionLogo :logo="fn.logo" />
|
||||
<div class="flex flex-col truncate">
|
||||
<div class="normal font-semibold text-foreground truncate hover:underline">
|
||||
<RouterLink
|
||||
:to="automationFunctionRoute(fn.id)"
|
||||
:target="externalMoreInfo ? '_blank' : undefined"
|
||||
>
|
||||
{{ fn.name }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
<div class="label-light flex items-center space-x-1">
|
||||
<span>by</span>
|
||||
<CommonTextLink external :to="fn.repo.url" size="sm">
|
||||
{{ fn.repo.owner }}
|
||||
</CommonTextLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="label-light text-foreground-2 line-clamp-3 h-16">
|
||||
{{ plaintextDescription }}
|
||||
</div>
|
||||
<div v-if="!noButtons" class="flex flex-col sm:flex-row sm:self-end gap-2">
|
||||
<template v-if="showEdit">
|
||||
<FormButton
|
||||
:icon-left="PencilIcon"
|
||||
full-width
|
||||
outlined
|
||||
@click="$emit('edit')"
|
||||
>
|
||||
Edit Details
|
||||
</FormButton>
|
||||
</template>
|
||||
<template v-else>
|
||||
<FormButton
|
||||
text
|
||||
:to="automationFunctionRoute(fn.id)"
|
||||
:external="externalMoreInfo"
|
||||
:target="externalMoreInfo ? '_blank' : undefined"
|
||||
>
|
||||
Learn More
|
||||
</FormButton>
|
||||
<FormButton :icon-left="BoltIcon" @click="$emit('use')">Use</FormButton>
|
||||
</template>
|
||||
</div>
|
||||
<div class="absolute top-0 right-0">
|
||||
<div
|
||||
v-if="hasLabel"
|
||||
class="rounded-bl-lg rounded-tr-[7px] text-xs px-2 py-1"
|
||||
:class="{
|
||||
'bg-foundation-focus text-foreground': fn.isFeatured,
|
||||
'bg-warning text-foreground-on-primary': isOutdated
|
||||
}"
|
||||
>
|
||||
<template v-if="isOutdated">Outdated</template>
|
||||
<template v-else-if="fn.isFeatured">Featured</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Component>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { graphql } from '~/lib/common/generated/gql'
|
||||
import type { AutomationsFunctionsCard_AutomateFunctionFragment } from '~/lib/common/generated/gql/graphql'
|
||||
import { BoltIcon, PencilIcon } from '@heroicons/vue/24/outline'
|
||||
import { automationFunctionRoute } from '~/lib/common/helpers/route'
|
||||
import { useMarkdown } from '~/lib/common/composables/markdown'
|
||||
|
||||
graphql(`
|
||||
fragment AutomationsFunctionsCard_AutomateFunction on AutomateFunction {
|
||||
id
|
||||
name
|
||||
isFeatured
|
||||
description
|
||||
logo
|
||||
repo {
|
||||
id
|
||||
url
|
||||
owner
|
||||
name
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
defineEmits<{
|
||||
edit: []
|
||||
use: []
|
||||
}>()
|
||||
|
||||
const props = defineProps<{
|
||||
fn: AutomationsFunctionsCard_AutomateFunctionFragment
|
||||
showEdit?: boolean
|
||||
noButtons?: boolean
|
||||
externalMoreInfo?: boolean
|
||||
selected?: boolean
|
||||
isOutdated?: boolean
|
||||
}>()
|
||||
|
||||
const NuxtLink = resolveComponent('NuxtLink')
|
||||
const hasLabel = computed(() => props.fn.isFeatured || props.isOutdated)
|
||||
const { html: plaintextDescription } = useMarkdown(
|
||||
computed(() => props.fn.description || ''),
|
||||
{ plaintext: true }
|
||||
)
|
||||
|
||||
const classes = computed(() => {
|
||||
const classParts = ['rounded-lg']
|
||||
|
||||
if (props.selected) {
|
||||
classParts.push('ring-2 ring-primary')
|
||||
} else if (props.noButtons) {
|
||||
classParts.push('ring-outline-2 hover:ring-2 cursor-pointer')
|
||||
}
|
||||
|
||||
return classParts.join(' ')
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,25 @@
|
||||
<template>
|
||||
<div :class="classes">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
smallView?: boolean
|
||||
vertical?: boolean
|
||||
}>()
|
||||
|
||||
const classes = computed(() => {
|
||||
const classParts = ['grid gap-4 lg:gap-6']
|
||||
|
||||
if (!props.vertical) {
|
||||
classParts.push('sm:grid-cols-2')
|
||||
|
||||
if (!props.smallView) {
|
||||
classParts.push('lg:grid-cols-3')
|
||||
}
|
||||
}
|
||||
|
||||
return classParts.join(' ')
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,287 @@
|
||||
<template>
|
||||
<LayoutDialog
|
||||
v-model:open="open"
|
||||
:title="title"
|
||||
:buttons="buttons"
|
||||
max-width="md"
|
||||
:buttons-wrapper-classes="buttonsWrapperClasses"
|
||||
:on-submit="onDialogSubmit"
|
||||
prevent-close-on-click-outside
|
||||
>
|
||||
<div class="flex flex-col gap-11">
|
||||
<CommonStepsNumber
|
||||
v-if="shouldShowStepsWidget"
|
||||
v-model="stepsWidgetModel"
|
||||
:steps="stepsWidgetSteps"
|
||||
:go-vertical-below="TailwindBreakpoints.sm"
|
||||
non-interactive
|
||||
/>
|
||||
<AutomateFunctionCreateDialogAuthorizeStep
|
||||
v-if="enumStep === FunctionCreateSteps.Authorize"
|
||||
/>
|
||||
<AutomateFunctionCreateDialogTemplateStep
|
||||
v-else-if="enumStep === FunctionCreateSteps.Template"
|
||||
v-model:selected-template="selectedTemplate"
|
||||
:templates="templates"
|
||||
/>
|
||||
<AutomateFunctionCreateDialogDetailsStep
|
||||
v-else-if="enumStep === FunctionCreateSteps.Details"
|
||||
:github-orgs="githubOrgs"
|
||||
/>
|
||||
<AutomateFunctionCreateDialogDoneStep
|
||||
v-else-if="enumStep === FunctionCreateSteps.Done && createdFunction"
|
||||
:created-function="createdFunction"
|
||||
/>
|
||||
</div>
|
||||
</LayoutDialog>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ArrowRightIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon
|
||||
} from '@heroicons/vue/24/outline'
|
||||
import {
|
||||
CommonStepsNumber,
|
||||
type LayoutDialogButton,
|
||||
TailwindBreakpoints
|
||||
} from '@speckle/ui-components'
|
||||
import type {
|
||||
CreatableFunctionTemplate,
|
||||
FunctionDetailsFormValues
|
||||
} from '~/lib/automate/helpers/functions'
|
||||
import { automateGithubAppAuthorizationCallback } from '~/lib/common/helpers/route'
|
||||
import { useEnumSteps, useEnumStepsWidgetSetup } from '~/lib/form/composables/steps'
|
||||
import { useForm } from 'vee-validate'
|
||||
import { useCreateAutomateFunction } from '~/lib/automate/composables/management'
|
||||
import { useMutationLoading } from '@vue/apollo-composable'
|
||||
import type { AutomateFunctionCreateDialogDoneStep_AutomateFunctionFragment } from '~~/lib/common/generated/gql/graphql'
|
||||
import { automationFunctionRoute } from '~/lib/common/helpers/route'
|
||||
|
||||
enum FunctionCreateSteps {
|
||||
Authorize,
|
||||
Template,
|
||||
Details,
|
||||
Done
|
||||
}
|
||||
|
||||
type DetailsFormValues = FunctionDetailsFormValues
|
||||
|
||||
const props = defineProps<{
|
||||
isAuthorized: boolean
|
||||
templates: CreatableFunctionTemplate[]
|
||||
githubOrgs: string[]
|
||||
}>()
|
||||
const open = defineModel<boolean>('open', { required: true })
|
||||
|
||||
const logger = useLogger()
|
||||
const mutationLoading = useMutationLoading()
|
||||
const createFunction = useCreateAutomateFunction()
|
||||
const { handleSubmit: handleDetailsSubmit } = useForm<DetailsFormValues>()
|
||||
const onDetailsSubmit = handleDetailsSubmit(async (values) => {
|
||||
if (!selectedTemplate.value) {
|
||||
logger.warn('Unexpectedly missing selected template')
|
||||
return
|
||||
}
|
||||
|
||||
const res = await createFunction({
|
||||
input: {
|
||||
supportedSourceApps: (values.allowedSourceApps || []).map((a) => a.name),
|
||||
tags: values.tags || [],
|
||||
template: selectedTemplate.value.id,
|
||||
logo: values.image,
|
||||
name: values.name,
|
||||
description: values.description
|
||||
}
|
||||
})
|
||||
|
||||
if (res?.id) {
|
||||
createdFunction.value = res
|
||||
step.value++
|
||||
}
|
||||
})
|
||||
|
||||
const onSubmit = computed(() => {
|
||||
switch (enumStep.value) {
|
||||
case FunctionCreateSteps.Details:
|
||||
return onDetailsSubmit
|
||||
default:
|
||||
return noop
|
||||
}
|
||||
})
|
||||
const stepsOrder = computed(() => [
|
||||
...(props.isAuthorized ? [] : [FunctionCreateSteps.Authorize]),
|
||||
FunctionCreateSteps.Template,
|
||||
FunctionCreateSteps.Details,
|
||||
FunctionCreateSteps.Done
|
||||
])
|
||||
|
||||
const stepsWidgetData = computed(() => [
|
||||
{
|
||||
step: FunctionCreateSteps.Template,
|
||||
title: 'Choose a template'
|
||||
},
|
||||
{
|
||||
step: FunctionCreateSteps.Details,
|
||||
title: 'Function details'
|
||||
}
|
||||
])
|
||||
|
||||
const selectedTemplate = ref<CreatableFunctionTemplate>()
|
||||
const githubScopes = ref(['read:org', 'read:user', 'repo'])
|
||||
const createdFunction =
|
||||
ref<AutomateFunctionCreateDialogDoneStep_AutomateFunctionFragment>()
|
||||
|
||||
const {
|
||||
public: { automateGhClientId }
|
||||
} = useRuntimeConfig()
|
||||
const apiBaseUrl = useApiOrigin()
|
||||
const { enumStep, step } = useEnumSteps({ order: stepsOrder })
|
||||
const {
|
||||
items: stepsWidgetSteps,
|
||||
model: stepsWidgetModel,
|
||||
shouldShowWidget: shouldShowStepsWidget
|
||||
} = useEnumStepsWidgetSetup({ enumStep, widgetStepsMap: stepsWidgetData })
|
||||
|
||||
const title = computed(() => {
|
||||
switch (enumStep.value) {
|
||||
case FunctionCreateSteps.Authorize:
|
||||
return 'Authorize GitHub'
|
||||
case FunctionCreateSteps.Template:
|
||||
case FunctionCreateSteps.Details:
|
||||
case FunctionCreateSteps.Done:
|
||||
return 'Create function'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
})
|
||||
|
||||
const authorizeGithubUrl = computed(() => {
|
||||
const redirectUrl = new URL(automateGithubAppAuthorizationCallback, apiBaseUrl)
|
||||
const url = new URL(`/login/oauth/authorize`, 'https://github.com')
|
||||
|
||||
url.searchParams.set('client_id', automateGhClientId)
|
||||
url.searchParams.set('scope', githubScopes.value.join(','))
|
||||
url.searchParams.set('redirect_uri', redirectUrl.toString())
|
||||
|
||||
return url.toString()
|
||||
})
|
||||
|
||||
const buttons = computed((): LayoutDialogButton[] => {
|
||||
switch (enumStep.value) {
|
||||
case FunctionCreateSteps.Authorize:
|
||||
return [
|
||||
{
|
||||
id: 'authorizeClose',
|
||||
text: 'Close',
|
||||
props: {
|
||||
color: 'secondary',
|
||||
fullWidth: true
|
||||
},
|
||||
onClick: () => (open.value = false)
|
||||
},
|
||||
{
|
||||
id: 'authorizeAuthorize',
|
||||
text: 'Authorize',
|
||||
props: {
|
||||
fullWidth: true,
|
||||
disabled: !automateGhClientId.length,
|
||||
to: authorizeGithubUrl.value,
|
||||
external: true
|
||||
}
|
||||
}
|
||||
]
|
||||
case FunctionCreateSteps.Template:
|
||||
return [
|
||||
{
|
||||
id: 'templateNext',
|
||||
text: 'Next',
|
||||
props: {
|
||||
iconRight: ChevronRightIcon,
|
||||
disabled: !selectedTemplate.value
|
||||
},
|
||||
onClick: () => step.value++
|
||||
}
|
||||
]
|
||||
case FunctionCreateSteps.Details:
|
||||
return [
|
||||
{
|
||||
id: 'detailsPrevious',
|
||||
text: 'Previous',
|
||||
props: {
|
||||
color: 'secondary',
|
||||
iconLeft: ChevronLeftIcon,
|
||||
textColor: 'primary'
|
||||
},
|
||||
onClick: () => step.value--
|
||||
},
|
||||
{
|
||||
id: 'detailsCreate',
|
||||
text: 'Create',
|
||||
submit: true,
|
||||
disabled: mutationLoading.value
|
||||
}
|
||||
]
|
||||
case FunctionCreateSteps.Done:
|
||||
return [
|
||||
{
|
||||
id: 'doneClose',
|
||||
text: 'Close',
|
||||
props: {
|
||||
color: 'secondary',
|
||||
fullWidth: true
|
||||
},
|
||||
onClick: () => (open.value = false)
|
||||
},
|
||||
{
|
||||
id: 'doneGoToFunction',
|
||||
text: 'Go to Function',
|
||||
props: {
|
||||
iconRight: ArrowRightIcon,
|
||||
fullWidth: true,
|
||||
to: createdFunction.value?.id
|
||||
? automationFunctionRoute(createdFunction.value.id)
|
||||
: undefined
|
||||
}
|
||||
}
|
||||
]
|
||||
default:
|
||||
return []
|
||||
}
|
||||
})
|
||||
|
||||
const buttonsWrapperClasses = computed(() => {
|
||||
switch (enumStep.value) {
|
||||
case FunctionCreateSteps.Authorize:
|
||||
case FunctionCreateSteps.Done:
|
||||
return 'flex-col sm:flex-row'
|
||||
case FunctionCreateSteps.Template:
|
||||
return 'justify-end'
|
||||
case FunctionCreateSteps.Details:
|
||||
return 'justify-between'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
})
|
||||
|
||||
const reset = () => {
|
||||
step.value = 0
|
||||
selectedTemplate.value = undefined
|
||||
}
|
||||
|
||||
const onDialogSubmit = (e: SubmitEvent) => onSubmit.value(e)
|
||||
|
||||
watch(
|
||||
() => props.isAuthorized,
|
||||
(newVal, oldVal) => {
|
||||
if (newVal === oldVal) return
|
||||
reset()
|
||||
}
|
||||
)
|
||||
|
||||
watch(open, (newVal, oldVal) => {
|
||||
if (newVal && !oldVal) {
|
||||
reset()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,88 @@
|
||||
<template>
|
||||
<LayoutDialog
|
||||
v-model:open="open"
|
||||
title="Edit Function"
|
||||
:buttons="buttons"
|
||||
max-width="md"
|
||||
buttons-wrapper-classes="justify-between"
|
||||
:on-submit="onSubmit"
|
||||
prevent-close-on-click-outside
|
||||
>
|
||||
<AutomateFunctionCreateDialogDetailsStep />
|
||||
</LayoutDialog>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import type { LayoutDialogButton } from '@speckle/ui-components'
|
||||
import { useMutationLoading } from '@vue/apollo-composable'
|
||||
import { difference, differenceBy } from 'lodash-es'
|
||||
import { useForm } from 'vee-validate'
|
||||
import { useUpdateAutomateFunction } from '~/lib/automate/composables/management'
|
||||
import type { FunctionDetailsFormValues } from '~/lib/automate/helpers/functions'
|
||||
|
||||
const props = defineProps<{
|
||||
model: FunctionDetailsFormValues
|
||||
fnId: string
|
||||
}>()
|
||||
const open = defineModel<boolean>('open', { required: true })
|
||||
const { handleSubmit, setValues } = useForm<FunctionDetailsFormValues>()
|
||||
const mutationLoading = useMutationLoading()
|
||||
const updateFunction = useUpdateAutomateFunction()
|
||||
|
||||
const buttons = computed((): LayoutDialogButton[] => [
|
||||
{
|
||||
text: 'Cancel',
|
||||
props: {
|
||||
color: 'secondary',
|
||||
textColor: 'primary'
|
||||
},
|
||||
onClick: () => (open.value = false)
|
||||
},
|
||||
{
|
||||
text: 'Save',
|
||||
submit: true,
|
||||
disabled: mutationLoading.value
|
||||
}
|
||||
])
|
||||
|
||||
const onSubmit = handleSubmit(async (values) => {
|
||||
const res = await updateFunction({
|
||||
input: {
|
||||
id: props.fnId,
|
||||
name: values.name !== props.model.name ? values.name : null,
|
||||
description:
|
||||
values.description !== props.model.description ? values.description : null,
|
||||
logo: values.image !== props.model.image ? values.image : null,
|
||||
tags: difference(values.tags, props.model.tags || []).length ? values.tags : null,
|
||||
supportedSourceApps: differenceBy(
|
||||
values.allowedSourceApps,
|
||||
props.model.allowedSourceApps || [],
|
||||
(i) => i.name
|
||||
)
|
||||
? (values.allowedSourceApps || []).map((a) => a.name)
|
||||
: null
|
||||
}
|
||||
})
|
||||
|
||||
if (res?.id) {
|
||||
open.value = false
|
||||
}
|
||||
})
|
||||
|
||||
const reset = () => {
|
||||
setValues(props.model)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.model,
|
||||
() => {
|
||||
reset()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(open, (newVal, oldVal) => {
|
||||
if (newVal && !oldVal) {
|
||||
reset()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<div :class="classes">
|
||||
<img v-if="finalLogo" :src="finalLogo" alt="Function logo" class="h-10 w-10" />
|
||||
<span v-else :class="fallbackIconClasses">λ</span>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import type { MaybeNullOrUndefined } from '@speckle/shared'
|
||||
import { cleanFunctionLogo } from '~/lib/automate/helpers/functions'
|
||||
|
||||
type Size = 'base' | 'xs'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
logo?: MaybeNullOrUndefined<string>
|
||||
size?: Size
|
||||
}>(),
|
||||
{
|
||||
size: 'base'
|
||||
}
|
||||
)
|
||||
|
||||
const finalLogo = computed(() => cleanFunctionLogo(props.logo))
|
||||
const classes = computed(() => {
|
||||
const classParts = [
|
||||
'bg-foundation-focus text-primary font-bold rounded-full shrink-0 flex justify-center text-center items-center overflow-hidden select-none'
|
||||
]
|
||||
|
||||
switch (props.size) {
|
||||
case 'xs':
|
||||
classParts.push('h-4 w-4')
|
||||
break
|
||||
case 'base':
|
||||
default:
|
||||
classParts.push('h-10 w-10')
|
||||
break
|
||||
}
|
||||
|
||||
return classParts.join(' ')
|
||||
})
|
||||
|
||||
const fallbackIconClasses = computed(() => {
|
||||
const classParts: string[] = []
|
||||
|
||||
switch (props.size) {
|
||||
case 'xs':
|
||||
classParts.push('text-xs')
|
||||
break
|
||||
}
|
||||
|
||||
return classParts.join(' ')
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<div class="text-foreground text-normal">
|
||||
You need to authorize access to the Speckle Automate GitHub app first. This will
|
||||
enable us to automatically set up your function and its associated resources
|
||||
(repositories e.g.).
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,96 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-6 mb-4">
|
||||
<div class="flex flex-col gap-2 sm:gap-0 sm:flex-row w-full">
|
||||
<UserAvatarEditable
|
||||
v-model:edit-mode="avatarEditMode"
|
||||
name="image"
|
||||
placeholder="F N"
|
||||
size="xxl"
|
||||
class="sm:w-5/12"
|
||||
@update:model-value="avatarEditMode = false"
|
||||
/>
|
||||
<div class="sm:w-7/12">
|
||||
<FormTextInput
|
||||
size="lg"
|
||||
name="name"
|
||||
label="Name"
|
||||
placeholder="Function Name"
|
||||
help="This will be used as the function's display name and also as the name of the Git repository."
|
||||
show-label
|
||||
show-required
|
||||
:rules="nameRules"
|
||||
validate-on-value-update
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<FormMarkdownEditor
|
||||
name="description"
|
||||
label="Description"
|
||||
show-label
|
||||
show-required
|
||||
:rules="descriptionRules"
|
||||
validate-on-value-update
|
||||
/>
|
||||
<FormSelectSourceApps
|
||||
name="allowedSourceApps"
|
||||
label="Supported source apps"
|
||||
show-label
|
||||
multiple
|
||||
help="Versions submitted from these apps will support this function. If left empty, all apps will be supported."
|
||||
clearable
|
||||
button-style="tinted"
|
||||
validate-on-value-update
|
||||
/>
|
||||
<FormTags
|
||||
name="tags"
|
||||
label="Tags"
|
||||
show-label
|
||||
show-clear
|
||||
help="Appropriate tags will help other people find your function."
|
||||
validate-on-value-update
|
||||
/>
|
||||
<FormSelectBase
|
||||
v-if="githubOrgs?.length"
|
||||
name="org"
|
||||
label="Organization"
|
||||
show-label
|
||||
allow-unset
|
||||
button-style="tinted"
|
||||
clearable
|
||||
placeholder="Choose a GitHub organization (optional)"
|
||||
help="Choose an organization to publish your Git repository to. If left empty, it will be published to your personal account."
|
||||
:items="githubOrgs"
|
||||
mount-menu-on-body
|
||||
validate-on-value-update
|
||||
>
|
||||
<template #something-selected="{ value }">
|
||||
<div class="label label--light">
|
||||
{{ isArray(value) ? value[0] : value }}
|
||||
</div>
|
||||
</template>
|
||||
<template #option="{ item, selected }">
|
||||
<div class="flex flex-col">
|
||||
<div :class="['label label--light', selected ? 'text-primary' : '']">
|
||||
{{ item }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</FormSelectBase>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ValidationHelpers } from '@speckle/ui-components'
|
||||
import { isArray } from 'lodash-es'
|
||||
|
||||
defineProps<{
|
||||
githubOrgs?: string[]
|
||||
}>()
|
||||
|
||||
const avatarEditMode = ref(false)
|
||||
|
||||
const nameRules = computed(() => [
|
||||
ValidationHelpers.isRequired,
|
||||
ValidationHelpers.isStringOfLength({ maxLength: 150 })
|
||||
])
|
||||
const descriptionRules = computed(() => [ValidationHelpers.isRequired])
|
||||
</script>
|
||||
@@ -0,0 +1,79 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="flex items-center justify-center gap-0.5">
|
||||
<CheckCircleIcon class="h-9 w-9 text-success" />
|
||||
<span class="h4 font-bold">Success</span>
|
||||
</div>
|
||||
<div class="label-light">
|
||||
Your function is ready to go!
|
||||
<br />
|
||||
<CommonTextLink
|
||||
:icon-right="ArrowTopRightOnSquareIcon"
|
||||
size="sm"
|
||||
external
|
||||
:to="repoLink"
|
||||
target="_blank"
|
||||
>
|
||||
Go to the repository
|
||||
</CommonTextLink>
|
||||
or
|
||||
<CommonTextLink
|
||||
:icon-right="ArrowTopRightOnSquareIcon"
|
||||
size="sm"
|
||||
external
|
||||
:to="repoCodespaceLink"
|
||||
target="_blank"
|
||||
>
|
||||
edit in Codespace
|
||||
</CommonTextLink>
|
||||
</div>
|
||||
<CommonCodeOutput :rows="7" :content="cloneInstructions" />
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import type { AutomateFunctionCreateDialogDoneStep_AutomateFunctionFragment } from '~~/lib/common/generated/gql/graphql'
|
||||
import { CheckCircleIcon } from '@heroicons/vue/24/solid'
|
||||
import { ArrowTopRightOnSquareIcon } from '@heroicons/vue/24/outline'
|
||||
import { graphql } from '~/lib/common/generated/gql'
|
||||
import {
|
||||
buildGithubRepoHttpCloneUrl,
|
||||
buildGithubRepoSshUrl
|
||||
} from '~/lib/common/helpers/github'
|
||||
|
||||
graphql(`
|
||||
fragment AutomateFunctionCreateDialogDoneStep_AutomateFunction on AutomateFunction {
|
||||
id
|
||||
repo {
|
||||
id
|
||||
url
|
||||
owner
|
||||
name
|
||||
}
|
||||
...AutomationsFunctionsCard_AutomateFunction
|
||||
}
|
||||
`)
|
||||
|
||||
const props = defineProps<{
|
||||
createdFunction: AutomateFunctionCreateDialogDoneStep_AutomateFunctionFragment
|
||||
}>()
|
||||
|
||||
const repoCodespaceLink = computed(() => {
|
||||
const { owner, name } = props.createdFunction.repo
|
||||
return `https://codespaces.new/${owner}/${name}`
|
||||
})
|
||||
|
||||
const repoLink = computed(() => props.createdFunction.repo.url)
|
||||
|
||||
const cloneInstructions = computed(() => {
|
||||
const repo = props.createdFunction.repo
|
||||
|
||||
const htmlUrl = buildGithubRepoHttpCloneUrl(repo)
|
||||
const sshUrl = buildGithubRepoSshUrl(repo)
|
||||
|
||||
return `# Clone the repository using SSH (recommended)
|
||||
git clone ${sshUrl}
|
||||
|
||||
# Or using HTTPS
|
||||
git clone ${htmlUrl}`
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,36 @@
|
||||
<template>
|
||||
<LayoutPanel
|
||||
ring
|
||||
class="cursor-pointer"
|
||||
:panel-classes="selected ? 'ring-2' : ''"
|
||||
@click="$emit('click', $event)"
|
||||
>
|
||||
<div class="flex space-x-4">
|
||||
<div
|
||||
class="w-1/3 bg-center bg-no-repeat bg-contain aspect-square max-w-[100px]"
|
||||
:style="{ backgroundImage: `url(${template.logo})` }"
|
||||
/>
|
||||
<div class="flex space-x-1 justify-start items-center self-start">
|
||||
<div class="h6 font-bold text-foreground">{{ template.title }}</div>
|
||||
<div v-tippy="'Click to read more'">
|
||||
<CommonTextLink size="xs" external :to="template.url" target="_blank">
|
||||
<InformationCircleIcon class="h-4 w-4 hover:text-primary" />
|
||||
</CommonTextLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</LayoutPanel>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { InformationCircleIcon } from '@heroicons/vue/24/outline'
|
||||
import type { CreatableFunctionTemplate } from '~/lib/automate/helpers/functions'
|
||||
|
||||
defineEmits<{
|
||||
(e: 'click', val: MouseEvent): void
|
||||
}>()
|
||||
|
||||
defineProps<{
|
||||
template: CreatableFunctionTemplate
|
||||
selected?: boolean
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<AutomateFunctionCreateDialogTemplateCard
|
||||
v-for="t in templates"
|
||||
:key="t.url"
|
||||
:template="t"
|
||||
:selected="selectedTemplate?.url === t.url"
|
||||
@click="selectedTemplate = t"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import type { CreatableFunctionTemplate } from '~/lib/automate/helpers/functions'
|
||||
import { graphql } from '~/lib/common/generated/gql'
|
||||
|
||||
graphql(`
|
||||
fragment AutomateFunctionCreateDialogTemplateStep_AutomateFunctionTemplate on AutomateFunctionTemplate {
|
||||
id
|
||||
title
|
||||
logo
|
||||
url
|
||||
}
|
||||
`)
|
||||
|
||||
defineProps<{
|
||||
templates: CreatableFunctionTemplate[]
|
||||
}>()
|
||||
|
||||
const selectedTemplate = defineModel<CreatableFunctionTemplate>('selectedTemplate')
|
||||
</script>
|
||||
@@ -0,0 +1,64 @@
|
||||
<template>
|
||||
<div class="pt-4 flex gap-4 flex-col sm:flex-row sm:items-center sm:justify-between">
|
||||
<Portal to="navigation">
|
||||
<HeaderNavLink
|
||||
:to="automationFunctionsRoute"
|
||||
:name="'Automate Functions'"
|
||||
></HeaderNavLink>
|
||||
<HeaderNavLink
|
||||
:to="automationFunctionRoute(fn.id)"
|
||||
:name="fn.name"
|
||||
></HeaderNavLink>
|
||||
</Portal>
|
||||
<div class="flex items-center gap-4">
|
||||
<AutomateFunctionLogo :logo="fn.logo" />
|
||||
<h1 class="h3 font-bold">{{ fn.name }}</h1>
|
||||
<FormButton v-if="isOwner" size="sm" text class="mt-1" @click="$emit('edit')">
|
||||
Edit
|
||||
</FormButton>
|
||||
</div>
|
||||
<div class="flex gap-2 shrink-0">
|
||||
<FormButton
|
||||
:icon-left="BoltIcon"
|
||||
class="shrink-0"
|
||||
full-width
|
||||
@click="$emit('createAutomation')"
|
||||
>
|
||||
Use in Automation
|
||||
</FormButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { BoltIcon } from '@heroicons/vue/24/outline'
|
||||
import { graphql } from '~/lib/common/generated/gql'
|
||||
import type { AutomateFunctionPageHeader_FunctionFragment } from '~/lib/common/generated/gql/graphql'
|
||||
import {
|
||||
automationFunctionRoute,
|
||||
automationFunctionsRoute
|
||||
} from '~/lib/common/helpers/route'
|
||||
|
||||
defineEmits<{
|
||||
createAutomation: []
|
||||
edit: []
|
||||
}>()
|
||||
|
||||
graphql(`
|
||||
fragment AutomateFunctionPageHeader_Function on AutomateFunction {
|
||||
id
|
||||
name
|
||||
logo
|
||||
repo {
|
||||
id
|
||||
url
|
||||
owner
|
||||
name
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
defineProps<{
|
||||
fn: AutomateFunctionPageHeader_FunctionFragment
|
||||
isOwner: boolean
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,171 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="grid gap-4 grid-cols-1 sm:grid-cols-2">
|
||||
<AutomateFunctionPageInfoBlock :icon="CodeBracketIcon" title="Source">
|
||||
<div class="space-y-1">
|
||||
<CommonTextLink
|
||||
v-tippy="license"
|
||||
external
|
||||
:to="repoUrl"
|
||||
target="_blank"
|
||||
:icon-right="ArrowTopRightOnSquareIcon"
|
||||
class="max-w-full"
|
||||
>
|
||||
<span class="truncate">{{ repo }}</span>
|
||||
</CommonTextLink>
|
||||
<div v-if="githubDetails" class="flex items-center space-x-1">
|
||||
<span>by</span>
|
||||
<CommonTextLink
|
||||
external
|
||||
:to="githubDetails.owner.html_url"
|
||||
target="_blank"
|
||||
:icon-right="ArrowTopRightOnSquareIcon"
|
||||
class="max-w-full"
|
||||
>
|
||||
<span class="truncate">
|
||||
{{ githubDetails.owner.login }}
|
||||
</span>
|
||||
<img
|
||||
:src="githubDetails.owner.avatar_url"
|
||||
alt="Github account icon"
|
||||
class="ml-1 w-6 h-6 rounded-full"
|
||||
/>
|
||||
</CommonTextLink>
|
||||
</div>
|
||||
</div>
|
||||
</AutomateFunctionPageInfoBlock>
|
||||
<AutomateFunctionPageInfoBlock :icon="InformationCircleIcon" title="Info">
|
||||
<div class="space-y-3">
|
||||
<div v-if="latestRelease">
|
||||
<span>Last published: </span>
|
||||
<CommonText class="font-bold" :text="publishedAt" />
|
||||
</div>
|
||||
<div>
|
||||
<span>Used by: </span>
|
||||
<CommonText class="font-bold" :text="`${fn.automationCount} automations`" />
|
||||
</div>
|
||||
<CommonTextLink
|
||||
v-if="latestRelease?.inputSchema"
|
||||
:icon-right="ArrowTopRightOnSquareIcon"
|
||||
@click="onViewParameters"
|
||||
>
|
||||
View Parameters
|
||||
</CommonTextLink>
|
||||
</div>
|
||||
</AutomateFunctionPageInfoBlock>
|
||||
</div>
|
||||
<AutomateFunctionPageInfoBlock
|
||||
title="Description"
|
||||
:icon="ChatBubbleBottomCenterTextIcon"
|
||||
>
|
||||
<CommonProseMarkdownDescription :markdown="description" />
|
||||
</AutomateFunctionPageInfoBlock>
|
||||
<AutomateFunctionPageInfoBlock title="Readme" :icon="BookOpenIcon">
|
||||
<CommonProseGithubReadme
|
||||
:readme-markdown="rawReadme || ''"
|
||||
:repo="repo || ''"
|
||||
:commit-id="selectedReleaseCommitId"
|
||||
/>
|
||||
</AutomateFunctionPageInfoBlock>
|
||||
<div
|
||||
class="mt-6 flex flex-col gap-2 sm:flex-row sm:justify-between sm:items-center"
|
||||
>
|
||||
<div>
|
||||
<div class="font-bold mb-2">Ready to go?</div>
|
||||
<div class="label-light text-foreground-2">
|
||||
Use this function to create an automation on your project.
|
||||
</div>
|
||||
</div>
|
||||
<FormButton
|
||||
:icon-left="BoltIcon"
|
||||
class="shrink-0"
|
||||
@click="$emit('createAutomation')"
|
||||
>
|
||||
Use in Automation
|
||||
</FormButton>
|
||||
</div>
|
||||
<AutomateFunctionPageParametersDialog
|
||||
v-if="latestRelease"
|
||||
v-model:open="showParamsDialog"
|
||||
:release="latestRelease"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
CodeBracketIcon,
|
||||
InformationCircleIcon,
|
||||
ArrowTopRightOnSquareIcon,
|
||||
BookOpenIcon,
|
||||
BoltIcon,
|
||||
ChatBubbleBottomCenterTextIcon
|
||||
} from '@heroicons/vue/24/outline'
|
||||
import dayjs from 'dayjs'
|
||||
import {
|
||||
useGetGithubRepo,
|
||||
useGetRawGithubReadme,
|
||||
useResolveGitHubRepoFromUrl
|
||||
} from '~/lib/automate/composables/github'
|
||||
import { graphql } from '~/lib/common/generated/gql'
|
||||
import type { AutomateFunctionPageInfo_AutomateFunctionFragment } from '~/lib/common/generated/gql/graphql'
|
||||
|
||||
// TODO: Responsivity everywhere
|
||||
|
||||
graphql(`
|
||||
fragment AutomateFunctionPageInfo_AutomateFunction on AutomateFunction {
|
||||
id
|
||||
repo {
|
||||
id
|
||||
url
|
||||
owner
|
||||
name
|
||||
}
|
||||
automationCount
|
||||
description
|
||||
releases(limit: 1) {
|
||||
items {
|
||||
id
|
||||
inputSchema
|
||||
createdAt
|
||||
commitId
|
||||
...AutomateFunctionPageParametersDialog_AutomateFunctionRelease
|
||||
}
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
defineEmits<{
|
||||
createAutomation: []
|
||||
}>()
|
||||
|
||||
const props = defineProps<{
|
||||
fn: AutomateFunctionPageInfo_AutomateFunctionFragment
|
||||
}>()
|
||||
|
||||
const repoUrl = computed(() => props.fn.repo.url)
|
||||
const latestRelease = computed(() =>
|
||||
props.fn.releases.items.length ? props.fn.releases.items[0] : undefined
|
||||
)
|
||||
const selectedReleaseCommitId = computed(() => latestRelease.value?.commitId)
|
||||
|
||||
const { repo } = useResolveGitHubRepoFromUrl(repoUrl)
|
||||
const { data: githubDetails } = useGetGithubRepo(computed(() => repo.value || ''))
|
||||
const { data: rawReadme } = useGetRawGithubReadme(
|
||||
computed(() => repo.value || ''),
|
||||
selectedReleaseCommitId
|
||||
)
|
||||
|
||||
const showParamsDialog = ref(false)
|
||||
|
||||
const license = computed(() => githubDetails.value?.license?.name)
|
||||
|
||||
const publishedAt = computed(() => dayjs(latestRelease.value?.createdAt).from(dayjs()))
|
||||
const description = computed(() =>
|
||||
props.fn.description?.length ? props.fn.description : 'No description provided.'
|
||||
)
|
||||
|
||||
const onViewParameters = () => {
|
||||
if (!latestRelease.value) return
|
||||
showParamsDialog.value = true
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,21 @@
|
||||
<template>
|
||||
<div
|
||||
class="bg-foundation basis-1/2 shrink-0 grow-0 border border-outline-3 rounded-lg"
|
||||
>
|
||||
<div class="flex items-center gap-2 px-4 py-3 border-b border-outline-3 mb-4">
|
||||
<Component :is="icon" class="w-5 h-5" />
|
||||
<h3 class="h5 font-bold">{{ title }}</h3>
|
||||
</div>
|
||||
<div class="px-4 pb-4">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import type { PropAnyComponent } from '@speckle/ui-components'
|
||||
|
||||
defineProps<{
|
||||
icon: PropAnyComponent
|
||||
title: string
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,49 @@
|
||||
<template>
|
||||
<LayoutDialog v-model:open="open" max-width="md" title="Function Parameters">
|
||||
<div class="flex flex-col space-y-4">
|
||||
<template v-if="finalParams">
|
||||
<FormJsonForm :schema="finalParams" />
|
||||
<LayoutDialogSection
|
||||
title="Parameter Schema"
|
||||
:icon="BeakerIcon"
|
||||
border-t
|
||||
border-b
|
||||
>
|
||||
<FormTextArea
|
||||
name="actionYaml"
|
||||
readonly
|
||||
:model-value="JSON.stringify(finalParams, null, 2)"
|
||||
class="text-sm text-primary"
|
||||
rows="15"
|
||||
/>
|
||||
</LayoutDialogSection>
|
||||
</template>
|
||||
<CommonAlert v-else color="info">
|
||||
<template #title>
|
||||
No parameters defined for the selected function release
|
||||
</template>
|
||||
</CommonAlert>
|
||||
</div>
|
||||
</LayoutDialog>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { BeakerIcon } from '@heroicons/vue/24/outline'
|
||||
import { LayoutDialogSection } from '@speckle/ui-components'
|
||||
import { formatVersionParams } from '~/lib/automate/helpers/jsonSchema'
|
||||
import { graphql } from '~/lib/common/generated/gql'
|
||||
import type { AutomateFunctionPageParametersDialog_AutomateFunctionReleaseFragment } from '~/lib/common/generated/gql/graphql'
|
||||
|
||||
graphql(`
|
||||
fragment AutomateFunctionPageParametersDialog_AutomateFunctionRelease on AutomateFunctionRelease {
|
||||
id
|
||||
inputSchema
|
||||
}
|
||||
`)
|
||||
|
||||
const props = defineProps<{
|
||||
release: AutomateFunctionPageParametersDialog_AutomateFunctionReleaseFragment
|
||||
}>()
|
||||
|
||||
const open = defineModel<boolean>('open', { required: true })
|
||||
const finalParams = computed(() => formatVersionParams(props.release.inputSchema))
|
||||
</script>
|
||||
@@ -0,0 +1,126 @@
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
class="pt-4 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<Portal to="navigation">
|
||||
<HeaderNavLink
|
||||
:to="automationFunctionsRoute"
|
||||
:name="'Automate Functions'"
|
||||
></HeaderNavLink>
|
||||
</Portal>
|
||||
|
||||
<h1 class="h3 font-bold">Automate Functions</h1>
|
||||
<div class="flex flex-col sm:flex-row gap-2">
|
||||
<FormTextInput
|
||||
name="search"
|
||||
placeholder="Search Functions"
|
||||
show-clear
|
||||
:model-value="bind.modelValue.value"
|
||||
color="foundation"
|
||||
v-on="on"
|
||||
/>
|
||||
<FormButton
|
||||
v-if="canCreateFunction"
|
||||
:icon-left="PlusIcon"
|
||||
@click="() => (createDialogOpen = true)"
|
||||
>
|
||||
New Function
|
||||
</FormButton>
|
||||
</div>
|
||||
</div>
|
||||
<AutomateFunctionCreateDialog
|
||||
v-model:open="createDialogOpen"
|
||||
:is-authorized="!!activeUser?.automateInfo.hasAutomateGithubApp"
|
||||
:github-orgs="activeUser?.automateInfo.availableGithubOrgs || []"
|
||||
:templates="availableTemplates"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { PlusIcon } from '@heroicons/vue/24/outline'
|
||||
import type { Nullable, Optional } from '@speckle/shared'
|
||||
import { useDebouncedTextInput } from '@speckle/ui-components'
|
||||
import { graphql } from '~/lib/common/generated/gql'
|
||||
import type { AutomateFunctionsPageHeader_QueryFragment } from '~/lib/common/generated/gql/graphql'
|
||||
import { automationFunctionsRoute } from '~/lib/common/helpers/route'
|
||||
|
||||
graphql(`
|
||||
fragment AutomateFunctionsPageHeader_Query on Query {
|
||||
activeUser {
|
||||
id
|
||||
automateInfo {
|
||||
hasAutomateGithubApp
|
||||
availableGithubOrgs
|
||||
}
|
||||
}
|
||||
serverInfo {
|
||||
automate {
|
||||
availableFunctionTemplates {
|
||||
...AutomateFunctionCreateDialogTemplateStep_AutomateFunctionTemplate
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
const props = defineProps<{
|
||||
activeUser: Optional<AutomateFunctionsPageHeader_QueryFragment['activeUser']>
|
||||
serverInfo: Optional<AutomateFunctionsPageHeader_QueryFragment['serverInfo']>
|
||||
}>()
|
||||
const {
|
||||
public: { automateGhClientId }
|
||||
} = useRuntimeConfig()
|
||||
const search = defineModel<string>('search')
|
||||
const { on, bind } = useDebouncedTextInput({ model: search })
|
||||
const { triggerNotification } = useGlobalToast()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const createDialogOpen = ref(false)
|
||||
|
||||
const availableTemplates = computed(
|
||||
() => props.serverInfo?.automate.availableFunctionTemplates || []
|
||||
)
|
||||
const canCreateFunction = computed(
|
||||
() =>
|
||||
!!props.activeUser?.id &&
|
||||
!!automateGhClientId.length &&
|
||||
!!availableTemplates.value.length
|
||||
)
|
||||
|
||||
if (process.client) {
|
||||
watch(
|
||||
() => route.query['ghAuth'] as Nullable<string>,
|
||||
(ghAuthVal) => {
|
||||
if (!ghAuthVal?.length) return
|
||||
|
||||
if (ghAuthVal === 'success') {
|
||||
triggerNotification({
|
||||
type: ToastNotificationType.Success,
|
||||
title: 'GitHub authorization successful'
|
||||
})
|
||||
createDialogOpen.value = true
|
||||
} else if (ghAuthVal === 'access_denied') {
|
||||
triggerNotification({
|
||||
type: ToastNotificationType.Danger,
|
||||
title: 'GitHub authorization failed',
|
||||
description:
|
||||
"You've explicitly denied access to your GitHub account. Please try again."
|
||||
})
|
||||
} else {
|
||||
triggerNotification({
|
||||
type: ToastNotificationType.Danger,
|
||||
title: 'GitHub authorization failed',
|
||||
description:
|
||||
(route.query['ghAuthDesc'] as Nullable<string>) ||
|
||||
'An unknown issue occurred'
|
||||
})
|
||||
}
|
||||
|
||||
void router.replace({ query: {} })
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,47 @@
|
||||
<template>
|
||||
<div>
|
||||
<AutomateFunctionCardView v-if="fns.length">
|
||||
<AutomateFunctionCard
|
||||
v-for="fn in fns"
|
||||
:key="fn.id"
|
||||
:fn="fn"
|
||||
@use="() => $emit('createAutomationFrom', fn)"
|
||||
/>
|
||||
</AutomateFunctionCardView>
|
||||
<CommonGenericEmptyState
|
||||
v-else
|
||||
:search="!!search"
|
||||
@clear-search="$emit('clearSearch')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { graphql } from '~/lib/common/generated/gql'
|
||||
import type { AutomateFunctionsPageItems_QueryFragment } from '~/lib/common/generated/gql/graphql'
|
||||
import type { CreateAutomationSelectableFunction } from '~/lib/automate/helpers/automations'
|
||||
|
||||
// TODO: Pagination
|
||||
|
||||
defineEmits<{
|
||||
createAutomationFrom: [fn: CreateAutomationSelectableFunction]
|
||||
clearSearch: []
|
||||
}>()
|
||||
|
||||
graphql(`
|
||||
fragment AutomateFunctionsPageItems_Query on Query {
|
||||
automateFunctions(limit: 21, filter: { search: $search }) {
|
||||
items {
|
||||
...AutomationsFunctionsCard_AutomateFunction
|
||||
...AutomateAutomationCreateDialog_AutomateFunction
|
||||
}
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
const props = defineProps<{
|
||||
functions?: AutomateFunctionsPageItems_QueryFragment
|
||||
search?: boolean
|
||||
}>()
|
||||
|
||||
const fns = computed(() => props.functions?.automateFunctions.items || [])
|
||||
</script>
|
||||
+7
-8
@@ -9,9 +9,8 @@
|
||||
<script setup lang="ts">
|
||||
import { PaperClipIcon } from '@heroicons/vue/20/solid'
|
||||
import { useQuery } from '@vue/apollo-composable'
|
||||
import { blobInfoQuery } from '~~/lib/projects/graphql/queries'
|
||||
import { projectBlobInfoQuery } from '~~/lib/projects/graphql/queries'
|
||||
import { useFileDownload } from '~~/lib/core/composables/fileUpload'
|
||||
import { ToastNotificationType, useGlobalToast } from '~~/lib/common/composables/toast'
|
||||
import { ensureError } from '@speckle/shared'
|
||||
|
||||
const { download } = useFileDownload()
|
||||
@@ -21,23 +20,23 @@ const props = withDefaults(
|
||||
defineProps<{
|
||||
blobId: string
|
||||
projectId: string
|
||||
restrictWidth: boolean
|
||||
restrictWidth?: boolean
|
||||
}>(),
|
||||
{
|
||||
restrictWidth: true
|
||||
}
|
||||
)
|
||||
|
||||
const { result } = useQuery(blobInfoQuery, () => ({
|
||||
streamId: props.projectId,
|
||||
const { result } = useQuery(projectBlobInfoQuery, () => ({
|
||||
projectId: props.projectId,
|
||||
blobId: props.blobId
|
||||
}))
|
||||
|
||||
const fileInfo = computed(() => {
|
||||
return {
|
||||
name: result.value?.stream?.blob?.fileName,
|
||||
type: result.value?.stream?.blob?.fileType,
|
||||
size: result.value?.stream?.blob?.fileSize
|
||||
name: result.value?.project?.blob?.fileName,
|
||||
type: result.value?.project?.blob?.fileType,
|
||||
size: result.value?.project?.blob?.fileSize
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
<template>
|
||||
<CommonBadge :color-classes="[runStatusClasses(run), 'shrink-0 grow-0'].join(' ')">
|
||||
{{ run.status.toUpperCase() }}
|
||||
</CommonBadge>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { useAutomationRunDetailsFns } from '~/lib/automate/composables/runs'
|
||||
import type { AutomationRunDetailsFragment } from '~/lib/common/generated/gql/graphql'
|
||||
|
||||
defineProps<{
|
||||
run: AutomationRunDetailsFragment
|
||||
}>()
|
||||
|
||||
const { runStatusClasses } = useAutomationRunDetailsFns()
|
||||
</script>
|
||||
@@ -0,0 +1,75 @@
|
||||
<template>
|
||||
<div>
|
||||
<LayoutTable
|
||||
:columns="[
|
||||
{ id: 'status', header: 'status', classes: 'col-span-2' },
|
||||
{ id: 'runId', header: 'Run ID', classes: 'col-span-3' },
|
||||
{ id: 'modelVersion', header: 'Model Version', classes: 'col-span-2' },
|
||||
{ id: 'date', header: 'Date', classes: 'col-span-2' },
|
||||
{ id: 'duration', header: 'Duration', classes: 'col-span-3' }
|
||||
]"
|
||||
:items="runs"
|
||||
:buttons="[
|
||||
{
|
||||
icon: EyeIcon,
|
||||
label: 'View',
|
||||
action: onView,
|
||||
textColor: 'primary'
|
||||
}
|
||||
]"
|
||||
empty-message="Automation does not have any runs"
|
||||
>
|
||||
<template #status="{ item }">
|
||||
<AutomateRunsStatusBadge :run="item" />
|
||||
</template>
|
||||
<template #runId="{ item }">
|
||||
<span class="text-foreground label-light">{{ item.id }}</span>
|
||||
</template>
|
||||
<template #modelVersion="{ item }">
|
||||
<CommonTextLink
|
||||
:to="
|
||||
runModelVersionUrl({
|
||||
run: item,
|
||||
projectId
|
||||
})
|
||||
"
|
||||
>
|
||||
{{ item.trigger.version.id }}
|
||||
</CommonTextLink>
|
||||
</template>
|
||||
<template #date="{ item }">
|
||||
<span class="caption">{{ runDate(item) }}</span>
|
||||
</template>
|
||||
<template #duration="{ item }">
|
||||
<span class="caption">{{ runDuration(item) }}</span>
|
||||
</template>
|
||||
</LayoutTable>
|
||||
<ProjectPageAutomationsRunDialog
|
||||
v-model:open="runInfoOpen"
|
||||
:run="openedRun"
|
||||
:automation-id="automationId"
|
||||
:project-id="projectId"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { EyeIcon } from '@heroicons/vue/24/outline'
|
||||
import { useAutomationRunDetailsFns } from '~/lib/automate/composables/runs'
|
||||
import type { AutomationRunDetailsFragment } from '~/lib/common/generated/gql/graphql'
|
||||
|
||||
defineProps<{
|
||||
projectId: string
|
||||
automationId: string
|
||||
runs: AutomationRunDetailsFragment[]
|
||||
}>()
|
||||
|
||||
const { runDate, runDuration, runModelVersionUrl } = useAutomationRunDetailsFns()
|
||||
|
||||
const openedRun = ref<AutomationRunDetailsFragment>()
|
||||
const runInfoOpen = ref(false)
|
||||
|
||||
const onView = (run: AutomationRunDetailsFragment) => {
|
||||
openedRun.value = run
|
||||
runInfoOpen.value = true
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,52 @@
|
||||
<template>
|
||||
<div v-if="status" v-tippy="summary.longSummary" @click.stop.prevent @mousemove.stop>
|
||||
<button
|
||||
class="rounded-full flex items-center justify-center outline-none"
|
||||
@click="onClick"
|
||||
>
|
||||
<AutomateRunsTriggerStatusIcon :summary="summary" class="h-6 w-6 m-3" />
|
||||
</button>
|
||||
<AutomateRunsTriggerStatusDialog
|
||||
v-model:open="showDialog"
|
||||
:status="status"
|
||||
:summary="summary"
|
||||
:project-id="projectId"
|
||||
:model-id="modelId"
|
||||
:version-id="versionId"
|
||||
/>
|
||||
</div>
|
||||
<div v-else />
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import type { MaybeNullOrUndefined } from '@speckle/shared'
|
||||
import { useAutomationsStatusRunsSummary } from '~/lib/automate/composables/runStatus'
|
||||
import { graphql } from '~/lib/common/generated/gql'
|
||||
import type { AutomateRunsTriggerStatus_TriggeredAutomationsStatusFragment } from '~/lib/common/generated/gql/graphql'
|
||||
|
||||
// TODO: Subscriptions?
|
||||
|
||||
graphql(`
|
||||
fragment AutomateRunsTriggerStatus_TriggeredAutomationsStatus on TriggeredAutomationsStatus {
|
||||
id
|
||||
...TriggeredAutomationsStatusSummary
|
||||
...AutomateRunsTriggerStatusDialog_TriggeredAutomationsStatus
|
||||
}
|
||||
`)
|
||||
|
||||
const props = defineProps<{
|
||||
status: MaybeNullOrUndefined<AutomateRunsTriggerStatus_TriggeredAutomationsStatusFragment>
|
||||
projectId: string
|
||||
modelId: string
|
||||
versionId?: string
|
||||
}>()
|
||||
|
||||
const { summary } = useAutomationsStatusRunsSummary({
|
||||
status: computed(() => props.status)
|
||||
})
|
||||
|
||||
const showDialog = ref(false)
|
||||
|
||||
const onClick = () => {
|
||||
showDialog.value = true
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,89 @@
|
||||
<template>
|
||||
<LayoutDialog v-model:open="showDialog" max-width="lg">
|
||||
<template #header>
|
||||
<div class="flex flex-col">
|
||||
<div class="flex items-center space-x-2 max-w-full w-full">
|
||||
<div class="mt-[6px] shrink-0">
|
||||
<AutomateRunsTriggerStatusIcon
|
||||
:summary="summary"
|
||||
class="h-6 w-6 sm:h-10 sm:w-10"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex min-w-0 flex-col gap-1">
|
||||
<h4 :class="[`h6 sm:h5 font-bold whitespace-normal`, summary.titleColor]">
|
||||
{{ summary.title }}
|
||||
</h4>
|
||||
<div class="caption text-foreground-2 whitespace-normal">
|
||||
{{ summary.longSummary }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div>
|
||||
<AutomateRunsTriggerStatusDialogRunsRows
|
||||
:runs="status.automationRuns"
|
||||
:project-id="projectId"
|
||||
:model-id="modelId"
|
||||
:version-id="versionId"
|
||||
/>
|
||||
</div>
|
||||
<template #buttons>
|
||||
<div
|
||||
class="flex flex-col gap-2 items-start sm:gap-0 sm:flex-row sm:items-center sm:justify-between w-full pl-2"
|
||||
>
|
||||
<FormButton
|
||||
text
|
||||
size="xs"
|
||||
target="_blank"
|
||||
external
|
||||
to="https://speckle.systems/blog/automate-with-speckle/"
|
||||
class="order-2 sm:order-1"
|
||||
>
|
||||
Learn more about Automate here!
|
||||
</FormButton>
|
||||
<div
|
||||
class="flex w-full justify-between order-1 sm:order-2 sm:justify-normal sm:w-auto sm:space-x-1"
|
||||
>
|
||||
<FormButton color="secondary" @click="showDialog = false">Close</FormButton>
|
||||
<FormButton :to="viewUrl">
|
||||
Open {{ versionId ? 'Version' : 'Model' }}
|
||||
</FormButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</LayoutDialog>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { SpeckleViewer } from '@speckle/shared'
|
||||
import type { RunsStatusSummary } from '~/lib/automate/composables/runStatus'
|
||||
import { graphql } from '~/lib/common/generated/gql'
|
||||
import type { AutomateRunsTriggerStatusDialog_TriggeredAutomationsStatusFragment } from '~/lib/common/generated/gql/graphql'
|
||||
import { modelRoute } from '~/lib/common/helpers/route'
|
||||
|
||||
graphql(`
|
||||
fragment AutomateRunsTriggerStatusDialog_TriggeredAutomationsStatus on TriggeredAutomationsStatus {
|
||||
id
|
||||
automationRuns {
|
||||
id
|
||||
...AutomateRunsTriggerStatusDialogRunsRows_AutomateRun
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
const props = defineProps<{
|
||||
status: AutomateRunsTriggerStatusDialog_TriggeredAutomationsStatusFragment
|
||||
summary: RunsStatusSummary
|
||||
projectId: string
|
||||
modelId: string
|
||||
versionId?: string
|
||||
}>()
|
||||
|
||||
const showDialog = defineModel<boolean>('open', { required: true })
|
||||
|
||||
const viewUrl = computed(() => {
|
||||
const resourceIdStringBuilder = SpeckleViewer.ViewerRoute.resourceBuilder()
|
||||
resourceIdStringBuilder.addModel(props.modelId, props.versionId)
|
||||
return modelRoute(props.projectId, resourceIdStringBuilder.toString())
|
||||
})
|
||||
</script>
|
||||
+4
-2
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<svg width="120" height="120" viewBox="0 0 120 120" style="height: 95%; width: 95%">
|
||||
<svg width="120" height="120" viewBox="0 0 120 120">
|
||||
<circle cx="60" cy="60" r="40" fill="none" stroke="#e6e6e6" stroke-width="12" />
|
||||
<circle
|
||||
class="base stroke-red-400 origin-center"
|
||||
@@ -34,8 +34,10 @@
|
||||
</svg>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import type { RunsStatusSummary } from '~/lib/automate/composables/runStatus'
|
||||
|
||||
const props = defineProps<{
|
||||
summary: { failed: number; passed: number; inProgress: number; total: number }
|
||||
summary: RunsStatusSummary
|
||||
}>()
|
||||
|
||||
// segment: percentage + offset, where offset = prev percentage in radians
|
||||
@@ -0,0 +1,137 @@
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
class="flex flex-col gap-y-2 sm:flex-row sm:gap-y-0 sm:gap-x-2 sm:items-center my-2 py-1 px-2 border border-blue-500/10 rounded-md sm:h-12"
|
||||
>
|
||||
<div class="flex gap-x-2 items-center truncate">
|
||||
<div>
|
||||
<Component
|
||||
:is="statusMetaData.icon"
|
||||
v-tippy="functionRun.status"
|
||||
:class="['h-5 w-5 outline-none', statusMetaData.iconColor]"
|
||||
/>
|
||||
</div>
|
||||
<AutomateFunctionLogo :logo="functionRun.function.logo" size="xs" />
|
||||
|
||||
<div class="font-bold text-sm truncate">
|
||||
{{ automationName ? automationName + ' / ' : ''
|
||||
}}{{ functionRun.function.name }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col sm:flex-row gap-2 sm:items-center truncate">
|
||||
<div class="sm:truncate">
|
||||
<div
|
||||
v-if="
|
||||
functionRun.status === AutomateRunStatus.Initializing ||
|
||||
functionRun.status === AutomateRunStatus.Running
|
||||
"
|
||||
class="text-sm text-foreground-2 italic whitespace-normal sm:truncate"
|
||||
>
|
||||
Function is {{ functionRun.status.toLowerCase() }}.
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="text-sm text-foreground-2 italic whitespace-normal sm:truncate"
|
||||
>
|
||||
{{ functionRun.statusMessage }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-grow text-right flex-shrink-0 bg-pink-300/0 justify-end space-x-2 items-center"
|
||||
>
|
||||
<div
|
||||
v-if="attachments && attachments.length !== 0"
|
||||
class="flex space-x-1 shrink items-center"
|
||||
>
|
||||
<div v-if="attachments.length === 1">
|
||||
<AutomateRunsAttachmentButton
|
||||
:blob-id="attachments[0]"
|
||||
:project-id="projectId"
|
||||
size="xs"
|
||||
/>
|
||||
</div>
|
||||
<FormButton
|
||||
v-if="attachments.length > 1"
|
||||
size="xs"
|
||||
color="card"
|
||||
class="mt-1"
|
||||
@click="showAttachmentDialog = true"
|
||||
>
|
||||
{{ attachments.length }} attachments
|
||||
</FormButton>
|
||||
<LayoutDialog
|
||||
v-model:open="showAttachmentDialog"
|
||||
:title="`${functionRun.function.name} attachments`"
|
||||
max-width="sm"
|
||||
>
|
||||
<div v-for="id in attachments" :key="id">
|
||||
<AutomateRunsAttachmentButton
|
||||
:blob-id="id"
|
||||
:restrict-width="false"
|
||||
:project-id="projectId"
|
||||
size="xs"
|
||||
/>
|
||||
</div>
|
||||
</LayoutDialog>
|
||||
</div>
|
||||
<div class="flex-shrink-0">
|
||||
<FormButton
|
||||
v-if="functionRun.contextView"
|
||||
size="xs"
|
||||
:to="functionRun.contextView"
|
||||
target="_blank"
|
||||
>
|
||||
View Results
|
||||
</FormButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { useRunStatusMetadata } from '~/lib/automate/composables/runStatus'
|
||||
import { useAutomationFunctionRunResults } from '~/lib/automate/composables/runs'
|
||||
import { graphql } from '~/lib/common/generated/gql'
|
||||
import {
|
||||
AutomateRunStatus,
|
||||
type AutomateRunsTriggerStatusDialogFunctionRun_AutomateFunctionRunFragment
|
||||
} from '~~/lib/common/generated/gql/graphql'
|
||||
|
||||
graphql(`
|
||||
fragment AutomateRunsTriggerStatusDialogFunctionRun_AutomateFunctionRun on AutomateFunctionRun {
|
||||
id
|
||||
results
|
||||
status
|
||||
statusMessage
|
||||
contextView
|
||||
function {
|
||||
id
|
||||
logo
|
||||
name
|
||||
}
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
`)
|
||||
|
||||
const props = defineProps<{
|
||||
functionRun: AutomateRunsTriggerStatusDialogFunctionRun_AutomateFunctionRunFragment
|
||||
automationName?: string
|
||||
projectId: string
|
||||
modelId: string
|
||||
versionId?: string
|
||||
}>()
|
||||
|
||||
const { metadata: statusMetaData } = useRunStatusMetadata({
|
||||
status: computed(() => props.functionRun.status)
|
||||
})
|
||||
const results = useAutomationFunctionRunResults({
|
||||
results: computed(() => props.functionRun.results)
|
||||
})
|
||||
|
||||
const showAttachmentDialog = ref(false)
|
||||
|
||||
const attachments = computed(() => results.value?.values.blobIds || [])
|
||||
</script>
|
||||
@@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<div class="space-y-2">
|
||||
<AutomateRunsTriggerStatusDialogFunctionRun
|
||||
v-for="fRun in runs"
|
||||
:key="fRun.id"
|
||||
:automation-name="fRun.automationName"
|
||||
:function-run="fRun"
|
||||
:project-id="projectId"
|
||||
:model-id="modelId"
|
||||
:version-id="versionId"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { useAutomationsStatusOrderedRuns } from '~/lib/automate/composables/runs'
|
||||
import { graphql } from '~/lib/common/generated/gql'
|
||||
import type { AutomateRunsTriggerStatusDialogRunsRows_AutomateRunFragment } from '~/lib/common/generated/gql/graphql'
|
||||
|
||||
graphql(`
|
||||
fragment AutomateRunsTriggerStatusDialogRunsRows_AutomateRun on AutomateRun {
|
||||
id
|
||||
functionRuns {
|
||||
id
|
||||
...AutomateRunsTriggerStatusDialogFunctionRun_AutomateFunctionRun
|
||||
}
|
||||
...AutomationsStatusOrderedRuns_AutomationRun
|
||||
}
|
||||
`)
|
||||
|
||||
const props = defineProps<{
|
||||
runs: AutomateRunsTriggerStatusDialogRunsRows_AutomateRunFragment[]
|
||||
projectId: string
|
||||
modelId: string
|
||||
versionId?: string
|
||||
}>()
|
||||
|
||||
const { runs } = useAutomationsStatusOrderedRuns({
|
||||
automationRuns: computed(() => props.runs)
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,59 @@
|
||||
<template>
|
||||
<div>
|
||||
<ViewerLayoutPanel @close="$emit('close')">
|
||||
<template #title>Automate</template>
|
||||
|
||||
<div class="flex items-center space-x-2 w-full pl-3 mt-2">
|
||||
<div class="mt-[6px] shrink-0">
|
||||
<AutomateRunsTriggerStatusIcon :summary="summary" class="h-6 w-6" />
|
||||
</div>
|
||||
<div class="flex min-w-0 flex-col gap-1">
|
||||
<h4 :class="[`label font-bold whitespace-normal`, summary.titleColor]">
|
||||
{{ summary.title }}
|
||||
</h4>
|
||||
<div class="caption text-foreground-2 whitespace-normal">
|
||||
{{ summary.longSummary }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative flex flex-col space-y-2 p-2">
|
||||
<AutomateViewerPanelFunctionRunRow
|
||||
v-for="run in runs"
|
||||
:key="run.id"
|
||||
:function-run="run"
|
||||
:automation-name="run.automationName"
|
||||
/>
|
||||
</div>
|
||||
</ViewerLayoutPanel>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { type RunsStatusSummary } from '~/lib/automate/composables/runStatus'
|
||||
import { useAutomationsStatusOrderedRuns } from '~/lib/automate/composables/runs'
|
||||
import { graphql } from '~/lib/common/generated/gql'
|
||||
import type { AutomateViewerPanel_AutomateRunFragment } from '~~/lib/common/generated/gql/graphql'
|
||||
|
||||
// TODO: Subscriptions
|
||||
|
||||
graphql(`
|
||||
fragment AutomateViewerPanel_AutomateRun on AutomateRun {
|
||||
id
|
||||
functionRuns {
|
||||
id
|
||||
...AutomateViewerPanelFunctionRunRow_AutomateFunctionRun
|
||||
}
|
||||
...AutomationsStatusOrderedRuns_AutomationRun
|
||||
}
|
||||
`)
|
||||
|
||||
defineEmits(['close'])
|
||||
|
||||
const props = defineProps<{
|
||||
automationRuns: AutomateViewerPanel_AutomateRunFragment[]
|
||||
summary: RunsStatusSummary
|
||||
}>()
|
||||
|
||||
const { runs } = useAutomationsStatusOrderedRuns({
|
||||
automationRuns: computed(() => props.automationRuns)
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,163 @@
|
||||
<template>
|
||||
<div
|
||||
:class="`border border-blue-500/10 rounded-md space-y-2 overflow-hidden ${
|
||||
expanded ? 'shadow' : ''
|
||||
}`"
|
||||
>
|
||||
<button
|
||||
class="flex space-x-1 items-center max-w-full w-full px-1 py-1 h-8 transition hover:bg-primary-muted"
|
||||
@click="expanded = !expanded"
|
||||
>
|
||||
<div>
|
||||
<Component
|
||||
:is="statusMetaData.icon"
|
||||
v-tippy="functionRun.status"
|
||||
:class="['h-4 w-4 outline-none', statusMetaData.iconColor]"
|
||||
/>
|
||||
</div>
|
||||
<AutomateFunctionLogo :logo="functionRun.function.logo" size="xs" />
|
||||
<div class="font-bold text-xs truncate">
|
||||
{{ automationName ? automationName + ' / ' : ''
|
||||
}}{{ functionRun.function.name }}
|
||||
</div>
|
||||
|
||||
<div class="h-full grow flex justify-end">
|
||||
<button
|
||||
class="hover:bg-primary-muted hover:text-primary flex h-full items-center justify-center rounded"
|
||||
>
|
||||
<ChevronDownIcon
|
||||
:class="`h-3 w-3 transition ${!expanded ? '-rotate-90' : 'rotate-0'}`"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</button>
|
||||
<div v-if="expanded" class="px-2 pb-2 space-y-4">
|
||||
<!-- Status message -->
|
||||
<div class="space-y-1">
|
||||
<div class="text-xs font-bold text-foreground-2">Status</div>
|
||||
<div
|
||||
v-if="
|
||||
functionRun.status === AutomateRunStatus.Initializing ||
|
||||
functionRun.status === AutomateRunStatus.Running
|
||||
"
|
||||
class="text-xs text-foreground-2 italic"
|
||||
>
|
||||
Function is {{ functionRun.status.toLowerCase() }}.
|
||||
</div>
|
||||
<div v-else class="text-xs text-foreground-2 italic">
|
||||
{{ functionRun.statusMessage || 'No status message' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Attachments -->
|
||||
<div
|
||||
v-if="attachments.length !== 0"
|
||||
class="border-t pt-2 border-foreground-2 space-y-1"
|
||||
>
|
||||
<div class="text-xs font-bold text-foreground-2">Attachments</div>
|
||||
<div class="ml-[2px] justify-start">
|
||||
<AutomateRunsAttachmentButton
|
||||
v-for="id in attachments"
|
||||
:key="id"
|
||||
:blob-id="id"
|
||||
:project-id="projectId"
|
||||
size="xs"
|
||||
link
|
||||
class="mr-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Context view -->
|
||||
<div
|
||||
v-if="functionRun.contextView?.length"
|
||||
class="border-t pt-2 border-foreground-2"
|
||||
>
|
||||
<div>
|
||||
<FormButton
|
||||
size="xs"
|
||||
link
|
||||
class="truncate max-w-full"
|
||||
:to="functionRun.contextView"
|
||||
>
|
||||
Open view
|
||||
</FormButton>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Results -->
|
||||
<div
|
||||
v-if="!!results?.values.objectResults.length"
|
||||
class="border-t pt-2 border-foreground-2"
|
||||
>
|
||||
<div class="text-xs font-bold text-foreground-2 mb-2">Results</div>
|
||||
<div class="space-y-1">
|
||||
<AutomateViewerPanelFunctionRunRowObjectResult
|
||||
v-for="(result, index) in results.values.objectResults.slice(
|
||||
0,
|
||||
pageRunLimit
|
||||
)"
|
||||
:key="index"
|
||||
:function-id="functionRun.function.id"
|
||||
:result="result"
|
||||
/>
|
||||
<FormButton
|
||||
v-if="pageRunLimit < results.values.objectResults.length"
|
||||
size="xs"
|
||||
color="card"
|
||||
class="w-full"
|
||||
@click="pageRunLimit += 10"
|
||||
>
|
||||
Load more ({{ results.values.objectResults.length - pageRunLimit }}
|
||||
hidden results)
|
||||
</FormButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ChevronDownIcon } from '@heroicons/vue/24/outline'
|
||||
import { AutomateRunStatus } from '~~/lib/common/generated/gql/graphql'
|
||||
import type { AutomateViewerPanelFunctionRunRow_AutomateFunctionRunFragment } from '~~/lib/common/generated/gql/graphql'
|
||||
import { useInjectedViewerState } from '~~/lib/viewer/composables/setup'
|
||||
import { graphql } from '~/lib/common/generated/gql'
|
||||
import { useAutomationFunctionRunResults } from '~/lib/automate/composables/runs'
|
||||
import { useRunStatusMetadata } from '~/lib/automate/composables/runStatus'
|
||||
|
||||
graphql(`
|
||||
fragment AutomateViewerPanelFunctionRunRow_AutomateFunctionRun on AutomateFunctionRun {
|
||||
id
|
||||
results
|
||||
status
|
||||
statusMessage
|
||||
contextView
|
||||
function {
|
||||
id
|
||||
logo
|
||||
name
|
||||
}
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
`)
|
||||
|
||||
const { projectId } = useInjectedViewerState()
|
||||
|
||||
const props = defineProps<{
|
||||
functionRun: AutomateViewerPanelFunctionRunRow_AutomateFunctionRunFragment
|
||||
automationName: string
|
||||
}>()
|
||||
|
||||
const results = useAutomationFunctionRunResults({
|
||||
results: computed(() => props.functionRun.results)
|
||||
})
|
||||
const { metadata: statusMetaData } = useRunStatusMetadata({
|
||||
status: computed(() => props.functionRun.status)
|
||||
})
|
||||
|
||||
const pageRunLimit = ref(5)
|
||||
const expanded = ref(false)
|
||||
|
||||
const attachments = computed(() =>
|
||||
(results.value?.values.blobIds || []).filter((b) => !!b)
|
||||
)
|
||||
</script>
|
||||
+8
-19
@@ -38,20 +38,12 @@ import { useInjectedViewerState } from '~~/lib/viewer/composables/setup'
|
||||
import { useFilterUtilities, useSelectionUtilities } from '~~/lib/viewer/composables/ui'
|
||||
import type { NumericPropertyInfo } from '@speckle/viewer'
|
||||
import { containsAll } from '~~/lib/common/helpers/utils'
|
||||
import type { Automate } from '@speckle/shared'
|
||||
|
||||
type ObjectResultWithOptionalMetadata = {
|
||||
category: string
|
||||
objectIds: string[]
|
||||
message: string | null
|
||||
level: 'ERROR' | 'WARNING' | 'INFO'
|
||||
metadata?: {
|
||||
gradient?: boolean
|
||||
gradientValues: Record<string, { gradientValue: number }> // TODO simplify convention, it's unweildly
|
||||
}
|
||||
}
|
||||
type ObjectResult = Automate.AutomateTypes.ResultsSchema['values']['objectResults'][0]
|
||||
|
||||
const props = defineProps<{
|
||||
result: ObjectResultWithOptionalMetadata
|
||||
result: ObjectResult
|
||||
functionId: string
|
||||
}>()
|
||||
|
||||
@@ -122,10 +114,10 @@ const computedPropInfo = computed(() => {
|
||||
passMax: 0
|
||||
}
|
||||
|
||||
const keys = Object.keys(props.result.metadata.gradientValues)
|
||||
propInfo.objectCount = keys.length
|
||||
for (const key of keys) {
|
||||
const value = props.result.metadata.gradientValues[key].gradientValue
|
||||
const gradientValues = props.result.metadata.gradientValues || {}
|
||||
propInfo.objectCount = Object.keys(gradientValues).length
|
||||
|
||||
for (const [key, { gradientValue: value }] of Object.entries(gradientValues)) {
|
||||
const valueGroup = {
|
||||
id: key,
|
||||
value
|
||||
@@ -167,14 +159,11 @@ const iconAndColor = computed(() => {
|
||||
color: 'text-warning font-bold'
|
||||
}
|
||||
case 'INFO':
|
||||
default:
|
||||
return {
|
||||
icon: InformationCircleIcon,
|
||||
color: 'text-foreground font-bold'
|
||||
}
|
||||
}
|
||||
return {
|
||||
icon: XMarkIcon,
|
||||
color: 'text-danger font-bold'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -1,209 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
:class="`border border-blue-500/10 rounded-md space-y-2 overflow-hidden ${
|
||||
expanded ? 'shadow' : ''
|
||||
}`"
|
||||
>
|
||||
<button
|
||||
class="flex space-x-1 items-center max-w-full w-full px-1 py-1 h-8 transition hover:bg-primary-muted"
|
||||
@click="expanded = !expanded"
|
||||
>
|
||||
<div>
|
||||
<Component
|
||||
:is="statusMetaData.icon"
|
||||
v-tippy="functionRun.status"
|
||||
:class="['h-4 w-4 outline-none', statusMetaData.iconColor]"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="bg-blue-500/10 text-primary font-bold h-4 w-4 rounded-md shrink-0 flex justify-center text-center items-center overflow-hidden"
|
||||
>
|
||||
<img
|
||||
v-if="functionRun.functionLogo"
|
||||
:src="functionRun.functionLogo"
|
||||
alt="function logo"
|
||||
/>
|
||||
<span v-else class="text-xs">λ</span>
|
||||
</div>
|
||||
|
||||
<div class="font-bold text-xs truncate">
|
||||
{{ automationName ? automationName + ' / ' : '' }}{{ functionRun.functionName }}
|
||||
</div>
|
||||
|
||||
<div class="h-full grow flex justify-end">
|
||||
<button
|
||||
class="hover:bg-primary-muted hover:text-primary flex h-full items-center justify-center rounded"
|
||||
>
|
||||
<ChevronDownIcon
|
||||
:class="`h-3 w-3 transition ${!expanded ? '-rotate-90' : 'rotate-0'}`"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</button>
|
||||
<div v-if="expanded" class="px-2 pb-2 space-y-4">
|
||||
<!-- Status message -->
|
||||
<div class="space-y-1">
|
||||
<div class="text-xs font-bold text-foreground-2">Status</div>
|
||||
<div
|
||||
v-if="
|
||||
functionRun.status === AutomationRunStatus.Initializing ||
|
||||
functionRun.status === AutomationRunStatus.Running
|
||||
"
|
||||
class="text-xs text-foreground-2 italic"
|
||||
>
|
||||
Function is {{ functionRun.status.toLowerCase() }}.
|
||||
</div>
|
||||
<div v-else class="text-xs text-foreground-2 italic">
|
||||
{{ functionRun.statusMessage || 'No status message' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Attachments -->
|
||||
<div
|
||||
v-if="attachments.length !== 0"
|
||||
class="border-t pt-2 border-foreground-2 space-y-1"
|
||||
>
|
||||
<div class="text-xs font-bold text-foreground-2">Attachments</div>
|
||||
<div class="ml-[2px] justify-start">
|
||||
<AutomationAttachmentButton
|
||||
v-for="id in attachments"
|
||||
:key="id"
|
||||
:blob-id="id"
|
||||
:project-id="projectId"
|
||||
size="xs"
|
||||
link
|
||||
class="mr-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- TODO: Overlay result versions -->
|
||||
<div
|
||||
v-if="typedFunctionRun.resultVersions.length !== 0"
|
||||
class="border-t pt-2 border-foreground-2"
|
||||
>
|
||||
<div class="text-xs font-bold text-foreground-2 mb-2">Resulting Models</div>
|
||||
<!-- <div class="text-xs">{{ typedFunctionRun.resultVersions }}</div> -->
|
||||
<div v-for="version in typedFunctionRun.resultVersions" :key="version.id">
|
||||
<FormButton
|
||||
v-if="!hasResource(version)"
|
||||
size="xs"
|
||||
link
|
||||
class="truncate max-w-full"
|
||||
@click="loadResultVersion(version)"
|
||||
>
|
||||
Overlay "{{ version.model.name }}"
|
||||
</FormButton>
|
||||
<FormButton v-else size="xs" link class="truncate max-w-full" disabled>
|
||||
"{{ version.model.name }}" is already overlaid
|
||||
</FormButton>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Results -->
|
||||
<div
|
||||
v-if="
|
||||
typedFunctionRun.results &&
|
||||
typedFunctionRun.results.values &&
|
||||
typedFunctionRun.results.values.objectResults &&
|
||||
typedFunctionRun.results.values.objectResults.length !== 0
|
||||
"
|
||||
class="border-t pt-2 border-foreground-2"
|
||||
>
|
||||
<div class="text-xs font-bold text-foreground-2 mb-2">Results</div>
|
||||
<div class="space-y-1">
|
||||
<AutomationViewerResultRowItem
|
||||
v-for="(
|
||||
result, index
|
||||
) in typedFunctionRun.results.values.objectResults.slice(0, pageRunLimit)"
|
||||
:key="index"
|
||||
:function-id="typedFunctionRun.functionId"
|
||||
:result="result"
|
||||
/>
|
||||
<FormButton
|
||||
v-if="pageRunLimit < typedFunctionRun.results.values.objectResults.length"
|
||||
size="xs"
|
||||
color="card"
|
||||
class="w-full"
|
||||
@click="pageRunLimit += 10"
|
||||
>
|
||||
Load more ({{
|
||||
typedFunctionRun.results.values.objectResults.length - pageRunLimit
|
||||
}}
|
||||
hidden results)
|
||||
</FormButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ChevronDownIcon } from '@heroicons/vue/24/outline'
|
||||
import { AutomationRunStatus } from '~~/lib/common/generated/gql/graphql'
|
||||
import type {
|
||||
AutomationFunctionRun,
|
||||
Version
|
||||
} from '~~/lib/common/generated/gql/graphql'
|
||||
import { SpeckleViewer } from '@speckle/shared'
|
||||
import { resolveStatusMetadata } from '~~/lib/automations/helpers/resolveStatusMetadata'
|
||||
import {
|
||||
useInjectedViewerState,
|
||||
useInjectedViewerRequestedResources
|
||||
} from '~~/lib/viewer/composables/setup'
|
||||
|
||||
const { projectId } = useInjectedViewerState()
|
||||
const { items } = useInjectedViewerRequestedResources()
|
||||
|
||||
type ObjectResult = {
|
||||
category: string
|
||||
objectIds: string[]
|
||||
message: string | null
|
||||
level: 'ERROR' | 'WARNING' | 'INFO'
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
functionRun: AutomationFunctionRun
|
||||
automationName: string
|
||||
}>()
|
||||
|
||||
const pageRunLimit = ref(5)
|
||||
|
||||
const typedFunctionRun = computed(() => {
|
||||
return props.functionRun as AutomationFunctionRun & {
|
||||
results: { values: { blobIds: string[]; objectResults: ObjectResult[] } }
|
||||
}
|
||||
})
|
||||
|
||||
const expanded = ref(false)
|
||||
|
||||
const attachments = computed(() => {
|
||||
if (
|
||||
!typedFunctionRun.value.results ||
|
||||
!typedFunctionRun.value.results.values ||
|
||||
!typedFunctionRun.value.results.values.blobIds
|
||||
)
|
||||
return []
|
||||
return typedFunctionRun.value.results?.values?.blobIds.filter((b) => !!b)
|
||||
})
|
||||
|
||||
const statusMetaData = resolveStatusMetadata(props.functionRun.status)
|
||||
|
||||
const hasResource = (version: Version) => {
|
||||
for (const res of items.value) {
|
||||
const typedRes = res as unknown as { modelId: string; versionId: string }
|
||||
if (typedRes.modelId === version.model.id && typedRes.versionId === version.id)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const loadResultVersion = async (version: Version) => {
|
||||
const modelId = version.model.id
|
||||
const versionId = version.id
|
||||
|
||||
await items.update([
|
||||
...items.value,
|
||||
...SpeckleViewer.ViewerRoute.resourceBuilder()
|
||||
.addModel(modelId, versionId)
|
||||
.toResources()
|
||||
])
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<div class="relative group">
|
||||
<FormTextArea
|
||||
name="codeOutput"
|
||||
readonly
|
||||
:model-value="content"
|
||||
class="text-sm text-primary font-mono"
|
||||
:rows="rows"
|
||||
/>
|
||||
<FormButton
|
||||
v-if="showCopyButton"
|
||||
text
|
||||
class="shrink-0 absolute z-10 top-1 right-2 group-hover:opacity-100 opacity-0 transition-opacity duration-200"
|
||||
:icon-left="ClipboardDocumentIcon"
|
||||
hide-text
|
||||
@click="onCopy"
|
||||
></FormButton>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ClipboardDocumentIcon } from '@heroicons/vue/24/outline'
|
||||
import { useClipboard } from '@vueuse/core'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
content: string
|
||||
showCopyButton?: boolean
|
||||
rows?: number
|
||||
}>(),
|
||||
{ showCopyButton: true, rows: 15 }
|
||||
)
|
||||
|
||||
const { copy } = useClipboard({ legacy: true })
|
||||
const { triggerNotification } = useGlobalToast()
|
||||
|
||||
const onCopy = async () => {
|
||||
await copy(props.content)
|
||||
triggerNotification({
|
||||
type: ToastNotificationType.Info,
|
||||
title: 'Value copied to clipboard'
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -1,205 +1,14 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Editable Title -->
|
||||
<div class="flex group">
|
||||
<label class="max-w-full overflow-hidden">
|
||||
<div class="sr-only">Edit title</div>
|
||||
<div
|
||||
:class="titleInputClasses"
|
||||
class="grow-textarea"
|
||||
:data-replicated-value="title"
|
||||
>
|
||||
<textarea
|
||||
v-model="title"
|
||||
name="Title"
|
||||
maxlength="512"
|
||||
:class="titleInputClasses"
|
||||
placeholder="Please enter a valid title"
|
||||
rows="1"
|
||||
spellcheck="false"
|
||||
:disabled="isDisabled"
|
||||
:cols="title && title.length < 20 ? title.length : undefined"
|
||||
data-type="title"
|
||||
@keydown="onInputKeydown"
|
||||
@blur="onBlur('title')"
|
||||
@input="onTitleInput"
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
<PencilIcon
|
||||
v-if="canEdit && !isDisabled"
|
||||
class="shrink-0 ml-2 mt-3 w-4 h-4 opacity-0 group-hover:opacity-100 transition text-foreground-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Editable Description -->
|
||||
<div class="flex gap-x-2 group">
|
||||
<label>
|
||||
<div class="sr-only">Edit description</div>
|
||||
<div
|
||||
class="grow-textarea"
|
||||
:data-replicated-value="description"
|
||||
:class="descriptionInputClasses"
|
||||
>
|
||||
<textarea
|
||||
v-model="description"
|
||||
name="Description"
|
||||
:class="[
|
||||
...descriptionInputClasses,
|
||||
description ? 'focus:min-w-0' : 'min-w-[260px]'
|
||||
]"
|
||||
:placeholder="description ? undefined : 'Click here to add a description.'"
|
||||
:disabled="isDisabled"
|
||||
rows="1"
|
||||
spellcheck="false"
|
||||
maxlength="1000"
|
||||
:cols="
|
||||
description && description?.length < 20 ? description.length : undefined
|
||||
"
|
||||
data-type="description"
|
||||
@keydown="onInputKeydown"
|
||||
@blur="onBlur('description')"
|
||||
@input="onDescriptionInput"
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
<div class="shrink-0 ml-2 mt-1 text-foreground-2">
|
||||
<PencilIcon
|
||||
v-if="canEdit && !isDisabled"
|
||||
class="w-4 h-4 opacity-0 group-hover:opacity-100 transition text-foreground-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<CommonEditableTitle v-model="title" :disabled="disabled" />
|
||||
<CommonEditableDescription v-model="description" :disabled="disabled" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { PencilIcon } from '@heroicons/vue/20/solid'
|
||||
import { debounce } from 'lodash-es'
|
||||
defineProps<{
|
||||
disabled?: boolean
|
||||
}>()
|
||||
|
||||
const props = defineProps({
|
||||
title: String,
|
||||
description: String,
|
||||
canEdit: Boolean,
|
||||
isDisabled: Boolean
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:title', 'update:description'])
|
||||
|
||||
const title = ref(props.title)
|
||||
const description = ref(props.description)
|
||||
|
||||
const lastTitleValue = ref(props.title)
|
||||
const lastDescriptionValue = ref(props.description)
|
||||
|
||||
const titleDebounceSaved = ref(false)
|
||||
const descriptionDebounceSaved = ref(false)
|
||||
|
||||
const emitTitle = () => {
|
||||
lastTitleValue.value = title.value
|
||||
titleDebounceSaved.value = true
|
||||
emit('update:title', title.value)
|
||||
}
|
||||
|
||||
const emitDescription = () => {
|
||||
lastDescriptionValue.value = description.value
|
||||
descriptionDebounceSaved.value = true
|
||||
emit('update:description', description.value)
|
||||
}
|
||||
|
||||
const debouncedEmitTitle = debounce(emitTitle, 2000)
|
||||
const debouncedEmitDescription = debounce(emitDescription, 2000)
|
||||
|
||||
const titleInputClasses = computed(() => [
|
||||
'h3 tracking-tight border-0 border-b-2 transition focus:border-outline-3 max-w-full',
|
||||
'p-0 pb-1 bg-transparent border-transparent focus:outline-none focus:ring-0'
|
||||
])
|
||||
|
||||
const descriptionInputClasses = computed(() => [
|
||||
'normal placeholder:text-foreground-2 text-foreground-2 focus:text-foreground',
|
||||
'border-0 border-b-2 focus:border-outline-3',
|
||||
'p-0 bg-transparent border-transparent focus:outline-none focus:ring-0'
|
||||
])
|
||||
|
||||
const onInputKeydown = (e: KeyboardEvent) => {
|
||||
if (e.target instanceof HTMLElement) {
|
||||
if (e.target.dataset.type === 'title' && e.code === 'Enter') {
|
||||
e.preventDefault()
|
||||
e.target.blur()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const onBlur = (inputType: string) => {
|
||||
debouncedEmitTitle.cancel()
|
||||
debouncedEmitDescription.cancel()
|
||||
|
||||
if (inputType === 'title' && !titleDebounceSaved.value) {
|
||||
if (lastTitleValue.value !== title.value) {
|
||||
lastTitleValue.value = title.value
|
||||
emitTitle()
|
||||
}
|
||||
} else if (inputType === 'description' && !descriptionDebounceSaved.value) {
|
||||
if (lastDescriptionValue.value !== description.value) {
|
||||
lastDescriptionValue.value = description.value
|
||||
emitDescription()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const onTitleInput = () => {
|
||||
titleDebounceSaved.value = false
|
||||
debouncedEmitTitle()
|
||||
}
|
||||
|
||||
const onDescriptionInput = () => {
|
||||
descriptionDebounceSaved.value = false
|
||||
debouncedEmitDescription()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.title,
|
||||
(newVal) => {
|
||||
title.value = newVal
|
||||
}
|
||||
)
|
||||
watch(
|
||||
() => props.description,
|
||||
(newVal) => {
|
||||
description.value = newVal
|
||||
}
|
||||
)
|
||||
const description = defineModel<string>('description', { required: true })
|
||||
const title = defineModel<string>('title', { required: true })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/** more info: https://css-tricks.com/the-cleanest-trick-for-autogrowing-textareas/ */
|
||||
.grow-textarea {
|
||||
/* easy way to plop the elements on top of each other and have them both sized based on the tallest one's height */
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.grow-textarea::after {
|
||||
/* Note the weird space! Needed to preventy jumpy behavior */
|
||||
content: attr(data-replicated-value) ' ';
|
||||
|
||||
/* This is how textarea text behaves */
|
||||
white-space: pre-wrap;
|
||||
|
||||
/* Hidden from view, clicks, and screen readers */
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.grow-textarea > textarea {
|
||||
/* You could leave this, but after a user resizes, then it ruins the auto sizing */
|
||||
resize: none;
|
||||
|
||||
/* Firefox shows scrollbar on growth, you can hide like this. */
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.grow-textarea > textarea,
|
||||
.grow-textarea::after {
|
||||
/* Place on top of each other - has to have the same styling as the textarea! */
|
||||
grid-area: 1 / 1 / 2 / 2;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
</CommonEmptyState>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* NOTE: This is deprecated, use CommonGenericEmptyState instead
|
||||
*/
|
||||
|
||||
const emit = defineEmits<{ 'clear-search': [] }>()
|
||||
|
||||
const cta = {
|
||||
|
||||
@@ -4,17 +4,27 @@
|
||||
<div class="text-sm">
|
||||
<slot>No data found!</slot>
|
||||
</div>
|
||||
<FormButton v-if="cta" size="sm" :to="cta.to" @click="cta.onClick">
|
||||
<FormButton v-if="cta" size="sm" :to="cta?.to" @click="onCtaClick">
|
||||
{{ cta.text }}
|
||||
</FormButton>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
// gotta import something otherwise eslint breaks...
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
import { homeRoute } from '~/lib/common/helpers/route'
|
||||
|
||||
const props = defineProps<{
|
||||
cta?: {
|
||||
text: string
|
||||
onClick?: (e: MouseEvent) => void
|
||||
to?: string
|
||||
}
|
||||
}>()
|
||||
|
||||
const onCtaClick = (e: MouseEvent) => {
|
||||
if (props.cta?.onClick) {
|
||||
props.cta.onClick(e)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<template>
|
||||
<CommonEmptyState :cta="cta">
|
||||
{{ search ? 'No items matching your search query found!' : message }}
|
||||
</CommonEmptyState>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
const emit = defineEmits<{ 'clear-search': [] }>()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
search?: boolean
|
||||
message?: string
|
||||
}>(),
|
||||
{
|
||||
message: 'No items found!'
|
||||
}
|
||||
)
|
||||
|
||||
const clearSearchCta = ref({
|
||||
text: 'Clear Search',
|
||||
onClick: () => emit('clear-search')
|
||||
})
|
||||
|
||||
const cta = computed(() => (props.search ? clearSearchCta.value : undefined))
|
||||
</script>
|
||||
@@ -0,0 +1,15 @@
|
||||
<template>
|
||||
<span>{{ text ?? '' }}</span>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import type { MaybeNullOrUndefined } from '@speckle/shared'
|
||||
|
||||
/**
|
||||
* Use this when rendering text that can cause hydration mismatches (e.g. "X minutes ago" which can be off by a second between server and client rendering)
|
||||
* If a hydration mismatch will happen only this component will be re-mounted, instead of the entire parent component
|
||||
*/
|
||||
|
||||
defineProps<{
|
||||
text: MaybeNullOrUndefined<string | number>
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,64 @@
|
||||
<template>
|
||||
<div class="flex gap-x-2 group">
|
||||
<label>
|
||||
<div class="sr-only">Edit description</div>
|
||||
<div
|
||||
class="grow-textarea"
|
||||
:data-replicated-value="visibleDescription"
|
||||
:class="descriptionInputClasses"
|
||||
>
|
||||
<textarea
|
||||
name="Description"
|
||||
:class="[
|
||||
...descriptionInputClasses,
|
||||
visibleDescription ? 'focus:min-w-0' : 'min-w-[260px]'
|
||||
]"
|
||||
:placeholder="
|
||||
visibleDescription ? undefined : 'Click here to add a description.'
|
||||
"
|
||||
:disabled="disabled"
|
||||
rows="1"
|
||||
spellcheck="false"
|
||||
maxlength="1000"
|
||||
:cols="
|
||||
visibleDescription && visibleDescription?.length < 20
|
||||
? visibleDescription.length
|
||||
: undefined
|
||||
"
|
||||
data-type="description"
|
||||
:value="visibleDescription"
|
||||
v-on="on"
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
<div class="shrink-0 ml-2 mt-1 text-foreground-2">
|
||||
<PencilIcon
|
||||
v-if="!disabled"
|
||||
class="w-4 h-4 opacity-0 group-hover:opacity-100 transition text-foreground-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { PencilIcon } from '@heroicons/vue/20/solid'
|
||||
import { useDebouncedTextInput } from '@speckle/ui-components'
|
||||
|
||||
defineProps<{
|
||||
disabled?: boolean
|
||||
}>()
|
||||
|
||||
const description = defineModel<string>({ required: true })
|
||||
const { on, bind } = useDebouncedTextInput({
|
||||
model: description,
|
||||
submitOnEnter: false,
|
||||
debouncedBy: 2000,
|
||||
isBasicHtmlInput: true
|
||||
})
|
||||
const visibleDescription = computed(() => bind.modelValue.value)
|
||||
|
||||
const descriptionInputClasses = computed(() => [
|
||||
'normal placeholder:text-foreground-2 text-foreground-2 focus:text-foreground',
|
||||
'border-0 border-b-2 focus:border-outline-3',
|
||||
'p-0 bg-transparent border-transparent focus:outline-none focus:ring-0'
|
||||
])
|
||||
</script>
|
||||
@@ -0,0 +1,79 @@
|
||||
<template>
|
||||
<div class="flex group">
|
||||
<label class="max-w-full overflow-hidden">
|
||||
<div class="sr-only">Edit title</div>
|
||||
<div
|
||||
:class="titleInputClasses"
|
||||
class="grow-textarea"
|
||||
:data-replicated-value="visibleTitle"
|
||||
>
|
||||
<textarea
|
||||
name="Title"
|
||||
maxlength="512"
|
||||
:class="titleInputClasses"
|
||||
placeholder="Please enter a valid title"
|
||||
rows="1"
|
||||
spellcheck="false"
|
||||
:disabled="disabled"
|
||||
:cols="
|
||||
visibleTitle && visibleTitle.length < 20 ? visibleTitle.length : undefined
|
||||
"
|
||||
data-type="title"
|
||||
:value="visibleTitle"
|
||||
v-on="on"
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
<PencilIcon v-if="!disabled" :class="pencilClasses" />
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { PencilIcon } from '@heroicons/vue/20/solid'
|
||||
import { useDebouncedTextInput } from '@speckle/ui-components'
|
||||
|
||||
const props = defineProps<{
|
||||
disabled?: boolean
|
||||
customClasses?: {
|
||||
input?: string
|
||||
pencil?: string
|
||||
}
|
||||
}>()
|
||||
|
||||
const title = defineModel<string>({ required: true })
|
||||
const { on, bind } = useDebouncedTextInput({
|
||||
model: title,
|
||||
debouncedBy: 2000,
|
||||
isBasicHtmlInput: true,
|
||||
submitOnEnter: true
|
||||
})
|
||||
const visibleTitle = computed(() => bind.modelValue.value)
|
||||
|
||||
const titleInputClasses = computed(() => {
|
||||
const classParts = [
|
||||
'border-0 border-b-2 transition focus:border-outline-3 max-w-full',
|
||||
'p-0 pb-1 bg-transparent border-transparent focus:outline-none focus:ring-0'
|
||||
]
|
||||
|
||||
if (props.customClasses?.input) {
|
||||
classParts.push(props.customClasses.input)
|
||||
} else {
|
||||
classParts.push('h3 tracking-tight')
|
||||
}
|
||||
|
||||
return classParts.join(' ')
|
||||
})
|
||||
|
||||
const pencilClasses = computed(() => {
|
||||
const classParts = [
|
||||
'shrink-0 opacity-0 group-hover:opacity-100 transition text-foreground-2'
|
||||
]
|
||||
|
||||
if (props.customClasses?.pencil) {
|
||||
classParts.push(props.customClasses.pencil)
|
||||
} else {
|
||||
classParts.push('ml-2 mt-3 w-4 h-4')
|
||||
}
|
||||
|
||||
return classParts.join(' ')
|
||||
})
|
||||
</script>
|
||||
@@ -10,6 +10,7 @@
|
||||
:allow-unset="allowUnset"
|
||||
:label-id="labelId"
|
||||
:button-id="buttonId"
|
||||
:help="help"
|
||||
by="id"
|
||||
>
|
||||
<template #nothing-selected>
|
||||
@@ -104,6 +105,10 @@ const props = defineProps({
|
||||
allowUnset: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
help: {
|
||||
type: String,
|
||||
default: undefined
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<template>
|
||||
<div
|
||||
v-if="cleanReadmeHtml.length"
|
||||
:class="proseClasses"
|
||||
v-html="cleanReadmeHtml"
|
||||
></div>
|
||||
<div v-else class="italic text-center">No readme found</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import type { Nullable } from '@speckle/shared'
|
||||
import { useMarkdown } from '~/lib/common/composables/markdown'
|
||||
|
||||
const relativeLinkRegex = /\[(.+?)\]\(\/(.*?)\)/gi
|
||||
|
||||
const props = defineProps<{
|
||||
readmeMarkdown: string
|
||||
repo: string // e.g. 'specklesystems/speckle-server'
|
||||
commitId?: string
|
||||
}>()
|
||||
|
||||
const logger = useLogger()
|
||||
|
||||
const relativeLinkBaseUrl = computed(
|
||||
() => `https://raw.githubusercontent.com/${props.repo}/${props.commitId || 'main'}/`
|
||||
)
|
||||
|
||||
const finalMarkdown = computed(() => {
|
||||
// Transform relative URLs to absolute URLs using the GitHub repo URL as a base URI
|
||||
const source = props.readmeMarkdown
|
||||
if (!source.length) return ''
|
||||
|
||||
// Find all relative links and append the repo URL to them
|
||||
const newSource = source.replace(
|
||||
relativeLinkRegex,
|
||||
(match, linkText: Nullable<string>, relativePath: Nullable<string>) => {
|
||||
let finalUrl = match
|
||||
if (!linkText?.length || !relativePath?.length) return match
|
||||
|
||||
try {
|
||||
const url = new URL(relativePath, relativeLinkBaseUrl.value).toString()
|
||||
finalUrl = `[${linkText}](${url})`
|
||||
} catch (e) {
|
||||
logger.warn(e)
|
||||
}
|
||||
|
||||
return finalUrl
|
||||
}
|
||||
)
|
||||
|
||||
return newSource
|
||||
})
|
||||
|
||||
const { html: cleanReadmeHtml } = useMarkdown(
|
||||
computed(() => finalMarkdown.value),
|
||||
{ key: 'CommonProseGithubReadme' }
|
||||
)
|
||||
|
||||
const proseClasses = ref([
|
||||
'prose max-w-none',
|
||||
'prose-img:inline',
|
||||
'prose-img:my-0',
|
||||
'prose-h1:h1 prose-h1:font-normal',
|
||||
'prose-h2:h2',
|
||||
'prose-h3:h3',
|
||||
'prose-h4:h4',
|
||||
'prose-h5:h5',
|
||||
'dark:prose-invert'
|
||||
])
|
||||
</script>
|
||||
@@ -0,0 +1,27 @@
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<template>
|
||||
<div :class="proseClasses" v-html="cleanReadmeHtml"></div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { useMarkdown } from '~/lib/common/composables/markdown'
|
||||
|
||||
const props = defineProps<{
|
||||
markdown: string | null | undefined
|
||||
}>()
|
||||
|
||||
const { html: cleanReadmeHtml } = useMarkdown(computed(() => props.markdown || ''))
|
||||
|
||||
// Scaling these down, cause these should appear smaller than the main site headings
|
||||
const proseClasses = ref([
|
||||
'prose max-w-none',
|
||||
'prose-img:inline',
|
||||
'prose-img:my-0',
|
||||
'prose-h1:h2 prose-h1:font-medium prose-h1:mb-8',
|
||||
'prose-h2:h3 prose-h2:font-medium prose-h2:mt-0 prose-h2:mb-6',
|
||||
'prose-h3:h4 prose-h3:mb-4',
|
||||
'prose-h4:h5 prose-h4:mb-4',
|
||||
'prose-h5:h6 prose-h5:mb-4 prose-h5:font-medium',
|
||||
'prose-h6:h6 prose-h6:mb-4 prose-h6:font-medium prose-h6:text-sm',
|
||||
'dark:prose-invert'
|
||||
])
|
||||
</script>
|
||||
+3
-3
@@ -40,7 +40,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { LayoutDialog } from '@speckle/ui-components'
|
||||
import { LayoutDialog, type LayoutDialogButton } from '@speckle/ui-components'
|
||||
import { ExclamationTriangleIcon } from '@heroicons/vue/24/outline'
|
||||
import type { ApplicationItem } from '~~/lib/developer-settings/helpers/types'
|
||||
|
||||
@@ -65,10 +65,10 @@ const authUrl = computed(() => {
|
||||
return null
|
||||
})
|
||||
|
||||
const dialogButtons = computed(() => [
|
||||
const dialogButtons = computed((): LayoutDialogButton[] => [
|
||||
{
|
||||
text: 'Close',
|
||||
props: { color: 'primary', fullWidth: true },
|
||||
props: { color: 'default', fullWidth: true },
|
||||
onClick: () => (isOpen.value = false)
|
||||
}
|
||||
])
|
||||
|
||||
@@ -61,7 +61,11 @@
|
||||
<script setup lang="ts">
|
||||
import { useMutation } from '@vue/apollo-composable'
|
||||
import { AllScopes } from '@speckle/shared'
|
||||
import { LayoutDialog, FormSelectBadges } from '@speckle/ui-components'
|
||||
import {
|
||||
LayoutDialog,
|
||||
FormSelectBadges,
|
||||
type LayoutDialogButton
|
||||
} from '@speckle/ui-components'
|
||||
import type {
|
||||
ApplicationFormValues,
|
||||
ApplicationItem
|
||||
@@ -190,7 +194,7 @@ const onSubmit = handleSubmit(async (applicationFormValues) => {
|
||||
}
|
||||
})
|
||||
|
||||
const dialogButtons = computed(() => [
|
||||
const dialogButtons = computed((): LayoutDialogButton[] => [
|
||||
{
|
||||
text: 'Cancel',
|
||||
props: { color: 'secondary', fullWidth: true, outline: true },
|
||||
@@ -200,7 +204,7 @@ const dialogButtons = computed(() => [
|
||||
},
|
||||
{
|
||||
text: props.application ? 'Save' : 'Create',
|
||||
props: { color: 'primary', fullWidth: true },
|
||||
props: { color: 'default', fullWidth: true },
|
||||
onClick: onSubmit
|
||||
}
|
||||
])
|
||||
|
||||
@@ -41,7 +41,11 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useMutation } from '@vue/apollo-composable'
|
||||
import { LayoutDialog, FormSelectBadges } from '@speckle/ui-components'
|
||||
import {
|
||||
LayoutDialog,
|
||||
FormSelectBadges,
|
||||
type LayoutDialogButton
|
||||
} from '@speckle/ui-components'
|
||||
import type { TokenFormValues } from '~~/lib/developer-settings/helpers/types'
|
||||
import { createAccessTokenMutation } from '~~/lib/developer-settings/graphql/mutations'
|
||||
import { isItemSelected, isRequired } from '~~/lib/common/helpers/validation'
|
||||
@@ -103,7 +107,7 @@ const onSubmit = handleSubmit(async (tokenFormValues) => {
|
||||
}
|
||||
})
|
||||
|
||||
const dialogButtons = computed(() => [
|
||||
const dialogButtons = computed((): LayoutDialogButton[] => [
|
||||
{
|
||||
text: 'Cancel',
|
||||
props: { color: 'secondary', fullWidth: true, outline: true },
|
||||
@@ -113,7 +117,7 @@ const dialogButtons = computed(() => [
|
||||
},
|
||||
{
|
||||
text: 'Create',
|
||||
props: { color: 'primary', fullWidth: true },
|
||||
props: { color: 'default', fullWidth: true },
|
||||
onClick: onSubmit
|
||||
}
|
||||
])
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { LayoutDialog } from '@speckle/ui-components'
|
||||
import { LayoutDialog, type LayoutDialogButton } from '@speckle/ui-components'
|
||||
import { ExclamationTriangleIcon } from '@heroicons/vue/24/outline'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -38,10 +38,10 @@ const props = defineProps<{
|
||||
|
||||
const isOpen = defineModel<boolean>('open', { required: true })
|
||||
|
||||
const dialogButtons = computed(() => [
|
||||
const dialogButtons = computed((): LayoutDialogButton[] => [
|
||||
{
|
||||
text: 'Close',
|
||||
props: { color: 'primary', fullWidth: true },
|
||||
props: { color: 'default', fullWidth: true },
|
||||
onClick: () => (isOpen.value = false)
|
||||
}
|
||||
])
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useMutation, useMutationLoading } from '@vue/apollo-composable'
|
||||
import { LayoutDialog } from '@speckle/ui-components'
|
||||
import { LayoutDialog, type LayoutDialogButton } from '@speckle/ui-components'
|
||||
import type {
|
||||
ApplicationItem,
|
||||
TokenItem,
|
||||
@@ -191,7 +191,7 @@ const deleteConfirmed = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const dialogButtons = computed(() => [
|
||||
const dialogButtons = computed((): LayoutDialogButton[] => [
|
||||
{
|
||||
text: 'Cancel',
|
||||
props: { color: 'secondary', fullWidth: true, outline: true },
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { LayoutDialog } from '@speckle/ui-components'
|
||||
import { LayoutDialog, type LayoutDialogButton } from '@speckle/ui-components'
|
||||
import type { ApplicationItem } from '~~/lib/developer-settings/helpers/types'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -34,10 +34,10 @@ const props = defineProps<{
|
||||
|
||||
const isOpen = defineModel<boolean>('open', { required: true })
|
||||
|
||||
const dialogButtons = computed(() => [
|
||||
const dialogButtons = computed((): LayoutDialogButton[] => [
|
||||
{
|
||||
text: 'Close',
|
||||
props: { color: 'primary', fullWidth: true },
|
||||
props: { color: 'default', fullWidth: true },
|
||||
onClick: () => {
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<div class="flex flex-col items-center space-y-8">
|
||||
<ErrorPageProjectInviteBanner />
|
||||
<h1 class="h1 font-bold">Error {{ finalError.statusCode || 500 }}</h1>
|
||||
<h2 class="h3 text-foreground-2 mx-4 break-words">
|
||||
<h2 class="h3 text-foreground-2 mx-4 break-words max-w-full">
|
||||
{{ finalError.message }}
|
||||
</h2>
|
||||
<div v-if="isDev && finalError.stack" class="max-w-xl" v-html="finalError.stack" />
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
<template>
|
||||
<div class="flex flex-col space-y-2">
|
||||
<div class="text-sm font-medium text-foreground-2 flex items-center space-x-1">
|
||||
<span>{{ label }}</span>
|
||||
<InformationCircleIcon
|
||||
v-tippy="{
|
||||
content: tooltipContent,
|
||||
allowHTML: true,
|
||||
interactive: true,
|
||||
appendTo: () => body
|
||||
}"
|
||||
class="w-4 h-4 outline-none"
|
||||
/>
|
||||
<div class="grow" />
|
||||
<CommonTextLink
|
||||
v-if="!isEditing || !isPreviewDisabled"
|
||||
size="xs"
|
||||
@click="toggleEditor"
|
||||
>
|
||||
{{ isEditing ? 'Preview' : 'Editor' }}
|
||||
</CommonTextLink>
|
||||
</div>
|
||||
<FormTextArea
|
||||
v-show="isEditing"
|
||||
v-model="value"
|
||||
textarea-classes="font-mono"
|
||||
:name="name"
|
||||
:label="label"
|
||||
:show-label="false"
|
||||
:show-required="showRequired"
|
||||
:placeholder="placeholder"
|
||||
:help="help"
|
||||
:rules="typesafeRules"
|
||||
:rows="rows"
|
||||
/>
|
||||
<LayoutPanel
|
||||
v-show="!isEditing"
|
||||
panel-classes="simple-scrollbar !overflow-auto max-h-64"
|
||||
>
|
||||
<CommonProseMarkdownDescription :markdown="value" />
|
||||
</LayoutPanel>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { InformationCircleIcon } from '@heroicons/vue/24/outline'
|
||||
import { type RuleExpression, useField } from 'vee-validate'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
name: string
|
||||
label?: string
|
||||
showRequired?: boolean
|
||||
placeholder?: string
|
||||
help?: string
|
||||
rules?: RuleExpression<string>
|
||||
rows?: number
|
||||
}>(),
|
||||
{
|
||||
rows: 7
|
||||
}
|
||||
)
|
||||
|
||||
defineModel<string>()
|
||||
const { value } = useField<string>(props.name, props.rules)
|
||||
|
||||
const body = computed(() => (process.client ? document.body : undefined))
|
||||
const isEditing = ref(true)
|
||||
const isPreviewDisabled = computed(() => !(value.value || '').trim().length)
|
||||
|
||||
const label = computed(() => props.label || props.name)
|
||||
const tooltipContent = computed(
|
||||
() =>
|
||||
`This field supports markdown. <a href="https://www.markdownguide.org/basic-syntax/" class="underline" target="_blank">Click here</a> for more info.`
|
||||
)
|
||||
|
||||
// Kinda stupid, but we have to do this because of minor vee-validate version mismatches and vue kinda messing up the types on ui-components build
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-explicit-any
|
||||
const typesafeRules = computed(() => props.rules as any)
|
||||
|
||||
const toggleEditor = () => (isEditing.value = !isEditing.value)
|
||||
</script>
|
||||
@@ -0,0 +1,56 @@
|
||||
<template>
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="flex flex-col space-y-1 justify-between">
|
||||
<FormButton
|
||||
size="xs"
|
||||
hide-text
|
||||
:icon-left="ArrowUpIcon"
|
||||
:disabled="!moveUpEnabled"
|
||||
type="button"
|
||||
@click.stop="moveUp?.()"
|
||||
/>
|
||||
|
||||
<FormButton
|
||||
size="xs"
|
||||
color="danger"
|
||||
hide-text
|
||||
:icon-left="XMarkIcon"
|
||||
:disabled="!deleteEnabled"
|
||||
type="button"
|
||||
@click.stop="doDelete?.()"
|
||||
/>
|
||||
<FormButton
|
||||
size="xs"
|
||||
hide-text
|
||||
:icon-left="ArrowDownIcon"
|
||||
:disabled="!moveDownEnabled"
|
||||
type="button"
|
||||
@click.stop="moveDown?.()"
|
||||
/>
|
||||
</div>
|
||||
<div class="grow">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ArrowDownIcon, ArrowUpIcon, XMarkIcon } from '@heroicons/vue/24/solid'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
initiallyExpanded?: boolean
|
||||
label?: string
|
||||
moveUpEnabled?: boolean
|
||||
moveDownEnabled?: boolean
|
||||
deleteEnabled?: boolean
|
||||
moveUp?: () => void
|
||||
moveDown?: () => void
|
||||
doDelete?: () => void
|
||||
}>(),
|
||||
{
|
||||
moveUpEnabled: true,
|
||||
moveDownEnabled: true,
|
||||
deleteEnabled: true
|
||||
}
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,142 @@
|
||||
<template>
|
||||
<fieldset class="flex flex-col">
|
||||
<legend class="flex items-center space-x-2">
|
||||
<div class="inline-flex space-x-1 items-center">
|
||||
<span class="font-medium text-foreground-2">{{ control.label }}</span>
|
||||
<span
|
||||
v-if="isRequired"
|
||||
class="text-2xl text-danger opacity-50 h-4 w-4 leading-6"
|
||||
>
|
||||
*
|
||||
</span>
|
||||
</div>
|
||||
<FormButton
|
||||
v-if="!isReadonly"
|
||||
color="success"
|
||||
hide-text
|
||||
:icon-left="PlusIcon"
|
||||
size="xs"
|
||||
:disabled="!control.enabled || (appliedOptions.restrict && maxItemsReached)"
|
||||
@click="onAdd"
|
||||
/>
|
||||
</legend>
|
||||
<div v-if="control.description?.length" class="text-sm text-foreground-2">
|
||||
{{ control.description }}
|
||||
</div>
|
||||
<div class="mb-2" />
|
||||
<template v-if="noData">
|
||||
<CommonAlert color="info" size="xs">
|
||||
<template #title>
|
||||
<span class="caption">
|
||||
No data defined!
|
||||
<template v-if="!isReadonly">
|
||||
Click on the
|
||||
<strong>+</strong>
|
||||
button to add some!
|
||||
</template>
|
||||
</span>
|
||||
</template>
|
||||
</CommonAlert>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="flex flex-col space-y-2 array-list-element-container">
|
||||
<FormJsonArrayListElement
|
||||
v-for="(_element, index) in control.data"
|
||||
:key="`${control.path}-${index}`"
|
||||
:move-up="baseControl.moveUp?.(control.path, index)"
|
||||
:move-up-enabled="control.enabled && index > 0"
|
||||
:move-down="baseControl.moveDown?.(control.path, index)"
|
||||
:move-down-enabled="control.enabled && index < control.data.length - 1"
|
||||
:delete-enabled="control.enabled && !minItemsReached"
|
||||
:do-delete="baseControl.removeItems?.(control.path, [index])"
|
||||
:label="childLabelForIndex(index)"
|
||||
>
|
||||
<DispatchRenderer
|
||||
:schema="control.schema"
|
||||
:uischema="childUiSchema"
|
||||
:path="composePaths(control.path, `${index}`)"
|
||||
:enabled="control.enabled"
|
||||
:renderers="control.renderers"
|
||||
:cells="control.cells"
|
||||
/>
|
||||
</FormJsonArrayListElement>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="error" class="mt-2 text-sm text-danger">{{ error }}</div>
|
||||
</fieldset>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
import { PlusIcon } from '@heroicons/vue/24/solid'
|
||||
import {
|
||||
type ControlElement,
|
||||
Resolve,
|
||||
composePaths,
|
||||
createDefaultValue
|
||||
} from '@jsonforms/core'
|
||||
import {
|
||||
DispatchRenderer,
|
||||
rendererProps,
|
||||
useJsonFormsArrayControl
|
||||
} from '@jsonforms/vue'
|
||||
import { useJsonRendererArrayBaseSetup } from '~/lib/form/composables/jsonRenderers'
|
||||
|
||||
/**
|
||||
* TODO:
|
||||
* - Render errors & other fields that others do
|
||||
* - (on entire array + separate items?)
|
||||
*/
|
||||
|
||||
const props = defineProps({
|
||||
...rendererProps<ControlElement>()
|
||||
})
|
||||
|
||||
const {
|
||||
control,
|
||||
baseControl,
|
||||
childLabelForIndex,
|
||||
childUiSchema,
|
||||
appliedOptions,
|
||||
isRequired,
|
||||
error
|
||||
} = useJsonRendererArrayBaseSetup(useJsonFormsArrayControl(props))
|
||||
|
||||
const noData = computed(() => !control.value.data || !control.value.data.length)
|
||||
const isReadonly = computed(() => !control.value.enabled)
|
||||
const arraySchema = computed(() =>
|
||||
Resolve.schema(props.schema, control.value.uischema.scope, control.value.rootSchema)
|
||||
)
|
||||
|
||||
const minItemsReached = computed(() => {
|
||||
const minItems = arraySchema.value.minItems
|
||||
const data = control.value.data
|
||||
if (minItems === undefined || data === undefined) return false
|
||||
|
||||
return data.length <= minItems
|
||||
})
|
||||
|
||||
const maxItemsReached = computed(() => {
|
||||
const maxItems = arraySchema.value.maxItems
|
||||
const data = control.value.data
|
||||
if (maxItems === undefined || data === undefined) return false
|
||||
|
||||
return data.length >= maxItems
|
||||
})
|
||||
|
||||
const onAdd = () => {
|
||||
const path = control.value.path
|
||||
const val = createDefaultValue(control.value.schema, props.schema)
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
const addItem = baseControl.addItem
|
||||
if (!addItem) return
|
||||
|
||||
addItem(path, val)()
|
||||
}
|
||||
</script>
|
||||
<style scoped lang="postcss">
|
||||
.array-list-element-container :deep(label) {
|
||||
@apply hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<FormCheckbox
|
||||
:name="fieldName"
|
||||
:disabled="!control.enabled"
|
||||
:model-value="modelValue"
|
||||
:rules="validator"
|
||||
:label="control.label"
|
||||
:value="true"
|
||||
:description="control.description"
|
||||
show-label
|
||||
:validate-on-value-update="validateOnValueUpdate"
|
||||
@update:model-value="handleChange"
|
||||
/>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { type ControlElement } from '@jsonforms/core'
|
||||
import { rendererProps, useJsonFormsControl } from '@jsonforms/vue'
|
||||
import { useJsonRendererBaseSetup } from '~/lib/form/composables/jsonRenderers'
|
||||
|
||||
const props = defineProps({
|
||||
...rendererProps<ControlElement>()
|
||||
})
|
||||
|
||||
const { handleChange, control, validator, fieldName, validateOnValueUpdate } =
|
||||
useJsonRendererBaseSetup(useJsonFormsControl(props), {
|
||||
onChangeValueConverter: (val: true | undefined) => {
|
||||
return !!val
|
||||
}
|
||||
})
|
||||
|
||||
const modelValue = computed(() => {
|
||||
return control.value.data ? true : undefined
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,37 @@
|
||||
<template>
|
||||
<FormTextInput
|
||||
:name="fieldName"
|
||||
:disabled="!control.enabled"
|
||||
:model-value="control.data"
|
||||
:rules="validator"
|
||||
:label="control.label"
|
||||
show-label
|
||||
type="date"
|
||||
size="lg"
|
||||
max="9999-12-31"
|
||||
:placeholder="appliedOptions['placeholder']"
|
||||
:help="control.description"
|
||||
:validate-on-value-update="validateOnValueUpdate"
|
||||
:show-required="isRequired"
|
||||
@update:model-value="handleChange"
|
||||
/>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { type ControlElement } from '@jsonforms/core'
|
||||
import { rendererProps, useJsonFormsControl } from '@jsonforms/vue'
|
||||
import { useJsonRendererBaseSetup } from '~/lib/form/composables/jsonRenderers'
|
||||
|
||||
const props = defineProps({
|
||||
...rendererProps<ControlElement>()
|
||||
})
|
||||
|
||||
const {
|
||||
handleChange,
|
||||
control,
|
||||
validator,
|
||||
appliedOptions,
|
||||
fieldName,
|
||||
validateOnValueUpdate,
|
||||
isRequired
|
||||
} = useJsonRendererBaseSetup(useJsonFormsControl(props))
|
||||
</script>
|
||||
@@ -0,0 +1,52 @@
|
||||
<template>
|
||||
<FormTextInput
|
||||
:name="fieldName"
|
||||
:disabled="!control.enabled"
|
||||
:model-value="modelValue"
|
||||
:rules="validator"
|
||||
:label="control.label"
|
||||
:show-clear="isRequired"
|
||||
show-label
|
||||
type="datetime-local"
|
||||
size="lg"
|
||||
max="9999-12-31T23:59"
|
||||
:placeholder="appliedOptions['placeholder']"
|
||||
:help="control.description"
|
||||
:validate-on-value-update="validateOnValueUpdate"
|
||||
@update:model-value="handleChange"
|
||||
/>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { type ControlElement } from '@jsonforms/core'
|
||||
import { rendererProps, useJsonFormsControl } from '@jsonforms/vue'
|
||||
import { useJsonRendererBaseSetup } from '~/lib/form/composables/jsonRenderers'
|
||||
|
||||
const zuluTimeSuffix = ':00.000Z'
|
||||
|
||||
const props = defineProps({
|
||||
...rendererProps<ControlElement>()
|
||||
})
|
||||
|
||||
const toISOString = (inputDateTime: string) => {
|
||||
return inputDateTime ? inputDateTime + zuluTimeSuffix : undefined
|
||||
}
|
||||
|
||||
const {
|
||||
handleChange,
|
||||
control,
|
||||
validator,
|
||||
appliedOptions,
|
||||
fieldName,
|
||||
validateOnValueUpdate,
|
||||
isRequired
|
||||
} = useJsonRendererBaseSetup(useJsonFormsControl(props), {
|
||||
onChangeValueConverter: (val) => toISOString(val as string)
|
||||
})
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
|
||||
const modelValue = computed(() =>
|
||||
control.value.data
|
||||
? (control.value.data as string).replace(zuluTimeSuffix, '')
|
||||
: undefined
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,123 @@
|
||||
<template>
|
||||
<FormSelectBase
|
||||
:model-value="modelValue"
|
||||
:name="fieldName"
|
||||
:rules="validator"
|
||||
:label="control.label"
|
||||
:items="control.options"
|
||||
:multiple="multiple"
|
||||
:help="control.description"
|
||||
:disabled="!control.enabled"
|
||||
:show-required="isRequired"
|
||||
show-label
|
||||
by="value"
|
||||
button-style="tinted"
|
||||
:validate-on-value-update="validateOnValueUpdate"
|
||||
@update:model-value="handleChange"
|
||||
>
|
||||
<template #nothing-selected>
|
||||
{{
|
||||
appliedOptions['placeholder']
|
||||
? appliedOptions['placeholder']
|
||||
: multiple
|
||||
? 'Select values'
|
||||
: 'Select a value'
|
||||
}}
|
||||
</template>
|
||||
<template #something-selected="{ value }">
|
||||
<template v-if="isMultiItemArrayValue(value)">
|
||||
<div ref="elementToWatchForChanges" class="flex items-center space-x-0.5">
|
||||
<div
|
||||
ref="itemContainer"
|
||||
class="flex flex-wrap overflow-hidden space-x-0.5 h-6"
|
||||
>
|
||||
<div v-for="(item, i) in value" :key="item.value" class="text-foreground">
|
||||
{{ item.label + (i < value.length - 1 ? ', ' : '') }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="hiddenSelectedItemCount > 0" class="text-foreground-2 normal">
|
||||
+{{ hiddenSelectedItemCount }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="flex items-center">
|
||||
<span class="truncate text-foreground">
|
||||
{{ (isArrayValue(value) ? value[0] : value).label }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
<template #option="{ item }">
|
||||
<div class="flex items-center">
|
||||
<span class="truncate">{{ item.label }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</FormSelectBase>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { type ControlElement } from '@jsonforms/core'
|
||||
import { rendererProps, useJsonFormsEnumControl } from '@jsonforms/vue'
|
||||
import { type Nullable } from '@speckle/shared'
|
||||
import { useFormSelectChildInternals } from '@speckle/ui-components'
|
||||
import { useJsonRendererBaseSetup } from '~/lib/form/composables/jsonRenderers'
|
||||
|
||||
type OptionType = { value: string; label: string }
|
||||
type ValueType = OptionType | OptionType[] | undefined
|
||||
|
||||
const emit = defineEmits<(e: 'update:modelValue', v: ValueType) => void>()
|
||||
|
||||
const props = defineProps({
|
||||
...rendererProps<ControlElement>(),
|
||||
// TODO: Doesn't appear that jsonforms properly supports multiple selection
|
||||
multiple: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
controlOverrides: {
|
||||
type: Object as PropType<Nullable<ReturnType<typeof useJsonFormsEnumControl>>>,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
|
||||
const elementToWatchForChanges = ref(null as Nullable<HTMLElement>)
|
||||
const itemContainer = ref(null as Nullable<HTMLElement>)
|
||||
|
||||
const { hiddenSelectedItemCount, isArrayValue, isMultiItemArrayValue } =
|
||||
useFormSelectChildInternals<OptionType>({
|
||||
props: toRefs(props),
|
||||
emit,
|
||||
dynamicVisibility: { elementToWatchForChanges, itemContainer }
|
||||
})
|
||||
|
||||
const {
|
||||
handleChange,
|
||||
control,
|
||||
validator,
|
||||
appliedOptions,
|
||||
fieldName,
|
||||
validateOnValueUpdate,
|
||||
isRequired
|
||||
} = useJsonRendererBaseSetup(props.controlOverrides || useJsonFormsEnumControl(props), {
|
||||
onChangeValueConverter: (newVal: ValueType) => {
|
||||
if (props.multiple && isArrayValue(newVal)) {
|
||||
return newVal.map((v) => v.value)
|
||||
} else if (newVal && !props.multiple && !isArrayValue(newVal)) {
|
||||
return newVal.value
|
||||
} else {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const modelValue = computed(() => {
|
||||
const val = control.value.data as string
|
||||
const res = control.value.options.find((o) => o.value === val)
|
||||
|
||||
if (props.multiple) {
|
||||
return res ? [res] : []
|
||||
} else {
|
||||
return res || undefined
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,18 @@
|
||||
<template>
|
||||
<FormJsonEnumControlRenderer
|
||||
v-bind="$props"
|
||||
:multiple="false"
|
||||
:control-overrides="controlOverrides"
|
||||
/>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { type ControlElement } from '@jsonforms/core'
|
||||
import { rendererProps, useJsonFormsOneOfEnumControl } from '@jsonforms/vue'
|
||||
|
||||
const props = defineProps({
|
||||
...rendererProps<ControlElement>()
|
||||
})
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
const controlOverrides = useJsonFormsOneOfEnumControl(props)
|
||||
</script>
|
||||
@@ -0,0 +1,95 @@
|
||||
<template>
|
||||
<form class="flex flex-col space-y-4 form-json-form">
|
||||
<JsonForms
|
||||
ref="internalRef"
|
||||
:renderers="renderers"
|
||||
:schema="finalSchema"
|
||||
:uischema="finalUiSchema"
|
||||
:data="data || {}"
|
||||
:readonly="readonly"
|
||||
@change="onChange"
|
||||
/>
|
||||
</form>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import type { JsonSchema, UISchemaElement } from '@jsonforms/core'
|
||||
import { JsonForms, type JsonFormsChangeEvent } from '@jsonforms/vue'
|
||||
import type { Nullable, Optional } from '@speckle/shared'
|
||||
import { useMounted } from '@vueuse/core'
|
||||
import { omit } from 'lodash-es'
|
||||
import { useForm } from 'vee-validate'
|
||||
import { renderers } from '~/lib/form/jsonRenderers'
|
||||
|
||||
type DataType = Record<string, unknown>
|
||||
|
||||
const emit = defineEmits<(e: 'change', val: JsonFormsChangeEvent) => void>()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
schema: JsonSchema
|
||||
uiSchema?: UISchemaElement
|
||||
readonly?: boolean
|
||||
validateOnMount?: boolean
|
||||
}>(),
|
||||
{ validateOnMount: true }
|
||||
)
|
||||
|
||||
const { validate } = useForm()
|
||||
|
||||
const isMounted = useMounted()
|
||||
const internalRef = ref<Nullable<{ jsonforms: { core: JsonFormsChangeEvent } }>>(null)
|
||||
const data = defineModel<Record<string, unknown>>('data')
|
||||
|
||||
const finalSchema = computed(() => {
|
||||
const base = props.schema
|
||||
return omit(base, ['$schema', '$id'])
|
||||
})
|
||||
|
||||
const autoGeneratedUiSchema = computed(() => {
|
||||
const properties = Object.keys(props.schema.properties || {})
|
||||
return {
|
||||
type: 'VerticalLayout',
|
||||
elements: properties.map((p) => ({
|
||||
type: 'Control',
|
||||
scope: `#/properties/${p}`
|
||||
}))
|
||||
}
|
||||
})
|
||||
const finalUiSchema = computed(() => props.uiSchema || autoGeneratedUiSchema.value)
|
||||
|
||||
const onChange = async (e: JsonFormsChangeEvent) => {
|
||||
if (!isMounted.value && !props.validateOnMount) {
|
||||
return
|
||||
}
|
||||
|
||||
data.value = e.data as DataType
|
||||
await validate({ mode: 'force' })
|
||||
emit('change', e)
|
||||
}
|
||||
|
||||
const getFormState = (): Optional<JsonFormsChangeEvent> =>
|
||||
internalRef.value?.jsonforms.core
|
||||
? ({
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
data: internalRef.value.jsonforms.core.data,
|
||||
errors: internalRef.value.jsonforms.core.errors
|
||||
} as JsonFormsChangeEvent)
|
||||
: undefined
|
||||
|
||||
const triggerChange = async () => {
|
||||
const state = getFormState()
|
||||
if (state) {
|
||||
await onChange(state)
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
defineExpose({ getFormState, triggerChange })
|
||||
</script>
|
||||
<style lang="postcss">
|
||||
.form-json-form {
|
||||
.vertical-layout {
|
||||
@apply space-y-4;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<FormTextInput
|
||||
:name="fieldName"
|
||||
:disabled="!control.enabled"
|
||||
:model-value="control.data + ''"
|
||||
:rules="validator"
|
||||
:label="control.label"
|
||||
:placeholder="appliedOptions['placeholder']"
|
||||
:help="control.description"
|
||||
type="number"
|
||||
step="1"
|
||||
size="lg"
|
||||
show-label
|
||||
:show-required="isRequired"
|
||||
:validate-on-value-update="validateOnValueUpdate"
|
||||
@update:model-value="handleChange"
|
||||
/>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { type ControlElement } from '@jsonforms/core'
|
||||
import { rendererProps, useJsonFormsControl } from '@jsonforms/vue'
|
||||
import { useJsonRendererBaseSetup } from '~/lib/form/composables/jsonRenderers'
|
||||
|
||||
const props = defineProps({
|
||||
...rendererProps<ControlElement>()
|
||||
})
|
||||
|
||||
const {
|
||||
handleChange,
|
||||
control,
|
||||
validator,
|
||||
appliedOptions,
|
||||
fieldName,
|
||||
validateOnValueUpdate,
|
||||
isRequired
|
||||
} = useJsonRendererBaseSetup(useJsonFormsControl(props), {
|
||||
onChangeValueConverter: (val: string) => {
|
||||
// Convert to int if possible
|
||||
const num = parseInt(val)
|
||||
return isNaN(num) ? undefined : num
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<FormTextArea
|
||||
:name="fieldName"
|
||||
:disabled="!control.enabled"
|
||||
:model-value="control.data"
|
||||
:rules="validator"
|
||||
:placeholder="appliedOptions['placeholder']"
|
||||
:help="control.description"
|
||||
:label="control.label"
|
||||
show-label
|
||||
:show-required="isRequired"
|
||||
:validate-on-value-update="validateOnValueUpdate"
|
||||
@update:model-value="handleChange"
|
||||
/>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { type ControlElement } from '@jsonforms/core'
|
||||
import { rendererProps, useJsonFormsControl } from '@jsonforms/vue'
|
||||
import { useJsonRendererBaseSetup } from '~/lib/form/composables/jsonRenderers'
|
||||
|
||||
const props = defineProps({
|
||||
...rendererProps<ControlElement>()
|
||||
})
|
||||
|
||||
const {
|
||||
handleChange,
|
||||
control,
|
||||
validator,
|
||||
appliedOptions,
|
||||
fieldName,
|
||||
validateOnValueUpdate,
|
||||
isRequired
|
||||
} = useJsonRendererBaseSetup(useJsonFormsControl(props))
|
||||
</script>
|
||||
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<FormTextInput
|
||||
:name="fieldName"
|
||||
:disabled="!control.enabled"
|
||||
:model-value="control.data + ''"
|
||||
:rules="validator"
|
||||
:label="control.label"
|
||||
type="number"
|
||||
step="any"
|
||||
size="lg"
|
||||
show-label
|
||||
:show-required="isRequired"
|
||||
:placeholder="appliedOptions['placeholder']"
|
||||
:help="control.description"
|
||||
:validate-on-value-update="validateOnValueUpdate"
|
||||
@update:model-value="handleChange"
|
||||
/>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { type ControlElement } from '@jsonforms/core'
|
||||
import { rendererProps, useJsonFormsControl } from '@jsonforms/vue'
|
||||
import { useJsonRendererBaseSetup } from '~/lib/form/composables/jsonRenderers'
|
||||
|
||||
const props = defineProps({
|
||||
...rendererProps<ControlElement>()
|
||||
})
|
||||
|
||||
const {
|
||||
handleChange,
|
||||
control,
|
||||
validator,
|
||||
appliedOptions,
|
||||
fieldName,
|
||||
validateOnValueUpdate,
|
||||
isRequired
|
||||
} = useJsonRendererBaseSetup(useJsonFormsControl(props), {
|
||||
onChangeValueConverter: (val: string) => {
|
||||
// Convert to number, if possible
|
||||
const num = parseFloat(val)
|
||||
return isNaN(num) ? undefined : num
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,35 @@
|
||||
<template>
|
||||
<FormTextInput
|
||||
:name="fieldName"
|
||||
:disabled="!control.enabled"
|
||||
:model-value="control.data"
|
||||
:rules="validator"
|
||||
:label="control.label"
|
||||
:placeholder="appliedOptions['placeholder']"
|
||||
:help="control.description"
|
||||
:show-required="isRequired"
|
||||
show-label
|
||||
size="lg"
|
||||
:validate-on-value-update="validateOnValueUpdate"
|
||||
@update:model-value="handleChange"
|
||||
/>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { type ControlElement } from '@jsonforms/core'
|
||||
import { rendererProps, useJsonFormsControl } from '@jsonforms/vue'
|
||||
import { useJsonRendererBaseSetup } from '~/lib/form/composables/jsonRenderers'
|
||||
|
||||
const props = defineProps({
|
||||
...rendererProps<ControlElement>()
|
||||
})
|
||||
|
||||
const {
|
||||
handleChange,
|
||||
control,
|
||||
validator,
|
||||
appliedOptions,
|
||||
fieldName,
|
||||
validateOnValueUpdate,
|
||||
isRequired
|
||||
} = useJsonRendererBaseSetup(useJsonFormsControl(props))
|
||||
</script>
|
||||
@@ -0,0 +1,37 @@
|
||||
<template>
|
||||
<FormTextInput
|
||||
:name="fieldName"
|
||||
:disabled="!control.enabled"
|
||||
:model-value="control.data"
|
||||
:rules="validator"
|
||||
:label="control.label"
|
||||
show-label
|
||||
type="time"
|
||||
step="1"
|
||||
size="lg"
|
||||
:show-required="isRequired"
|
||||
:placeholder="appliedOptions['placeholder']"
|
||||
:help="control.description"
|
||||
:validate-on-value-update="validateOnValueUpdate"
|
||||
@update:model-value="handleChange"
|
||||
/>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { type ControlElement } from '@jsonforms/core'
|
||||
import { rendererProps, useJsonFormsControl } from '@jsonforms/vue'
|
||||
import { useJsonRendererBaseSetup } from '~/lib/form/composables/jsonRenderers'
|
||||
|
||||
const props = defineProps({
|
||||
...rendererProps<ControlElement>()
|
||||
})
|
||||
|
||||
const {
|
||||
handleChange,
|
||||
control,
|
||||
validator,
|
||||
appliedOptions,
|
||||
fieldName,
|
||||
validateOnValueUpdate,
|
||||
isRequired
|
||||
} = useJsonRendererBaseSetup(useJsonFormsControl(props))
|
||||
</script>
|
||||
@@ -0,0 +1,180 @@
|
||||
<template>
|
||||
<FormSelectBase
|
||||
ref="select"
|
||||
v-model="selectedValue"
|
||||
:multiple="multiple"
|
||||
:search="true"
|
||||
:search-placeholder="searchPlaceholder"
|
||||
:get-search-results="invokeSearch"
|
||||
:label="label"
|
||||
:show-label="showLabel"
|
||||
:name="name || 'models'"
|
||||
:rules="rules"
|
||||
:validate-on-value-update="validateOnValueUpdate"
|
||||
:allow-unset="allowUnset"
|
||||
:show-required="showRequired"
|
||||
:label-id="labelId"
|
||||
:button-id="buttonId"
|
||||
mount-menu-on-body
|
||||
by="id"
|
||||
>
|
||||
<template #nothing-selected>
|
||||
<template v-if="selectorPlaceholder">
|
||||
{{ selectorPlaceholder }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ multiple ? 'Select models' : 'Select a model' }}
|
||||
</template>
|
||||
</template>
|
||||
<template #something-selected="{ value }">
|
||||
<template v-if="isMultiItemArrayValue(value)">
|
||||
<div ref="elementToWatchForChanges" class="flex items-center space-x-0.5">
|
||||
<div
|
||||
ref="itemContainer"
|
||||
class="flex flex-wrap overflow-hidden space-x-0.5 h-6"
|
||||
>
|
||||
<div v-for="(item, i) in value" :key="item.id" class="text-foreground">
|
||||
{{ item.name + (i < value.length - 1 ? ', ' : '') }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="hiddenSelectedItemCount > 0" class="text-foreground-2 normal">
|
||||
+{{ hiddenSelectedItemCount }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="flex items-center">
|
||||
<span class="truncate text-foreground">
|
||||
{{ value.name }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
<template #option="{ item }">
|
||||
<div class="flex items-center">
|
||||
<span class="truncate">{{ item.name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</FormSelectBase>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { type Nullable, type Optional } from '@speckle/shared'
|
||||
import { FormSelectBase, useFormSelectChildInternals } from '@speckle/ui-components'
|
||||
import { useApolloClient } from '@vue/apollo-composable'
|
||||
import { type RuleExpression } from 'vee-validate'
|
||||
import { type PropType } from 'vue'
|
||||
import { graphql } from '~/lib/common/generated/gql'
|
||||
import type { FormSelectModels_ModelFragment } from '~/lib/common/generated/gql/graphql'
|
||||
import { searchModelsQuery } from '~/lib/form/graphql/queries'
|
||||
|
||||
graphql(`
|
||||
fragment FormSelectModels_Model on Model {
|
||||
id
|
||||
name
|
||||
}
|
||||
`)
|
||||
|
||||
type ItemType = FormSelectModels_ModelFragment
|
||||
type ValueType = ItemType | ItemType[] | undefined
|
||||
|
||||
const emit = defineEmits<(e: 'update:modelValue', v: ValueType) => void>()
|
||||
|
||||
const props = defineProps({
|
||||
projectId: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
/**
|
||||
* Whether to allow selecting multiple items
|
||||
*/
|
||||
multiple: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
modelValue: {
|
||||
type: [Object, Array] as PropType<ValueType>,
|
||||
default: undefined
|
||||
},
|
||||
/**
|
||||
* Search placeholder text
|
||||
*/
|
||||
searchPlaceholder: {
|
||||
type: String,
|
||||
default: 'Search models'
|
||||
},
|
||||
selectorPlaceholder: {
|
||||
type: String as PropType<Optional<string>>,
|
||||
default: ''
|
||||
},
|
||||
/**
|
||||
* Label is required at the very least for screen-readers
|
||||
*/
|
||||
label: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
/**
|
||||
* Whether to show the label visually
|
||||
*/
|
||||
showLabel: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
name: {
|
||||
type: String as PropType<Optional<string>>,
|
||||
default: undefined
|
||||
},
|
||||
rules: {
|
||||
type: [String, Object, Function, Array] as PropType<RuleExpression<ValueType>>,
|
||||
default: undefined
|
||||
},
|
||||
validateOnValueUpdate: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
allowUnset: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
showRequired: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const select = ref(null as Nullable<{ triggerSearch: () => Promise<void> }>)
|
||||
|
||||
const elementToWatchForChanges = ref(null as Nullable<HTMLElement>)
|
||||
const itemContainer = ref(null as Nullable<HTMLElement>)
|
||||
const labelId = useId()
|
||||
const buttonId = useId()
|
||||
|
||||
const coreParams = computed(() => ({ projectId: props.projectId }))
|
||||
const { selectedValue, hiddenSelectedItemCount, isMultiItemArrayValue } =
|
||||
useFormSelectChildInternals<ItemType>({
|
||||
props: toRefs(props),
|
||||
emit,
|
||||
dynamicVisibility: { elementToWatchForChanges, itemContainer }
|
||||
})
|
||||
|
||||
const apollo = useApolloClient().client
|
||||
const { isLoggedIn } = useActiveUser()
|
||||
|
||||
const invokeSearch = async (search: string) => {
|
||||
if (!isLoggedIn.value) return []
|
||||
const results = await apollo.query({
|
||||
query: searchModelsQuery,
|
||||
variables: {
|
||||
search: search.trim().length ? search : null,
|
||||
...coreParams.value
|
||||
}
|
||||
})
|
||||
return results.data.project.models.items || []
|
||||
}
|
||||
|
||||
watch(coreParams, () => {
|
||||
// Re-trigger search
|
||||
if (!select.value) return
|
||||
void select.value.triggerSearch()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,148 @@
|
||||
<template>
|
||||
<FormSelectBase
|
||||
ref="select"
|
||||
v-model="selectedValue"
|
||||
:multiple="multiple"
|
||||
:search="true"
|
||||
:search-placeholder="searchPlaceholder"
|
||||
:get-search-results="invokeSearch"
|
||||
:label="label"
|
||||
:show-label="showLabel"
|
||||
:name="name || 'functionReleases'"
|
||||
:rules="rules"
|
||||
:validate-on-value-update="validateOnValueUpdate"
|
||||
:allow-unset="allowUnset"
|
||||
class="min-w-[180px]"
|
||||
by="id"
|
||||
>
|
||||
<template #nothing-selected>
|
||||
<template v-if="selectorPlaceholder">
|
||||
{{ selectorPlaceholder }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ multiple ? 'Select releases' : 'Select a release' }}
|
||||
</template>
|
||||
</template>
|
||||
<template #something-selected="{ value }">
|
||||
<template v-if="isMultiItemArrayValue(value)">
|
||||
<div ref="elementToWatchForChanges" class="flex items-center space-x-0.5">
|
||||
<div
|
||||
ref="itemContainer"
|
||||
class="flex flex-wrap overflow-hidden space-x-0.5 h-6"
|
||||
>
|
||||
<div v-for="(item, i) in value" :key="item.id" class="text-foreground">
|
||||
{{ displayFunctionId(item) + (i < value.length - 1 ? ', ' : '') }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="hiddenSelectedItemCount > 0" class="text-foreground-2 normal">
|
||||
+{{ hiddenSelectedItemCount }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="flex items-center">
|
||||
<span class="truncate text-foreground">
|
||||
{{ displayFunctionId(value) }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
<template #option="{ item }">
|
||||
<div class="flex flex-col">
|
||||
<span class="truncate">{{ displayFunctionId(item) }}</span>
|
||||
<span class="label-light truncate text-foreground-2">
|
||||
{{ timeAgoCreatedAt(item) }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</FormSelectBase>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import type { Nullable, Optional } from '@speckle/shared'
|
||||
import { useFormSelectChildInternals } from '@speckle/ui-components'
|
||||
import { useApolloClient } from '@vue/apollo-composable'
|
||||
import dayjs from 'dayjs'
|
||||
import type { RuleExpression } from 'vee-validate'
|
||||
import { searchAutomateFunctionReleasesQuery } from '~/lib/automate/graphql/queries'
|
||||
import type { SearchAutomateFunctionReleaseItemFragment } from '~~/lib/common/generated/gql/graphql'
|
||||
|
||||
type ItemType = SearchAutomateFunctionReleaseItemFragment
|
||||
type ValueType = ItemType | ItemType[] | undefined
|
||||
|
||||
const emit = defineEmits<(e: 'update:modelValue', v: ValueType) => void>()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
functionId: string
|
||||
label: string
|
||||
name?: string
|
||||
rules?: RuleExpression<ValueType>
|
||||
multiple?: boolean
|
||||
modelValue?: ValueType
|
||||
searchPlaceholder?: string
|
||||
selectorPlaceholder?: string
|
||||
showLabel?: boolean
|
||||
validateOnValueUpdate?: boolean
|
||||
allowUnset?: boolean
|
||||
/**
|
||||
* If you don't have the actual model required to set the initial modelValue, you can use
|
||||
* the async retrieval API call of this component to set it
|
||||
*/
|
||||
resolveFirstModelValue?: (res: ItemType[]) => Optional<ItemType>
|
||||
}>(),
|
||||
{
|
||||
searchPlaceholder: 'Search releases'
|
||||
}
|
||||
)
|
||||
|
||||
const { isLoggedIn } = useActiveUser()
|
||||
const apollo = useApolloClient().client
|
||||
|
||||
const firstValueResolved = ref(false)
|
||||
const select = ref(null as Nullable<{ triggerSearch: () => Promise<void> }>)
|
||||
const elementToWatchForChanges = ref(null as Nullable<HTMLElement>)
|
||||
const itemContainer = ref(null as Nullable<HTMLElement>)
|
||||
|
||||
const { selectedValue, hiddenSelectedItemCount, isMultiItemArrayValue } =
|
||||
useFormSelectChildInternals<ItemType>({
|
||||
props: toRefs(props),
|
||||
emit,
|
||||
dynamicVisibility: { elementToWatchForChanges, itemContainer }
|
||||
})
|
||||
|
||||
const displayFunctionId = (item: ItemType) => item.versionTag
|
||||
const timeAgoCreatedAt = (item: ItemType) => dayjs(item.createdAt).from(dayjs())
|
||||
|
||||
const invokeSearch = async (search: string) => {
|
||||
if (!isLoggedIn.value) return []
|
||||
const results = await apollo.query({
|
||||
query: searchAutomateFunctionReleasesQuery,
|
||||
variables: {
|
||||
functionId: props.functionId,
|
||||
filter: {
|
||||
search: search.trim().length ? search : null
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const res = results.data.automateFunction?.releases?.items || []
|
||||
if (!firstValueResolved.value && props.resolveFirstModelValue) {
|
||||
const resolvedVal = props.resolveFirstModelValue(res)
|
||||
if (resolvedVal) {
|
||||
selectedValue.value = resolvedVal
|
||||
}
|
||||
}
|
||||
firstValueResolved.value = true
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.functionId,
|
||||
async () => {
|
||||
firstValueResolved.value = false
|
||||
await select.value?.triggerSearch()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,38 @@
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
>
|
||||
<path
|
||||
d="M4.16699 4.16667C4.16699 3.72464 4.34259 3.30072 4.65515 2.98816C4.96771 2.67559 5.39163 2.5 5.83366 2.5H14.167C14.609 2.5 15.0329 2.67559 15.3455 2.98816C15.6581 3.30072 15.8337 3.72464 15.8337 4.16667V15.8333C15.8337 16.2754 15.6581 16.6993 15.3455 17.0118C15.0329 17.3244 14.609 17.5 14.167 17.5H5.83366C5.39163 17.5 4.96771 17.3244 4.65515 17.0118C4.34259 16.6993 4.16699 16.2754 4.16699 15.8333V4.16667Z"
|
||||
stroke="#334155"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M7.5 5.8335H12.5"
|
||||
stroke="#334155"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M7.5 9.1665H12.5"
|
||||
stroke="#334155"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M7.5 12.5H10.8333"
|
||||
stroke="#334155"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -12,7 +12,7 @@
|
||||
},
|
||||
{
|
||||
text: 'Save',
|
||||
props: { color: 'primary', fullWidth: true, disabled: loading },
|
||||
props: { color: 'default', fullWidth: true, disabled: loading },
|
||||
onClick: () => {
|
||||
onSubmit()
|
||||
}
|
||||
|
||||
@@ -109,6 +109,7 @@ import {
|
||||
import { useClipboard } from '~~/composables/browser'
|
||||
import { SpeckleViewer } from '@speckle/shared'
|
||||
import { graphql } from '~~/lib/common/generated/gql'
|
||||
import type { LayoutDialogButton } from '@speckle/ui-components'
|
||||
|
||||
graphql(`
|
||||
fragment ProjectsModelPageEmbed_Project on Project {
|
||||
@@ -191,7 +192,7 @@ const iframeCode = computed(() => {
|
||||
return `<iframe title="Speckle" src="${updatedUrl.value}" width="600" height="400" frameborder="0"></iframe>`
|
||||
})
|
||||
|
||||
const discoverableButtons = computed(() => [
|
||||
const discoverableButtons = computed((): LayoutDialogButton[] => [
|
||||
{
|
||||
text: 'Cancel',
|
||||
props: { color: 'invert', fullWidth: true, outline: true },
|
||||
@@ -201,14 +202,14 @@ const discoverableButtons = computed(() => [
|
||||
},
|
||||
{
|
||||
text: 'Copy Embed Code',
|
||||
props: { color: 'primary', fullWidth: true },
|
||||
props: { color: 'default', fullWidth: true },
|
||||
onClick: () => {
|
||||
handleEmbedCodeCopy(iframeCode.value)
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
const nonDiscoverableButtons = computed(() => [
|
||||
const nonDiscoverableButtons = computed((): LayoutDialogButton[] => [
|
||||
{
|
||||
text: 'Close',
|
||||
props: { color: 'invert', fullWidth: true, outline: true },
|
||||
|
||||
@@ -17,16 +17,14 @@
|
||||
<PreviewImage :preview-url="version.previewUrl" />
|
||||
</NuxtLink>
|
||||
<div
|
||||
v-if="!isPendingVersionFragment(version) && version.automationStatus"
|
||||
v-if="!isPendingVersionFragment(version) && version.automationsStatus"
|
||||
class="absolute top-1 left-0 p-2"
|
||||
>
|
||||
<ProjectPageModelsCardAutomationStatusRefactor
|
||||
<AutomateRunsTriggerStatus
|
||||
:project-id="projectId"
|
||||
:model-or-version="{
|
||||
...version,
|
||||
automationStatus: version.automationStatus
|
||||
}"
|
||||
:status="version.automationsStatus"
|
||||
:model-id="modelId"
|
||||
:version-id="version.id"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
@@ -114,7 +112,9 @@ graphql(`
|
||||
}
|
||||
...ProjectModelPageDialogDeleteVersion
|
||||
...ProjectModelPageDialogMoveToVersion
|
||||
...ModelCardAutomationStatus_Version
|
||||
automationsStatus {
|
||||
...AutomateRunsTriggerStatus_TriggeredAutomationsStatus
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
@@ -134,12 +134,11 @@ const props = defineProps<{
|
||||
selected?: boolean
|
||||
selectionDisabled?: boolean
|
||||
}>()
|
||||
provide('projectId', props.projectId)
|
||||
|
||||
const showActionsMenu = ref(false)
|
||||
|
||||
const hasAutomationStatus = computed(
|
||||
() => !isPendingVersionFragment(props.version) && props.version.automationStatus
|
||||
() => !isPendingVersionFragment(props.version) && props.version.automationsStatus
|
||||
)
|
||||
const createdAt = computed(() => {
|
||||
const date = isPendingVersionFragment(props.version)
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<div
|
||||
class="relative z-10 grid gap-3"
|
||||
:class="[
|
||||
smallView ? 'grid-cols-2 sm:grid-cols-3 md:grid-cols-4' : '',
|
||||
!vertical ? 'grid-cols-1 md:grid-cols-2 lg:grid-cols-4' : ''
|
||||
]"
|
||||
>
|
||||
<!-- Decrementing z-index necessary for the actions menu to render correctly. Each card has its own stacking context because of the scale property -->
|
||||
<ProjectPageModelsCard
|
||||
v-for="(item, i) in items"
|
||||
:key="item.id"
|
||||
:model="item"
|
||||
:project-id="projectId"
|
||||
:project="project"
|
||||
:show-actions="showActions"
|
||||
:show-versions="showVersions"
|
||||
height="h-32 sm:h-64"
|
||||
:disable-default-link="disableDefaultLinks"
|
||||
:style="`z-index: ${items.length - i};`"
|
||||
@click="($event) => $emit('model-clicked', { id: item.id, e: $event })"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import type { Optional } from '@speckle/shared'
|
||||
import type {
|
||||
PendingFileUploadFragment,
|
||||
ProjectPageLatestItemsModelItemFragment,
|
||||
ProjectPageModelsCardProjectFragment
|
||||
} from '~/lib/common/generated/gql/graphql'
|
||||
|
||||
defineEmits<{
|
||||
(e: 'model-clicked', v: { id: string; e: MouseEvent | KeyboardEvent }): void
|
||||
}>()
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
project: Optional<ProjectPageModelsCardProjectFragment>
|
||||
items: Array<ProjectPageLatestItemsModelItemFragment | PendingFileUploadFragment>
|
||||
projectId: string
|
||||
smallView?: boolean
|
||||
vertical?: boolean
|
||||
showActions?: boolean
|
||||
showVersions?: boolean
|
||||
disableDefaultLinks?: boolean
|
||||
}>(),
|
||||
{
|
||||
showActions: true,
|
||||
showVersions: true
|
||||
}
|
||||
)
|
||||
</script>
|
||||
@@ -70,6 +70,7 @@ import { useTeamInternals } from '~~/lib/projects/composables/team'
|
||||
import { useMixpanel } from '~~/lib/core/composables/mp'
|
||||
import { useServerInfo } from '~~/lib/core/composables/server'
|
||||
import { graphql } from '~/lib/common/generated/gql/gql'
|
||||
import type { LayoutDialogButton } from '@speckle/ui-components'
|
||||
|
||||
graphql(`
|
||||
fragment ProjectPageInviteDialog_Project on Project {
|
||||
@@ -107,7 +108,7 @@ const projectData = computed(() => props.project)
|
||||
|
||||
const { collaboratorListItems } = useTeamInternals(projectData)
|
||||
|
||||
const dialogButtons = computed(() => [
|
||||
const dialogButtons = computed<LayoutDialogButton[]>(() => [
|
||||
{
|
||||
text: 'Cancel',
|
||||
props: { color: 'secondary', fullWidth: true },
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
<template>
|
||||
<LayoutDialog
|
||||
v-model:open="open"
|
||||
max-width="md"
|
||||
:title="`Function settings`"
|
||||
@fully-closed="$emit('fully-closed')"
|
||||
>
|
||||
<div v-if="false" class="flex flex-col space-y-4">
|
||||
<CommonLoadingIcon class="mx-auto" />
|
||||
</div>
|
||||
<div v-else-if="hasRequiredData && functionId" class="flex flex-col space-y-4">
|
||||
<FormSelectAutomateFunctionReleases
|
||||
v-model="selectedRelease"
|
||||
show-label
|
||||
:function-id="functionId"
|
||||
:resolve-first-model-value="resolveFirstModelValue"
|
||||
name="version"
|
||||
label="Function release"
|
||||
button-style="tinted"
|
||||
:class="{ hidden: !selectedRelease }"
|
||||
/>
|
||||
<template v-if="!selectedRelease">
|
||||
<CommonLoadingBar loading class="w-full" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<CommonAlert v-if="!inputSchema" color="info">
|
||||
<template #title>
|
||||
No parameters defined for the selected function release
|
||||
</template>
|
||||
</CommonAlert>
|
||||
<FormJsonForm
|
||||
v-else
|
||||
v-model:data="selectedVersionInputs"
|
||||
:schema="inputSchema"
|
||||
class="space-y-4"
|
||||
@change="handler"
|
||||
/>
|
||||
<CommonModelSelect
|
||||
v-model="selectedModel"
|
||||
class="!mt-8"
|
||||
:project-id="projectId"
|
||||
name="model"
|
||||
label="Target model"
|
||||
show-label
|
||||
help="Select model that the function will run on when new versions are created"
|
||||
/>
|
||||
<div class="h-32">
|
||||
<!-- To ensure the dropdown doesn't cause a vertical scrollbar -->
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<template #buttons>
|
||||
<div class="flex w-full space-x-2">
|
||||
<FormButton
|
||||
v-if="revisionFn"
|
||||
text
|
||||
target="_blank"
|
||||
:to="automationFunctionRoute(revisionFn.release.function.id)"
|
||||
>
|
||||
View function
|
||||
</FormButton>
|
||||
<div class="grow" />
|
||||
<FormButton outlined @click="open = false">Close</FormButton>
|
||||
<FormButton
|
||||
:disabled="hasErrors || loading || !hasRequiredData || !selectedModel"
|
||||
@click="onSave"
|
||||
>
|
||||
Save
|
||||
</FormButton>
|
||||
</div>
|
||||
</template>
|
||||
</LayoutDialog>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import type { MaybeNullOrUndefined, Optional } from '@speckle/shared'
|
||||
import { automationFunctionRoute } from '~/lib/common/helpers/route'
|
||||
import { useJsonFormsChangeHandler } from '~/lib/automate/composables/jsonSchema'
|
||||
import {
|
||||
formatJsonFormSchemaInputs,
|
||||
formattedJsonFormSchema
|
||||
} from '~/lib/automate/helpers/jsonSchema'
|
||||
import { graphql } from '~/lib/common/generated/gql'
|
||||
import {
|
||||
AutomateRunTriggerType,
|
||||
type CommonModelSelectorModelFragment,
|
||||
type ProjectPageAutomationFunctionSettingsDialog_AutomationRevisionFragment,
|
||||
type ProjectPageAutomationFunctionSettingsDialog_AutomationRevisionFunctionFragment,
|
||||
type SearchAutomateFunctionReleaseItemFragment
|
||||
} from '~/lib/common/generated/gql/graphql'
|
||||
import { Automate } from '@speckle/shared'
|
||||
import { useCreateAutomationRevision } from '~/lib/projects/composables/automationManagement'
|
||||
import {
|
||||
useAutomationInputEncryptor,
|
||||
type AutomationInputEncryptor
|
||||
} from '~/lib/automate/composables/automations'
|
||||
|
||||
type AutomationRevisionFunction =
|
||||
ProjectPageAutomationFunctionSettingsDialog_AutomationRevisionFunctionFragment
|
||||
|
||||
type AutomationRevision =
|
||||
ProjectPageAutomationFunctionSettingsDialog_AutomationRevisionFragment
|
||||
|
||||
graphql(`
|
||||
fragment ProjectPageAutomationFunctionSettingsDialog_AutomationRevisionFunction on AutomationRevisionFunction {
|
||||
parameters
|
||||
release {
|
||||
id
|
||||
inputSchema
|
||||
function {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
graphql(`
|
||||
fragment ProjectPageAutomationFunctionSettingsDialog_AutomationRevision on AutomationRevision {
|
||||
id
|
||||
triggerDefinitions {
|
||||
... on VersionCreatedTriggerDefinition {
|
||||
type
|
||||
model {
|
||||
id
|
||||
...CommonModelSelectorModel
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'fully-closed'): void
|
||||
(e: 'save'): void
|
||||
}>()
|
||||
|
||||
const props = defineProps<{
|
||||
projectId: string
|
||||
automationId: string
|
||||
revisionFn: MaybeNullOrUndefined<AutomationRevisionFunction>
|
||||
revision: MaybeNullOrUndefined<AutomationRevision>
|
||||
}>()
|
||||
|
||||
const open = defineModel<boolean>('open', { required: true })
|
||||
const createNewAutomationRevision = useCreateAutomationRevision()
|
||||
const inputEncryption = useAutomationInputEncryptor({ ensureWhen: open })
|
||||
const { triggerNotification } = useGlobalToast()
|
||||
const logger = useLogger()
|
||||
|
||||
const selectedModel = ref<CommonModelSelectorModelFragment>()
|
||||
const selectedRelease = ref<SearchAutomateFunctionReleaseItemFragment>()
|
||||
const inputSchema = computed(() =>
|
||||
formattedJsonFormSchema(selectedRelease.value?.inputSchema)
|
||||
)
|
||||
const {
|
||||
handler,
|
||||
hasErrors: hasJsonFormErrors,
|
||||
reset: resetJsonFormsState
|
||||
} = useJsonFormsChangeHandler({ schema: inputSchema })
|
||||
|
||||
const selectedVersionInputs = ref<Record<string, unknown>>()
|
||||
const loading = ref(false)
|
||||
|
||||
const parentSelectedModel = computed(() => props.revision?.triggerDefinitions[0]?.model)
|
||||
const hasRequiredData = computed(() => !!props.revisionFn && !!props.revision)
|
||||
const functionId = computed(() => props.revisionFn?.release.function.id)
|
||||
const currentReleaseId = computed(() => props.revisionFn?.release.id)
|
||||
const selectedReleaseId = computed(() => selectedRelease.value?.id)
|
||||
|
||||
const hasErrors = computed(() => {
|
||||
if (hasJsonFormErrors.value) return true
|
||||
if (!selectedRelease.value) return true
|
||||
return false
|
||||
})
|
||||
|
||||
const resolveFirstModelValue = (items: SearchAutomateFunctionReleaseItemFragment[]) => {
|
||||
const modelValue = currentReleaseId.value
|
||||
? items.find((i) => i.id === currentReleaseId.value)
|
||||
: undefined
|
||||
|
||||
if (!modelValue) {
|
||||
// This def shouldn't happen, something's wrong
|
||||
triggerNotification({
|
||||
type: ToastNotificationType.Danger,
|
||||
title: 'Could not find the selected function version',
|
||||
description: 'Please try again or contact support'
|
||||
})
|
||||
logger.error('Could not find the selected function version', {
|
||||
functionId: functionId.value,
|
||||
functionVersionId: currentReleaseId.value
|
||||
})
|
||||
}
|
||||
|
||||
return modelValue
|
||||
}
|
||||
|
||||
const onSave = async () => {
|
||||
const fId = functionId.value
|
||||
const rId = selectedReleaseId.value
|
||||
const model = selectedModel.value
|
||||
|
||||
if (hasErrors.value || !fId || !rId || !hasRequiredData.value || !model) return
|
||||
|
||||
loading.value = true
|
||||
let automationEncrypt: Optional<AutomationInputEncryptor> = undefined
|
||||
try {
|
||||
automationEncrypt = await inputEncryption.forAutomation({
|
||||
automationId: props.automationId,
|
||||
projectId: props.projectId
|
||||
})
|
||||
|
||||
const cleanParameters =
|
||||
formatJsonFormSchemaInputs(selectedVersionInputs.value, inputSchema.value) || null
|
||||
const parameters = automationEncrypt.encryptInputs({
|
||||
inputs: cleanParameters
|
||||
})
|
||||
|
||||
// TODO: Apollo cache mutation afterwards
|
||||
await createNewAutomationRevision({
|
||||
projectId: props.projectId,
|
||||
input: {
|
||||
automationId: props.automationId,
|
||||
functions: [
|
||||
{
|
||||
functionReleaseId: rId,
|
||||
functionId: fId,
|
||||
parameters
|
||||
}
|
||||
],
|
||||
triggerDefinitions: <Automate.AutomateTypes.TriggerDefinitionsSchema>{
|
||||
version: Automate.AutomateTypes.TRIGGER_DEFINITIONS_SCHEMA_VERSION,
|
||||
definitions: [
|
||||
{
|
||||
type: AutomateRunTriggerType.VersionCreated,
|
||||
modelId: model.id
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
} finally {
|
||||
automationEncrypt?.dispose()
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
open.value = false
|
||||
emit('save')
|
||||
}
|
||||
|
||||
// Reset everything if props change
|
||||
watch(
|
||||
() => <const>[props.revisionFn?.release.function.id, props.revisionFn?.release.id],
|
||||
([newFunctionId, newFunctionRevisionId], [oldFunctionId, oldFunctionRevisionId]) => {
|
||||
if (
|
||||
newFunctionId === oldFunctionId &&
|
||||
newFunctionRevisionId === oldFunctionRevisionId
|
||||
)
|
||||
return
|
||||
|
||||
selectedRelease.value = undefined
|
||||
}
|
||||
)
|
||||
|
||||
// Update inputs when selected version changes
|
||||
watch(selectedRelease, (newSelectedVersion, oldSelectedVersion) => {
|
||||
const id = newSelectedVersion?.id
|
||||
const oldId = oldSelectedVersion?.id
|
||||
if (id === oldId) return
|
||||
|
||||
if (!id || id !== props.revisionFn?.release.id) {
|
||||
selectedVersionInputs.value = undefined
|
||||
resetJsonFormsState()
|
||||
return
|
||||
}
|
||||
|
||||
const existingValues = formatJsonFormSchemaInputs(
|
||||
props.revisionFn.parameters,
|
||||
inputSchema.value,
|
||||
{ cleanRedacted: true }
|
||||
)
|
||||
selectedVersionInputs.value = existingValues
|
||||
resetJsonFormsState()
|
||||
})
|
||||
|
||||
// Update model when props change
|
||||
watch(
|
||||
parentSelectedModel,
|
||||
(newVal, oldVal) => {
|
||||
if (newVal?.id === oldVal?.id) return
|
||||
|
||||
selectedModel.value = newVal
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,98 @@
|
||||
<template>
|
||||
<div class="flex flex-col">
|
||||
<h2 class="h6 font-bold mb-6">Function</h2>
|
||||
<AutomateFunctionCardView v-if="functions.length" vertical>
|
||||
<AutomateFunctionCard
|
||||
v-for="fn in functions"
|
||||
:key="fn.fn.id"
|
||||
:fn="fn.fn"
|
||||
:is-outdated="isOutdated(fn)"
|
||||
show-edit
|
||||
@edit="onEdit(fn.fn)"
|
||||
/>
|
||||
</AutomateFunctionCardView>
|
||||
<CommonGenericEmptyState
|
||||
v-else
|
||||
message="No valid functions are associated with this automation"
|
||||
/>
|
||||
<ProjectPageAutomationFunctionSettingsDialog
|
||||
v-model:open="dialogOpen"
|
||||
:project-id="projectId"
|
||||
:automation-id="automation.id"
|
||||
:revision-fn="dialogFunction"
|
||||
:revision="automation.currentRevision"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import type { Optional } from '@speckle/shared'
|
||||
import { graphql } from '~/lib/common/generated/gql'
|
||||
import type {
|
||||
AutomationsFunctionsCard_AutomateFunctionFragment,
|
||||
ProjectPageAutomationFunctionSettingsDialog_AutomationRevisionFunctionFragment,
|
||||
ProjectPageAutomationFunctions_AutomationFragment
|
||||
} from '~/lib/common/generated/gql/graphql'
|
||||
|
||||
type EditableFunction = AutomationsFunctionsCard_AutomateFunctionFragment
|
||||
type EditableFunctionRevision =
|
||||
ProjectPageAutomationFunctionSettingsDialog_AutomationRevisionFunctionFragment
|
||||
|
||||
graphql(`
|
||||
fragment ProjectPageAutomationFunctions_Automation on Automation {
|
||||
id
|
||||
currentRevision {
|
||||
id
|
||||
...ProjectPageAutomationFunctionSettingsDialog_AutomationRevision
|
||||
functions {
|
||||
release {
|
||||
id
|
||||
function {
|
||||
id
|
||||
...AutomationsFunctionsCard_AutomateFunction
|
||||
releases(limit: 1) {
|
||||
items {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
...ProjectPageAutomationFunctionSettingsDialog_AutomationRevisionFunction
|
||||
}
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
const props = defineProps<{
|
||||
projectId: string
|
||||
automation: ProjectPageAutomationFunctions_AutomationFragment
|
||||
}>()
|
||||
|
||||
const dialogOpen = ref(false)
|
||||
const dialogFunction = ref<Optional<EditableFunctionRevision>>()
|
||||
|
||||
const functionRevisions = computed(
|
||||
() => props.automation.currentRevision?.functions || []
|
||||
)
|
||||
const functions = computed(
|
||||
() =>
|
||||
functionRevisions.value.map((f) => ({
|
||||
fn: f.release.function,
|
||||
fnReleaseId: f.release.id
|
||||
})) || []
|
||||
)
|
||||
|
||||
const onEdit = (fn: EditableFunction) => {
|
||||
const fid = fn.id
|
||||
const revision = functionRevisions.value.find((f) => f.release.function.id === fid)
|
||||
|
||||
if (revision) {
|
||||
dialogOpen.value = true
|
||||
dialogFunction.value = revision
|
||||
}
|
||||
}
|
||||
|
||||
const isOutdated = (fn: (typeof functions.value)[0]) => {
|
||||
const latestRelease = fn.fn.releases.items[0]
|
||||
return latestRelease.id !== fn.fnReleaseId
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,134 @@
|
||||
<template>
|
||||
<div class="flex flex-col items-start w-full">
|
||||
<div class="flex gap-2 flex-col sm:flex-row sm:justify-between w-full">
|
||||
<div class="flex flex-col items-start gap-1">
|
||||
<CommonTextLink :icon-left="ArrowLeftIcon" size="xs" :to="automationsLink">
|
||||
Back to Automations
|
||||
</CommonTextLink>
|
||||
<CommonEditableTitle
|
||||
v-model="name"
|
||||
:disabled="loading"
|
||||
:custom-classes="{
|
||||
input: 'h4 font-bold',
|
||||
pencil: 'ml-2 mt-2 w-4 h-4'
|
||||
}"
|
||||
class="relative top-1.5"
|
||||
/>
|
||||
</div>
|
||||
<FormSwitch
|
||||
:id="switchId"
|
||||
v-model="enabled"
|
||||
name="enable"
|
||||
:label="enabled ? 'Enabled' : 'Disabled'"
|
||||
:disabled="loading"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ArrowLeftIcon } from '@heroicons/vue/24/outline'
|
||||
import { FormSwitch } from '@speckle/ui-components'
|
||||
import { useMutationLoading } from '@vue/apollo-composable'
|
||||
import { graphql } from '~/lib/common/generated/gql'
|
||||
import type {
|
||||
ProjectPageAutomationHeader_AutomationFragment,
|
||||
ProjectPageAutomationHeader_ProjectFragment
|
||||
} from '~/lib/common/generated/gql/graphql'
|
||||
import { projectRoute } from '~/lib/common/helpers/route'
|
||||
import { useUpdateAutomation } from '~/lib/projects/composables/automationManagement'
|
||||
|
||||
graphql(`
|
||||
fragment ProjectPageAutomationHeader_Automation on Automation {
|
||||
id
|
||||
name
|
||||
enabled
|
||||
currentRevision {
|
||||
id
|
||||
triggerDefinitions {
|
||||
... on VersionCreatedTriggerDefinition {
|
||||
model {
|
||||
...ProjectPageLatestItemsModelItem
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
graphql(`
|
||||
fragment ProjectPageAutomationHeader_Project on Project {
|
||||
id
|
||||
...ProjectPageModelsCardProject
|
||||
}
|
||||
`)
|
||||
|
||||
const props = defineProps<{
|
||||
project: ProjectPageAutomationHeader_ProjectFragment
|
||||
automation: ProjectPageAutomationHeader_AutomationFragment
|
||||
}>()
|
||||
|
||||
const switchId = useId()
|
||||
const loading = useMutationLoading()
|
||||
const updateAutomation = useUpdateAutomation()
|
||||
|
||||
const automationsLink = computed(() => projectRoute(props.project.id, 'automations'))
|
||||
const name = computed({
|
||||
get: () => props.automation.name,
|
||||
set: async (newVal) => {
|
||||
if (newVal === props.automation.name) return
|
||||
|
||||
const args = {
|
||||
projectId: props.project.id,
|
||||
input: {
|
||||
id: props.automation.id,
|
||||
name: newVal
|
||||
}
|
||||
}
|
||||
await updateAutomation(args, {
|
||||
optimisticResponse: {
|
||||
projectMutations: {
|
||||
automationMutations: {
|
||||
update: {
|
||||
id: args.input.id,
|
||||
name: args.input.name || props.automation.name,
|
||||
enabled: props.automation.enabled
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const enabled = computed({
|
||||
get: () => props.automation.enabled,
|
||||
set: async (newVal) => {
|
||||
if (newVal === props.automation.enabled) return
|
||||
|
||||
const args = {
|
||||
projectId: props.project.id,
|
||||
input: {
|
||||
id: props.automation.id,
|
||||
enabled: newVal
|
||||
}
|
||||
}
|
||||
await updateAutomation(args, {
|
||||
optimisticResponse: {
|
||||
projectMutations: {
|
||||
automationMutations: {
|
||||
update: {
|
||||
id: args.input.id,
|
||||
enabled: args.input.enabled,
|
||||
name: props.automation.name
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
messages: {
|
||||
success: `Automation ${args.input.enabled ? 'enabled' : 'disabled'}`,
|
||||
failure: `Failed to ${args.input.enabled ? 'enable' : 'disable'} automation`
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,59 @@
|
||||
<template>
|
||||
<div class="flex flex-col items-start w-full">
|
||||
<h2 class="h6 font-bold mb-6">Model</h2>
|
||||
<div class="w-full">
|
||||
<ProjectModelsBasicCardView
|
||||
v-if="triggerModels.length"
|
||||
:items="triggerModels"
|
||||
vertical
|
||||
:project="project"
|
||||
:project-id="project.id"
|
||||
/>
|
||||
<CommonGenericEmptyState
|
||||
v-else
|
||||
message="No valid models found for this automation. They may have been deleted."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { graphql } from '~/lib/common/generated/gql'
|
||||
import type {
|
||||
ProjectPageAutomationHeader_AutomationFragment,
|
||||
ProjectPageAutomationHeader_ProjectFragment
|
||||
} from '~/lib/common/generated/gql/graphql'
|
||||
|
||||
graphql(`
|
||||
fragment ProjectPageAutomationHeader_Automation on Automation {
|
||||
id
|
||||
name
|
||||
enabled
|
||||
currentRevision {
|
||||
id
|
||||
triggerDefinitions {
|
||||
... on VersionCreatedTriggerDefinition {
|
||||
model {
|
||||
...ProjectPageLatestItemsModelItem
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
graphql(`
|
||||
fragment ProjectPageAutomationHeader_Project on Project {
|
||||
id
|
||||
...ProjectPageModelsCardProject
|
||||
}
|
||||
`)
|
||||
|
||||
const props = defineProps<{
|
||||
project: ProjectPageAutomationHeader_ProjectFragment
|
||||
automation: ProjectPageAutomationHeader_AutomationFragment
|
||||
}>()
|
||||
|
||||
const triggerModels = computed(
|
||||
() => props.automation.currentRevision?.triggerDefinitions.map((t) => t.model) || []
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,52 @@
|
||||
<template>
|
||||
<div class="flex flex-col w-full">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h2 class="h6 font-bold">Runs</h2>
|
||||
<FormButton
|
||||
:icon-left="ArrowPathIcon"
|
||||
:disabled="!automation.enabled"
|
||||
@click="onTrigger"
|
||||
>
|
||||
Trigger Automation
|
||||
</FormButton>
|
||||
</div>
|
||||
<AutomateRunsTable
|
||||
class="mt-3"
|
||||
:runs="automation.runs.items"
|
||||
:project-id="projectId"
|
||||
:automation-id="automation.id"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ArrowPathIcon } from '@heroicons/vue/24/outline'
|
||||
import { graphql } from '~/lib/common/generated/gql'
|
||||
import type { ProjectPageAutomationRuns_AutomationFragment } from '~/lib/common/generated/gql/graphql'
|
||||
import { useTriggerAutomation } from '~/lib/projects/composables/automationManagement'
|
||||
|
||||
// TODO: Pagination
|
||||
// TODO: Subscriptions for new runs
|
||||
|
||||
graphql(`
|
||||
fragment ProjectPageAutomationRuns_Automation on Automation {
|
||||
id
|
||||
enabled
|
||||
runs {
|
||||
items {
|
||||
...AutomationRunDetails
|
||||
}
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
const props = defineProps<{
|
||||
automation: ProjectPageAutomationRuns_AutomationFragment
|
||||
projectId: string
|
||||
}>()
|
||||
|
||||
const triggerAutomation = useTriggerAutomation()
|
||||
|
||||
const onTrigger = () => {
|
||||
triggerAutomation(props.projectId, props.automation.id)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,89 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-12">
|
||||
<div class="p-4 flex flex-col gap-6 rounded-lg max-w-2xl mx-auto items-center">
|
||||
<ProjectPageAutomationsScaleImpactImage />
|
||||
<div class="gap-2 flex flex-col text-center">
|
||||
<div class="h3 leading-10 text-fancy-gradient">Scale your digital impact</div>
|
||||
<div class="text-foreground normal">
|
||||
Speckle Automate empowers you to continuously monitor your published models,
|
||||
automatically ensuring project data standards, identifying potential design
|
||||
faults, and effortlessly creating delivery artifacts.
|
||||
<CommonTextLink
|
||||
:icon-right="ArrowTopRightOnSquareIcon"
|
||||
target="_blank"
|
||||
external
|
||||
to="https://speckle.systems/blog/automate-with-speckle/"
|
||||
>
|
||||
Learn more
|
||||
</CommonTextLink>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<FormButton
|
||||
v-if="isAutomateEnabled"
|
||||
:icon-left="PlusIcon"
|
||||
size="lg"
|
||||
@click="$emit('new-automation')"
|
||||
>
|
||||
New Automation
|
||||
</FormButton>
|
||||
<FormButton
|
||||
v-else
|
||||
:icon-left="PlusIcon"
|
||||
size="lg"
|
||||
external
|
||||
target="_blank"
|
||||
to="https://docs.google.com/forms/d/e/1FAIpQLSc5e4q0gyG8VkGqA3gRzN71c4TDu0P9W0PXeVarFu_8po3qRA/viewform"
|
||||
>
|
||||
Sign Up for Beta
|
||||
</FormButton>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isAutomateEnabled" class="flex flex-col gap-9">
|
||||
<div class="flex gap-2 flex-col sm:flex-row sm:justify-between sm:items-center">
|
||||
<h2 class="h5 font-bold">Featured Functions</h2>
|
||||
<FormButton color="secondary" class="shrink-0" :to="automationFunctionsRoute">
|
||||
Explore All Functions
|
||||
</FormButton>
|
||||
</div>
|
||||
<AutomateFunctionCardView v-if="functions.length">
|
||||
<AutomateFunctionCard
|
||||
v-for="fn in functions"
|
||||
:key="fn.id"
|
||||
:fn="fn"
|
||||
@use="() => $emit('new-automation', fn)"
|
||||
/>
|
||||
</AutomateFunctionCardView>
|
||||
<CommonGenericEmptyState v-else />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ArrowTopRightOnSquareIcon, PlusIcon } from '@heroicons/vue/24/outline'
|
||||
import { graphql } from '~/lib/common/generated/gql'
|
||||
import type { ProjectPageAutomationsEmptyState_QueryFragment } from '~/lib/common/generated/gql/graphql'
|
||||
import { automationFunctionsRoute } from '~/lib/common/helpers/route'
|
||||
import type { CreateAutomationSelectableFunction } from '~/lib/automate/helpers/automations'
|
||||
|
||||
graphql(`
|
||||
fragment ProjectPageAutomationsEmptyState_Query on Query {
|
||||
automateFunctions(limit: 9, filter: { featuredFunctionsOnly: true }) {
|
||||
items {
|
||||
...AutomationsFunctionsCard_AutomateFunction
|
||||
...AutomateAutomationCreateDialog_AutomateFunction
|
||||
}
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
defineEmits<{
|
||||
'new-automation': [fn?: CreateAutomationSelectableFunction]
|
||||
}>()
|
||||
|
||||
const props = defineProps<{
|
||||
functions?: ProjectPageAutomationsEmptyState_QueryFragment
|
||||
isAutomateEnabled: boolean
|
||||
}>()
|
||||
|
||||
const functions = computed(() => props.functions?.automateFunctions.items || [])
|
||||
</script>
|
||||
@@ -1,10 +1,49 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col space-y-2 lg:space-y-0 lg:flex-row lg:justify-between lg:items-center mb-4"
|
||||
class="flex flex-col gap-y-2 md:gap-y-0 md:flex-row md:justify-between md:items-center"
|
||||
>
|
||||
<div class="flex justify-between items-center flex-wrap sm:flex-nowrap">
|
||||
<h1 class="block h4 font-bold">Automations</h1>
|
||||
<div v-if="hasAutomations" class="flex flex-col gap-2 md:flex-row md:items-center">
|
||||
<FormTextInput
|
||||
name="search"
|
||||
color="foundation"
|
||||
placeholder="Search Automations"
|
||||
wrapper-classes="shrink-0"
|
||||
show-clear
|
||||
:model-value="bind.modelValue.value"
|
||||
v-on="on"
|
||||
/>
|
||||
<FormButton
|
||||
:icon-left="ArrowTopRightOnSquareIcon"
|
||||
color="secondary"
|
||||
class="shrink-0"
|
||||
:to="automationFunctionsRoute"
|
||||
>
|
||||
Explore Functions
|
||||
</FormButton>
|
||||
<FormButton
|
||||
:icon-left="PlusIcon"
|
||||
class="shrink-0"
|
||||
@click="$emit('new-automation')"
|
||||
>
|
||||
New Automation
|
||||
</FormButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts"></script>
|
||||
<script setup lang="ts">
|
||||
import { ArrowTopRightOnSquareIcon, PlusIcon } from '@heroicons/vue/24/outline'
|
||||
import { useDebouncedTextInput } from '@speckle/ui-components'
|
||||
import { automationFunctionsRoute } from '~/lib/common/helpers/route'
|
||||
|
||||
defineEmits<{
|
||||
'new-automation': []
|
||||
}>()
|
||||
|
||||
defineProps<{
|
||||
hasAutomations?: boolean
|
||||
}>()
|
||||
|
||||
const search = defineModel<string>('search')
|
||||
const { on, bind } = useDebouncedTextInput({ model: search })
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
<template>
|
||||
<div class="bg-foundation border border-outline-3 rounded-lg pt-5 px-6 pb-6">
|
||||
<div class="flex w-full justify-between items-center mb-2">
|
||||
<div class="flex flex-col md:flex-row gap-4">
|
||||
<RouterLink
|
||||
class="h5 font-bold text-foreground hover:underline"
|
||||
:to="projectAutomationRoute(projectId, automation.id)"
|
||||
>
|
||||
{{ automation.name }}
|
||||
</RouterLink>
|
||||
<template v-if="!isEnabled">
|
||||
<div>
|
||||
<CommonBadge size="lg" color-classes="bg-danger-lighter text-danger-darker">
|
||||
Disabled
|
||||
</CommonBadge>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<CommonTextLink
|
||||
class="font-bold"
|
||||
:to="projectAutomationRoute(projectId, automation.id)"
|
||||
>
|
||||
View Details
|
||||
<ChevronRightIcon class="ml-2 w-4 h-4" />
|
||||
</CommonTextLink>
|
||||
</div>
|
||||
<div class="flex flex-col mb-6">
|
||||
<template v-if="triggerModels.length">
|
||||
<div class="flex gap-2">
|
||||
<div class="mt-1">Triggered by</div>
|
||||
<div v-for="model in triggerModels" :key="model.id" class="truncate">
|
||||
<CommonTextLink :icon-left="CubeIcon" :to="finalModelUrl(model.id)">
|
||||
{{ model.name }}
|
||||
</CommonTextLink>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div
|
||||
v-else
|
||||
class="flex items-center gap-1 truncate text-foreground-2 label-light"
|
||||
>
|
||||
<ExclamationTriangleIcon class="w-5 h-5" />
|
||||
<span>No valid models attached to this automation</span>
|
||||
</div>
|
||||
</div>
|
||||
<AutomateRunsTable
|
||||
:runs="automation.runs.items.slice(0, 5)"
|
||||
:project-id="projectId"
|
||||
:automation-id="automation.id"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ChevronRightIcon,
|
||||
CubeIcon,
|
||||
ExclamationTriangleIcon
|
||||
} from '@heroicons/vue/24/outline'
|
||||
import { graphql } from '~/lib/common/generated/gql'
|
||||
import { type ProjectPageAutomationsRow_AutomationFragment } from '~/lib/common/generated/gql/graphql'
|
||||
import { projectAutomationRoute } from '~/lib/common/helpers/route'
|
||||
import { useViewerRouteBuilder } from '~/lib/projects/composables/models'
|
||||
|
||||
graphql(`
|
||||
fragment ProjectPageAutomationsRow_Automation on Automation {
|
||||
id
|
||||
name
|
||||
enabled
|
||||
currentRevision {
|
||||
id
|
||||
triggerDefinitions {
|
||||
... on VersionCreatedTriggerDefinition {
|
||||
model {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
runs {
|
||||
totalCount
|
||||
items {
|
||||
...AutomationRunDetails
|
||||
}
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
const props = defineProps<{
|
||||
projectId: string
|
||||
automation: ProjectPageAutomationsRow_AutomationFragment
|
||||
}>()
|
||||
|
||||
const { modelUrl } = useViewerRouteBuilder()
|
||||
|
||||
const isEnabled = computed(() => props.automation.enabled)
|
||||
|
||||
const triggerModels = computed(
|
||||
() =>
|
||||
props.automation.currentRevision?.triggerDefinitions.map(
|
||||
(trigger) => trigger.model
|
||||
) || []
|
||||
)
|
||||
|
||||
const finalModelUrl = (modelId: string) =>
|
||||
modelUrl({ projectId: props.projectId, modelId })
|
||||
</script>
|
||||
@@ -0,0 +1,108 @@
|
||||
<template>
|
||||
<LayoutDialog
|
||||
v-model:open="open"
|
||||
:buttons="[
|
||||
{
|
||||
text: 'Close',
|
||||
onClick: () => {
|
||||
open = false
|
||||
},
|
||||
props: { color: 'secondary', fullWidth: true }
|
||||
},
|
||||
{
|
||||
text: 'View Model Version',
|
||||
props: {
|
||||
fullWidth: true,
|
||||
to:
|
||||
run && projectId
|
||||
? versionUrl({
|
||||
projectId,
|
||||
modelId: run.trigger.model.id,
|
||||
versionId: run.trigger.version.id
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
}
|
||||
]"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex flex-col">
|
||||
<div class="flex items-center space-x-2 max-w-full w-full">
|
||||
<div class="mt-[6px] shrink-0">
|
||||
<AutomateRunsTriggerStatusIcon
|
||||
:summary="summary"
|
||||
class="h-6 w-6 sm:h-10 sm:w-10"
|
||||
/>
|
||||
</div>
|
||||
<div>Run Details</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="run && projectId && automationId" class="flex flex-col gap-2">
|
||||
<div class="grid gap-2 grid-cols-[auto,1fr] items-center">
|
||||
<div class="font-bold">Run ID:</div>
|
||||
<div>{{ run.id }}</div>
|
||||
<div class="font-bold">Status:</div>
|
||||
<AutomateRunsStatusBadge :run="run" />
|
||||
<template v-if="summary.errorMessage">
|
||||
<div class="font-bold">Error:</div>
|
||||
<div>{{ summary.errorMessage }}</div>
|
||||
</template>
|
||||
<div class="font-bold">Time started:</div>
|
||||
<div>{{ runDate(run) }}</div>
|
||||
<div class="font-bold">Duration:</div>
|
||||
<div>{{ runDuration(run) }}</div>
|
||||
<div class="font-bold">Log output:</div>
|
||||
<CommonLoadingIcon v-if="showLoader" size="sm" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<CommonCodeOutput :content="codeOutputContent" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-else />
|
||||
</LayoutDialog>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
useAutomationRunDetailsFns,
|
||||
useAutomationRunLogs,
|
||||
useAutomationRunSummary
|
||||
} from '~/lib/automate/composables/runs'
|
||||
import type { AutomationRunDetailsFragment } from '~/lib/common/generated/gql/graphql'
|
||||
import { useViewerRouteBuilder } from '~/lib/projects/composables/models'
|
||||
|
||||
const props = defineProps<{
|
||||
// These are optional so that we can mount the dialog even before we have selected
|
||||
// a run to display
|
||||
run?: AutomationRunDetailsFragment
|
||||
projectId?: string
|
||||
automationId?: string
|
||||
}>()
|
||||
|
||||
const open = defineModel<boolean>('open', { required: true })
|
||||
const { versionUrl } = useViewerRouteBuilder()
|
||||
|
||||
const { summary } = useAutomationRunSummary({ run: computed(() => props.run) })
|
||||
const { runDate, runDuration } = useAutomationRunDetailsFns()
|
||||
const {
|
||||
data: logsData,
|
||||
isDataLoaded: areLogsFullyRead,
|
||||
loading: logsLoading
|
||||
} = useAutomationRunLogs({
|
||||
automationId: computed(() => props.automationId),
|
||||
runId: computed(() => props.run?.id)
|
||||
})
|
||||
|
||||
const showLoader = computed(() => logsLoading.value || !areLogsFullyRead.value)
|
||||
const codeOutputContent = computed(() => {
|
||||
if (logsData.value) {
|
||||
return logsData.value
|
||||
}
|
||||
|
||||
if (areLogsFullyRead.value) {
|
||||
return 'No logs found'
|
||||
}
|
||||
|
||||
return ''
|
||||
})
|
||||
</script>
|
||||
File diff suppressed because one or more lines are too long
@@ -1,5 +1,70 @@
|
||||
<template>
|
||||
<!-- <ProjectPageAutomationsHeader :project="project" /> -->
|
||||
<div />
|
||||
<div class="flex flex-col gap-8">
|
||||
<ProjectPageAutomationsHeader
|
||||
v-model:search="search"
|
||||
:has-automations="hasAutomations && isAutomateEnabled"
|
||||
@new-automation="onNewAutomation"
|
||||
/>
|
||||
<template v-if="loading">
|
||||
<CommonLoadingBar loading />
|
||||
</template>
|
||||
<script setup lang="ts"></script>
|
||||
<template v-else>
|
||||
<ProjectPageAutomationsEmptyState
|
||||
v-if="!hasAutomations || !isAutomateEnabled"
|
||||
:functions="result"
|
||||
:is-automate-enabled="isAutomateEnabled"
|
||||
@new-automation="onNewAutomation"
|
||||
/>
|
||||
<template v-else>
|
||||
<ProjectPageAutomationsRow
|
||||
v-for="a in automations"
|
||||
:key="a.id"
|
||||
:automation="a"
|
||||
:project-id="projectId"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
<AutomateAutomationCreateDialog
|
||||
v-model:open="showNewAutomationDialog"
|
||||
:preselected-project="project"
|
||||
:preselected-function="newAutomationTargetFn"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { useQuery } from '@vue/apollo-composable'
|
||||
import { projectAutomationsTabQuery } from '~/lib/projects/graphql/queries'
|
||||
import type { CreateAutomationSelectableFunction } from '~/lib/automate/helpers/automations'
|
||||
|
||||
const route = useRoute()
|
||||
const projectId = computed(() => route.params.id as string)
|
||||
const search = ref('')
|
||||
const isAutomateEnabled = useIsAutomateModuleEnabled()
|
||||
|
||||
const { result, loading } = useQuery(
|
||||
projectAutomationsTabQuery,
|
||||
() => ({
|
||||
projectId: projectId.value,
|
||||
search: search.value?.length ? search.value : null,
|
||||
// TODO: Pagination & search
|
||||
cursor: null
|
||||
}),
|
||||
() => ({
|
||||
enabled: isAutomateEnabled.value
|
||||
})
|
||||
)
|
||||
|
||||
const showNewAutomationDialog = ref(false)
|
||||
const newAutomationTargetFn = ref<CreateAutomationSelectableFunction>()
|
||||
|
||||
const project = computed(() => result.value?.project)
|
||||
const hasAutomations = computed(
|
||||
() => (result.value?.project?.automations.totalCount ?? 1) > 0
|
||||
)
|
||||
const automations = computed(() => result.value?.project?.automations.items || [])
|
||||
|
||||
const onNewAutomation = (fn?: CreateAutomationSelectableFunction) => {
|
||||
newAutomationTargetFn.value = fn
|
||||
showNewAutomationDialog.value = true
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -92,23 +92,23 @@
|
||||
v-if="
|
||||
!isPendingModelFragment(model) && model.commentThreadCount.totalCount !== 0
|
||||
"
|
||||
:class="`absolute opacity-100 top-0 right-0 p-2 flex items-center transition border-2 border-primary-muted h-8 bg-foundation shadow-md justify-center rounded-tr-full rounded-tl-full rounded-br-full text-xs m-2 ${
|
||||
:class="[
|
||||
`z-10 absolute opacity-100 top-0 right-0 p-2 flex items-center transition`,
|
||||
'border-2 border-primary-muted h-8 bg-foundation shadow-md justify-center',
|
||||
'rounded-tr-full rounded-tl-full rounded-br-full text-xs m-2',
|
||||
hovered ? 'sm:opacity-100' : 'sm:opacity-0'
|
||||
}`"
|
||||
]"
|
||||
>
|
||||
<ChatBubbleLeftRightIcon class="w-4 h-4" />
|
||||
<span>{{ model.commentThreadCount.totalCount }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="!isPendingModelFragment(model) && model.automationStatus"
|
||||
class="absolute top-0 left-0 p-2"
|
||||
v-if="!isPendingModelFragment(model) && model.automationsStatus"
|
||||
class="z-20 absolute top-0 left-0"
|
||||
>
|
||||
<ProjectPageModelsCardAutomationStatusRefactor
|
||||
<AutomateRunsTriggerStatus
|
||||
:project-id="projectId"
|
||||
:model-or-version="{
|
||||
...model,
|
||||
automationStatus: model.automationStatus
|
||||
}"
|
||||
:status="model.automationsStatus"
|
||||
:model-id="model.id"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,28 +1,15 @@
|
||||
<template>
|
||||
<template v-if="itemsCount">
|
||||
<div
|
||||
class="relative z-10 grid gap-3"
|
||||
:class="
|
||||
smallView
|
||||
? 'grid-cols-2 sm:grid-cols-3 md:grid-cols-4'
|
||||
: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-4'
|
||||
"
|
||||
>
|
||||
<!-- Decrementing z-index necessary for the actions menu to render correctly. Each card has its own stacking context because of the scale property -->
|
||||
<ProjectPageModelsCard
|
||||
v-for="(item, i) in items"
|
||||
:key="item.id"
|
||||
:model="item"
|
||||
:project-id="projectId"
|
||||
<ProjectModelsBasicCardView
|
||||
:items="items"
|
||||
:project="project"
|
||||
:project-id="projectId"
|
||||
:small-view="smallView"
|
||||
:show-actions="showActions"
|
||||
:show-versions="showVersions"
|
||||
height="h-32 sm:h-64"
|
||||
:disable-default-link="disableDefaultLinks"
|
||||
:style="`z-index: ${items.length - i};`"
|
||||
@click="($event) => $emit('model-clicked', { id: item.id, e: $event })"
|
||||
:disable-default-links="disableDefaultLinks"
|
||||
@model-clicked="$emit('model-clicked', $event)"
|
||||
/>
|
||||
</div>
|
||||
<FormButtonSecondaryViewAll
|
||||
v-if="showViewAll"
|
||||
class="mt-4"
|
||||
|
||||
@@ -87,7 +87,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ProjectPageModelsNewDialog v-model:open="showNewDialog" :project-id="project.id" />
|
||||
<ProjectPageModelsNewDialog v-model:open="showNewDialog" :project-id="projectId" />
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
@@ -120,6 +120,7 @@ graphql(`
|
||||
sourceApps
|
||||
role
|
||||
team {
|
||||
id
|
||||
user {
|
||||
...FormUsersSelectItem
|
||||
}
|
||||
@@ -128,7 +129,8 @@ graphql(`
|
||||
`)
|
||||
|
||||
const props = defineProps<{
|
||||
project: ProjectModelsPageHeader_ProjectFragment
|
||||
projectId: string
|
||||
project?: ProjectModelsPageHeader_ProjectFragment
|
||||
selectedMembers: FormUsersSelectItemFragment[]
|
||||
selectedApps: SourceAppDefinition[]
|
||||
search: string
|
||||
@@ -149,7 +151,9 @@ const trackFederateAll = () =>
|
||||
source: 'project page'
|
||||
})
|
||||
|
||||
const canContribute = computed(() => canModifyModels(props.project))
|
||||
const canContribute = computed(() =>
|
||||
props.project ? canModifyModels(props.project) : false
|
||||
)
|
||||
const showNewDialog = ref(false)
|
||||
|
||||
const debouncedSearch = computed({
|
||||
@@ -170,19 +174,21 @@ const finalGridOrList = computed({
|
||||
})
|
||||
|
||||
const availableSourceApps = computed((): SourceAppDefinition[] =>
|
||||
SourceApps.filter((a) =>
|
||||
props.project.sourceApps.find((pa) => pa.toLowerCase().includes(a.searchKey))
|
||||
props.project
|
||||
? SourceApps.filter((a) =>
|
||||
props.project!.sourceApps.find((pa) => pa.toLowerCase().includes(a.searchKey))
|
||||
)
|
||||
: []
|
||||
)
|
||||
|
||||
const allModelsRoute = computed(() => {
|
||||
const resourceIdString = SpeckleViewer.ViewerRoute.resourceBuilder()
|
||||
.addAllModels()
|
||||
.toString()
|
||||
return modelRoute(props.project.id, resourceIdString)
|
||||
return modelRoute(props.projectId, resourceIdString)
|
||||
})
|
||||
|
||||
const team = computed(() => props.project.team.map((t) => t.user))
|
||||
const team = computed(() => props.project?.team.map((t) => t.user) || [])
|
||||
|
||||
const updateDebouncedSearch = debounce(() => {
|
||||
debouncedSearch.value = localSearch.value.trim()
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { CubeIcon } from '@heroicons/vue/24/solid'
|
||||
import type { LayoutDialogButton } from '@speckle/ui-components'
|
||||
import { useMutationLoading } from '@vue/apollo-composable'
|
||||
import { useForm } from 'vee-validate'
|
||||
import { useMixpanel } from '~~/lib/core/composables/mp'
|
||||
@@ -89,7 +90,7 @@ watch(
|
||||
}
|
||||
)
|
||||
|
||||
const dialogButtons = computed(() => [
|
||||
const dialogButtons = computed((): LayoutDialogButton[] => [
|
||||
{
|
||||
text: 'Cancel',
|
||||
props: { color: 'secondary', fullWidth: true, outline: true },
|
||||
@@ -99,7 +100,7 @@ const dialogButtons = computed(() => [
|
||||
},
|
||||
{
|
||||
text: 'Create',
|
||||
props: { color: 'primary', fullWidth: true },
|
||||
props: { color: 'default', fullWidth: true },
|
||||
onClick: () => {
|
||||
onSubmit()
|
||||
},
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
v-if="gridOrList === GridListToggleValue.List"
|
||||
:search="finalSearch"
|
||||
:project="project"
|
||||
:project-id="project.id"
|
||||
:project-id="projectId"
|
||||
:source-apps="sourceApps"
|
||||
:contributors="contributors"
|
||||
@update:loading="finalLoading = $event"
|
||||
@@ -15,7 +15,7 @@
|
||||
v-if="gridOrList === GridListToggleValue.Grid"
|
||||
:search="finalSearch"
|
||||
:project="project"
|
||||
:project-id="project.id"
|
||||
:project-id="projectId"
|
||||
:source-apps="sourceApps"
|
||||
:contributors="contributors"
|
||||
@update:loading="finalLoading = $event"
|
||||
@@ -46,7 +46,8 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const props = defineProps<{
|
||||
project: ProjectModelsPageResults_ProjectFragment
|
||||
projectId: string
|
||||
project?: ProjectModelsPageResults_ProjectFragment
|
||||
search: string
|
||||
gridOrList: GridListToggleValue
|
||||
loading: boolean
|
||||
|
||||
@@ -94,13 +94,10 @@
|
||||
<span>{{ model?.commentThreadCount.totalCount }}</span>
|
||||
<ChatBubbleLeftRightIcon class="w-4 h-4" />
|
||||
</div>
|
||||
<div v-if="model && model.automationStatus" class="text-xs text-foreground-2">
|
||||
<ProjectPageModelsCardAutomationStatusRefactor
|
||||
<div v-if="model?.automationsStatus">
|
||||
<AutomateRunsTriggerStatus
|
||||
:project-id="project.id"
|
||||
:model-or-version="{
|
||||
...model,
|
||||
automationStatus: model.automationStatus
|
||||
}"
|
||||
:status="model.automationsStatus"
|
||||
:model-id="model.id"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="project">
|
||||
<ProjectPageModelsHeader
|
||||
v-model:selected-apps="selectedApps"
|
||||
v-model:selected-members="selectedMembers"
|
||||
v-model:grid-or-list="gridOrList"
|
||||
v-model:search="search"
|
||||
:project="project"
|
||||
:project-id="projectId"
|
||||
:disabled="loading"
|
||||
class="z-[1] relative"
|
||||
/>
|
||||
@@ -17,11 +17,11 @@
|
||||
:source-apps="selectedApps"
|
||||
:contributors="selectedMembers"
|
||||
:project="project"
|
||||
:project-id="projectId"
|
||||
class="z-[0] relative mt-8"
|
||||
@clear-search="clearSearch"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { useQuery } from '@vue/apollo-composable'
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
<template>
|
||||
<div class="space-y-2">
|
||||
<!-- Note: keeping for the sake of potential future layout expansions. For now it looks cleaner
|
||||
without the automation name here, and having that in combined with the functino name in the components below. -->
|
||||
<!-- <div class="text-sm text-foreground-2">{{ run.automationName }} {{ run.id }}</div> -->
|
||||
<ProjectPageModelsCardFunctionRun
|
||||
v-for="fRun in run.functionRuns"
|
||||
:key="fRun.id"
|
||||
:automation-name="run.automationName"
|
||||
:function-run="(fRun as RemoveOnceBEIsHappy)"
|
||||
:project-id="projectId"
|
||||
:model-id="modelId"
|
||||
:version-id="versionId"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
// import dayjs from 'dayjs'
|
||||
import type {
|
||||
AutomationFunctionRun,
|
||||
AutomationRun
|
||||
} from '~~/lib/common/generated/gql/graphql'
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
type RemoveOnceBEIsHappy = AutomationFunctionRun & {
|
||||
results: { values: { blobIds: string[] } }
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
run: AutomationRun
|
||||
projectId: string
|
||||
modelId: string
|
||||
versionId?: string
|
||||
}>()
|
||||
</script>
|
||||
@@ -1,253 +0,0 @@
|
||||
<template>
|
||||
<div v-if="allFunctionRuns.length > 0" @click.stop.prevent>
|
||||
<button
|
||||
v-tippy="summary.longSummary"
|
||||
class="h-6 w-6 bg-foundation rounded-full flex items-center justify-center"
|
||||
@click="showDialog = true"
|
||||
>
|
||||
<AutomationDoughnutSummary :summary="summary" />
|
||||
</button>
|
||||
<LayoutDialog v-model:open="showDialog" max-width="lg">
|
||||
<template #header>
|
||||
<div class="flex flex-col">
|
||||
<div class="flex items-center space-x-2 max-w-full w-full">
|
||||
<div class="h-10 w-10 mt-[6px]">
|
||||
<AutomationDoughnutSummary :summary="summary" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<h4 :class="`text-xl font-bold ${summary.titleColor}`">
|
||||
{{ summary.title }}
|
||||
</h4>
|
||||
<div class="text-xs text-foreground-2 truncate">
|
||||
{{ summary.longSummary }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div class="">
|
||||
<div v-for="run in automationRuns" :key="run.id">
|
||||
<ProjectPageModelsCardAutomationRun
|
||||
:run="(run as AutomationRun)"
|
||||
:project-id="projectId"
|
||||
:model-id="modelId"
|
||||
:version-id="versionId"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #buttons>
|
||||
<div class="flex w-full justify-between items-center pl-2">
|
||||
<FormButton text size="xs" to="https://automate.speckle.dev" target="_blank">
|
||||
Learn more about Automate here!
|
||||
</FormButton>
|
||||
<div class="space-x-1">
|
||||
<FormButton color="secondary" @click="showDialog = false">Close</FormButton>
|
||||
<FormButton :to="viewModelLink">
|
||||
Open {{ versionId ? 'Version' : 'Model' }}
|
||||
</FormButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</LayoutDialog>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import type { SetFullyRequired } from '~~/lib/common/helpers/type'
|
||||
import { graphql } from '~~/lib/common/generated/gql'
|
||||
import { AutomationRunStatus } from '~~/lib/common/generated/gql/graphql'
|
||||
import type {
|
||||
AutomationFunctionRun,
|
||||
AutomationRun,
|
||||
ModelCardAutomationStatus_ModelFragment,
|
||||
ModelCardAutomationStatus_VersionFragment
|
||||
} from '~~/lib/common/generated/gql/graphql'
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
import dayjs from 'dayjs'
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
import { modelRoute } from '~~/lib/common/helpers/route'
|
||||
import { SpeckleViewer } from '@speckle/shared'
|
||||
import { useServerInfo } from '~~/lib/core/composables/server'
|
||||
import { resolveStatusMetadata } from '~~/lib/automations/helpers/resolveStatusMetadata'
|
||||
import { useModelVersionCardAutomationsStatusUpdateTracking } from '~~/lib/automations/composables/automationsStatus'
|
||||
|
||||
// TODO: Clean up unnecessary fields
|
||||
// Remember about stories
|
||||
|
||||
/**
|
||||
* - Show checkmark/red cross
|
||||
* - On hover show global status msg
|
||||
* - On click: show modal w/ more specific info about version and its runs
|
||||
*
|
||||
* VERSION CARD:
|
||||
* - Same essentially
|
||||
*
|
||||
* VIEWER:
|
||||
* - Where to show per-object info? In object explorer cards?
|
||||
*/
|
||||
|
||||
type Model = SetFullyRequired<
|
||||
ModelCardAutomationStatus_ModelFragment,
|
||||
'automationStatus'
|
||||
>
|
||||
type Version = SetFullyRequired<
|
||||
ModelCardAutomationStatus_VersionFragment,
|
||||
'automationStatus'
|
||||
>
|
||||
|
||||
const isModel = (val: Model | Version): val is Model => 'displayName' in val
|
||||
|
||||
graphql(`
|
||||
fragment ModelCardAutomationStatus_AutomationsStatus on AutomationsStatus {
|
||||
id
|
||||
status
|
||||
statusMessage
|
||||
automationRuns {
|
||||
id
|
||||
automationId
|
||||
automationName
|
||||
createdAt
|
||||
status
|
||||
functionRuns {
|
||||
id
|
||||
functionId
|
||||
functionName
|
||||
functionLogo
|
||||
elapsed
|
||||
status
|
||||
statusMessage
|
||||
contextView
|
||||
results
|
||||
resultVersions {
|
||||
id
|
||||
model {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
graphql(`
|
||||
fragment ModelCardAutomationStatus_Model on Model {
|
||||
id
|
||||
displayName
|
||||
automationStatus {
|
||||
...ModelCardAutomationStatus_AutomationsStatus
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
graphql(`
|
||||
fragment ModelCardAutomationStatus_Version on Version {
|
||||
id
|
||||
automationStatus {
|
||||
...ModelCardAutomationStatus_AutomationsStatus
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
const props = defineProps<{
|
||||
modelOrVersion: Model | Version
|
||||
projectId: string
|
||||
modelId: string
|
||||
}>()
|
||||
|
||||
useModelVersionCardAutomationsStatusUpdateTracking(props.projectId)
|
||||
|
||||
const { serverInfo } = useServerInfo()
|
||||
|
||||
const showDialog = ref(false)
|
||||
|
||||
const automationStatus = computed(() => props.modelOrVersion.automationStatus)
|
||||
|
||||
const versionId = computed(() => {
|
||||
return !isModel(props.modelOrVersion) ? props.modelOrVersion.id : undefined
|
||||
})
|
||||
|
||||
const viewModelLink = computed(() => {
|
||||
return !versionId.value
|
||||
? modelRoute(props.projectId, props.modelId)
|
||||
: modelRoute(props.projectId, `${props.modelId}@${versionId.value}`)
|
||||
})
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const statusIconAndColor = computed(() =>
|
||||
resolveStatusMetadata(automationStatus.value.status)
|
||||
)
|
||||
|
||||
const automationRuns = computed(() => automationStatus.value.automationRuns)
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const automateBaseUrl = computed(() => serverInfo.value?.automateUrl)
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const viewResultVersionsRoute = (versions: Array<{ id: string }>) => {
|
||||
const modelId = props.modelId
|
||||
const versionIds = versions.map((v) => v.id)
|
||||
|
||||
const resourceIdStringBuilder = SpeckleViewer.ViewerRoute.resourceBuilder()
|
||||
versionIds.forEach((vId) => resourceIdStringBuilder.addModel(modelId, vId))
|
||||
const resourceIdString = resourceIdStringBuilder.toString()
|
||||
return modelRoute(props.projectId, resourceIdString)
|
||||
}
|
||||
|
||||
const allFunctionRuns = computed(() => {
|
||||
const allRuns: AutomationFunctionRun[] = [] // as AutomationFunctionRun[]
|
||||
if (!props.modelOrVersion.automationStatus) return allRuns
|
||||
for (const myRun of props.modelOrVersion.automationStatus.automationRuns) {
|
||||
allRuns.push(...(myRun.functionRuns as AutomationFunctionRun[]))
|
||||
}
|
||||
return allRuns
|
||||
})
|
||||
|
||||
// TODO: move to somewhere central, it's copy pasted around currently
|
||||
const summary = computed(() => {
|
||||
const result = {
|
||||
failed: 0,
|
||||
passed: 0,
|
||||
inProgress: 0,
|
||||
total: allFunctionRuns.value.length,
|
||||
title: 'All runs passed.',
|
||||
titleColor: 'text-success',
|
||||
longSummary: ''
|
||||
}
|
||||
|
||||
for (const run of allFunctionRuns.value) {
|
||||
switch (run.status) {
|
||||
case AutomationRunStatus.Succeeded:
|
||||
result.passed++
|
||||
break
|
||||
case AutomationRunStatus.Failed:
|
||||
result.title = 'Some runs failed.'
|
||||
result.titleColor = 'text-danger'
|
||||
result.failed++
|
||||
break
|
||||
default:
|
||||
if (result.failed === 0) {
|
||||
result.title = 'Some runs are still in progress.'
|
||||
result.titleColor = 'text-warning'
|
||||
}
|
||||
result.inProgress++
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// format:
|
||||
// 2 failed, 1 passed runs
|
||||
// 1 passed, 2 in progress, 1 failed runs
|
||||
// 1 passed run
|
||||
const longSummarySegments = []
|
||||
if (result.passed > 0) longSummarySegments.push(`${result.passed} passed`)
|
||||
if (result.inProgress > 0)
|
||||
longSummarySegments.push(`${result.inProgress} in progress`)
|
||||
if (result.failed > 0) longSummarySegments.push(`${result.failed} failed`)
|
||||
|
||||
result.longSummary = (
|
||||
longSummarySegments.join(', ') + ` run${result.total > 1 ? 's' : ''}.`
|
||||
).replace(/,(?=[^,]+$)/, ', and')
|
||||
|
||||
return result
|
||||
})
|
||||
</script>
|
||||
@@ -12,7 +12,7 @@
|
||||
},
|
||||
{
|
||||
text: 'Save',
|
||||
props: { color: 'primary', fullWidth: true },
|
||||
props: { color: 'default', fullWidth: true },
|
||||
onClick: () => {
|
||||
onSubmit()
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user