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:
Rijk van Zanten
2021-09-20 20:24:10 -04:00
committed by GitHub
parent 46a92e7d3d
commit bf72917a4c
121 changed files with 7411 additions and 1885 deletions

View File

@@ -19,7 +19,7 @@ export default defineModule({
component: NotFound,
},
],
order: 20,
order: 30,
});
function getRoutes(routes: DocsRoutes): RouteRecordRaw[] {

View File

@@ -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 />

View 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>

View 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>

View 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>

View 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>

View 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;
},
});

View 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>

View 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>

View 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>

View 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>

View File

@@ -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>

View File

@@ -505,8 +505,6 @@ export default defineComponent({
.form-grid {
--form-vertical-gap: 24px;
@include form-grid;
}
.required {

View File

@@ -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;
}

View File

@@ -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);