mirror of
https://github.com/directus/directus.git
synced 2026-04-25 03:00:53 -04:00
Add Insights Module & API Aggregation Functionality (#8009)
* Apply aggregation query to dbQuery
* Override fields/sortField when group/aggr is used
* Sanitize incoming group/aggr fields
* Validate for new group/aggr query params
* Document new aggregate/group endpoint
* Add changeset
* Add new system tables
* Add schema, rest/gql resolvers for insights
* Add insights store
* Render insights overview page
* Add dashboard creation flow
* Add not found route
* Show editing grid
* Add panels as extension type
* Render panel selection
* Add edit existing
* Add saving changes
* Add positioning
* Finish resizing
* Start on metric panel
* Auto-expand workspace
* WIP add frappe-chart
* Add functional time-series chart
* Deep watch option changes
* Fix o2m fetch when not grouping
* Allow PK in metric panel
* Add breadcrumb
* Various tweaks and fixes
* Fix metric alignment, only load on options change, Show header
* Add delete panel
* Add updating dashboard
* Swap docs / insights
* Add sort/limit to metric
* Add decimal places, units
* Add label type panel
* Track corner intersaction
* Don't hit the API if there aren't any staged changes
* Remove limit from metric
* Extend resize handlers beyond border
* Fix repositioning on update existing
* Add duplicate panel
* panel duplicate icon
* Increase time series min height
* Improve time series styling
* make panels selectable
* Button styling and fullscren (button only)
* Time series color
* Panel plot display
* Optically align metric
* Add number formatting to metric
* Insights placeholders and defaults
* Fix codemirror placeholder color
* Restart docker containers on docker restart
* Move insights to Vue 3
* Fix val check
* Add button style props
* Fix input/value
* Fix panel init
* Fix buttons on panels
* Fix animation on panel config
* Fix panel location not resetting on cancel
* Add fullscreen / zoom to fit support
* Temp remove transition to prevent browser glitches
* Fix vertical size calculation
* Fix panel editing
* Update params to match fields
* Setup datetime abstraction
* Restructure fn helper
* Add fields support for date functions
* Allow functions in sort/filter
* Fix missing knex passthrough
* Finish date retrieval abstraction for all vendors
* Delete witty-emus-approve.md
* Delete dependabot.yml
* Add renovate.json (#6322)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
* New Crowdin updates (#6309)
* New translations en-US.yaml (Japanese)
* New translations en-US.yaml (Japanese)
* New translations en-US.yaml (Arabic)
* New translations en-US.yaml (Arabic)
* New translations en-US.yaml (Arabic)
* New translations en-US.yaml (Arabic)
* fix link (#6339)
* fix(deps): pin dependencies (#6323)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
* chore(deps): update dependency globby to v11.0.4 (#6324)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
* Add support for `read` hooks on `items` (#6341)
* Add emitter on item read
* Add performance warning to docs
* Make result instead of query the payload
* Redact tokens from logs (#6347)
* Fixed issue that would cause uploads to the root folder of the file library to fail (#6348)
fixes #6310
* Use existing file extension as default (#6349)
* Don't send sensitive data in webhooks (#6350)
Fixes #6246
* Trim val before check
h/t @aidenfoxx
* chore(deps): update mariadb docker tag to v10.6 (#6332)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
* chore(deps): update node.js to v16 (#6336)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
* chore(deps): update postgres docker tag to v13 (#6338)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
* chore(deps): update dependency rollup to v2.52.1 (#6337)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
* chore(deps): update dependency vue-router to v4.0.9 (#6327)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
* chore(deps): update dependency typescript to v4.3.3 (#6329)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
* fix(deps): update dependency ms to v2.1.3 (#6328)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
* chore(deps): update dependency marked to v2.1.1 (#6330)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
* chore(deps): update fullcalendar monorepo to v5.8.0 (#6331)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
* chore(deps): update dependency dotenv to v10 (#6333)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
* fix(deps): update dependency chalk to v4 (#6342)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
* chore(deps): update dependency fs-extra to v10 (#6334)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
* Change cache-control heeaders (#6355)
* chore(deps): update dependency typescript to v4.3.4 (#6357)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
* Fixed invalid onDelete constraint for some schemas (#6308)
* Fixed invalid onDelete clause for some schemas
* Ran prettier
* Updated all onDelete statements to be Oracle friendly
Co-authored-by: Aiden Foxx <aiden.foxx@sbab.se>
* Fix import in aggregation
* Fix cancel button on new modal dialog
* Add default icon to new dashboards
* Add information sidebar component
* Don't open sidebar on window resize
* Add distinct options to metrics panel
* Use updated aggregate function type signature
* Reset field value on collection change
* Don't show resize stats on edit click
* Add panel options to header headline on drawer
* Add page-bottom padding to drawers
* Show panel icon in header, fix active state buttons
* Add date range functionality to time-series
* Fix z-index of edit buttons
* Fix header icon color
* Update insights module icon
* Fix datetime formatter, set date range, add padding
* Time series
* tweaks on time series
* format tweaks
* Fix edit dashboard modal
* Add auto-format option
* Fix number formatting w/ decimals
* Add metric conditional color
* Fix defaults rendering in list, add defaults to metric
* Fix decimal points in metrics
* Remove sort
* Tweaks in metrics settings
* Add filters to time series
* Update options for metric
* Time series tweaks
* Allow empty field for metric
* Set label min height to 4
* Add first/last to metric
* Add "move" option, various tweaks
* Upgrade "move to" to "copy to"
* Add white to color preset defaults
* Tweaks
* Use 0 for decimal default
* Use default false for abbreviate
* Fix panel registration
* Show color placeholder, fix edit modal
* Add navigation guard
* Don't fire navguard on subroute
* Show create button on empty dashboards in nav
* Use synced charts
* Undo sync test
* Have metric render 0
* Fix abbreviate decimal places
* Fix min 0 in time-series
* less blocking whitespace
* new metric min width
* new time series min width
* time series style updates
* Fixed typo (#6558)
* Fix auto-fill of directus_files in relational setup (#6555)
Fixes #6487
* v9.0.0-rc.82
* Update changelog.md
* Add limit options for deleteMany files (#6561)
* Changed filesize to bigint for large files
* Update api/src/database/migrations/20210626A-change-filesize-bigint.ts
* add `limit -1` for deleteMany files options from #6560
Co-authored-by: Rijk van Zanten <rijkvanzanten@me.com>
* Fix cleaning order
* update dependency ts-node-dev to v1.1.7 (#6564)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
* Fix order of form group filter (#6566)
Fixes #6557
* New Crowdin updates (#6554)
* New translations en-US.yaml (Bulgarian)
* Update source file en-US.yaml
* v9.0.0-rc.83
* Update the required Node version to 12.20.0 (#6578)
* update dependency rollup to v2.52.4 (#6572)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
* Add skip admin init flag (#6576)
* adds skipAdminInit flag to bootstrap
* checks for skipAdminInit flag
* update docs for skipAdminInit
* Fix extension loading on windows (#6579)
Javascript import syntax uses URLs instead of paths, so we have to
normalize the extension paths to forward slashes when importing them
inside the virtual entrypoints.
Fixes #6550
* New Crowdin updates (#6575)
* New translations en-US.yaml (Hebrew)
* New translations en-US.yaml (Hebrew)
* insights time series min size
* Only ask for are you sure when edits are made
* Add cancel confirmation
* Add system collections to pane dropdown
* Disable zoom to fit when enabling edit mode
* Render browser popup on reload
* Fix padding in TV mode
* Fix box
* Add show X/Y axis options
* Default to 0 decimals
* Use configured decimals in Y axis labels
* Fix build
* Aggregate resolvers added to GraphQL options (#7373)
* Don't use tags interface for CSV filter (#7258)
Fixes #6778
* Rely on `RETURNING` when possible (#7259)
* WIP use returning clause instead of max from id
* Use returning where applicable, fallback to fetch
Fixes #6279
* update dependency p-queue to v7 (#7255)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
* update dependency @vitejs/plugin-vue to v1.4.0 (#7263)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
* Move p-queue to app dev dependencies (#7273)
* Log error message when registering app extension fails (#7274)
* update dependency rollup to v2.56.1 (#7269)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
* update dependency vue-router to v4.0.11 (#7272)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
* update dependency ts-node to v10.2.0 (#7271)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
* Only loads app extensions if SERVE_APP is true (#7275)
This also ensures API/App only load their respective extensions in dev.
* Fix gitignore file in extension templates being deleted when publishing (#7279)
* New Crowdin updates (#7260)
* New translations en-US.yaml (Spanish)
* New translations en-US.yaml (Spanish)
* New translations en-US.yaml (Russian)
* New translations en-US.yaml (Russian)
* New translations en-US.yaml (Russian)
* New translations en-US.yaml (Russian)
* update typescript-eslint monorepo to v4.29.1 (#7283)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
* Only treat `tinyint(1)` and `tinyint(0)` as booleans (#7287)
* added an if catch for tinyint(1) and tinyint(0)
* made suggested changes toLowerCase()
* update dependency @vue/compiler-sfc to v3.2.0 (#7288)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
* update dependency vue to v3.2.0 (#7289)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
* Handle JSON in labels display (#7292)
Fixes #7278
* update dependency pinia to v2.0.0-rc.3 (#7055)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
* update vue monorepo to v3.2.1 (#7293)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
* Flush caches on server (re)start (#7294)
* v9.0.0-rc.89
* Update package-lock
* Update release script
To workaround breaking change in npm patch 🎉
* Update changelog
* update dependency pinia to v2.0.0-rc.4 (#7297)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
* update dependency rollup to v2.56.2 (#7303)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
* Fix HTTP method for collections.createMany in SDK (#7304)
* Fix HTTP method for collections.createMany in SDK
* Post collections in data body
Co-authored-by: rijkvanzanten <rijkvanzanten@me.com>
* Add perm check for sqlite, upload, extensions dirs (#7310)
Co-authored-by: Rijk van Zanten <rijkvanzanten@me.com>
* update dependency eslint-plugin-vue to v7.16.0 (#7300)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
* Fix uuid resolving in SQLite (#7312)
Fixes #7306
* Clear the file payload after file upload (#7315)
Fixes #7305
* Improve type checking
* Mention TELEMETRY environment variable in docs (#7317)
* Mention TELEMETRY environment variable in docs
* Add clarification
Co-authored-by: rijkvanzanten <rijkvanzanten@me.com>
* Import access from fs-extra instead of fs/promises
* Resolve sorting in list-o2m-tree-view on dnd
* Fix graphql GET request cache query extraction (#7319)
Fixes #7298
* Check for related collection before creation relation (#7323)
Fixes #7302
* Fix colors on different types (#7322)
Co-authored-by: Rijk van Zanten <rijkvanzanten@me.com>
* group is working on aggregate resolver
* Check for non-existing parent pk records (#7331)
Fixes #7330
* Schema field types are not translated in the app (#7327)
* Fix field type label translations
* Use translate-object-values util
Co-authored-by: rijkvanzanten <rijkvanzanten@me.com>
* Update release script
* Add import ref for TS
* Tweak, hopefully fix release flow
* getAggregateQuery
* clean up payload
Co-authored-by: Rijk van Zanten <rijkvanzanten@me.com>
* Treat alias-only fields properly
* Add missing translations (#7358)
* v9.0.0-rc.90
* Update changelog.md
* update dependency nanoid to v3.1.24 (#7365)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
* update dependency supertest to v6.1.5 (#7360)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
* update vue monorepo to v3.2.2 (#7355)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
* filters working avg{id} format with number fields
* Fix english string after #7358 (#7371)
Fixed wrong string in en-US after #7358 PR
* group field working
* update dependency nanoid to v3.1.25 (#7375)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
* update dependency directory-tree to v2.3.0 (#7376)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
* Export Collection button now shows collection name not table name (#7379)
* export collection button to uses name not db name
* removed unused var
* fixed for review
* computed collectionName
* Add support for Geometry type, add Map Layout & Interface (#5684)
* Added map layout
* Cleanup and bug fixes
* Removed package-lock
* Cleanup and fixes
* Small fix
* Added back package-lock
* Saved camera, autofitting option, bug fixes
* Refactor and ui improvements
* Improvements
* Added seled mode
* Removed unused dependency
* Changed selection behaviour, cleanup.
* update import and dependencies
* make custom style into drawer
* remove unused imports
* use lodash functions
* add popups
* allow header to become small
* reorganize settings
* add styling to popup
* change default template
* add projection option
* add basic map interface
* finish simple map
* add mapbox style
* support more mapbox layouts
* add api key option
* add mapbox backgrounds to layout
* warn when no api key is set
* fix for latest version
* Improved map layout and interface, bug fixes, refactoring.
.
.
* Added postgis geometry format, added marker icon shadow
* Made map buttons bigger and their icons thinner. Added transition to header bar.
* Bug fixes and error handling in map interface.
* Moved box-select control out of the map component. Removed material icons sprite and use addImage for marker support.
* Handle MultiGeometry -> Geometry interface error.
* Removed hardcoded styles. Added migrations for basemap column. Lots of refactoring.
Removed hardcoded styles. Added migrations for basemap column. Lots of refactoring.
* Fixed style reloading error. Added translations.
* Moved worker code to lib.
* Removed worker code. Prevent Mapbox from removing access_token from the URL.
* Refactoring.
* Change basemap selection to in-map dropdown for layout and interface.
* Touchscreen selection support and small fixes.
* Small change.
* Fixed unused imports.
* Added support for PostgreSQL identity column
* Renamed migration. Added crs translation.
* Only show fields using the map interface in the map layout.
* Removed logging.
* Reverted Dockerfile change.
* Improved crs support.
* Fixed translations.
* Check for schema identity before updating it.
* Fixed popup not updating on feature hover.
* Added feature hover styling. Fixed layer customization input. Added out of bounds error handling.
* Added geometry type and support for database native geometries.
* Fixed linting.
* Fixed layout.
* Fixed layout.
* Actually fixed linting
* Full support for native geometries
Fixed basemap input
Improved feature popup on hover
Locked interfaced support
* Fixed geometryType option not updating
* Bug fixes in interface
* Fixed crash when empty basemap settings. Fixed fitBounds option not updating.
* Added back storage type option. Improved interface behaviour.
* Dropped wkb because of vendor inconsistency with binary data
* Updated layout to match new geometry type. Fixed geojson payload transform.
* Added missing geometry_format attributes to local types.
* Fixed typos & refactoring
* Removed dependency on proj4
* Fix error when empty map interface options
* Set geometry SRID to 4326 when inserting into the database
* Add support for selectMode
* Fix error on initial source load
* Added geocoder, use GeoJSON for api i/o, removed geometry_format option, refactoring
* Added geometry intersects filter. Created geometry helper class.
* Fix error when null geometryOptions, added mapbox_key setting.
* Moved all geometry parsing/serializing into processGeometries in `payload.ts`. Fixed type errors.
* Migrate to Vue 3
* Use wellknown instead of wkx
* Fixed basemap selection.
* Added available operator for geometry type
* Added nintersects filter, fixed map interface for filter input
* Added intersects_bbox filter & bug fixes.
* Fixed icons rendering
* Fixed cursor icon in select mode
* Added geometry aggregate function
* Fixed geometry processing bug when imported from relational field.
* Fixed error with geocoder instanciation
* Removed @types/maplibre-gl dependency
* Removed fitViewToData options
* Merge remote-tracking branch 'upstream/main' into map-layout
* Fixed style and geometryType in map interface options
* Fixed style change on map interface.
* Improved fitViewToData behaviour
* Fixed type imports and previous merge conflict
* Fixed linting
* Added available operators
* Fix and merge migrations
* Remove outdated p-queue dep
* Fix get-schema column extract
* Replace pg with postgis for local debugging
* Re-add missing import
* Add mapbox as a basemap when key exists
* Remove unused tz flag
* Process delta in payloadservice
* Set default map, add limit number styling
* Default display template to just PK
* Tweak styling of error dialog
* Fix method usage in helpers
* Move sdo_geo to oracle section
* Remove extensions from ts config exclude
* Move geo types to shared, remove _Geometry
* Remove unused type
* Tiny Tweaks
* Remove fit to bounds option in favor of on
* Validate incoming intersects query
* Deepmap filter values
* Add GraphQL support
* No defaultValue for geometryType
* Resolve c
* Fix translations
Co-authored-by: Nitwel <nitwel@arcor.de>
Co-authored-by: Rijk van Zanten <rijkvanzanten@me.com>
* New Crowdin updates (#7359)
* New translations en-US.yaml (Estonian)
* New translations en-US.yaml (Ukrainian)
* New translations en-US.yaml (Norwegian)
* New translations en-US.yaml (Polish)
* New translations en-US.yaml (Portuguese)
* New translations en-US.yaml (Russian)
* New translations en-US.yaml (Serbian (Cyrillic))
* New translations en-US.yaml (Swedish)
* New translations en-US.yaml (Turkish)
* New translations en-US.yaml (Chinese Traditional)
* New translations en-US.yaml (Portuguese, Brazilian)
* New translations en-US.yaml (Indonesian)
* New translations en-US.yaml (Spanish, Chile)
* New translations en-US.yaml (Thai)
* New translations en-US.yaml (Hindi)
* New translations en-US.yaml (Malay)
* New translations en-US.yaml (Serbian (Latin))
* New translations en-US.yaml (Dutch)
* New translations en-US.yaml (Italian)
* New translations en-US.yaml (Afrikaans)
* New translations en-US.yaml (Lithuanian)
* New translations en-US.yaml (Spanish, Latin America)
* New translations en-US.yaml (Slovenian)
* New translations en-US.yaml (Vietnamese)
* New translations en-US.yaml (Chinese Simplified)
* New translations en-US.yaml (Bulgarian)
* New translations en-US.yaml (Romanian)
* New translations en-US.yaml (French)
* New translations en-US.yaml (Spanish)
* New translations en-US.yaml (Arabic)
* New translations en-US.yaml (Georgian)
* New translations en-US.yaml (Catalan)
* New translations en-US.yaml (Czech)
* New translations en-US.yaml (Danish)
* New translations en-US.yaml (German)
* New translations en-US.yaml (Greek)
* New translations en-US.yaml (Finnish)
* New translations en-US.yaml (Hebrew)
* New translations en-US.yaml (Hungarian)
* New translations en-US.yaml (Japanese)
* Update source file en-US.yaml
* New translations en-US.yaml (Italian)
* New translations en-US.yaml (Slovenian)
* New translations en-US.yaml (Estonian)
* New translations en-US.yaml (Estonian)
* New translations en-US.yaml (Sinhala)
* New translations en-US.yaml (Russian)
* New translations en-US.yaml (Russian)
* New translations en-US.yaml (Bulgarian)
* Update source file en-US.yaml
* New translations en-US.yaml (Estonian)
* New translations en-US.yaml (Norwegian)
* New translations en-US.yaml (Polish)
* New translations en-US.yaml (Portuguese)
* New translations en-US.yaml (Russian)
* New translations en-US.yaml (Swedish)
* New translations en-US.yaml (Turkish)
* New translations en-US.yaml (Portuguese, Brazilian)
* New translations en-US.yaml (Spanish, Chile)
* New translations en-US.yaml (Thai)
* New translations en-US.yaml (Serbian (Latin))
* New translations en-US.yaml (Dutch)
* New translations en-US.yaml (Lithuanian)
* New translations en-US.yaml (Spanish, Latin America)
* New translations en-US.yaml (Vietnamese)
* New translations en-US.yaml (Chinese Simplified)
* New translations en-US.yaml (Bulgarian)
* New translations en-US.yaml (French)
* New translations en-US.yaml (Spanish)
* New translations en-US.yaml (Arabic)
* New translations en-US.yaml (German)
* New translations en-US.yaml (Finnish)
* New translations en-US.yaml (Hungarian)
* update dependency directory-tree to v2.3.1 (#7380)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
* pin dependencies (#7384)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
* update dependency macos-release to v3 (#7381)
* update dependency macos-release to v3
* Update package-lock
Co-authored-by: Renovate Bot <bot@renovateapp.com>
Co-authored-by: rijkvanzanten <rijkvanzanten@me.com>
* New Crowdin updates (#7386)
* Update source file en-US.yaml
* New translations en-US.yaml (Estonian)
* New translations en-US.yaml (Polish)
* New translations en-US.yaml (Portuguese)
* New translations en-US.yaml (Russian)
* New translations en-US.yaml (Swedish)
* New translations en-US.yaml (Turkish)
* New translations en-US.yaml (Chinese Traditional)
* New translations en-US.yaml (Portuguese, Brazilian)
* New translations en-US.yaml (Indonesian)
* New translations en-US.yaml (Spanish, Chile)
* New translations en-US.yaml (Thai)
* New translations en-US.yaml (Serbian (Latin))
* New translations en-US.yaml (Dutch)
* New translations en-US.yaml (Italian)
* New translations en-US.yaml (Lithuanian)
* New translations en-US.yaml (Spanish, Latin America)
* New translations en-US.yaml (Slovenian)
* New translations en-US.yaml (Vietnamese)
* New translations en-US.yaml (Chinese Simplified)
* New translations en-US.yaml (Bulgarian)
* New translations en-US.yaml (French)
* New translations en-US.yaml (Spanish)
* New translations en-US.yaml (Arabic)
* New translations en-US.yaml (German)
* New translations en-US.yaml (Finnish)
* New translations en-US.yaml (Hungarian)
* Revert "update dependency macos-release to v3 (#7381)" (#7389)
This reverts commit ca111a80cb.
* update dependency npm to v7.20.6 (#7387)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
* Fix flat lock number
* Small tweaks, fix type bug
Co-authored-by: Rijk van Zanten <rijkvanzanten@me.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Renovate Bot <bot@renovateapp.com>
Co-authored-by: Nicola Krumschmidt <nicola.krumschmidt@freenet.de>
Co-authored-by: Pascal Jufer <paescuj@users.noreply.github.com>
Co-authored-by: Adrian Dimitrov <dimitrov.adrian@gmail.com>
Co-authored-by: Oreille <33065839+Oreilles@users.noreply.github.com>
Co-authored-by: Nitwel <nitwel@arcor.de>
* Fix merge quirk
* Add support for aliasing fields (#7419)
* Don't double split csv values
* Still join them on create tho
* Add support for `alias` query param
* Support aliases in wildcards
* Alias Support Within GraphQL (#7410)
* graphQL support for aliases
* moved aliases to its own function parseAliases
* Tweak types
Co-authored-by: rijkvanzanten <rijkvanzanten@me.com>
* Fix field resolution on alias usage
Fixes #5551
* Add `*_func` resolvers for date/time/datetime/timestamp fields
* graphQL Enum for groupby (#7445)
Co-authored-by: Rijk van Zanten <rijkvanzanten@me.com>
Co-authored-by: Rijk van Zanten <rijkvanzanten@me.com>
* Docs for Aggregation + Group By + Aliases (#7436)
* aggregation docs for graphql
* aliases
* added REST examples
* rest queries
* logo max size
* Recreate package-lock
* Update types/structure
* Fix childNode fetching
* Fix grouping
* Fix time-series
* Fix metric panel
* Add date func support in filter input graphql
* List panel (#8129)
* Merge branch 'main' of https://github.com/directus/directus into list-panel
* list showing mostly styled.
* Add missing options, cleanup
* Add editing to list panel type
* Tweak sizing
Co-authored-by: jaycammarano <jay.cammarano@gmail.com>
* Add no-data notice to list panel
* Camelcasify show_header
* Add cmd+s shortcut
* Tweak sizing, fix translation key
* Update docs
* Add multi-group support to GraphQL
* Align syntax of interfaces & panels
* Tweak min-size of label panel
* Fix linter warnings/errors
* Fix totally unrelated issue
But I'm here now anyways, so might as well
Co-authored-by: Ben Haynes <ben@rngr.org>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Renovate Bot <bot@renovateapp.com>
Co-authored-by: Geert Ijewski <51948919+geertijewski@users.noreply.github.com>
Co-authored-by: Thijs-Jan <13321277+MoltenCoffee@users.noreply.github.com>
Co-authored-by: Nacho García <hello@nachogarcia.dev>
Co-authored-by: Aiden Foxx <aiden.foxx.mail@gmail.com>
Co-authored-by: Aiden Foxx <aiden.foxx@sbab.se>
Co-authored-by: Oreille <33065839+Oreilles@users.noreply.github.com>
Co-authored-by: Zorin Sergey <36981278+Enhed@users.noreply.github.com>
Co-authored-by: Nicola Krumschmidt <nicola.krumschmidt@freenet.de>
Co-authored-by: Tommaso Bartolucci <tommasobartolucci11@gmail.com>
Co-authored-by: Jay Cammarano <67079013+jaycammarano@users.noreply.github.com>
Co-authored-by: Pascal Jufer <paescuj@users.noreply.github.com>
Co-authored-by: Adrian Dimitrov <dimitrov.adrian@gmail.com>
Co-authored-by: Nitwel <nitwel@arcor.de>
Co-authored-by: jaycammarano <jay.cammarano@gmail.com>
This commit is contained in:
@@ -19,7 +19,7 @@ export default defineModule({
|
||||
component: NotFound,
|
||||
},
|
||||
],
|
||||
order: 20,
|
||||
order: 30,
|
||||
});
|
||||
|
||||
function getRoutes(routes: DocsRoutes): RouteRecordRaw[] {
|
||||
|
||||
@@ -82,12 +82,12 @@
|
||||
|
||||
<template
|
||||
v-if="
|
||||
file.metadata.ifd0?.Make ||
|
||||
file.metadata.ifd0?.Model ||
|
||||
file.metadata.exif?.FNumber ||
|
||||
file.metadata.exif?.ExposureTime ||
|
||||
file.metadata.exif?.FocalLength ||
|
||||
file.metadata.exif?.ISO
|
||||
file.metadata?.ifd0?.Make ||
|
||||
file.metadata?.ifd0?.Model ||
|
||||
file.metadata?.exif?.FNumber ||
|
||||
file.metadata?.exif?.ExposureTime ||
|
||||
file.metadata?.exif?.FocalLength ||
|
||||
file.metadata?.exif?.ISO
|
||||
"
|
||||
>
|
||||
<v-divider />
|
||||
|
||||
117
app/src/modules/insights/components/dashboard-dialog.vue
Normal file
117
app/src/modules/insights/components/dashboard-dialog.vue
Normal file
@@ -0,0 +1,117 @@
|
||||
<template>
|
||||
<v-dialog :model-value="modelValue" persistent @update:modelValue="$emit('update:modelValue', $event)" @esc="cancel">
|
||||
<template #activator="slotBinding">
|
||||
<slot name="activator" v-bind="slotBinding" />
|
||||
</template>
|
||||
|
||||
<v-card>
|
||||
<v-card-title v-if="!dashboard">{{ t('create_dashboard') }}</v-card-title>
|
||||
<v-card-title v-else>{{ t('edit_dashboard') }}</v-card-title>
|
||||
|
||||
<v-card-text>
|
||||
<div class="fields">
|
||||
<v-input v-model="values.name" autofocus :placeholder="t('dashboard_name')" />
|
||||
<interface-select-icon :value="values.icon" @input="values.icon = $event" />
|
||||
<v-input v-model="values.note" class="full" :placeholder="t('note')" />
|
||||
</div>
|
||||
</v-card-text>
|
||||
|
||||
<v-card-actions>
|
||||
<v-button secondary @click="cancel">
|
||||
{{ t('cancel') }}
|
||||
</v-button>
|
||||
<v-button :disabled="!values.name" :loading="saving" @click="save">
|
||||
{{ t('save') }}
|
||||
</v-button>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import api from '@/api';
|
||||
import { unexpectedError } from '@/utils/unexpected-error';
|
||||
import { defineComponent, ref, reactive, PropType, watch } from 'vue';
|
||||
import { useInsightsStore } from '@/stores';
|
||||
import { router } from '@/router';
|
||||
import { Dashboard } from '@/types';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { isEqual } from 'lodash';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'DashboardDialog',
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
dashboard: {
|
||||
type: Object as PropType<Dashboard>,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
setup(props, { emit }) {
|
||||
const { t } = useI18n();
|
||||
|
||||
const insightsStore = useInsightsStore();
|
||||
|
||||
const values = reactive({
|
||||
name: props.dashboard?.name ?? null,
|
||||
icon: props.dashboard?.icon ?? 'dashboard',
|
||||
note: props.dashboard?.note ?? null,
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newValue, oldValue) => {
|
||||
if (isEqual(newValue, oldValue) === false) {
|
||||
values.name = props.dashboard?.name ?? null;
|
||||
values.icon = props.dashboard?.icon ?? 'dashboard';
|
||||
values.note = props.dashboard?.note ?? null;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const saving = ref(false);
|
||||
|
||||
return { values, cancel, saving, save, t };
|
||||
|
||||
function cancel() {
|
||||
emit('update:modelValue', false);
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true;
|
||||
|
||||
try {
|
||||
if (props.dashboard) {
|
||||
await api.patch(`/dashboards/${props.dashboard.id}`, values, { params: { fields: ['id'] } });
|
||||
await insightsStore.hydrate();
|
||||
} else {
|
||||
const response = await api.post('/dashboards', values, { params: { fields: ['id'] } });
|
||||
await insightsStore.hydrate();
|
||||
router.push(`/insights/${response.data.data.id}`);
|
||||
}
|
||||
emit('update:modelValue', false);
|
||||
} catch (err) {
|
||||
unexpectedError(err);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fields {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.full {
|
||||
grid-column: 1 / span 2;
|
||||
}
|
||||
</style>
|
||||
42
app/src/modules/insights/components/navigation.vue
Normal file
42
app/src/modules/insights/components/navigation.vue
Normal file
@@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<v-list large>
|
||||
<v-button v-if="navItems.length === 0" full-width outlined dashed @click="$emit('create')">
|
||||
{{ t('create_dashboard') }}
|
||||
</v-button>
|
||||
|
||||
<v-list-item v-for="navItem in navItems" v-else :key="navItem.to" :to="navItem.to">
|
||||
<v-list-item-icon><v-icon :name="navItem.icon" /></v-list-item-icon>
|
||||
<v-list-item-content>
|
||||
<v-text-overflow :text="navItem.name" />
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, computed, ref } from 'vue';
|
||||
import { useInsightsStore } from '@/stores';
|
||||
import { Dashboard } from '@/types';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'InsightsNavigation',
|
||||
emits: ['create'],
|
||||
setup() {
|
||||
const { t } = useI18n();
|
||||
const insightsStore = useInsightsStore();
|
||||
|
||||
const createDialogActive = ref(false);
|
||||
|
||||
const navItems = computed(() =>
|
||||
insightsStore.dashboards.map((dashboard: Dashboard) => ({
|
||||
icon: dashboard.icon,
|
||||
name: dashboard.name,
|
||||
to: `/insights/${dashboard.id}`,
|
||||
}))
|
||||
);
|
||||
|
||||
return { navItems, createDialogActive, t };
|
||||
},
|
||||
});
|
||||
</script>
|
||||
510
app/src/modules/insights/components/panel.vue
Normal file
510
app/src/modules/insights/components/panel.vue
Normal file
@@ -0,0 +1,510 @@
|
||||
<template>
|
||||
<div
|
||||
class="panel"
|
||||
:style="positionStyling"
|
||||
:class="{
|
||||
editing: editMode,
|
||||
dragging,
|
||||
'br-tl': dragging || panel.borderRadius[0],
|
||||
'br-tr': dragging || panel.borderRadius[1],
|
||||
'br-br': dragging || panel.borderRadius[2],
|
||||
'br-bl': dragging || panel.borderRadius[3],
|
||||
}"
|
||||
data-move
|
||||
@pointerdown="onPointerDown('move', $event)"
|
||||
>
|
||||
<div v-if="panel.show_header" class="header">
|
||||
<v-icon class="icon" :style="iconColor" :name="panel.icon" />
|
||||
<v-text-overflow class="name selectable" :text="panel.name || ''" />
|
||||
<div class="spacer" />
|
||||
<v-icon v-if="panel.note" v-tooltip="panel.note" class="note" name="info" />
|
||||
</div>
|
||||
|
||||
<div v-if="editMode" class="edit-actions" @pointerdown.stop>
|
||||
<v-icon
|
||||
v-tooltip="t('edit')"
|
||||
class="edit-icon"
|
||||
name="edit"
|
||||
clickable
|
||||
@click.stop="$router.push(`/insights/${panel.dashboard}/${panel.id}`)"
|
||||
/>
|
||||
|
||||
<v-menu placement="bottom-end" show-arrow>
|
||||
<template #activator="{ toggle }">
|
||||
<v-icon class="more-icon" name="more_vert" clickable @click="toggle" />
|
||||
</template>
|
||||
|
||||
<v-list>
|
||||
<v-list-item clickable :disabled="panel.id.startsWith('_')" @click="$emit('move')">
|
||||
<v-list-item-icon>
|
||||
<v-icon class="move-icon" name="input" />
|
||||
</v-list-item-icon>
|
||||
<v-list-item-content>
|
||||
{{ t('copy_to') }}
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item clickable @click="$emit('duplicate')">
|
||||
<v-list-item-icon>
|
||||
<v-icon name="control_point_duplicate" />
|
||||
</v-list-item-icon>
|
||||
<v-list-item-content>{{ t('duplicate') }}</v-list-item-content>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item class="delete-action" clickable @click="$emit('delete')">
|
||||
<v-list-item-icon>
|
||||
<v-icon name="delete" />
|
||||
</v-list-item-icon>
|
||||
<v-list-item-content>{{ t('delete_panel') }}</v-list-item-content>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</div>
|
||||
|
||||
<div class="resize-details">
|
||||
({{ positioning.x - 1 }}:{{ positioning.y - 1 }}) {{ positioning.width }}×{{ positioning.height }}
|
||||
</div>
|
||||
|
||||
<div v-if="editMode" class="resize-handlers">
|
||||
<div class="top" @pointerdown.stop="onPointerDown('resize-top', $event)" />
|
||||
<div class="right" @pointerdown.stop="onPointerDown('resize-right', $event)" />
|
||||
<div class="bottom" @pointerdown.stop="onPointerDown('resize-bottom', $event)" />
|
||||
<div class="left" @pointerdown.stop="onPointerDown('resize-left', $event)" />
|
||||
<div class="top-left" @pointerdown.stop="onPointerDown('resize-top-left', $event)" />
|
||||
<div class="top-right" @pointerdown.stop="onPointerDown('resize-top-right', $event)" />
|
||||
<div class="bottom-right" @pointerdown.stop="onPointerDown('resize-bottom-right', $event)" />
|
||||
<div class="bottom-left" @pointerdown.stop="onPointerDown('resize-bottom-left', $event)" />
|
||||
</div>
|
||||
|
||||
<div class="panel-content" :class="{ 'has-header': panel.show_header }">
|
||||
<component
|
||||
:is="`panel-${panel.type}`"
|
||||
v-bind="panel.options"
|
||||
:id="panel.id"
|
||||
:show-header="panel.show_header"
|
||||
:height="panel.height"
|
||||
:width="panel.width"
|
||||
:dashboard="panel.dashboard"
|
||||
:now="now"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { getPanels } from '@/panels';
|
||||
import { Panel } from '@/types';
|
||||
import { defineComponent, PropType, computed, ref, reactive } from 'vue';
|
||||
import { throttle, omit } from 'lodash';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'Panel',
|
||||
props: {
|
||||
panel: {
|
||||
type: Object as PropType<Panel>,
|
||||
required: true,
|
||||
},
|
||||
editMode: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
now: {
|
||||
type: Date,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
emits: ['update', 'move', 'duplicate', 'delete'],
|
||||
setup(props, { emit }) {
|
||||
const { t } = useI18n();
|
||||
const { panels } = getPanels();
|
||||
|
||||
const panelTypeInfo = computed(() => {
|
||||
return panels.value.find((panelConfig) => {
|
||||
return panelConfig.id === props.panel.type;
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* When drag-n-dropping for positiniong/resizing, we're
|
||||
*/
|
||||
const editedPosition = reactive<Partial<Panel>>({
|
||||
position_x: undefined,
|
||||
position_y: undefined,
|
||||
width: undefined,
|
||||
height: undefined,
|
||||
});
|
||||
|
||||
const { onPointerDown, onPointerUp, onPointerMove, dragging } = useDragDrop();
|
||||
|
||||
const positioning = computed(() => {
|
||||
if (dragging.value) {
|
||||
return {
|
||||
x: editedPosition.position_x ?? props.panel.position_x,
|
||||
y: editedPosition.position_y ?? props.panel.position_y,
|
||||
width: editedPosition.width ?? props.panel.width,
|
||||
height: editedPosition.height ?? props.panel.height,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
x: props.panel.position_x,
|
||||
y: props.panel.position_y,
|
||||
width: props.panel.width,
|
||||
height: props.panel.height,
|
||||
};
|
||||
});
|
||||
|
||||
const positionStyling = computed(() => {
|
||||
if (dragging.value) {
|
||||
return {
|
||||
'--pos-x': editedPosition.position_x ?? props.panel.position_x,
|
||||
'--pos-y': editedPosition.position_y ?? props.panel.position_y,
|
||||
'--width': editedPosition.width ?? props.panel.width,
|
||||
'--height': editedPosition.height ?? props.panel.height,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
'--pos-x': props.panel.position_x,
|
||||
'--pos-y': props.panel.position_y,
|
||||
'--width': props.panel.width,
|
||||
'--height': props.panel.height,
|
||||
};
|
||||
});
|
||||
|
||||
const iconColor = computed(() => ({
|
||||
'--v-icon-color': props.panel.color,
|
||||
}));
|
||||
|
||||
return {
|
||||
positioning,
|
||||
positionStyling,
|
||||
iconColor,
|
||||
onPointerDown,
|
||||
onPointerUp,
|
||||
onPointerMove,
|
||||
dragging,
|
||||
editedPosition,
|
||||
t,
|
||||
omit,
|
||||
};
|
||||
|
||||
function useDragDrop() {
|
||||
const dragging = ref(false);
|
||||
|
||||
let pointerStartPosX = 0;
|
||||
let pointerStartPosY = 0;
|
||||
|
||||
let panelStartPosX = 0;
|
||||
let panelStartPosY = 0;
|
||||
let panelStartWidth = 0;
|
||||
let panelStartHeight = 0;
|
||||
|
||||
type Operation =
|
||||
| 'move'
|
||||
| 'resize-top'
|
||||
| 'resize-right'
|
||||
| 'resize-bottom'
|
||||
| 'resize-left'
|
||||
| 'resize-top-left'
|
||||
| 'resize-top-right'
|
||||
| 'resize-bottom-right'
|
||||
| 'resize-bottom-left';
|
||||
|
||||
let operation: Operation = 'move';
|
||||
|
||||
const onPointerMove = throttle((event: PointerEvent) => {
|
||||
if (props.editMode === false) return;
|
||||
if (dragging.value === false) return;
|
||||
|
||||
const pointerDeltaX = event.pageX - pointerStartPosX;
|
||||
const pointerDeltaY = event.pageY - pointerStartPosY;
|
||||
|
||||
const gridDeltaX = Math.round(pointerDeltaX / 20);
|
||||
const gridDeltaY = Math.round(pointerDeltaY / 20);
|
||||
|
||||
if (operation === 'move') {
|
||||
editedPosition.position_x = panelStartPosX + gridDeltaX;
|
||||
editedPosition.position_y = panelStartPosY + gridDeltaY;
|
||||
|
||||
if (editedPosition.position_x < 1) editedPosition.position_x = 1;
|
||||
if (editedPosition.position_y < 1) editedPosition.position_y = 1;
|
||||
} else {
|
||||
if (operation.includes('top')) {
|
||||
editedPosition.height = panelStartHeight - gridDeltaY;
|
||||
editedPosition.position_y = panelStartPosY + gridDeltaY;
|
||||
}
|
||||
|
||||
if (operation.includes('right')) {
|
||||
editedPosition.width = panelStartWidth + gridDeltaX;
|
||||
}
|
||||
|
||||
if (operation.includes('bottom')) {
|
||||
editedPosition.height = panelStartHeight + gridDeltaY;
|
||||
}
|
||||
|
||||
if (operation.includes('left')) {
|
||||
editedPosition.width = panelStartWidth - gridDeltaX;
|
||||
editedPosition.position_x = panelStartPosX + gridDeltaX;
|
||||
}
|
||||
|
||||
const minWidth = panelTypeInfo.value?.minWidth || 6;
|
||||
const minHeight = panelTypeInfo.value?.minHeight || 6;
|
||||
|
||||
if (editedPosition.position_x && editedPosition.position_x < 1) editedPosition.position_x = 1;
|
||||
if (editedPosition.position_y && editedPosition.position_y < 1) editedPosition.position_y = 1;
|
||||
if (editedPosition.width && editedPosition.width < minWidth) editedPosition.width = minWidth;
|
||||
if (editedPosition.height && editedPosition.height < minHeight) editedPosition.height = minHeight;
|
||||
}
|
||||
}, 20);
|
||||
|
||||
return { dragging, onPointerDown, onPointerUp, onPointerMove };
|
||||
|
||||
function onPointerDown(op: Operation, event: PointerEvent) {
|
||||
if (props.editMode === false) return;
|
||||
|
||||
operation = op;
|
||||
|
||||
dragging.value = true;
|
||||
|
||||
pointerStartPosX = event.pageX;
|
||||
pointerStartPosY = event.pageY;
|
||||
|
||||
panelStartPosX = props.panel.position_x;
|
||||
panelStartPosY = props.panel.position_y;
|
||||
|
||||
panelStartWidth = props.panel.width;
|
||||
panelStartHeight = props.panel.height;
|
||||
|
||||
window.addEventListener('pointerup', onPointerUp);
|
||||
window.addEventListener('pointermove', onPointerMove);
|
||||
}
|
||||
|
||||
function onPointerUp() {
|
||||
dragging.value = false;
|
||||
if (props.editMode === false) return;
|
||||
emit('update', editedPosition);
|
||||
window.removeEventListener('pointerup', onPointerUp);
|
||||
window.removeEventListener('pointermove', onPointerMove);
|
||||
|
||||
editedPosition.position_x = undefined;
|
||||
editedPosition.position_y = undefined;
|
||||
editedPosition.width = undefined;
|
||||
editedPosition.height = undefined;
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.panel {
|
||||
--pos-x: 1;
|
||||
--pos-y: 1;
|
||||
--width: 6;
|
||||
--height: 6;
|
||||
|
||||
position: relative;
|
||||
display: block;
|
||||
grid-row: var(--pos-y) / span var(--height);
|
||||
grid-column: var(--pos-x) / span var(--width);
|
||||
background-color: var(--background-page);
|
||||
border: 1px solid var(--border-subdued);
|
||||
box-shadow: 0 0 0 1px var(--border-subdued);
|
||||
}
|
||||
|
||||
.panel:hover {
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.panel.editing {
|
||||
border-color: var(--border-normal);
|
||||
box-shadow: 0 0 0 1px var(--border-normal);
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
.panel.editing:hover {
|
||||
border-color: var(--border-normal-alt);
|
||||
box-shadow: 0 0 0 1px var(--border-normal-alt);
|
||||
}
|
||||
|
||||
.panel.editing.dragging {
|
||||
z-index: 3 !important;
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 1px var(--primary);
|
||||
}
|
||||
|
||||
.resize-details {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
z-index: 2;
|
||||
padding: 17px 14px;
|
||||
color: var(--foreground-subdued);
|
||||
font-weight: 500;
|
||||
font-size: 15px;
|
||||
font-family: var(--family-monospace);
|
||||
font-style: normal;
|
||||
line-height: 1;
|
||||
text-align: right;
|
||||
background-color: var(--background-page);
|
||||
border-top-right-radius: var(--border-radius-outline);
|
||||
opacity: 0;
|
||||
transition: opacity var(--fast) var(--transition), color var(--fast) var(--transition);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.panel.editing.dragging .resize-details {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.panel-content {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.panel-content.has-header {
|
||||
height: calc(100% - 48px);
|
||||
}
|
||||
|
||||
.panel.editing .panel-content {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 48px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
--v-icon-color: var(--foreground-subdued);
|
||||
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.name {
|
||||
color: var(--foreground-normal-alt);
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
font-family: var(--family-sans-serif);
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.more-icon,
|
||||
.edit-icon,
|
||||
.note {
|
||||
--v-icon-color: var(--foreground-subdued);
|
||||
--v-icon-color-hover: var(--foreground-normal);
|
||||
}
|
||||
|
||||
.delete-action {
|
||||
--v-list-item-color: var(--danger);
|
||||
--v-list-item-color-hover: var(--danger);
|
||||
--v-list-item-icon-color: var(--danger);
|
||||
}
|
||||
|
||||
.edit-actions {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
padding: 12px 12px 8px 12px;
|
||||
background-color: var(--background-page);
|
||||
border-top-right-radius: var(--border-radius-outline);
|
||||
}
|
||||
|
||||
.resize-handlers div {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.resize-handlers .top {
|
||||
top: -3px;
|
||||
width: 100%;
|
||||
height: 10px;
|
||||
cursor: ns-resize;
|
||||
}
|
||||
|
||||
.resize-handlers .right {
|
||||
top: 0;
|
||||
right: -3px;
|
||||
width: 10px;
|
||||
height: 100%;
|
||||
cursor: ew-resize;
|
||||
}
|
||||
|
||||
.resize-handlers .bottom {
|
||||
bottom: -3px;
|
||||
width: 100%;
|
||||
height: 10px;
|
||||
cursor: ns-resize;
|
||||
}
|
||||
|
||||
.resize-handlers .left {
|
||||
top: 0;
|
||||
left: -3px;
|
||||
width: 10px;
|
||||
height: 100%;
|
||||
cursor: ew-resize;
|
||||
}
|
||||
|
||||
.resize-handlers .top-left {
|
||||
top: -3px;
|
||||
left: -3px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
cursor: nwse-resize;
|
||||
}
|
||||
|
||||
.resize-handlers .top-right {
|
||||
top: -3px;
|
||||
right: -3px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
cursor: nesw-resize;
|
||||
}
|
||||
|
||||
.resize-handlers .bottom-right {
|
||||
right: -3px;
|
||||
bottom: -3px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
cursor: nwse-resize;
|
||||
}
|
||||
|
||||
.resize-handlers .bottom-left {
|
||||
bottom: -3px;
|
||||
left: -3px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
cursor: nesw-resize;
|
||||
}
|
||||
|
||||
.br-tl {
|
||||
border-top-left-radius: var(--border-radius-outline);
|
||||
}
|
||||
|
||||
.br-tr {
|
||||
border-top-right-radius: var(--border-radius-outline);
|
||||
}
|
||||
|
||||
.br-br {
|
||||
border-bottom-right-radius: var(--border-radius-outline);
|
||||
}
|
||||
|
||||
.br-bl {
|
||||
border-bottom-left-radius: var(--border-radius-outline);
|
||||
}
|
||||
</style>
|
||||
174
app/src/modules/insights/components/workspace.vue
Normal file
174
app/src/modules/insights/components/workspace.vue
Normal file
@@ -0,0 +1,174 @@
|
||||
<template>
|
||||
<div
|
||||
class="workspace-padding-box"
|
||||
:class="{ editing: editMode }"
|
||||
:style="{ width: workspaceBoxSize.width + 'px', height: workspaceBoxSize.height + 'px' }"
|
||||
>
|
||||
<div
|
||||
class="workspace"
|
||||
:style="{
|
||||
transform: `scale(${zoomScale})`,
|
||||
width: workspaceSize.width + 'px',
|
||||
height: workspaceSize.height + 'px',
|
||||
}"
|
||||
>
|
||||
<insights-panel
|
||||
v-for="panel in panels"
|
||||
:key="panel.id"
|
||||
:panel="panel"
|
||||
:edit-mode="editMode"
|
||||
:now="now"
|
||||
@update="$emit('update', { edits: $event, id: panel.id })"
|
||||
@move="$emit('move', panel.id)"
|
||||
@delete="$emit('delete', panel.id)"
|
||||
@duplicate="$emit('duplicate', panel)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, PropType, computed, inject, ref } from 'vue';
|
||||
import { Panel } from '@/types';
|
||||
import InsightsPanel from '../components/panel.vue';
|
||||
import { useElementSize } from '@/composables/use-element-size';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'InsightsWorkspace',
|
||||
components: { InsightsPanel },
|
||||
props: {
|
||||
panels: {
|
||||
type: Array as PropType<Panel[]>,
|
||||
required: true,
|
||||
},
|
||||
editMode: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
zoomToFit: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
now: {
|
||||
type: Date,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
emits: ['update', 'move', 'delete', 'duplicate'],
|
||||
setup(props) {
|
||||
const mainElement = inject('main-element', ref<Element>());
|
||||
const mainElementSize = useElementSize(mainElement);
|
||||
|
||||
const paddingSize = computed(() => Number(getVar('--content-padding')?.slice(0, -2) || 0));
|
||||
|
||||
const workspaceSize = computed(() => {
|
||||
const furthestPanelX = props.panels.reduce(
|
||||
(aggr, panel) => {
|
||||
if (panel.position_x! > aggr.position_x!) {
|
||||
aggr.position_x = panel.position_x!;
|
||||
aggr.width = panel.width!;
|
||||
}
|
||||
|
||||
return aggr;
|
||||
},
|
||||
{ position_x: 0, width: 0 }
|
||||
);
|
||||
|
||||
const furthestPanelY = props.panels.reduce(
|
||||
(aggr, panel) => {
|
||||
if (panel.position_y! > aggr.position_y!) {
|
||||
aggr.position_y = panel.position_y!;
|
||||
aggr.height = panel.height!;
|
||||
}
|
||||
|
||||
return aggr;
|
||||
},
|
||||
{ position_y: 0, height: 0 }
|
||||
);
|
||||
|
||||
if (props.editMode === true) {
|
||||
return {
|
||||
width: (furthestPanelX.position_x! + furthestPanelX.width! + 25) * 20,
|
||||
height: (furthestPanelY.position_y! + furthestPanelY.height! + 25) * 20,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
width: (furthestPanelX.position_x! + furthestPanelX.width! - 1) * 20,
|
||||
height: (furthestPanelY.position_y! + furthestPanelY.height! - 1) * 20,
|
||||
};
|
||||
});
|
||||
|
||||
const zoomScale = computed(() => {
|
||||
if (props.zoomToFit === false) return 1;
|
||||
|
||||
const { width, height } = mainElementSize;
|
||||
|
||||
const scaleWidth: number = (width.value - paddingSize.value * 2) / workspaceSize.value.width;
|
||||
const scaleHeight: number = (height.value - 114 - paddingSize.value * 2) / workspaceSize.value.height;
|
||||
|
||||
return Math.min(scaleWidth, scaleHeight);
|
||||
});
|
||||
|
||||
const workspaceBoxSize = computed(() => {
|
||||
return {
|
||||
width: workspaceSize.value.width * zoomScale.value + paddingSize.value * 2,
|
||||
height: workspaceSize.value.height * zoomScale.value + paddingSize.value * 2,
|
||||
};
|
||||
});
|
||||
|
||||
return { workspaceSize, workspaceBoxSize, mainElement, zoomScale };
|
||||
|
||||
function getVar(cssVar: string) {
|
||||
if (!mainElement.value) return;
|
||||
return getComputedStyle(mainElement.value).getPropertyValue(cssVar).trim();
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.workspace-padding-box {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.workspace {
|
||||
position: absolute;
|
||||
left: var(--content-padding);
|
||||
display: grid;
|
||||
grid-template-rows: repeat(auto-fill, 20px);
|
||||
grid-template-columns: repeat(auto-fill, 20px);
|
||||
min-width: calc(100%);
|
||||
min-height: calc(100% - 120px);
|
||||
transform: scale(1);
|
||||
transform-origin: top left;
|
||||
|
||||
/* This causes the header bar to "unhinge" on the left edge :C */
|
||||
|
||||
/* transition: transform var(--slow) var(--transition); */
|
||||
}
|
||||
|
||||
.workspace > * {
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.workspace::before {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
left: -4px;
|
||||
display: block;
|
||||
width: calc(100% + 8px);
|
||||
height: calc(100% + 8px);
|
||||
background-image: radial-gradient(var(--border-normal) 10%, transparent 10%);
|
||||
background-position: -6px -6px;
|
||||
background-size: 20px 20px;
|
||||
opacity: 0;
|
||||
transition: opacity var(--slow) var(--transition);
|
||||
content: '';
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.workspace-padding-box.editing .workspace::before {
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
45
app/src/modules/insights/index.ts
Normal file
45
app/src/modules/insights/index.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import InsightsOverview from './routes/overview.vue';
|
||||
import InsightsDashboard from './routes/dashboard.vue';
|
||||
import InsightsPanelConfiguration from './routes/panel-configuration.vue';
|
||||
import { defineModule } from '@directus/shared/utils';
|
||||
|
||||
export default defineModule({
|
||||
id: 'insights',
|
||||
name: '$t:insights',
|
||||
icon: 'insights',
|
||||
routes: [
|
||||
{
|
||||
name: 'insights-overview',
|
||||
path: '',
|
||||
component: InsightsOverview,
|
||||
},
|
||||
{
|
||||
name: 'insights-dashboard',
|
||||
path: ':primaryKey',
|
||||
component: InsightsDashboard,
|
||||
props: true,
|
||||
children: [
|
||||
{
|
||||
name: 'panel-detail',
|
||||
path: ':panelKey',
|
||||
props: true,
|
||||
components: {
|
||||
detail: InsightsPanelConfiguration,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
order: 20,
|
||||
preRegisterCheck(user, permissions) {
|
||||
const admin = user.role.admin_access;
|
||||
|
||||
if (admin) return true;
|
||||
|
||||
const permission = permissions.find(
|
||||
(permission) => permission.collection === 'directus_dashboards' && permission.action === 'read'
|
||||
);
|
||||
|
||||
return !!permission;
|
||||
},
|
||||
});
|
||||
493
app/src/modules/insights/routes/dashboard.vue
Normal file
493
app/src/modules/insights/routes/dashboard.vue
Normal file
@@ -0,0 +1,493 @@
|
||||
<template>
|
||||
<insights-not-found v-if="!currentDashboard" />
|
||||
<private-view v-else :title="currentDashboard.name">
|
||||
<template #title-outer:prepend>
|
||||
<v-button class="header-icon" rounded disabled icon secondary>
|
||||
<v-icon :name="currentDashboard.icon" />
|
||||
</v-button>
|
||||
</template>
|
||||
|
||||
<template #headline>
|
||||
<v-breadcrumb :items="[{ name: t('insights'), to: '/insights' }]" />
|
||||
</template>
|
||||
|
||||
<template #actions>
|
||||
<template v-if="editMode">
|
||||
<v-button
|
||||
v-tooltip.bottom="t('clear_changes')"
|
||||
class="clear-changes"
|
||||
rounded
|
||||
icon
|
||||
outlined
|
||||
@click="attemptCancelChanges"
|
||||
>
|
||||
<v-icon name="clear" />
|
||||
</v-button>
|
||||
|
||||
<v-button v-tooltip.bottom="t('create_panel')" rounded icon outlined :to="`/insights/${currentDashboard.id}/+`">
|
||||
<v-icon name="add" />
|
||||
</v-button>
|
||||
|
||||
<v-button v-tooltip.bottom="t('save')" rounded icon :loading="saving" @click="saveChanges">
|
||||
<v-icon name="check" />
|
||||
</v-button>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<v-button
|
||||
v-tooltip.bottom="t('fit_to_screen')"
|
||||
:active="zoomToFit"
|
||||
class="zoom-to-fit"
|
||||
rounded
|
||||
icon
|
||||
outlined
|
||||
@click="toggleZoomToFit"
|
||||
>
|
||||
<v-icon name="aspect_ratio" />
|
||||
</v-button>
|
||||
|
||||
<v-button
|
||||
v-tooltip.bottom="t('full_screen')"
|
||||
:active="fullScreen"
|
||||
class="fullscreen"
|
||||
rounded
|
||||
icon
|
||||
outlined
|
||||
@click="toggleFullScreen"
|
||||
>
|
||||
<v-icon name="fullscreen" />
|
||||
</v-button>
|
||||
|
||||
<v-button v-tooltip.bottom="t('edit_panels')" rounded icon outlined @click="editMode = !editMode">
|
||||
<v-icon name="edit" />
|
||||
</v-button>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<template #sidebar>
|
||||
<sidebar-detail icon="info_outline" :title="t('information')" close>
|
||||
<div v-md="t('page_help_insights_dashboard')" class="page-description" />
|
||||
</sidebar-detail>
|
||||
</template>
|
||||
|
||||
<template #navigation>
|
||||
<insights-navigation />
|
||||
</template>
|
||||
|
||||
<insights-workspace
|
||||
:edit-mode="editMode"
|
||||
:panels="panels"
|
||||
:zoom-to-fit="zoomToFit"
|
||||
:now="now"
|
||||
@update="stagePanelEdits"
|
||||
@move="movePanelID = $event"
|
||||
@delete="deletePanel"
|
||||
@duplicate="duplicatePanel"
|
||||
/>
|
||||
|
||||
<router-view
|
||||
name="detail"
|
||||
:dashboard-key="primaryKey"
|
||||
:panel="panels.find((panel) => panel.id === panelKey)"
|
||||
@save="stageConfiguration"
|
||||
@cancel="$router.push(`/insights/${primaryKey}`)"
|
||||
/>
|
||||
|
||||
<v-dialog :model-value="!!movePanelID" @update:model-value="movePanelID = null" @esc="movePanelID = null">
|
||||
<v-card>
|
||||
<v-card-title>{{ t('copy_to') }}</v-card-title>
|
||||
|
||||
<v-card-text>
|
||||
<v-notice v-if="movePanelChoices.length === 0">
|
||||
{{ t('no_other_dashboards_copy') }}
|
||||
</v-notice>
|
||||
<v-select v-else v-model="movePanelTo" :items="movePanelChoices" item-text="name" item-value="id" />
|
||||
</v-card-text>
|
||||
|
||||
<v-card-actions>
|
||||
<v-button secondary @click="movePanelID = null">
|
||||
{{ t('cancel') }}
|
||||
</v-button>
|
||||
<v-button :loading="movePanelLoading" @click="movePanel">
|
||||
{{ t('copy') }}
|
||||
</v-button>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<v-dialog v-model="confirmLeave" @esc="confirmLeave = false">
|
||||
<v-card>
|
||||
<v-card-title>{{ t('unsaved_changes') }}</v-card-title>
|
||||
<v-card-text>{{ t('unsaved_changes_copy') }}</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-button secondary @click="discardAndLeave">
|
||||
{{ t('discard_changes') }}
|
||||
</v-button>
|
||||
<v-button @click="confirmLeave = false">{{ t('keep_editing') }}</v-button>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<v-dialog v-model="confirmCancel" @esc="confirmCancel = false">
|
||||
<v-card>
|
||||
<v-card-title>{{ t('unsaved_changes') }}</v-card-title>
|
||||
<v-card-text>{{ t('discard_changes_copy') }}</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-button secondary @click="cancelChanges">
|
||||
{{ t('discard_changes') }}
|
||||
</v-button>
|
||||
<v-button @click="confirmCancel = false">{{ t('keep_editing') }}</v-button>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</private-view>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import InsightsNavigation from '../components/navigation.vue';
|
||||
import { defineComponent, computed, ref, toRefs, watch } from 'vue';
|
||||
import { useInsightsStore, useAppStore } from '@/stores';
|
||||
import InsightsNotFound from './not-found.vue';
|
||||
import { Panel } from '@/types';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { merge, omit } from 'lodash';
|
||||
import { router } from '@/router';
|
||||
import { unexpectedError } from '@/utils/unexpected-error';
|
||||
import api from '@/api';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { pointOnLine } from '@/utils/point-on-line';
|
||||
import InsightsWorkspace from '../components/workspace.vue';
|
||||
import { md } from '@/utils/md';
|
||||
import { onBeforeRouteUpdate, onBeforeRouteLeave, NavigationGuard } from 'vue-router';
|
||||
import useShortcut from '@/composables/use-shortcut';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'InsightsDashboard',
|
||||
components: { InsightsNotFound, InsightsNavigation, InsightsWorkspace },
|
||||
props: {
|
||||
primaryKey: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
panelKey: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const { t } = useI18n();
|
||||
|
||||
const insightsStore = useInsightsStore();
|
||||
const appStore = useAppStore();
|
||||
|
||||
const { fullScreen } = toRefs(appStore);
|
||||
|
||||
const editMode = ref(false);
|
||||
const saving = ref(false);
|
||||
const movePanelLoading = ref(false);
|
||||
|
||||
const movePanelTo = ref(insightsStore.dashboards.find((dashboard) => dashboard.id !== props.primaryKey)?.id);
|
||||
|
||||
const movePanelID = ref<string | null>();
|
||||
|
||||
const zoomToFit = ref(false);
|
||||
|
||||
useShortcut('meta+s', () => {
|
||||
saveChanges();
|
||||
});
|
||||
|
||||
watch(editMode, (editModeEnabled) => {
|
||||
if (editModeEnabled) {
|
||||
zoomToFit.value = false;
|
||||
window.onbeforeunload = () => '';
|
||||
} else {
|
||||
window.onbeforeunload = null;
|
||||
}
|
||||
});
|
||||
|
||||
const currentDashboard = computed(() =>
|
||||
insightsStore.dashboards.find((dashboard) => dashboard.id === props.primaryKey)
|
||||
);
|
||||
|
||||
const movePanelChoices = computed(() => {
|
||||
return insightsStore.dashboards.filter((dashboard) => dashboard.id !== props.primaryKey);
|
||||
});
|
||||
|
||||
const stagedPanels = ref<Partial<Panel & { borderRadius: [boolean, boolean, boolean, boolean] }>[]>([]);
|
||||
const panelsToBeDeleted = ref<string[]>([]);
|
||||
|
||||
const now = new Date();
|
||||
|
||||
const panels = computed(() => {
|
||||
const savedPanels = (currentDashboard.value?.panels || []).filter(
|
||||
(panel) => panelsToBeDeleted.value.includes(panel.id) === false
|
||||
);
|
||||
|
||||
const raw = [
|
||||
...savedPanels.map((panel) => {
|
||||
const updates = stagedPanels.value.find((updatedPanel) => updatedPanel.id === panel.id);
|
||||
|
||||
if (updates) {
|
||||
return merge({}, panel, updates);
|
||||
}
|
||||
|
||||
return panel;
|
||||
}),
|
||||
...stagedPanels.value.filter((panel) => panel.id?.startsWith('_')),
|
||||
];
|
||||
|
||||
const withCoords = raw.map((panel) => ({
|
||||
...panel,
|
||||
_coordinates: [
|
||||
[panel.position_x!, panel.position_y!],
|
||||
[panel.position_x! + panel.width!, panel.position_y!],
|
||||
[panel.position_x! + panel.width!, panel.position_y! + panel.height!],
|
||||
[panel.position_x!, panel.position_y! + panel.height!],
|
||||
] as [number, number][],
|
||||
}));
|
||||
|
||||
const withBorderRadii = withCoords.map((panel) => {
|
||||
let topLeftIntersects = false;
|
||||
let topRightIntersects = false;
|
||||
let bottomRightIntersects = false;
|
||||
let bottomLeftIntersects = false;
|
||||
|
||||
for (const otherPanel of withCoords) {
|
||||
if (otherPanel.id === panel.id) continue;
|
||||
|
||||
const borders = [
|
||||
[otherPanel._coordinates[0], otherPanel._coordinates[1]],
|
||||
[otherPanel._coordinates[1], otherPanel._coordinates[2]],
|
||||
[otherPanel._coordinates[2], otherPanel._coordinates[3]],
|
||||
[otherPanel._coordinates[3], otherPanel._coordinates[0]],
|
||||
];
|
||||
|
||||
if (topLeftIntersects === false)
|
||||
topLeftIntersects = borders.some(([p1, p2]) => pointOnLine(panel._coordinates[0], p1, p2));
|
||||
if (topRightIntersects === false)
|
||||
topRightIntersects = borders.some(([p1, p2]) => pointOnLine(panel._coordinates[1], p1, p2));
|
||||
if (bottomRightIntersects === false)
|
||||
bottomRightIntersects = borders.some(([p1, p2]) => pointOnLine(panel._coordinates[2], p1, p2));
|
||||
if (bottomLeftIntersects === false)
|
||||
bottomLeftIntersects = borders.some(([p1, p2]) => pointOnLine(panel._coordinates[3], p1, p2));
|
||||
}
|
||||
|
||||
return {
|
||||
...panel,
|
||||
borderRadius: [!topLeftIntersects, !topRightIntersects, !bottomRightIntersects, !bottomLeftIntersects],
|
||||
};
|
||||
});
|
||||
|
||||
return withBorderRadii;
|
||||
});
|
||||
|
||||
const confirmCancel = ref(false);
|
||||
const confirmLeave = ref(false);
|
||||
const leaveTo = ref<string | null>(null);
|
||||
|
||||
const editsGuard: NavigationGuard = (to) => {
|
||||
const hasEdits = panelsToBeDeleted.value.length > 0 || stagedPanels.value.length > 0;
|
||||
|
||||
if (editMode.value && to.params.primaryKey !== props.primaryKey) {
|
||||
if (hasEdits) {
|
||||
confirmLeave.value = true;
|
||||
leaveTo.value = to.fullPath;
|
||||
return false;
|
||||
} else {
|
||||
editMode.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
onBeforeRouteUpdate(editsGuard);
|
||||
onBeforeRouteLeave(editsGuard);
|
||||
|
||||
return {
|
||||
currentDashboard,
|
||||
editMode,
|
||||
panels,
|
||||
stagePanelEdits,
|
||||
stagedPanels,
|
||||
saving,
|
||||
saveChanges,
|
||||
stageConfiguration,
|
||||
movePanelID,
|
||||
movePanel,
|
||||
deletePanel,
|
||||
attemptCancelChanges,
|
||||
duplicatePanel,
|
||||
movePanelLoading,
|
||||
t,
|
||||
toggleFullScreen,
|
||||
zoomToFit,
|
||||
fullScreen,
|
||||
toggleZoomToFit,
|
||||
md,
|
||||
movePanelChoices,
|
||||
movePanelTo,
|
||||
confirmLeave,
|
||||
discardAndLeave,
|
||||
now,
|
||||
confirmCancel,
|
||||
cancelChanges,
|
||||
};
|
||||
|
||||
function stagePanelEdits(event: { edits: Partial<Panel>; id?: string }) {
|
||||
const key = event.id ?? props.panelKey;
|
||||
|
||||
if (key === '+') {
|
||||
stagedPanels.value = [
|
||||
...stagedPanels.value,
|
||||
{
|
||||
id: `_${nanoid()}`,
|
||||
dashboard: props.primaryKey,
|
||||
...event.edits,
|
||||
},
|
||||
];
|
||||
} else {
|
||||
if (stagedPanels.value.some((panel) => panel.id === key)) {
|
||||
stagedPanels.value = stagedPanels.value.map((panel) => {
|
||||
if (panel.id === key) {
|
||||
return merge({ id: key, dashboard: props.primaryKey }, panel, event.edits);
|
||||
}
|
||||
|
||||
return panel;
|
||||
});
|
||||
} else {
|
||||
stagedPanels.value = [...stagedPanels.value, { id: key, dashboard: props.primaryKey, ...event.edits }];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function stageConfiguration(edits: Partial<Panel>) {
|
||||
stagePanelEdits({ edits });
|
||||
router.push(`/insights/${props.primaryKey}`);
|
||||
}
|
||||
|
||||
async function saveChanges() {
|
||||
if (!currentDashboard.value) return;
|
||||
|
||||
if (stagedPanels.value.length === 0 && panelsToBeDeleted.value.length === 0) {
|
||||
editMode.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
saving.value = true;
|
||||
|
||||
const currentIDs = currentDashboard.value.panels.map((panel) => panel.id);
|
||||
|
||||
const updatedPanels = [
|
||||
...currentIDs.map((id) => {
|
||||
return stagedPanels.value.find((panel) => panel.id === id) || id;
|
||||
}),
|
||||
...stagedPanels.value.filter((panel) => panel.id?.startsWith('_')).map((panel) => omit(panel, 'id')),
|
||||
];
|
||||
|
||||
try {
|
||||
if (stagedPanels.value.length > 0) {
|
||||
await api.patch(`/dashboards/${props.primaryKey}`, {
|
||||
panels: updatedPanels,
|
||||
});
|
||||
}
|
||||
|
||||
if (panelsToBeDeleted.value.length > 0) {
|
||||
await api.delete(`/panels`, { data: panelsToBeDeleted.value });
|
||||
}
|
||||
|
||||
await insightsStore.hydrate();
|
||||
|
||||
stagedPanels.value = [];
|
||||
editMode.value = false;
|
||||
} catch (err) {
|
||||
unexpectedError(err);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deletePanel(id: string) {
|
||||
if (!currentDashboard.value) return;
|
||||
|
||||
stagedPanels.value = stagedPanels.value.filter((panel) => panel.id !== id);
|
||||
if (id.startsWith('_') === false) panelsToBeDeleted.value.push(id);
|
||||
}
|
||||
|
||||
function attemptCancelChanges(): void {
|
||||
const hasEdits = stagedPanels.value.length > 0 || panelsToBeDeleted.value.length > 0;
|
||||
|
||||
if (hasEdits) {
|
||||
confirmCancel.value = true;
|
||||
} else {
|
||||
cancelChanges();
|
||||
}
|
||||
}
|
||||
|
||||
function cancelChanges() {
|
||||
confirmCancel.value = false;
|
||||
stagedPanels.value = [];
|
||||
panelsToBeDeleted.value = [];
|
||||
editMode.value = false;
|
||||
}
|
||||
|
||||
function discardAndLeave() {
|
||||
if (!leaveTo.value) return;
|
||||
cancelChanges();
|
||||
confirmLeave.value = false;
|
||||
router.push(leaveTo.value);
|
||||
}
|
||||
|
||||
function duplicatePanel(panel: Panel) {
|
||||
const newPanel = omit(merge({}, panel), 'id');
|
||||
newPanel.position_x = newPanel.position_x + 2;
|
||||
newPanel.position_y = newPanel.position_y + 2;
|
||||
stagePanelEdits({ edits: newPanel, id: '+' });
|
||||
}
|
||||
|
||||
function toggleFullScreen() {
|
||||
fullScreen.value = !fullScreen.value;
|
||||
}
|
||||
|
||||
function toggleZoomToFit() {
|
||||
zoomToFit.value = !zoomToFit.value;
|
||||
}
|
||||
|
||||
async function movePanel() {
|
||||
movePanelLoading.value = true;
|
||||
|
||||
const currentPanel = panels.value.find((panel) => panel.id === movePanelID.value);
|
||||
|
||||
try {
|
||||
await api.post(`/panels`, {
|
||||
...omit(currentPanel, ['id']),
|
||||
dashboard: movePanelTo.value,
|
||||
});
|
||||
|
||||
await insightsStore.hydrate();
|
||||
|
||||
movePanelID.value = null;
|
||||
} catch (err) {
|
||||
unexpectedError(err);
|
||||
} finally {
|
||||
movePanelLoading.value = false;
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fullscreen,
|
||||
.zoom-to-fit,
|
||||
.clear-changes {
|
||||
--v-button-color: var(--foreground-normal);
|
||||
--v-button-color-hover: var(--foreground-normal);
|
||||
--v-button-background-color: var(--foreground-subdued);
|
||||
--v-button-background-color-hover: var(--foreground-normal);
|
||||
--v-button-color-active: var(--foreground-inverted);
|
||||
--v-button-background-color-active: var(--primary);
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
--v-button-color-disabled: var(--foreground-normal);
|
||||
}
|
||||
</style>
|
||||
37
app/src/modules/insights/routes/not-found.vue
Normal file
37
app/src/modules/insights/routes/not-found.vue
Normal file
@@ -0,0 +1,37 @@
|
||||
<template>
|
||||
<private-view :title="t('insights')">
|
||||
<template #navigation>
|
||||
<insights-navigation />
|
||||
</template>
|
||||
|
||||
<div v-if="!currentDashboard" class="not-found">
|
||||
<v-info :title="t('page_not_found')" icon="not_interested">
|
||||
{{ t('page_not_found_body') }}
|
||||
</v-info>
|
||||
</div>
|
||||
</private-view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineComponent } from 'vue';
|
||||
import InsightsNavigation from '../components/navigation.vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
export default defineComponent({
|
||||
components: { InsightsNavigation },
|
||||
setup() {
|
||||
const { t } = useI18n();
|
||||
|
||||
return { t };
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.not-found {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20vh 0;
|
||||
}
|
||||
</style>
|
||||
218
app/src/modules/insights/routes/overview.vue
Normal file
218
app/src/modules/insights/routes/overview.vue
Normal file
@@ -0,0 +1,218 @@
|
||||
<template>
|
||||
<private-view :title="t('insights')">
|
||||
<template #title-outer:prepend>
|
||||
<v-button class="header-icon" rounded disabled icon secondary>
|
||||
<v-icon name="insights" />
|
||||
</v-button>
|
||||
</template>
|
||||
|
||||
<template #navigation>
|
||||
<insights-navigation @create="createDialogActive = true" />
|
||||
</template>
|
||||
|
||||
<template #actions>
|
||||
<dashboard-dialog v-model="createDialogActive">
|
||||
<template #activator="{ on }">
|
||||
<v-button
|
||||
v-tooltip.bottom="createAllowed ? t('create_dashboard') : t('not_allowed')"
|
||||
rounded
|
||||
icon
|
||||
:disabled="createAllowed === false"
|
||||
@click="on"
|
||||
>
|
||||
<v-icon name="add" />
|
||||
</v-button>
|
||||
</template>
|
||||
</dashboard-dialog>
|
||||
</template>
|
||||
|
||||
<template #sidebar>
|
||||
<sidebar-detail icon="info_outline" :title="t('information')" close>
|
||||
<div v-md="t('page_help_insights_overview')" class="page-description" />
|
||||
</sidebar-detail>
|
||||
</template>
|
||||
|
||||
<v-table
|
||||
v-if="dashboards.length > 0"
|
||||
v-model:headers="tableHeaders"
|
||||
:items="dashboards"
|
||||
show-resize
|
||||
fixed-header
|
||||
@click:row="navigateToDashboard"
|
||||
>
|
||||
<template #[`item.icon`]="{ item }">
|
||||
<v-icon class="icon" :name="item.icon" />
|
||||
</template>
|
||||
|
||||
<template #item-append="{ item }">
|
||||
<v-menu placement="left-start" show-arrow>
|
||||
<template #activator="{ toggle }">
|
||||
<v-icon name="more_vert" class="ctx-toggle" @click="toggle" />
|
||||
</template>
|
||||
|
||||
<v-list>
|
||||
<v-list-item class="warning" clickable @click="editDashboard = item">
|
||||
<v-list-item-icon>
|
||||
<v-icon name="edit" outline />
|
||||
</v-list-item-icon>
|
||||
<v-list-item-content>
|
||||
{{ t('edit_dashboard') }}
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item class="danger" clickable @click="confirmDelete = item.id">
|
||||
<v-list-item-icon>
|
||||
<v-icon name="delete" outline />
|
||||
</v-list-item-icon>
|
||||
<v-list-item-content>
|
||||
{{ t('delete_dashboard') }}
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</template>
|
||||
</v-table>
|
||||
|
||||
<v-info v-else icon="dashboard" :title="t('no_dashboards')" center>
|
||||
{{ t('no_dashboards_copy') }}
|
||||
</v-info>
|
||||
|
||||
<v-dialog :model-value="!!confirmDelete" @esc="confirmDelete = null">
|
||||
<v-card>
|
||||
<v-card-title>{{ t('dashboard_delete_confirm') }}</v-card-title>
|
||||
|
||||
<v-card-actions>
|
||||
<v-button secondary @click="confirmDelete = null">
|
||||
{{ t('cancel') }}
|
||||
</v-button>
|
||||
<v-button danger :loading="deletingDashboard" @click="deleteDashboard">
|
||||
{{ t('delete_label') }}
|
||||
</v-button>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<dashboard-dialog
|
||||
:model-value="!!editDashboard"
|
||||
:dashboard="editDashboard"
|
||||
@update:model-value="editDashboard = null"
|
||||
/>
|
||||
</private-view>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, computed, ref } from 'vue';
|
||||
import { useInsightsStore, usePermissionsStore } from '@/stores';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { Dashboard } from '@/types';
|
||||
import { router } from '@/router';
|
||||
import InsightsNavigation from '../components/navigation.vue';
|
||||
import DashboardDialog from '../components/dashboard-dialog.vue';
|
||||
import api from '@/api';
|
||||
import { unexpectedError } from '@/utils/unexpected-error';
|
||||
import { md } from '@/utils/md';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'InsightsOverview',
|
||||
components: { InsightsNavigation, DashboardDialog },
|
||||
setup() {
|
||||
const { t } = useI18n();
|
||||
|
||||
const insightsStore = useInsightsStore();
|
||||
const permissionsStore = usePermissionsStore();
|
||||
|
||||
const confirmDelete = ref<string | null>(null);
|
||||
const deletingDashboard = ref(false);
|
||||
const editDashboard = ref<Dashboard | null>(null);
|
||||
|
||||
const createDialogActive = ref(false);
|
||||
|
||||
const createAllowed = computed<boolean>(() => {
|
||||
return permissionsStore.hasPermission('directus_dashboards', 'create');
|
||||
});
|
||||
|
||||
const tableHeaders = [
|
||||
{
|
||||
text: '',
|
||||
value: 'icon',
|
||||
width: 42,
|
||||
sortable: false,
|
||||
},
|
||||
{
|
||||
text: t('name'),
|
||||
value: 'name',
|
||||
width: 240,
|
||||
},
|
||||
{
|
||||
text: t('note'),
|
||||
value: 'note',
|
||||
width: 360,
|
||||
},
|
||||
];
|
||||
|
||||
const dashboards = computed(() => insightsStore.dashboards);
|
||||
|
||||
return {
|
||||
dashboards,
|
||||
createAllowed,
|
||||
tableHeaders,
|
||||
navigateToDashboard,
|
||||
createDialogActive,
|
||||
confirmDelete,
|
||||
deletingDashboard,
|
||||
deleteDashboard,
|
||||
editDashboard,
|
||||
t,
|
||||
md,
|
||||
};
|
||||
|
||||
function navigateToDashboard(dashboard: Dashboard) {
|
||||
router.push(`/insights/${dashboard.id}`);
|
||||
}
|
||||
|
||||
async function deleteDashboard() {
|
||||
if (!confirmDelete.value) return;
|
||||
|
||||
deletingDashboard.value = true;
|
||||
|
||||
try {
|
||||
await api.delete(`/dashboards/${confirmDelete.value}`);
|
||||
await insightsStore.hydrate();
|
||||
confirmDelete.value = null;
|
||||
} catch (err) {
|
||||
unexpectedError(err);
|
||||
} finally {
|
||||
deletingDashboard.value = false;
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.v-table {
|
||||
padding: var(--content-padding);
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.ctx-toggle {
|
||||
--v-icon-color: var(--foreground-subdued);
|
||||
--v-icon-color-hover: var(--foreground-normal);
|
||||
}
|
||||
|
||||
.v-list-item.danger {
|
||||
--v-list-item-color: var(--danger);
|
||||
--v-list-item-color-hover: var(--danger);
|
||||
--v-list-item-icon-color: var(--danger);
|
||||
}
|
||||
|
||||
.v-list-item.warning {
|
||||
--v-list-item-color: var(--warning);
|
||||
--v-list-item-color-hover: var(--warning);
|
||||
--v-list-item-icon-color: var(--warning);
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
--v-button-color-disabled: var(--foreground-normal);
|
||||
}
|
||||
</style>
|
||||
190
app/src/modules/insights/routes/panel-configuration.vue
Normal file
190
app/src/modules/insights/routes/panel-configuration.vue
Normal file
@@ -0,0 +1,190 @@
|
||||
<template>
|
||||
<v-drawer
|
||||
:model-value="isOpen"
|
||||
:title="panel?.name || t('panel')"
|
||||
:subtitle="t('panel_options')"
|
||||
:icon="panel?.icon || 'insert_chart'"
|
||||
persistent
|
||||
@cancel="$emit('cancel')"
|
||||
>
|
||||
<template #actions>
|
||||
<v-button v-tooltip.bottom="t('done')" :disabled="!edits.type" icon rounded @click="emitSave">
|
||||
<v-icon name="check" />
|
||||
</v-button>
|
||||
</template>
|
||||
|
||||
<div class="content">
|
||||
<p class="type-label panel-type-label">{{ t('type') }}</p>
|
||||
|
||||
<v-fancy-select v-model="edits.type" class="select" :items="selectItems" />
|
||||
|
||||
<template v-if="edits.type && selectedPanel">
|
||||
<v-notice v-if="!selectedPanel.options || selectedPanel.options.length === 0">
|
||||
{{ t('no_options_available') }}
|
||||
</v-notice>
|
||||
|
||||
<v-form
|
||||
v-else-if="Array.isArray(selectedPanel.options)"
|
||||
v-model="edits.options"
|
||||
:fields="selectedPanel.options"
|
||||
primary-key="+"
|
||||
:initial-values="panel && panel.options"
|
||||
/>
|
||||
|
||||
<component :is="`panel-options-${selectedPanel.id}`" v-else v-model="edits.options" :collection="collection" />
|
||||
</template>
|
||||
|
||||
<v-divider :inline-title="false" large>
|
||||
<template #icon><v-icon name="info" /></template>
|
||||
<template #default>{{ t('panel_header') }}</template>
|
||||
</v-divider>
|
||||
|
||||
<div class="form-grid">
|
||||
<div class="field half-left">
|
||||
<p class="type-label">{{ t('visible') }}</p>
|
||||
<v-checkbox v-model="edits.show_header" block :label="t('show_header')" />
|
||||
</div>
|
||||
|
||||
<div class="field half-right">
|
||||
<p class="type-label">{{ t('name') }}</p>
|
||||
<v-input
|
||||
v-model="edits.name"
|
||||
:nullable="false"
|
||||
:disabled="edits.show_header !== true"
|
||||
:placeholder="t('panel_name_placeholder')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field half-left">
|
||||
<p class="type-label">{{ t('icon') }}</p>
|
||||
<interface-select-icon
|
||||
:value="edits.icon"
|
||||
:disabled="edits.show_header !== true"
|
||||
@input="edits.icon = $event"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field half-right">
|
||||
<p class="type-label">{{ t('color') }}</p>
|
||||
<interface-select-color
|
||||
:value="edits.color"
|
||||
:disabled="edits.show_header !== true"
|
||||
width="half"
|
||||
@input="edits.color = $event"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field full">
|
||||
<p class="type-label">{{ t('note') }}</p>
|
||||
<v-input
|
||||
v-model="edits.note"
|
||||
:disabled="edits.show_header !== true"
|
||||
:placeholder="t('panel_note_placeholder')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</v-drawer>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, reactive, watch, PropType } from 'vue';
|
||||
import { getPanels } from '@/panels';
|
||||
import { FancySelectItem } from '@/components/v-fancy-select/types';
|
||||
import { Panel } from '@/types';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useDialogRoute } from '@/composables/use-dialog-route';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'PanelConfiguration',
|
||||
props: {
|
||||
panel: {
|
||||
type: Object as PropType<Partial<Panel>>,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
emits: ['cancel', 'save'],
|
||||
setup(props, { emit }) {
|
||||
const { t } = useI18n();
|
||||
|
||||
const { panels } = getPanels();
|
||||
|
||||
const isOpen = useDialogRoute();
|
||||
|
||||
const edits = reactive<Partial<Panel>>({
|
||||
show_header: props.panel?.show_header ?? true,
|
||||
type: props.panel?.type || undefined,
|
||||
name: props.panel?.name,
|
||||
note: props.panel?.note,
|
||||
icon: props.panel?.icon ?? 'insert_chart',
|
||||
color: props.panel?.color ?? '#00C897',
|
||||
width: props.panel?.width ?? undefined,
|
||||
height: props.panel?.height ?? undefined,
|
||||
position_x: props.panel?.position_x ?? 1,
|
||||
position_y: props.panel?.position_y ?? 1,
|
||||
options: props.panel?.options ?? {},
|
||||
});
|
||||
|
||||
const selectItems = computed<FancySelectItem[]>(() => {
|
||||
return panels.value.map((panel) => {
|
||||
const item: FancySelectItem = {
|
||||
text: panel.name,
|
||||
icon: panel.icon,
|
||||
description: panel.description,
|
||||
value: panel.id,
|
||||
};
|
||||
|
||||
return item;
|
||||
});
|
||||
});
|
||||
|
||||
const selectedPanel = computed(() => {
|
||||
return panels.value.find((panel) => panel.id === edits.type);
|
||||
});
|
||||
|
||||
watch(selectedPanel, (newPanel) => {
|
||||
if (newPanel) {
|
||||
edits.width = newPanel.minWidth;
|
||||
edits.height = newPanel.minHeight;
|
||||
} else {
|
||||
edits.width = undefined;
|
||||
edits.height = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
selectItems,
|
||||
selectedPanel,
|
||||
close,
|
||||
emitSave,
|
||||
edits,
|
||||
t,
|
||||
isOpen,
|
||||
};
|
||||
|
||||
function emitSave() {
|
||||
emit('save', edits);
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.content {
|
||||
padding: var(--content-padding);
|
||||
padding-top: 0;
|
||||
padding-bottom: var(--content-padding-bottom);
|
||||
}
|
||||
|
||||
.select {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.panel-type-label {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.v-divider {
|
||||
margin: 68px 0 48px;
|
||||
}
|
||||
</style>
|
||||
@@ -519,6 +519,6 @@ export default defineComponent({
|
||||
.content {
|
||||
padding: var(--content-padding);
|
||||
padding-top: 0;
|
||||
padding-bottom: var(--content-padding);
|
||||
padding-bottom: var(--content-padding-bottom);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -505,8 +505,6 @@ export default defineComponent({
|
||||
|
||||
.form-grid {
|
||||
--form-vertical-gap: 24px;
|
||||
|
||||
@include form-grid;
|
||||
}
|
||||
|
||||
.required {
|
||||
|
||||
@@ -92,14 +92,10 @@ export default defineComponent({
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/styles/mixins/form-grid';
|
||||
|
||||
.form-grid {
|
||||
--form-horizontal-gap: 12px;
|
||||
--form-vertical-gap: 24px;
|
||||
|
||||
@include form-grid;
|
||||
|
||||
.type-label {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
@@ -72,6 +72,7 @@ import { Permission } from '@directus/shared/types';
|
||||
import api from '@/api';
|
||||
import { appRecommendedPermissions, appMinimalPermissions } from '../../app-permissions';
|
||||
import { unexpectedError } from '@/utils/unexpected-error';
|
||||
import { orderBy } from 'lodash';
|
||||
|
||||
export default defineComponent({
|
||||
components: { PermissionsOverviewHeader, PermissionsOverviewRow },
|
||||
@@ -100,7 +101,10 @@ export default defineComponent({
|
||||
);
|
||||
|
||||
const systemCollections = computed(() =>
|
||||
collectionsStore.collections.filter((collection) => collection.collection.startsWith('directus_') === true)
|
||||
orderBy(
|
||||
collectionsStore.collections.filter((collection) => collection.collection.startsWith('directus_') === true),
|
||||
'name'
|
||||
)
|
||||
);
|
||||
|
||||
const systemVisible = ref(false);
|
||||
|
||||
Reference in New Issue
Block a user