mirror of
https://github.com/directus/directus.git
synced 2026-04-25 03:00:53 -04:00
Insights 2.0 (#14096)
* query function added to list * dashboard reading query, adding to object * typecasting of filter vals needed still * numbers accepting strings too * json-to-graphql-query => devD * fixed unneeded return in list index.ts * stitching and calling but not actually calling * calls on panel change * query object += new panel before dashboard save * uuid generated in app not api * fixed panel ids in query * fixed the tests I just wrote * passing the query data down! * list showing data * objDiff test moved to test * metric bug fixes + data * dashboard logic * time series conversion started * timeseries GQL query almost there * query querying * chart loading * aggregate handling improved * error handling for aggregate+filter errors * removed query on empty queryObj * maybe more error handling * more error handling working * improvements to erorr handling * stitchGQL() error return type corrected * added string fields to COUNT * pushing up but needs work * not an endless recursion * its not pretty but it works. * throws an error * system collections supported * refactor to solve some errors * loading correct * metric function fixed * data loading but not blocking rendering * removed redundant code. * relational fields * deep nesting relations * options.precision has a default * relational fields fix. (thanks azri) * the limit * limit and time series * range has a default * datat to workspace * v-if * panels loading * workspaces dont get data anymore * package.json * requested changes * loading * get groups util * timeseries => script setup * list => script setup * metric => script setup * label => script setup * declare optional props * loadingPanels: only loading spinner on loading panels * remove unneeded parseDate!! * applyDataToPanels tests * -.only * remove unneeded steps * processQuery tests * tests * removed unused var * jest.config and some queryCaller tests * one more test * query tests * typo * clean up * fix some but not all bugs * bugs from merge fixed * Start cleaning up 🧹 * Refactor custom input type * Small tweaks in list index * Cleanup imports * Require Query object to be returned from query prop * Tweak return statement * Fix imports * Cleanup metric watch effect * Tweaks tweaks tweaks * Don't rely on options, simplify fetch logic * Add paths to validation errors * [WIP] Start handling things in the store * Rework query fetching logic into store * Clean up data passing * Use composition setup for insights store * Remove outdated * Fix missing return * Allow batch updating in REST API Allows sending an array of partial items to the endpoints, updating all to their own values * Add batch update to graphql * Start integrating edits * Readd clear * Add deletion * Add duplication * Finish create flow * Resolve cache refresh on panel config * Prevent warnings about component name * Improve loading state * Finalize dashboard overhaul * Add auto-refresh sidebar detail * Add efficient panel reloading * Set/remove errors on succeeded requests * Move options rendering to shared * Fix wrong imports, render options in app * Selectively reload panels with changed variables * Ensure newly added panels don't lose data * Only refresh panel if data query changed * Never use empty filter object in metric query * Add default value support to variable panel * Centralize no-data state * Only reload data on var change when query is altered * Fix build * Fix time series order * Remove unused utils * Remove no-longer-used logic * Mark batch update result as non-nullable in GraphQL schema * Interim flows fix * Skip parsing undefined keys * Refresh insights dashboard when discarding changes * Don't submit primary key when updating batch * Handle null prop field better * Tweak panel padding Co-authored-by: jaycammarano <jay.cammarano@gmail.com> Co-authored-by: Azri Kahar <42867097+azrikahar@users.noreply.github.com> Co-authored-by: ian <licitdev@gmail.com>
This commit is contained in:
@@ -103,6 +103,7 @@ export type AppTile = {
|
||||
minHeight?: number;
|
||||
draggable?: boolean;
|
||||
borderRadius?: [boolean, boolean, boolean, boolean];
|
||||
data?: Record<string, any>;
|
||||
};
|
||||
|
||||
// Right now, it is not possible to do type Props = AppTile & {resizable?: boolean; editMode?: boolean}
|
||||
|
||||
@@ -12,26 +12,26 @@
|
||||
height: workspaceSize.height + 'px',
|
||||
}"
|
||||
>
|
||||
<template v-if="!$slots.panel">
|
||||
<template v-if="!$slots.tile">
|
||||
<v-workspace-tile
|
||||
v-for="panel in panels"
|
||||
:key="panel.id"
|
||||
v-bind="panel"
|
||||
v-for="tile in tiles"
|
||||
:key="tile.id"
|
||||
v-bind="tile"
|
||||
:edit-mode="editMode"
|
||||
:resizable="resizable"
|
||||
@preview="$emit('preview', panel)"
|
||||
@edit="$emit('edit', panel)"
|
||||
@update="$emit('update', { edits: $event, id: panel.id })"
|
||||
@move="$emit('move', panel.id)"
|
||||
@delete="$emit('delete', panel.id)"
|
||||
@duplicate="$emit('duplicate', panel)"
|
||||
@preview="$emit('preview', tile)"
|
||||
@edit="$emit('edit', tile)"
|
||||
@update="$emit('update', { edits: $event, id: tile.id })"
|
||||
@move="$emit('move', tile.id)"
|
||||
@delete="$emit('delete', tile.id)"
|
||||
@duplicate="$emit('duplicate', tile)"
|
||||
>
|
||||
<slot :panel="panel"></slot>
|
||||
<slot :tile="tile"></slot>
|
||||
</v-workspace-tile>
|
||||
</template>
|
||||
<template v-else>
|
||||
<template v-for="panel in panels" :key="panel.id">
|
||||
<slot name="panel" :panel="panel"></slot>
|
||||
<template v-for="tile in tiles" :key="tile.id">
|
||||
<slot name="tile" :tile="tile"></slot>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
@@ -46,7 +46,7 @@ import { cssVar } from '@directus/shared/utils/browser';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
panels: AppTile[];
|
||||
tiles: AppTile[];
|
||||
editMode?: boolean;
|
||||
zoomToFit?: boolean;
|
||||
resizable?: boolean;
|
||||
@@ -66,11 +66,11 @@ const mainElementSize = useElementSize(mainElement);
|
||||
const paddingSize = computed(() => Number(cssVar('--content-padding', mainElement.value)?.slice(0, -2) || 0));
|
||||
|
||||
const workspaceSize = computed(() => {
|
||||
const furthestPanelX = props.panels.reduce(
|
||||
(aggr, panel) => {
|
||||
if (panel.x! > aggr.x!) {
|
||||
aggr.x = panel.x!;
|
||||
aggr.width = panel.width!;
|
||||
const furthestTileX = props.tiles.reduce(
|
||||
(aggr, tile) => {
|
||||
if (tile.x! > aggr.x!) {
|
||||
aggr.x = tile.x!;
|
||||
aggr.width = tile.width!;
|
||||
}
|
||||
|
||||
return aggr;
|
||||
@@ -78,11 +78,11 @@ const workspaceSize = computed(() => {
|
||||
{ x: 0, width: 0 }
|
||||
);
|
||||
|
||||
const furthestPanelY = props.panels.reduce(
|
||||
(aggr, panel) => {
|
||||
if (panel.y! > aggr.y!) {
|
||||
aggr.y = panel.y!;
|
||||
aggr.height = panel.height!;
|
||||
const furthestPanelY = props.tiles.reduce(
|
||||
(aggr, tile) => {
|
||||
if (tile.y! > aggr.y!) {
|
||||
aggr.y = tile.y!;
|
||||
aggr.height = tile.height!;
|
||||
}
|
||||
|
||||
return aggr;
|
||||
@@ -92,13 +92,13 @@ const workspaceSize = computed(() => {
|
||||
|
||||
if (props.editMode === true) {
|
||||
return {
|
||||
width: (furthestPanelX.x! + furthestPanelX.width! + 25) * 20,
|
||||
width: (furthestTileX.x! + furthestTileX.width! + 25) * 20,
|
||||
height: (furthestPanelY.y! + furthestPanelY.height! + 25) * 20,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
width: (furthestPanelX.x! + furthestPanelX.width! - 1) * 20,
|
||||
width: (furthestTileX.x! + furthestTileX.width! - 1) * 20,
|
||||
height: (furthestPanelY.y! + furthestPanelY.height! - 1) * 20,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -46,11 +46,11 @@ export default defineComponent({
|
||||
setup(props, { emit }) {
|
||||
const { t } = useI18n();
|
||||
|
||||
const nativeGeometryType = computed(() => props.field.type.split('.')[1] as GeometryType);
|
||||
const nativeGeometryType = computed(() => (props.field?.type.split('.')[1] as GeometryType) ?? 'Point');
|
||||
const geometryType = ref<GeometryType>(nativeGeometryType.value ?? props.value?.geometryType ?? 'Point');
|
||||
const defaultView = ref<CameraOptions | undefined>(props.value?.defaultView);
|
||||
|
||||
watch(() => props.field.type, watchType);
|
||||
watch(() => props.field?.type, watchType);
|
||||
watch(nativeGeometryType, watchNativeType);
|
||||
watch([geometryType, defaultView], input, { immediate: true });
|
||||
|
||||
|
||||
@@ -601,6 +601,7 @@ errors:
|
||||
VALUE_OUT_OF_RANGE: Value is out of range
|
||||
INVALID_FOREIGN_KEY: Invalid foreign key
|
||||
UNKNOWN: Unexpected Error
|
||||
GRAPHQL_VALIDATION_EXCEPTION: GraphQL Validation Exception
|
||||
security: Security
|
||||
value_hashed: Value Securely Hashed
|
||||
bookmark_name: Bookmark name...
|
||||
@@ -1983,6 +1984,10 @@ panels:
|
||||
bottom: Bottom
|
||||
monochrome: Monochrome
|
||||
monochrome_color: Monochrome Color
|
||||
variable:
|
||||
name: Global Variable
|
||||
description: Set a global value for use in other Panels' filters
|
||||
variable_key: Variable Key
|
||||
triggers:
|
||||
common:
|
||||
response_body: Response Body
|
||||
|
||||
@@ -2,6 +2,7 @@ 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';
|
||||
import { useInsightsStore } from '@/stores';
|
||||
|
||||
export default defineModule({
|
||||
id: 'insights',
|
||||
@@ -18,6 +19,11 @@ export default defineModule({
|
||||
path: ':primaryKey',
|
||||
component: InsightsDashboard,
|
||||
props: true,
|
||||
beforeEnter(to) {
|
||||
const store = useInsightsStore();
|
||||
// Refresh is async, but we'll let the view load while the data is being fetched
|
||||
store.refresh(to.params.primaryKey as string);
|
||||
},
|
||||
children: [
|
||||
{
|
||||
name: 'panel-detail',
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
rounded
|
||||
icon
|
||||
outlined
|
||||
@click="attemptCancelChanges"
|
||||
@click="cancelChanges"
|
||||
>
|
||||
<v-icon name="clear" />
|
||||
</v-button>
|
||||
@@ -28,7 +28,14 @@
|
||||
<v-icon name="add" />
|
||||
</v-button>
|
||||
|
||||
<v-button v-tooltip.bottom="t('save')" rounded icon :loading="saving" @click="saveChanges">
|
||||
<v-button
|
||||
v-tooltip.bottom="t('save')"
|
||||
:disabled="!hasEdits"
|
||||
rounded
|
||||
icon
|
||||
:loading="saving"
|
||||
@click="saveChanges"
|
||||
>
|
||||
<v-icon name="check" />
|
||||
</v-button>
|
||||
</template>
|
||||
@@ -76,6 +83,8 @@
|
||||
<sidebar-detail icon="info_outline" :title="t('information')" close>
|
||||
<div v-md="t('page_help_insights_dashboard')" class="page-description" />
|
||||
</sidebar-detail>
|
||||
|
||||
<refresh-sidebar-detail v-model="refreshInterval" @refresh="insightsStore.refresh(primaryKey)" />
|
||||
</template>
|
||||
|
||||
<template #navigation>
|
||||
@@ -84,58 +93,87 @@
|
||||
|
||||
<v-workspace
|
||||
:edit-mode="editMode"
|
||||
:panels="panels"
|
||||
:tiles="tiles"
|
||||
:zoom-to-fit="zoomToFit"
|
||||
:now="now"
|
||||
@edit="editPanel"
|
||||
@update="stagePanelEdits"
|
||||
@move="movePanelID = $event"
|
||||
@delete="deletePanel"
|
||||
@duplicate="duplicatePanel"
|
||||
@duplicate="(tile) => insightsStore.stagePanelDuplicate(tile.id)"
|
||||
@edit="(tile) => router.push(`/insights/${primaryKey}/${tile.id}`)"
|
||||
@update="insightsStore.stagePanelUpdate"
|
||||
@delete="insightsStore.stagePanelDelete"
|
||||
@move="copyPanelID = $event"
|
||||
>
|
||||
<template #default="{ panel }">
|
||||
<component
|
||||
:is="`panel-${panel.type}`"
|
||||
v-bind="panel.options"
|
||||
:id="panel.id"
|
||||
:show-header="panel.showHeader"
|
||||
:height="panel.height"
|
||||
:width="panel.width"
|
||||
:now="now"
|
||||
<template #default="{ tile }">
|
||||
<v-progress-circular
|
||||
v-if="loading.includes(tile.id) && !data[tile.id]"
|
||||
:class="{ 'header-offset': tile.showHeader }"
|
||||
class="panel-loading"
|
||||
indeterminate
|
||||
/>
|
||||
<div v-else class="panel-container" :class="{ loading: loading.includes(tile.id) }">
|
||||
<div v-if="errors[tile.id]" class="panel-error">
|
||||
<v-icon name="warning" />
|
||||
{{ t('unexpected_error') }}
|
||||
<v-error :error="errors[tile.id]" />
|
||||
</div>
|
||||
<div
|
||||
v-else-if="tile.id in data && isEmpty(data[tile.id])"
|
||||
class="panel-no-data type-note"
|
||||
:class="{ 'header-offset': tile.showHeader }"
|
||||
>
|
||||
{{ t('no_data') }}
|
||||
</div>
|
||||
<component
|
||||
:is="`panel-${tile.data.type}`"
|
||||
v-else
|
||||
v-bind="tile.data.options"
|
||||
:id="tile.id"
|
||||
:dashboard="primaryKey"
|
||||
:show-header="tile.showHeader"
|
||||
:height="tile.height"
|
||||
:width="tile.width"
|
||||
:now="now"
|
||||
:data="data[tile.id]"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</v-workspace>
|
||||
|
||||
<router-view
|
||||
name="detail"
|
||||
:dashboard-key="primaryKey"
|
||||
:panel="panelKey ? rawPanels.find((panel) => panel.id === panelKey) : null"
|
||||
@save="stageConfiguration"
|
||||
@cancel="$router.replace(`/insights/${primaryKey}`)"
|
||||
/>
|
||||
<router-view name="detail" :dashboard-key="primaryKey" :panel-key="panelKey" />
|
||||
|
||||
<v-dialog :model-value="!!movePanelID" @update:model-value="movePanelID = null" @esc="movePanelID = null">
|
||||
<v-dialog :model-value="!!copyPanelID" @update:model-value="copyPanelID = null" @esc="copyPanelID = null">
|
||||
<v-card>
|
||||
<v-card-title>{{ t('copy_to') }}</v-card-title>
|
||||
|
||||
<v-card-text>
|
||||
<v-notice v-if="movePanelChoices.length === 0">
|
||||
<v-notice v-if="copyPanelChoices.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-select v-else v-model="copyPanelTo" :items="copyPanelChoices" item-text="name" item-value="id" />
|
||||
</v-card-text>
|
||||
|
||||
<v-card-actions>
|
||||
<v-button secondary @click="movePanelID = null">
|
||||
<v-button secondary @click="copyPanelID = null">
|
||||
{{ t('cancel') }}
|
||||
</v-button>
|
||||
<v-button :loading="movePanelLoading" @click="movePanel">
|
||||
<v-button @click="copyPanel">
|
||||
{{ t('copy') }}
|
||||
</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(true)">
|
||||
{{ t('discard_changes') }}
|
||||
</v-button>
|
||||
<v-button @click="confirmCancel = false">{{ t('keep_editing') }}</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>
|
||||
@@ -144,20 +182,8 @@
|
||||
<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-button @click="confirmLeave = false">{{ t('keep_editing') }}</v-button>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
@@ -165,19 +191,17 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import api from '@/api';
|
||||
import { AppTile } from '@/components/v-workspace-tile.vue';
|
||||
import useEditsGuard from '@/composables/use-edits-guard';
|
||||
import useShortcut from '@/composables/use-shortcut';
|
||||
import { getPanels } from '@/panels';
|
||||
import { router } from '@/router';
|
||||
import { useAppStore, useInsightsStore, usePermissionsStore } from '@/stores';
|
||||
import { pointOnLine } from '@/utils/point-on-line';
|
||||
import { unexpectedError } from '@/utils/unexpected-error';
|
||||
import { Panel } from '@directus/shared/types';
|
||||
import camelCase from 'camelcase';
|
||||
import { mapKeys, merge, omit } from 'lodash';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { computed, ref, toRefs, watch } from 'vue';
|
||||
import RefreshSidebarDetail from '@/views/private/components/refresh-sidebar-detail/refresh-sidebar-detail.vue';
|
||||
import { applyOptionsData } from '@directus/shared/utils';
|
||||
import { assign, isEmpty } from 'lodash';
|
||||
import { computed, ref, toRefs, unref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import InsightsNavigation from '../components/navigation.vue';
|
||||
import InsightsNotFound from './not-found.vue';
|
||||
@@ -190,6 +214,7 @@ interface Props {
|
||||
const props = withDefaults(defineProps<Props>(), { panelKey: null });
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const { panels: panelsInfo } = getPanels();
|
||||
|
||||
const insightsStore = useInsightsStore();
|
||||
@@ -197,14 +222,7 @@ const appStore = useAppStore();
|
||||
const permissionsStore = usePermissionsStore();
|
||||
|
||||
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 { loading, errors, data, saving, hasEdits, refreshIntervals, variables } = toRefs(insightsStore);
|
||||
|
||||
const zoomToFit = ref(false);
|
||||
|
||||
@@ -212,57 +230,16 @@ const updateAllowed = computed<boolean>(() => {
|
||||
return permissionsStore.hasPermission('directus_panels', 'update');
|
||||
});
|
||||
|
||||
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 rawPanels = computed(() => {
|
||||
const savedPanels = (currentDashboard.value?.panels || []).filter(
|
||||
(panel) => panelsToBeDeleted.value.includes(panel.id) === false
|
||||
);
|
||||
const editMode = ref(false);
|
||||
|
||||
const raw = [
|
||||
...savedPanels.map((panel) => {
|
||||
const updates = stagedPanels.value.find((updatedPanel) => updatedPanel.id === panel.id);
|
||||
const tiles = computed<AppTile[]>(() => {
|
||||
const panels = insightsStore.getPanelsForDashboard(props.primaryKey);
|
||||
|
||||
if (updates) {
|
||||
return merge({}, panel, updates);
|
||||
}
|
||||
|
||||
return panel;
|
||||
}),
|
||||
...stagedPanels.value.filter((panel) => panel.id?.startsWith('_')),
|
||||
];
|
||||
|
||||
return raw;
|
||||
});
|
||||
|
||||
const panels = computed(() => {
|
||||
const withCoords = rawPanels.value.map((panel) => ({
|
||||
const panelsWithCoordinates = panels.map((panel) => ({
|
||||
...panel,
|
||||
_coordinates: [
|
||||
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!],
|
||||
@@ -270,206 +247,136 @@ const panels = computed(() => {
|
||||
] as [number, number][],
|
||||
}));
|
||||
|
||||
const withBorderRadii = withCoords.map((panel) => {
|
||||
let topLeftIntersects = false;
|
||||
let topRightIntersects = false;
|
||||
let bottomRightIntersects = false;
|
||||
let bottomLeftIntersects = false;
|
||||
const tiles: AppTile[] = panelsWithCoordinates
|
||||
.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;
|
||||
for (const otherPanel of panelsWithCoordinates) {
|
||||
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]],
|
||||
];
|
||||
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));
|
||||
}
|
||||
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,
|
||||
x: panel.position_x,
|
||||
y: panel.position_y,
|
||||
borderRadius: [!topLeftIntersects, !topRightIntersects, !bottomRightIntersects, !bottomLeftIntersects],
|
||||
};
|
||||
});
|
||||
const panelType = unref(panelsInfo).find((panelType) => panelType.id === panel.type);
|
||||
|
||||
const withIcons = withBorderRadii.map((panel) => {
|
||||
if (panel.icon) return panel;
|
||||
const tile: AppTile = {
|
||||
id: panel.id,
|
||||
x: panel.position_x,
|
||||
y: panel.position_y,
|
||||
width: panel.width,
|
||||
height: panel.height,
|
||||
name: panel.name,
|
||||
icon: panel.icon ?? panelType?.icon,
|
||||
color: panel.color,
|
||||
note: panel.note,
|
||||
showHeader: panel.show_header === true,
|
||||
minWidth: panelType?.minWidth,
|
||||
minHeight: panelType?.minHeight,
|
||||
draggable: true,
|
||||
borderRadius: [!topLeftIntersects, !topRightIntersects, !bottomRightIntersects, !bottomLeftIntersects],
|
||||
data: {
|
||||
options: applyOptionsData(panel.options ?? {}, unref(variables), panelType?.skipUndefinedKeys),
|
||||
type: panel.type,
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
...panel,
|
||||
icon: panelsInfo.value.find((panelConfig) => panelConfig.id === panel.type)?.icon,
|
||||
};
|
||||
});
|
||||
return tile;
|
||||
})
|
||||
.filter((t) => t);
|
||||
|
||||
// The workspace-tile relies on camelCased props, and these keys are passed as props with a v-bind
|
||||
const camelCased = withIcons.map((panel) => mapKeys(panel, (_value, key) => camelCase(key)));
|
||||
|
||||
return camelCased;
|
||||
return tiles;
|
||||
});
|
||||
|
||||
const hasEdits = computed(() => stagedPanels.value.length > 0 || panelsToBeDeleted.value.length > 0);
|
||||
|
||||
const { confirmLeave, leaveTo } = useEditsGuard(hasEdits);
|
||||
watch(
|
||||
() => props.primaryKey,
|
||||
() => {
|
||||
insightsStore.refresh(props.primaryKey);
|
||||
}
|
||||
);
|
||||
|
||||
const confirmCancel = ref(false);
|
||||
|
||||
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.replace(`/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 = [];
|
||||
panelsToBeDeleted.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 {
|
||||
if (hasEdits.value) {
|
||||
const cancelChanges = (force = false) => {
|
||||
if (unref(hasEdits) && force !== true) {
|
||||
confirmCancel.value = true;
|
||||
} else {
|
||||
cancelChanges();
|
||||
insightsStore.clearEdits();
|
||||
editMode.value = false;
|
||||
confirmCancel.value = false;
|
||||
insightsStore.refresh(props.primaryKey);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function cancelChanges() {
|
||||
confirmCancel.value = false;
|
||||
stagedPanels.value = [];
|
||||
panelsToBeDeleted.value = [];
|
||||
const copyPanelTo = ref(insightsStore.dashboards.find((dashboard) => dashboard.id !== props.primaryKey)?.id);
|
||||
const copyPanelID = ref<string | null>();
|
||||
const copyPanel = () => {
|
||||
insightsStore.stagePanelDuplicate(unref(copyPanelID)!, { dashboard: unref(copyPanelTo) });
|
||||
copyPanelID.value = null;
|
||||
};
|
||||
|
||||
useShortcut('meta+s', () => {
|
||||
saveChanges();
|
||||
});
|
||||
|
||||
async function saveChanges() {
|
||||
await insightsStore.saveChanges();
|
||||
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 editPanel(panel: Panel) {
|
||||
router.push(`/insights/${panel.dashboard}/${panel.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;
|
||||
watch(editMode, (editModeEnabled) => {
|
||||
if (editModeEnabled) {
|
||||
zoomToFit.value = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const currentDashboard = computed(() => insightsStore.getDashboard(props.primaryKey));
|
||||
|
||||
const copyPanelChoices = computed(() => {
|
||||
return insightsStore.dashboards.filter((dashboard) => dashboard.id !== props.primaryKey);
|
||||
});
|
||||
|
||||
const { confirmLeave, leaveTo } = useEditsGuard(hasEdits, {
|
||||
ignorePrefix: computed(() => `/insights/${props.primaryKey}`),
|
||||
});
|
||||
|
||||
const discardAndLeave = () => {
|
||||
if (!unref(leaveTo)) return;
|
||||
cancelChanges(true);
|
||||
confirmLeave.value = false;
|
||||
router.push(unref(leaveTo)!);
|
||||
};
|
||||
|
||||
const toggleFullScreen = () => (fullScreen.value = !fullScreen.value);
|
||||
const toggleZoomToFit = () => (zoomToFit.value = !zoomToFit.value);
|
||||
|
||||
const refreshInterval = computed({
|
||||
get() {
|
||||
return unref(refreshIntervals)[props.primaryKey];
|
||||
},
|
||||
set(val) {
|
||||
refreshIntervals.value = assign({}, unref(refreshIntervals), { [props.primaryKey]: val });
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
<style scoped lang="scss">
|
||||
.fullscreen,
|
||||
.zoom-to-fit,
|
||||
.clear-changes {
|
||||
@@ -484,4 +391,55 @@ async function movePanel() {
|
||||
.header-icon {
|
||||
--v-button-color-disabled: var(--foreground-normal);
|
||||
}
|
||||
|
||||
.panel-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 1;
|
||||
transition: opacity var(--fast) var(--transition);
|
||||
|
||||
&.loading {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.panel-loading {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
|
||||
&.header-offset {
|
||||
top: calc(50% - 12px);
|
||||
}
|
||||
}
|
||||
|
||||
.panel-error {
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
--v-icon-color: var(--danger);
|
||||
|
||||
.v-error {
|
||||
margin-top: 8px;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.panel-no-data {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
&.header-offset {
|
||||
height: calc(100% - 24px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
:subtitle="t('panel_options')"
|
||||
:icon="panel?.icon || 'insert_chart'"
|
||||
persistent
|
||||
@cancel="$emit('cancel')"
|
||||
@cancel="router.push(`/insights/${dashboardKey}`)"
|
||||
>
|
||||
<template #actions>
|
||||
<v-button v-tooltip.bottom="t('done')" :disabled="!edits.type" icon rounded @click="emitSave">
|
||||
<v-button v-tooltip.bottom="t('done')" :disabled="!panel.type" icon rounded @click="stageChanges">
|
||||
<v-icon name="check" />
|
||||
</v-button>
|
||||
</template>
|
||||
@@ -16,14 +16,20 @@
|
||||
<div class="content">
|
||||
<p class="type-label panel-type-label">{{ t('type') }}</p>
|
||||
|
||||
<v-fancy-select v-model="edits.type" class="select" :items="selectItems" />
|
||||
<v-fancy-select
|
||||
:model-value="panel.type"
|
||||
class="select"
|
||||
:items="selectItems"
|
||||
@update:model-value="edits.type = $event"
|
||||
/>
|
||||
|
||||
<extension-options
|
||||
v-if="edits.type"
|
||||
v-model="edits.options"
|
||||
v-if="panel.type"
|
||||
:model-value="panel.options"
|
||||
:options="customOptionsFields"
|
||||
type="panel"
|
||||
:extension="edits.type"
|
||||
:extension="panel.type"
|
||||
@update:model-value="edits.options = $event"
|
||||
/>
|
||||
|
||||
<v-divider :inline-title="false" large>
|
||||
@@ -34,24 +40,30 @@
|
||||
<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')" />
|
||||
<v-checkbox
|
||||
:model-value="panel.show_header"
|
||||
block
|
||||
:label="t('show_header')"
|
||||
@update:model-value="edits.show_header = $event"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field half-right">
|
||||
<p class="type-label">{{ t('name') }}</p>
|
||||
<v-input
|
||||
v-model="edits.name"
|
||||
:model-value="panel.name"
|
||||
:nullable="false"
|
||||
:disabled="edits.show_header !== true"
|
||||
:disabled="panel.show_header !== true"
|
||||
:placeholder="t('panel_name_placeholder')"
|
||||
@update:model-value="edits.name = $event"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field half-left">
|
||||
<p class="type-label">{{ t('icon') }}</p>
|
||||
<interface-select-icon
|
||||
:value="edits.icon"
|
||||
:disabled="edits.show_header !== true"
|
||||
:value="panel.icon"
|
||||
:disabled="panel.show_header !== true"
|
||||
@input="edits.icon = $event"
|
||||
/>
|
||||
</div>
|
||||
@@ -59,8 +71,8 @@
|
||||
<div class="field half-right">
|
||||
<p class="type-label">{{ t('color') }}</p>
|
||||
<interface-select-color
|
||||
:value="edits.color"
|
||||
:disabled="edits.show_header !== true"
|
||||
:value="panel.color"
|
||||
:disabled="panel.show_header !== true"
|
||||
width="half"
|
||||
@input="edits.color = $event"
|
||||
/>
|
||||
@@ -69,9 +81,10 @@
|
||||
<div class="field full">
|
||||
<p class="type-label">{{ t('note') }}</p>
|
||||
<v-input
|
||||
v-model="edits.note"
|
||||
:disabled="edits.show_header !== true"
|
||||
:model-value="panel.note"
|
||||
:disabled="panel.show_header !== true"
|
||||
:placeholder="t('panel_note_placeholder')"
|
||||
@update:model-value="edits.note = $event"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -79,101 +92,103 @@
|
||||
</v-drawer>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import ExtensionOptions from '../../settings/routes/data-model/field-detail/shared/extension-options.vue';
|
||||
import { computed, defineComponent, reactive, watch, PropType } from 'vue';
|
||||
import { getPanels, getPanel } from '@/panels';
|
||||
<script lang="ts" setup>
|
||||
import { FancySelectItem } from '@/components/v-fancy-select/types';
|
||||
import { Panel } from '@directus/shared/types';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useDialogRoute } from '@/composables/use-dialog-route';
|
||||
import { getPanel, getPanels } from '@/panels';
|
||||
import { useInsightsStore } from '@/stores';
|
||||
import { CreatePanel } from '@/stores/insights';
|
||||
import { Panel } from '@directus/shared/types';
|
||||
import { assign, clone, omitBy, isUndefined } from 'lodash';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { computed, reactive, unref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRouter } from 'vue-router';
|
||||
import ExtensionOptions from '../../settings/routes/data-model/field-detail/shared/extension-options.vue';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'PanelConfiguration',
|
||||
components: { ExtensionOptions },
|
||||
props: {
|
||||
panel: {
|
||||
type: Object as PropType<Partial<Panel>>,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
emits: ['cancel', 'save'],
|
||||
setup(props, { emit }) {
|
||||
const { t } = useI18n();
|
||||
interface Props {
|
||||
dashboardKey: string;
|
||||
panelKey: string;
|
||||
}
|
||||
|
||||
const { panels } = getPanels();
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const isOpen = useDialogRoute();
|
||||
const { t } = useI18n();
|
||||
|
||||
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 ?? undefined,
|
||||
color: props.panel?.color,
|
||||
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 isOpen = useDialogRoute();
|
||||
|
||||
const selectItems = computed<FancySelectItem[]>(() => {
|
||||
return panels.value.map((panel) => {
|
||||
const item: FancySelectItem = {
|
||||
text: panel.name,
|
||||
icon: panel.icon,
|
||||
description: panel.description,
|
||||
value: panel.id,
|
||||
};
|
||||
const edits = reactive<Partial<Panel>>({
|
||||
show_header: undefined,
|
||||
type: undefined,
|
||||
name: undefined,
|
||||
note: undefined,
|
||||
icon: undefined,
|
||||
color: undefined,
|
||||
width: undefined,
|
||||
height: undefined,
|
||||
position_x: undefined,
|
||||
position_y: undefined,
|
||||
options: undefined,
|
||||
});
|
||||
|
||||
return item;
|
||||
});
|
||||
});
|
||||
const insightsStore = useInsightsStore();
|
||||
|
||||
const extensionInfo = computed(() => {
|
||||
return getPanel(edits.type);
|
||||
});
|
||||
const { panels } = storeToRefs(insightsStore);
|
||||
const { panels: panelTypes } = getPanels();
|
||||
|
||||
watch(extensionInfo, (newPanel) => {
|
||||
if (newPanel) {
|
||||
edits.width = newPanel.minWidth;
|
||||
edits.height = newPanel.minHeight;
|
||||
} else {
|
||||
edits.width = undefined;
|
||||
edits.height = undefined;
|
||||
}
|
||||
});
|
||||
const router = useRouter();
|
||||
|
||||
const customOptionsFields = computed(() => {
|
||||
if (typeof extensionInfo.value?.options === 'function') {
|
||||
return extensionInfo.value?.options(edits);
|
||||
}
|
||||
const panel = computed<Partial<Panel>>(() => {
|
||||
if (props.panelKey === '+') return edits;
|
||||
const existing: Partial<Panel> = unref(panels).find((panel) => panel.id === props.panelKey) ?? {};
|
||||
return assign({}, existing, omitBy(edits, isUndefined));
|
||||
});
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
return {
|
||||
selectItems,
|
||||
close,
|
||||
emitSave,
|
||||
edits,
|
||||
t,
|
||||
isOpen,
|
||||
setOptionsValues,
|
||||
customOptionsFields,
|
||||
const selectItems = computed<FancySelectItem[]>(() => {
|
||||
return panelTypes.value.map((panelType) => {
|
||||
const item: FancySelectItem = {
|
||||
text: panelType.name,
|
||||
icon: panelType.icon,
|
||||
description: panelType.description,
|
||||
value: panelType.id,
|
||||
};
|
||||
|
||||
function emitSave() {
|
||||
emit('save', edits);
|
||||
}
|
||||
|
||||
function setOptionsValues(newValues: any) {
|
||||
edits.options = newValues;
|
||||
}
|
||||
},
|
||||
return item;
|
||||
});
|
||||
});
|
||||
|
||||
const currentTypeInfo = computed(() => {
|
||||
return unref(panel).type ? getPanel(unref(panel).type) : null;
|
||||
});
|
||||
|
||||
const customOptionsFields = computed(() => {
|
||||
if (typeof currentTypeInfo.value?.options === 'function') {
|
||||
return currentTypeInfo.value?.options(unref(panel)) ?? null;
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
const stageChanges = () => {
|
||||
if (props.panelKey === '+') {
|
||||
const createPanel = clone(unref(panel));
|
||||
|
||||
createPanel.id = `_${nanoid()}`;
|
||||
createPanel.dashboard = props.dashboardKey;
|
||||
createPanel.width ??= unref(currentTypeInfo)?.minWidth ?? 4;
|
||||
createPanel.height ??= unref(currentTypeInfo)?.minHeight ?? 4;
|
||||
createPanel.position_x ??= 1;
|
||||
createPanel.position_y ??= 1;
|
||||
createPanel.options ??= {};
|
||||
|
||||
insightsStore.stagePanelCreate(unref(createPanel as CreatePanel));
|
||||
router.push(`/insights/${props.dashboardKey}`);
|
||||
} else {
|
||||
insightsStore.stagePanelUpdate({ id: props.panelKey, edits: unref(panel) });
|
||||
router.push(`/insights/${props.dashboardKey}`);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -76,17 +76,17 @@
|
||||
:hovered-panel="hoveredPanelID"
|
||||
:subdued="flow.status === 'inactive'"
|
||||
/>
|
||||
<v-workspace :panels="panels" :edit-mode="editMode">
|
||||
<template #panel="{ panel }">
|
||||
<v-workspace :tiles="panels" :edit-mode="editMode">
|
||||
<template #tile="{ tile }">
|
||||
<operation
|
||||
v-if="flow"
|
||||
:edit-mode="editMode"
|
||||
:panel="panel"
|
||||
:type="panel.id === '$trigger' ? 'trigger' : 'operation'"
|
||||
:parent="parentPanels[panel.id]"
|
||||
:panel="tile"
|
||||
:type="tile.id === '$trigger' ? 'trigger' : 'operation'"
|
||||
:parent="parentPanels[tile.id]"
|
||||
:flow="flow"
|
||||
:panels-to-be-deleted="panelsToBeDeleted"
|
||||
:is-hovered="hoveredPanelID === panel.id"
|
||||
:is-hovered="hoveredPanelID === tile.id"
|
||||
:subdued="flow.status === 'inactive'"
|
||||
@create="createPanel"
|
||||
@edit="editPanel"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import PanelLabel from './label.vue';
|
||||
import { definePanel } from '@directus/shared/utils';
|
||||
import PanelLabel from './panel-label.vue';
|
||||
|
||||
export default definePanel({
|
||||
id: 'label',
|
||||
|
||||
@@ -4,25 +4,26 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
showHeader?: boolean;
|
||||
text?: string;
|
||||
color?: string | undefined;
|
||||
}>(),
|
||||
{
|
||||
showHeader: false,
|
||||
text: '',
|
||||
color: undefined,
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
showHeader: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
|
||||
text: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
default: undefined,
|
||||
},
|
||||
},
|
||||
inheritAttrs: false,
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { definePanel } from '@directus/shared/utils';
|
||||
import PanelList from './list.vue';
|
||||
import { useFieldsStore } from '@/stores';
|
||||
import adjustFieldsForDisplays from '@/utils/adjust-fields-for-displays';
|
||||
import { definePanel, getFieldsFromTemplate } from '@directus/shared/utils';
|
||||
import PanelList from './panel-list.vue';
|
||||
|
||||
export default definePanel({
|
||||
id: 'list',
|
||||
@@ -7,6 +9,31 @@ export default definePanel({
|
||||
description: '$t:panels.list.description',
|
||||
icon: 'list',
|
||||
component: PanelList,
|
||||
query(options) {
|
||||
if (!options?.collection) return;
|
||||
|
||||
const fieldsStore = useFieldsStore();
|
||||
const primaryKeyField = fieldsStore.getPrimaryKeyFieldForCollection(options.collection);
|
||||
const displayFields = [primaryKeyField!.field];
|
||||
|
||||
const sort = options.sortField ?? primaryKeyField?.field;
|
||||
|
||||
if (options.displayTemplate) {
|
||||
displayFields.push(
|
||||
...adjustFieldsForDisplays(getFieldsFromTemplate(options.displayTemplate), options.collection)
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
collection: options.collection,
|
||||
query: {
|
||||
filter: options.filter ?? {},
|
||||
fields: displayFields,
|
||||
sort: options.sortDirection === 'desc' ? `-${sort}` : sort,
|
||||
limit: options.limit === undefined ? 5 : options.limit,
|
||||
},
|
||||
};
|
||||
},
|
||||
options: [
|
||||
{
|
||||
field: 'collection',
|
||||
@@ -97,4 +124,5 @@ export default definePanel({
|
||||
],
|
||||
minWidth: 12,
|
||||
minHeight: 6,
|
||||
skipUndefinedKeys: ['displayTemplate'],
|
||||
});
|
||||
|
||||
@@ -1,193 +0,0 @@
|
||||
<template>
|
||||
<div class="list" :class="{ 'has-header': showHeader, loading, 'no-data': !hasData }">
|
||||
<v-progress-circular v-if="loading" indeterminate />
|
||||
<span v-else-if="!hasData" class="type-note">{{ t('no_data') }}</span>
|
||||
<div v-else>
|
||||
<v-list>
|
||||
<v-list-item
|
||||
v-for="row in list"
|
||||
:key="row[primaryKeyField.field]"
|
||||
class="selectable"
|
||||
clickable
|
||||
@click="startEditing(row)"
|
||||
>
|
||||
<render-template :item="row" :collection="collection" :template="renderTemplate" />
|
||||
<div class="spacer" />
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</div>
|
||||
|
||||
<drawer-item
|
||||
:active="!!currentlyEditing"
|
||||
:collection="collection"
|
||||
:primary-key="currentlyEditing ?? '+'"
|
||||
:edits="editsAtStart"
|
||||
@input="saveEdits"
|
||||
@update:active="cancelEdit"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, ref, watchEffect, computed, PropType } from 'vue';
|
||||
import api from '@/api';
|
||||
import { Filter } from '@directus/shared/types';
|
||||
import { useFieldsStore } from '@/stores';
|
||||
import DrawerItem from '@/views/private/components/drawer-item';
|
||||
import { unexpectedError } from '@/utils/unexpected-error';
|
||||
import { getFieldsFromTemplate } from '@directus/shared/utils';
|
||||
import { adjustFieldsForDisplays } from '@/utils/adjust-fields-for-displays';
|
||||
import { getEndpoint } from '@directus/shared/utils';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
export default defineComponent({
|
||||
components: { DrawerItem },
|
||||
props: {
|
||||
showHeader: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
|
||||
displayTemplate: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
sortField: {
|
||||
type: String,
|
||||
default: undefined,
|
||||
},
|
||||
sortDirection: {
|
||||
type: String,
|
||||
default: 'desc',
|
||||
},
|
||||
collection: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
limit: {
|
||||
type: Number,
|
||||
default: 5,
|
||||
},
|
||||
filter: {
|
||||
type: Object as PropType<Filter>,
|
||||
default: () => ({}),
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const { t } = useI18n();
|
||||
|
||||
const currentlyEditing = ref<number | string>();
|
||||
const editsAtStart = ref<Record<string, any>>();
|
||||
|
||||
const fieldsStore = useFieldsStore();
|
||||
|
||||
const list = ref<Record<string, any>[]>([]);
|
||||
const loading = ref(false);
|
||||
const error = ref();
|
||||
|
||||
const hasData = computed(() => {
|
||||
return list.value && list.value.length > 0;
|
||||
});
|
||||
|
||||
const renderTemplate = computed(() => {
|
||||
return props.displayTemplate;
|
||||
});
|
||||
|
||||
const primaryKeyField = computed(() => fieldsStore.getPrimaryKeyFieldForCollection(props.collection));
|
||||
|
||||
watchEffect(async () => await fetchData());
|
||||
|
||||
return {
|
||||
list,
|
||||
loading,
|
||||
renderTemplate,
|
||||
primaryKeyField,
|
||||
startEditing,
|
||||
saveEdits,
|
||||
cancelEdit,
|
||||
currentlyEditing,
|
||||
editsAtStart,
|
||||
hasData,
|
||||
t,
|
||||
};
|
||||
|
||||
async function fetchData() {
|
||||
if (!props) return;
|
||||
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
const sort = props.sortField;
|
||||
|
||||
const res = await api.get(getEndpoint(props.collection), {
|
||||
params: {
|
||||
fields: [
|
||||
primaryKeyField.value.field,
|
||||
...adjustFieldsForDisplays(getFieldsFromTemplate(renderTemplate.value), props.collection),
|
||||
],
|
||||
filter: props.filter,
|
||||
sort: props.sortDirection === 'desc' ? `-${sort}` : sort,
|
||||
limit: props.limit ?? 5,
|
||||
},
|
||||
});
|
||||
|
||||
list.value = res.data.data;
|
||||
} catch (err) {
|
||||
error.value = err;
|
||||
unexpectedError(err);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function startEditing(item: Record<string, any>) {
|
||||
currentlyEditing.value = item[primaryKeyField.value.field];
|
||||
editsAtStart.value = item;
|
||||
}
|
||||
|
||||
async function saveEdits(item: Record<string, any>) {
|
||||
try {
|
||||
await api.patch(`/items/${props.collection}/${currentlyEditing.value}`, item);
|
||||
} catch (err) {
|
||||
unexpectedError(err);
|
||||
}
|
||||
|
||||
await fetchData();
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
editsAtStart.value = undefined;
|
||||
currentlyEditing.value = undefined;
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.list {
|
||||
--v-list-padding: 0;
|
||||
--v-list-border-radius: 0;
|
||||
--v-list-item-border-radius: 0;
|
||||
--v-list-item-padding: 6px;
|
||||
--v-list-item-margin: 0;
|
||||
|
||||
height: 100%;
|
||||
padding: 0 12px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.list.loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.v-list-item {
|
||||
height: 48px;
|
||||
border-top: var(--border-width) solid var(--border-subdued);
|
||||
}
|
||||
|
||||
.v-list-item:last-child {
|
||||
border-bottom: var(--border-width) solid var(--border-subdued);
|
||||
}
|
||||
</style>
|
||||
66
app/src/panels/list/panel-list.vue
Normal file
66
app/src/panels/list/panel-list.vue
Normal file
@@ -0,0 +1,66 @@
|
||||
<template>
|
||||
<div class="list" :class="{ 'has-header': showHeader }">
|
||||
<div>
|
||||
<v-list>
|
||||
<v-list-item v-for="row in data" :key="row[primaryKeyField.field]" class="selectable">
|
||||
<render-template :item="row" :collection="collection" :template="displayTemplate" />
|
||||
<div class="spacer" />
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useFieldsStore } from '@/stores';
|
||||
import { Filter } from '@directus/shared/types';
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
showHeader?: boolean;
|
||||
displayTemplate?: string;
|
||||
sortField?: string;
|
||||
sortDirection?: string;
|
||||
collection: string;
|
||||
limit?: number;
|
||||
filter?: Filter;
|
||||
data?: object;
|
||||
}>(),
|
||||
{
|
||||
showHeader: false,
|
||||
displayTemplate: '',
|
||||
sortDirection: 'desc',
|
||||
limit: 5,
|
||||
filter: () => ({}),
|
||||
data: () => ({}),
|
||||
}
|
||||
);
|
||||
|
||||
const fieldsStore = useFieldsStore();
|
||||
|
||||
const primaryKeyField = computed(() => fieldsStore.getPrimaryKeyFieldForCollection(props.collection));
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.list {
|
||||
--v-list-padding: 0;
|
||||
--v-list-border-radius: 0;
|
||||
--v-list-item-border-radius: 0;
|
||||
--v-list-item-padding: 6px;
|
||||
--v-list-item-margin: 0;
|
||||
|
||||
height: 100%;
|
||||
padding: 0 12px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.v-list-item {
|
||||
height: 48px;
|
||||
border-top: var(--border-width) solid var(--border-subdued);
|
||||
}
|
||||
|
||||
.v-list-item:last-child {
|
||||
border-bottom: var(--border-width) solid var(--border-subdued);
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,8 @@
|
||||
import { computed } from 'vue';
|
||||
import { useFieldsStore } from '@/stores';
|
||||
import { PanelQuery } from '@directus/shared/types';
|
||||
import { definePanel } from '@directus/shared/utils';
|
||||
import PanelMetric from './metric.vue';
|
||||
import { computed } from 'vue';
|
||||
import PanelMetric from './panel-metric.vue';
|
||||
|
||||
export default definePanel({
|
||||
id: 'metric',
|
||||
@@ -9,6 +10,38 @@ export default definePanel({
|
||||
description: '$t:panels.metric.description',
|
||||
icon: 'functions',
|
||||
component: PanelMetric,
|
||||
query(options) {
|
||||
if (!options || !options.function) return;
|
||||
const isRawValue = ['first', 'last'].includes(options.function);
|
||||
|
||||
const sort = options.sortField && `${options.function === 'last' ? '-' : ''}${options.sortField}`;
|
||||
|
||||
const aggregate = isRawValue
|
||||
? undefined
|
||||
: {
|
||||
[options.function]: [options.field || '*'],
|
||||
};
|
||||
|
||||
const panelQuery: PanelQuery = {
|
||||
collection: options.collection,
|
||||
query: {
|
||||
sort,
|
||||
limit: 1,
|
||||
fields: [options.field],
|
||||
},
|
||||
};
|
||||
|
||||
if (options.filter && Object.keys(options.filter).length > 0) {
|
||||
panelQuery.query.filter = options.filter;
|
||||
}
|
||||
|
||||
if (aggregate) {
|
||||
panelQuery.query.aggregate = aggregate;
|
||||
delete panelQuery.query.fields;
|
||||
}
|
||||
|
||||
return panelQuery;
|
||||
},
|
||||
options: ({ options }) => {
|
||||
const fieldsStore = useFieldsStore();
|
||||
|
||||
@@ -18,7 +51,7 @@ export default definePanel({
|
||||
: null;
|
||||
});
|
||||
|
||||
const supportsAggregate = computed(() =>
|
||||
const fieldIsNumber = computed(() =>
|
||||
fieldType.value ? ['integer', 'bigInteger', 'float', 'decimal'].includes(fieldType.value) : false
|
||||
);
|
||||
|
||||
@@ -82,37 +115,37 @@ export default definePanel({
|
||||
{
|
||||
text: 'Count (Distinct)',
|
||||
value: 'countDistinct',
|
||||
disabled: !supportsAggregate.value,
|
||||
disabled: !fieldIsNumber.value,
|
||||
},
|
||||
{
|
||||
text: 'Average',
|
||||
value: 'avg',
|
||||
disabled: !supportsAggregate.value,
|
||||
disabled: !fieldIsNumber.value,
|
||||
},
|
||||
{
|
||||
text: 'Average (Distinct)',
|
||||
value: 'avgDistinct',
|
||||
disabled: !supportsAggregate.value,
|
||||
disabled: !fieldIsNumber.value,
|
||||
},
|
||||
{
|
||||
text: 'Sum',
|
||||
value: 'sum',
|
||||
disabled: !supportsAggregate.value,
|
||||
disabled: !fieldIsNumber.value,
|
||||
},
|
||||
{
|
||||
text: 'Sum (Distinct)',
|
||||
value: 'sumDistinct',
|
||||
disabled: !supportsAggregate.value,
|
||||
disabled: !fieldIsNumber.value,
|
||||
},
|
||||
{
|
||||
text: 'Minimum',
|
||||
value: 'min',
|
||||
disabled: !supportsAggregate.value,
|
||||
disabled: !fieldIsNumber.value,
|
||||
},
|
||||
{
|
||||
text: 'Maximum',
|
||||
value: 'max',
|
||||
disabled: !supportsAggregate.value,
|
||||
disabled: !fieldIsNumber.value,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -240,18 +273,22 @@ export default definePanel({
|
||||
{
|
||||
text: '$t:operators.gt',
|
||||
value: '>',
|
||||
disabled: !fieldIsNumber.value,
|
||||
},
|
||||
{
|
||||
text: '$t:operators.gte',
|
||||
value: '>=',
|
||||
disabled: !fieldIsNumber.value,
|
||||
},
|
||||
{
|
||||
text: '$t:operators.lt',
|
||||
value: '<',
|
||||
disabled: !fieldIsNumber.value,
|
||||
},
|
||||
{
|
||||
text: '$t:operators.lte',
|
||||
value: '<=',
|
||||
disabled: !fieldIsNumber.value,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -261,7 +298,7 @@ export default definePanel({
|
||||
{
|
||||
field: 'value',
|
||||
name: '$t:value',
|
||||
type: 'integer',
|
||||
type: 'string',
|
||||
schema: {
|
||||
default_value: 0,
|
||||
},
|
||||
|
||||
@@ -1,198 +0,0 @@
|
||||
<template>
|
||||
<div class="metric type-title selectable" :class="{ 'has-header': showHeader }">
|
||||
<v-progress-circular v-if="loading" indeterminate />
|
||||
<div v-else :style="{ color }">
|
||||
<span class="prefix">{{ prefix }}</span>
|
||||
<span class="value">{{ displayValue }}</span>
|
||||
<span class="suffix">{{ suffix }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, ref, PropType, computed, watchEffect } from 'vue';
|
||||
import api from '@/api';
|
||||
import { Filter } from '@directus/shared/types';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { isNil } from 'lodash';
|
||||
import { getEndpoint, abbreviateNumber } from '@directus/shared/utils';
|
||||
import { cssVar } from '@directus/shared/utils/browser';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
showHeader: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
|
||||
abbreviate: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
sortField: {
|
||||
type: String,
|
||||
default: undefined,
|
||||
},
|
||||
collection: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
field: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
function: {
|
||||
type: String as PropType<
|
||||
'avg' | 'avgDistinct' | 'sum' | 'sumDistinct' | 'count' | 'countDistinct' | 'min' | 'max' | 'first' | 'last'
|
||||
>,
|
||||
required: true,
|
||||
},
|
||||
filter: {
|
||||
type: Object as PropType<Filter>,
|
||||
default: () => ({}),
|
||||
},
|
||||
decimals: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
conditionalFormatting: {
|
||||
type: Array as PropType<
|
||||
{
|
||||
operator: '=' | '!=' | '>' | '>=' | '<' | '<=';
|
||||
color: string;
|
||||
value: number;
|
||||
}[]
|
||||
>,
|
||||
default: () => [],
|
||||
},
|
||||
prefix: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
suffix: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const { n } = useI18n();
|
||||
|
||||
const metric = ref<number | string>();
|
||||
const loading = ref(false);
|
||||
|
||||
watchEffect(async () => {
|
||||
const isRawValue = ['first', 'last'].includes(props.function);
|
||||
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
const sort = props.sortField && `${props.function === 'last' ? '-' : ''}${props.sortField}`;
|
||||
|
||||
const aggregate = isRawValue
|
||||
? undefined
|
||||
: {
|
||||
[props.function]: [props.field || '*'],
|
||||
};
|
||||
|
||||
const res = await api.get(getEndpoint(props.collection), {
|
||||
params: {
|
||||
aggregate,
|
||||
filter: props.filter,
|
||||
sort: sort,
|
||||
limit: 1,
|
||||
fields: [props.field],
|
||||
},
|
||||
});
|
||||
|
||||
if (props.field) {
|
||||
if (props.function === 'first' || props.function === 'last') {
|
||||
if (typeof res.data.data[0][props.field] === 'string') {
|
||||
metric.value = res.data.data[0][props.field];
|
||||
} else {
|
||||
metric.value = Number(res.data.data[0][props.field]);
|
||||
}
|
||||
} else {
|
||||
metric.value = Number(res.data.data[0][props.function][props.field]);
|
||||
}
|
||||
} else {
|
||||
metric.value = Number(res.data.data[0][props.function]);
|
||||
}
|
||||
} catch (err) {
|
||||
// oh no
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
const displayValue = computed(() => {
|
||||
if (isNil(metric.value)) return null;
|
||||
|
||||
if (props.abbreviate) {
|
||||
return abbreviateNumber(metric.value, props.decimals ?? 0);
|
||||
}
|
||||
|
||||
if (typeof metric.value === 'string') {
|
||||
return metric.value;
|
||||
}
|
||||
|
||||
return n(Number(metric.value), 'decimal', {
|
||||
minimumFractionDigits: props.decimals ?? 0,
|
||||
maximumFractionDigits: props.decimals ?? 0,
|
||||
} as any);
|
||||
});
|
||||
|
||||
const color = computed(() => {
|
||||
if (isNil(metric.value)) return null;
|
||||
|
||||
let matchingFormat: MetricOptions['conditionalFormatting'][number] | null = null;
|
||||
|
||||
for (const format of props.conditionalFormatting || []) {
|
||||
if (matchesOperator(format)) {
|
||||
matchingFormat = format;
|
||||
}
|
||||
}
|
||||
|
||||
return matchingFormat ? matchingFormat.color || cssVar('--primary') : null;
|
||||
|
||||
function matchesOperator(format: MetricOptions['conditionalFormatting'][number]) {
|
||||
const value = Number(metric.value);
|
||||
const compareValue = Number(format.value ?? 0);
|
||||
|
||||
switch (format.operator || '>=') {
|
||||
case '=':
|
||||
return value === compareValue;
|
||||
case '!=':
|
||||
return value !== compareValue;
|
||||
case '>':
|
||||
return value > compareValue;
|
||||
case '>=':
|
||||
return value >= compareValue;
|
||||
case '<':
|
||||
return value < compareValue;
|
||||
case '<=':
|
||||
return value < compareValue;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return { metric, loading, displayValue, color };
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.metric {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font-weight: 800;
|
||||
font-size: 42px;
|
||||
line-height: 52px;
|
||||
}
|
||||
|
||||
.metric.has-header {
|
||||
height: calc(100% - 16px);
|
||||
}
|
||||
</style>
|
||||
143
app/src/panels/metric/panel-metric.vue
Normal file
143
app/src/panels/metric/panel-metric.vue
Normal file
@@ -0,0 +1,143 @@
|
||||
<template>
|
||||
<div class="metric type-title selectable" :class="{ 'has-header': showHeader }">
|
||||
<div :style="{ color }">
|
||||
<span class="prefix">{{ prefix }}</span>
|
||||
<span class="value">{{ displayValue }}</span>
|
||||
<span class="suffix">{{ suffix }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Filter } from '@directus/shared/types';
|
||||
import { abbreviateNumber } from '@directus/shared/utils';
|
||||
import { cssVar } from '@directus/shared/utils/browser';
|
||||
import { isNil } from 'lodash';
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
interface Props {
|
||||
showHeader?: boolean;
|
||||
abbreviate?: boolean;
|
||||
sortField?: string;
|
||||
collection: string;
|
||||
field: string;
|
||||
function: string;
|
||||
filter?: Filter;
|
||||
data?: Record<string, any>[];
|
||||
decimals?: number;
|
||||
conditionalFormatting?: Record<string, any>[];
|
||||
prefix?: string | null;
|
||||
suffix?: string | null;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
showHeader: false,
|
||||
abbreviate: false,
|
||||
sortField: undefined,
|
||||
data: () => [],
|
||||
filter: () => ({}),
|
||||
decimals: 0,
|
||||
conditionalFormatting: () => [],
|
||||
prefix: null,
|
||||
suffix: null,
|
||||
});
|
||||
|
||||
const { n } = useI18n();
|
||||
|
||||
const metric = computed(() => {
|
||||
if (!props.data || props.data.length === 0) return null;
|
||||
|
||||
if (props.field) {
|
||||
if (props.function === 'first' || props.function === 'last') {
|
||||
if (typeof props.data[0][props.field] === 'string') {
|
||||
return props.data[0][props.field];
|
||||
} else {
|
||||
return Number(props.data[0][props.field]);
|
||||
}
|
||||
} else {
|
||||
return Number(props.data[0][props.function][props.field]);
|
||||
}
|
||||
} else {
|
||||
return Number(props.data[0][props.function]);
|
||||
}
|
||||
});
|
||||
|
||||
const displayValue = computed(() => {
|
||||
if (isNil(metric.value)) return null;
|
||||
|
||||
if (typeof metric.value === 'string') {
|
||||
return metric.value;
|
||||
}
|
||||
|
||||
if (props.abbreviate) {
|
||||
return abbreviateNumber(metric.value, props.decimals ?? 0);
|
||||
}
|
||||
|
||||
return n(Number(metric.value), 'decimal', {
|
||||
minimumFractionDigits: props.decimals ?? 0,
|
||||
maximumFractionDigits: props.decimals ?? 0,
|
||||
} as any);
|
||||
});
|
||||
|
||||
const color = computed(() => {
|
||||
if (isNil(metric.value)) return null;
|
||||
|
||||
let matchingFormat = null;
|
||||
|
||||
for (const format of props.conditionalFormatting || []) {
|
||||
if (matchesOperator(format)) {
|
||||
matchingFormat = format;
|
||||
}
|
||||
}
|
||||
|
||||
return matchingFormat ? matchingFormat.color || cssVar('--primary') : null;
|
||||
|
||||
function matchesOperator(format: Record<string, any>) {
|
||||
if (typeof metric.value === 'string') {
|
||||
const value = metric.value;
|
||||
const compareValue = format.value ?? '';
|
||||
switch (format.operator || '>=') {
|
||||
case '=':
|
||||
return value === compareValue;
|
||||
case '!=':
|
||||
return value !== compareValue;
|
||||
}
|
||||
} else {
|
||||
const value = Number(metric.value);
|
||||
const compareValue = Number(format.value ?? 0);
|
||||
switch (format.operator || '>=') {
|
||||
case '=':
|
||||
return value === compareValue;
|
||||
case '!=':
|
||||
return value !== compareValue;
|
||||
case '>':
|
||||
return value > compareValue;
|
||||
case '>=':
|
||||
return value >= compareValue;
|
||||
case '<':
|
||||
return value < compareValue;
|
||||
case '<=':
|
||||
return value < compareValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.metric {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font-weight: 800;
|
||||
font-size: 42px;
|
||||
line-height: 52px;
|
||||
}
|
||||
|
||||
.metric.has-header {
|
||||
height: calc(100% - 16px);
|
||||
}
|
||||
</style>
|
||||
@@ -1,11 +1,43 @@
|
||||
import { getGroups } from '@/utils/get-groups';
|
||||
import { definePanel } from '@directus/shared/utils';
|
||||
import PanelTimeSeries from './time-series.vue';
|
||||
import PanelTimeSeries from './panel-time-series.vue';
|
||||
|
||||
export default definePanel({
|
||||
id: 'time-series',
|
||||
name: '$t:panels.time_series.name',
|
||||
description: '$t:panels.time_series.description',
|
||||
icon: 'show_chart',
|
||||
query(options) {
|
||||
if (!options?.function || !options.valueField || !options.dateField) {
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
collection: options.collection,
|
||||
query: {
|
||||
group: getGroups(options.precision, options.dateField),
|
||||
aggregate: {
|
||||
[options.function]: [options.valueField],
|
||||
},
|
||||
filter: {
|
||||
_and: [
|
||||
{
|
||||
[options.dateField]: {
|
||||
_gte: `$NOW(-${options.range || '1 week'})`,
|
||||
},
|
||||
},
|
||||
{
|
||||
[options.dateField]: {
|
||||
_lte: `$NOW`,
|
||||
},
|
||||
},
|
||||
options.filter || {},
|
||||
],
|
||||
},
|
||||
limit: -1,
|
||||
},
|
||||
};
|
||||
},
|
||||
component: PanelTimeSeries,
|
||||
options: [
|
||||
{
|
||||
|
||||
350
app/src/panels/time-series/panel-time-series.vue
Normal file
350
app/src/panels/time-series/panel-time-series.vue
Normal file
@@ -0,0 +1,350 @@
|
||||
<template>
|
||||
<div class="time-series">
|
||||
<div ref="chartEl" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useFieldsStore } from '@/stores';
|
||||
import { adjustDate } from '@/utils/adjust-date';
|
||||
import { Filter } from '@directus/shared/types';
|
||||
import { abbreviateNumber } from '@directus/shared/utils';
|
||||
import { cssVar } from '@directus/shared/utils/browser';
|
||||
import ApexCharts from 'apexcharts';
|
||||
import { addWeeks } from 'date-fns';
|
||||
import { isNil } from 'lodash';
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { orderBy } from 'lodash';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
height: number;
|
||||
showHeader?: boolean;
|
||||
data?: object[];
|
||||
id: string;
|
||||
now: Date;
|
||||
collection: string;
|
||||
dateField: string;
|
||||
valueField: string;
|
||||
function: string;
|
||||
precision?: string;
|
||||
range?: string;
|
||||
color?: string;
|
||||
fillType?: string;
|
||||
curveType?: string;
|
||||
decimals?: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
filter?: Filter;
|
||||
showXAxis?: boolean;
|
||||
showYAxis?: boolean;
|
||||
}>(),
|
||||
{
|
||||
showHeader: false,
|
||||
data: () => [],
|
||||
precision: 'hour',
|
||||
color: cssVar('--primary'),
|
||||
range: '1 week',
|
||||
fillType: 'gradient',
|
||||
curveType: 'smooth',
|
||||
decimals: 0,
|
||||
min: undefined,
|
||||
max: undefined,
|
||||
filter: () => ({}),
|
||||
showXAxis: true,
|
||||
showYAxis: true,
|
||||
}
|
||||
);
|
||||
|
||||
const { d, t, n } = useI18n();
|
||||
|
||||
const fieldsStore = useFieldsStore();
|
||||
|
||||
const metrics = ref<Record<string, any>[]>([]);
|
||||
const chartEl = ref();
|
||||
const chart = ref<ApexCharts>();
|
||||
|
||||
const valueLabel = computed(() => {
|
||||
const field = fieldsStore.getField(props.collection, props.valueField)!;
|
||||
const operation = t(props.function);
|
||||
return `${field.name} (${operation})`;
|
||||
});
|
||||
|
||||
const yAxisRange = computed(() => {
|
||||
let min = isNil(props.min) ? undefined : Number(props.min);
|
||||
let max = isNil(props.max) ? undefined : Number(props.max);
|
||||
|
||||
if (max !== undefined && !min) {
|
||||
min = 0;
|
||||
}
|
||||
|
||||
if (max !== undefined && min !== undefined && max < min) {
|
||||
max = min;
|
||||
min = Number(props.max);
|
||||
}
|
||||
|
||||
return { max, min };
|
||||
});
|
||||
|
||||
watch(
|
||||
[
|
||||
() => props.data,
|
||||
() => props.color,
|
||||
() => props.fillType,
|
||||
() => props.curveType,
|
||||
() => props.decimals,
|
||||
() => props.min,
|
||||
() => props.max,
|
||||
() => props.showXAxis,
|
||||
() => props.showYAxis,
|
||||
],
|
||||
() => {
|
||||
chart.value?.destroy();
|
||||
setupChart();
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
onMounted(setupChart);
|
||||
|
||||
onUnmounted(() => {
|
||||
chart.value?.destroy();
|
||||
});
|
||||
|
||||
function setupChart() {
|
||||
metrics.value = [];
|
||||
|
||||
const isFieldTimestamp = fieldsStore.getField(props.collection, props.dateField)?.type === 'timestamp';
|
||||
|
||||
metrics.value = orderBy(
|
||||
props.data.map((metric) => ({
|
||||
x: new Date(toISO(metric.group)).getTime() - (isFieldTimestamp ? new Date().getTimezoneOffset() * 60 * 1000 : 0),
|
||||
|
||||
y: Number(Number(metric[props.function][props.valueField]).toFixed(props.decimals ?? 0)),
|
||||
})),
|
||||
'x'
|
||||
);
|
||||
|
||||
chart.value = new ApexCharts(chartEl.value, {
|
||||
colors: [props.color ? props.color : cssVar('--primary')],
|
||||
chart: {
|
||||
type: props.fillType === 'disabled' ? 'line' : 'area',
|
||||
height: '100%',
|
||||
toolbar: {
|
||||
show: false,
|
||||
},
|
||||
selection: {
|
||||
enabled: false,
|
||||
},
|
||||
zoom: {
|
||||
enabled: false,
|
||||
},
|
||||
fontFamily: 'var(--family-sans-serif)',
|
||||
foreColor: 'var(--foreground-subdued)',
|
||||
animations: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: props.collection,
|
||||
data: metrics.value,
|
||||
},
|
||||
],
|
||||
stroke: {
|
||||
curve: props.curveType,
|
||||
width: 2,
|
||||
lineCap: 'round',
|
||||
},
|
||||
markers: {
|
||||
hover: {
|
||||
size: undefined,
|
||||
sizeOffset: 4,
|
||||
},
|
||||
},
|
||||
fill: {
|
||||
type: props.fillType === 'disabled' ? 'solid' : props.fillType,
|
||||
gradient: {
|
||||
colorStops: [
|
||||
[
|
||||
{
|
||||
offset: 0,
|
||||
color: props.color ? props.color : cssVar('--primary'),
|
||||
opacity: 0.25,
|
||||
},
|
||||
{
|
||||
offset: 100,
|
||||
color: props.color ? props.color : cssVar('--primary'),
|
||||
opacity: 0,
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
borderColor: 'var(--border-subdued)',
|
||||
padding: {
|
||||
top: props.showHeader ? -20 : -4,
|
||||
bottom: 0,
|
||||
left: 8,
|
||||
},
|
||||
},
|
||||
dataLabels: {
|
||||
enabled: false,
|
||||
},
|
||||
tooltip: {
|
||||
marker: {
|
||||
show: false,
|
||||
},
|
||||
x: {
|
||||
show: true,
|
||||
formatter(date: number) {
|
||||
return d(new Date(date), 'long');
|
||||
},
|
||||
},
|
||||
y: {
|
||||
title: {
|
||||
formatter: () => valueLabel.value + ': ',
|
||||
},
|
||||
formatter(value: number) {
|
||||
return n(value);
|
||||
},
|
||||
},
|
||||
},
|
||||
xaxis: {
|
||||
type: 'datetime',
|
||||
tooltip: {
|
||||
enabled: false,
|
||||
},
|
||||
axisTicks: {
|
||||
show: false,
|
||||
},
|
||||
axisBorder: {
|
||||
show: false,
|
||||
},
|
||||
range: props.now.getTime() - adjustDate(props.now, `-${props.range}`)!.getTime(),
|
||||
max: props.now.getTime(),
|
||||
labels: {
|
||||
show: props.showXAxis ?? true,
|
||||
offsetY: -4,
|
||||
style: {
|
||||
fontFamily: 'var(--family-sans-serif)',
|
||||
foreColor: 'var(--foreground-subdued)',
|
||||
fontWeight: 600,
|
||||
fontSize: '10px',
|
||||
},
|
||||
datetimeUTC: false,
|
||||
},
|
||||
crosshairs: {
|
||||
stroke: {
|
||||
color: 'var(--border-normal)',
|
||||
},
|
||||
},
|
||||
},
|
||||
yaxis: {
|
||||
show: props.showYAxis ?? true,
|
||||
forceNiceScale: true,
|
||||
min: isNil(props.min) ? undefined : Number(props.min),
|
||||
max: isNil(props.max) ? undefined : Number(props.max),
|
||||
tickAmount: props.height - 4,
|
||||
labels: {
|
||||
offsetY: 1,
|
||||
offsetX: -4,
|
||||
formatter: (value: number) => {
|
||||
return value > 10000
|
||||
? abbreviateNumber(value, 1)
|
||||
: n(value, 'decimal', {
|
||||
minimumFractionDigits: props.decimals ?? 0,
|
||||
maximumFractionDigits: props.decimals ?? 0,
|
||||
} as any);
|
||||
},
|
||||
yaxis: {
|
||||
show: props.showYAxis ?? true,
|
||||
forceNiceScale: true,
|
||||
min: yAxisRange.value.min,
|
||||
max: yAxisRange.value.max,
|
||||
tickAmount: props.height - 4,
|
||||
labels: {
|
||||
offsetY: 1,
|
||||
offsetX: -4,
|
||||
formatter: (value: number) => {
|
||||
return value > 10000
|
||||
? abbreviateNumber(value, 1)
|
||||
: n(value, 'decimal', {
|
||||
minimumFractionDigits: props.decimals ?? 0,
|
||||
maximumFractionDigits: props.decimals ?? 0,
|
||||
} as any);
|
||||
},
|
||||
style: {
|
||||
fontFamily: 'var(--family-sans-serif)',
|
||||
foreColor: 'var(--foreground-subdued)',
|
||||
fontWeight: 600,
|
||||
fontSize: '10px',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
chart.value.render();
|
||||
|
||||
function toISO(metric: Record<string, any>) {
|
||||
const year = metric[`${props.dateField}_year`];
|
||||
const month = padZero(metric[`${props.dateField}_month`] ?? 1);
|
||||
const week = metric[`${props.dateField}_week`];
|
||||
const day = week ? padZero(getFirstDayOfNWeeksForYear(week, year)) : padZero(metric[`${props.dateField}_day`] ?? 1);
|
||||
const hour = padZero(metric[`${props.dateField}_hour`] ?? 0);
|
||||
const minute = padZero(metric[`${props.dateField}_minute`] ?? 0);
|
||||
const second = padZero(metric[`${props.dateField}_second`] ?? 0);
|
||||
|
||||
return `${year}-${month}-${day}T${hour}:${minute}:${second}`;
|
||||
|
||||
function padZero(value: number) {
|
||||
return String(value).padStart(2, '0');
|
||||
}
|
||||
|
||||
function getFirstDayOfNWeeksForYear(numberOfWeeks: number, year: number) {
|
||||
return addWeeks(new Date(year, 0, 1), numberOfWeeks).getDate();
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.time-series {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.apexcharts-tooltip.apexcharts-theme-light {
|
||||
border-color: var(--border-normal) !important;
|
||||
}
|
||||
|
||||
.apexcharts-tooltip.apexcharts-theme-light .apexcharts-tooltip-title {
|
||||
border-color: var(--border-normal) !important;
|
||||
margin-bottom: 0;
|
||||
padding: 0 4px;
|
||||
font-weight: 600 !important;
|
||||
font-size: 10px !important;
|
||||
background-color: var(--background-subdued) !important;
|
||||
}
|
||||
|
||||
.apexcharts-tooltip-y-group {
|
||||
padding: 0 0 0 4px;
|
||||
font-weight: 600 !important;
|
||||
font-size: 10px !important;
|
||||
}
|
||||
|
||||
.apexcharts-tooltip-series-group {
|
||||
background-color: var(--background-normal) !important;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.apexcharts-tooltip-series-group:last-child {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,450 +0,0 @@
|
||||
<template>
|
||||
<div ref="chartEl" class="time-series" />
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, PropType, ref, watch, onMounted, onUnmounted, computed } from 'vue';
|
||||
import api from '@/api';
|
||||
import ApexCharts from 'apexcharts';
|
||||
import { adjustDate } from '@/utils/adjust-date';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { isNil } from 'lodash';
|
||||
import { useFieldsStore } from '@/stores';
|
||||
import { Filter } from '@directus/shared/types';
|
||||
import { getEndpoint, abbreviateNumber } from '@directus/shared/utils';
|
||||
import { cssVar } from '@directus/shared/utils/browser';
|
||||
import { addWeeks } from 'date-fns';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
height: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
showHeader: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
id: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
now: {
|
||||
type: Date,
|
||||
required: true,
|
||||
},
|
||||
collection: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
dateField: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
valueField: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
function: {
|
||||
type: String as PropType<
|
||||
'avg' | 'avgDistinct' | 'sum' | 'sumDistinct' | 'count' | 'countDistinct' | 'min' | 'max'
|
||||
>,
|
||||
required: true,
|
||||
},
|
||||
precision: {
|
||||
type: String as PropType<'year' | 'month' | 'week' | 'day' | 'hour' | 'minute' | 'second'>,
|
||||
default: 'hour',
|
||||
},
|
||||
range: {
|
||||
type: String,
|
||||
default: '1 week',
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
default: cssVar('--primary'),
|
||||
},
|
||||
fillType: {
|
||||
type: String,
|
||||
default: 'gradient',
|
||||
},
|
||||
curveType: {
|
||||
type: String,
|
||||
default: 'smooth',
|
||||
},
|
||||
decimals: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
min: {
|
||||
type: Number,
|
||||
default: undefined,
|
||||
},
|
||||
max: {
|
||||
type: Number,
|
||||
default: undefined,
|
||||
},
|
||||
filter: {
|
||||
type: Object as PropType<Filter>,
|
||||
default: () => ({}),
|
||||
},
|
||||
showXAxis: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
showYAxis: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const { d, t, n } = useI18n();
|
||||
|
||||
const fieldsStore = useFieldsStore();
|
||||
|
||||
const metrics = ref<Record<string, any>[]>([]);
|
||||
const loading = ref(false);
|
||||
const error = ref();
|
||||
const chartEl = ref();
|
||||
const chart = ref<ApexCharts>();
|
||||
|
||||
const yAxisRange = computed(() => {
|
||||
let min = isNil(props.min) ? undefined : Number(props.min);
|
||||
let max = isNil(props.max) ? undefined : Number(props.max);
|
||||
|
||||
if (max !== undefined && !min) {
|
||||
min = 0;
|
||||
}
|
||||
|
||||
if (max !== undefined && min !== undefined && max < min) {
|
||||
max = min;
|
||||
min = Number(props.max);
|
||||
}
|
||||
|
||||
return { max, min };
|
||||
});
|
||||
|
||||
const valueLabel = computed(() => {
|
||||
const field = fieldsStore.getField(props.collection, props.valueField)!;
|
||||
const operation = t(props.function);
|
||||
return `${field.name} (${operation})`;
|
||||
});
|
||||
|
||||
watch(
|
||||
[
|
||||
() => props.collection,
|
||||
() => props.dateField,
|
||||
() => props.valueField,
|
||||
() => props.function,
|
||||
() => props.precision,
|
||||
() => props.range,
|
||||
() => props.color,
|
||||
() => props.fillType,
|
||||
() => props.curveType,
|
||||
() => props.decimals,
|
||||
() => props.min,
|
||||
() => props.max,
|
||||
() => props.filter,
|
||||
() => props.showXAxis,
|
||||
() => props.showYAxis,
|
||||
],
|
||||
() => {
|
||||
fetchData();
|
||||
chart.value?.destroy();
|
||||
setupChart();
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
fetchData();
|
||||
|
||||
onMounted(setupChart);
|
||||
|
||||
onUnmounted(() => {
|
||||
chart.value?.destroy();
|
||||
});
|
||||
|
||||
return { chartEl, metrics, loading, error };
|
||||
|
||||
async function fetchData() {
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
const results = await api.get(getEndpoint(props.collection), {
|
||||
params: {
|
||||
groupBy: getGroups(),
|
||||
aggregate: {
|
||||
[props.function]: [props.valueField],
|
||||
},
|
||||
filter: {
|
||||
_and: [
|
||||
{
|
||||
[props.dateField]: {
|
||||
_gte: `$NOW(-${props.range || '1 week'})`,
|
||||
},
|
||||
},
|
||||
{
|
||||
[props.dateField]: {
|
||||
_lte: `$NOW`,
|
||||
},
|
||||
},
|
||||
props.filter || {},
|
||||
],
|
||||
},
|
||||
limit: -1,
|
||||
},
|
||||
});
|
||||
|
||||
metrics.value = results.data.data;
|
||||
|
||||
const isFieldTimestamp = fieldsStore.getField(props.collection, props.dateField)?.type === 'timestamp';
|
||||
|
||||
chart.value?.updateSeries([
|
||||
{
|
||||
name: props.collection,
|
||||
data: metrics.value.map((metric) => ({
|
||||
x:
|
||||
new Date(toISO(metric)).getTime() - (isFieldTimestamp ? new Date().getTimezoneOffset() * 60 * 1000 : 0),
|
||||
y: Number(Number(metric[props.function][props.valueField]).toFixed(props.decimals ?? 0)),
|
||||
})),
|
||||
},
|
||||
]);
|
||||
} catch (err) {
|
||||
error.value = err;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
function toISO(metric: Record<string, any>) {
|
||||
const year = metric[`${props.dateField}_year`];
|
||||
const month = padZero(metric[`${props.dateField}_month`] ?? 1);
|
||||
const week = metric[`${props.dateField}_week`];
|
||||
const day = week
|
||||
? padZero(getFirstDayOfNWeeksForYear(week, year))
|
||||
: padZero(metric[`${props.dateField}_day`] ?? 1);
|
||||
const hour = padZero(metric[`${props.dateField}_hour`] ?? 0);
|
||||
const minute = padZero(metric[`${props.dateField}_minute`] ?? 0);
|
||||
const second = padZero(metric[`${props.dateField}_second`] ?? 0);
|
||||
|
||||
return `${year}-${month}-${day}T${hour}:${minute}:${second}`;
|
||||
|
||||
function padZero(value: number) {
|
||||
return String(value).padStart(2, '0');
|
||||
}
|
||||
|
||||
function getFirstDayOfNWeeksForYear(numberOfWeeks: number, year: number) {
|
||||
return addWeeks(new Date(year, 0, 1), numberOfWeeks).getDate();
|
||||
}
|
||||
}
|
||||
|
||||
function getGroups() {
|
||||
let groups: string[] = [];
|
||||
|
||||
switch (props.precision || 'hour') {
|
||||
case 'year':
|
||||
groups = ['year'];
|
||||
break;
|
||||
case 'month':
|
||||
groups = ['year', 'month'];
|
||||
break;
|
||||
case 'week':
|
||||
groups = ['year', 'month', 'week'];
|
||||
break;
|
||||
case 'day':
|
||||
groups = ['year', 'month', 'day'];
|
||||
break;
|
||||
case 'hour':
|
||||
groups = ['year', 'month', 'day', 'hour'];
|
||||
break;
|
||||
case 'minute':
|
||||
groups = ['year', 'month', 'day', 'hour', 'minute'];
|
||||
break;
|
||||
case 'second':
|
||||
groups = ['year', 'month', 'day', 'hour', 'minute', 'second'];
|
||||
break;
|
||||
default:
|
||||
groups = ['year', 'month', 'day', 'hour'];
|
||||
break;
|
||||
}
|
||||
|
||||
return groups.map((datePart) => `${datePart}(${props.dateField})`);
|
||||
}
|
||||
}
|
||||
|
||||
function setupChart() {
|
||||
chart.value = new ApexCharts(chartEl.value, {
|
||||
colors: [props.color ? props.color : cssVar('--primary')],
|
||||
chart: {
|
||||
type: props.fillType === 'disabled' ? 'line' : 'area',
|
||||
height: '100%',
|
||||
toolbar: {
|
||||
show: false,
|
||||
},
|
||||
selection: {
|
||||
enabled: false,
|
||||
},
|
||||
zoom: {
|
||||
enabled: false,
|
||||
},
|
||||
fontFamily: 'var(--family-sans-serif)',
|
||||
foreColor: 'var(--foreground-subdued)',
|
||||
},
|
||||
series: [],
|
||||
stroke: {
|
||||
curve: props.curveType,
|
||||
width: 2,
|
||||
lineCap: 'round',
|
||||
},
|
||||
markers: {
|
||||
hover: {
|
||||
size: undefined,
|
||||
sizeOffset: 4,
|
||||
},
|
||||
},
|
||||
fill: {
|
||||
type: props.fillType === 'disabled' ? 'solid' : props.fillType,
|
||||
gradient: {
|
||||
colorStops: [
|
||||
[
|
||||
{
|
||||
offset: 0,
|
||||
color: props.color ? props.color : cssVar('--primary'),
|
||||
opacity: 0.25,
|
||||
},
|
||||
{
|
||||
offset: 100,
|
||||
color: props.color ? props.color : cssVar('--primary'),
|
||||
opacity: 0,
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
borderColor: 'var(--border-subdued)',
|
||||
padding: {
|
||||
top: props.showHeader ? -20 : -4,
|
||||
bottom: 0,
|
||||
left: 8,
|
||||
},
|
||||
},
|
||||
dataLabels: {
|
||||
enabled: false,
|
||||
},
|
||||
tooltip: {
|
||||
marker: {
|
||||
show: false,
|
||||
},
|
||||
x: {
|
||||
show: true,
|
||||
formatter(date: number) {
|
||||
return d(new Date(date), 'long');
|
||||
},
|
||||
},
|
||||
y: {
|
||||
title: {
|
||||
formatter: () => valueLabel.value + ': ',
|
||||
},
|
||||
formatter(value: number) {
|
||||
return n(value);
|
||||
},
|
||||
},
|
||||
},
|
||||
xaxis: {
|
||||
type: 'datetime',
|
||||
tooltip: {
|
||||
enabled: false,
|
||||
},
|
||||
axisTicks: {
|
||||
show: false,
|
||||
},
|
||||
axisBorder: {
|
||||
show: false,
|
||||
},
|
||||
range: props.now.getTime() - adjustDate(props.now, `-${props.range}`)!.getTime(),
|
||||
max: props.now.getTime(),
|
||||
labels: {
|
||||
show: props.showXAxis ?? true,
|
||||
offsetY: -4,
|
||||
style: {
|
||||
fontFamily: 'var(--family-sans-serif)',
|
||||
foreColor: 'var(--foreground-subdued)',
|
||||
fontWeight: 600,
|
||||
fontSize: '10px',
|
||||
},
|
||||
datetimeUTC: false,
|
||||
},
|
||||
crosshairs: {
|
||||
stroke: {
|
||||
color: 'var(--border-normal)',
|
||||
},
|
||||
},
|
||||
},
|
||||
yaxis: {
|
||||
show: props.showYAxis ?? true,
|
||||
forceNiceScale: true,
|
||||
min: yAxisRange.value.min,
|
||||
max: yAxisRange.value.max,
|
||||
tickAmount: props.height - 4,
|
||||
labels: {
|
||||
offsetY: 1,
|
||||
offsetX: -4,
|
||||
formatter: (value: number) => {
|
||||
return value > 10000
|
||||
? abbreviateNumber(value, 1)
|
||||
: n(value, 'decimal', {
|
||||
minimumFractionDigits: props.decimals ?? 0,
|
||||
maximumFractionDigits: props.decimals ?? 0,
|
||||
} as any);
|
||||
},
|
||||
style: {
|
||||
fontFamily: 'var(--family-sans-serif)',
|
||||
foreColor: 'var(--foreground-subdued)',
|
||||
fontWeight: 600,
|
||||
fontSize: '10px',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
chart.value.render();
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.time-series {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.apexcharts-tooltip.apexcharts-theme-light {
|
||||
border-color: var(--border-normal) !important;
|
||||
}
|
||||
|
||||
.apexcharts-tooltip.apexcharts-theme-light .apexcharts-tooltip-title {
|
||||
border-color: var(--border-normal) !important;
|
||||
margin-bottom: 0;
|
||||
padding: 0 4px;
|
||||
font-weight: 600 !important;
|
||||
font-size: 10px !important;
|
||||
background-color: var(--background-subdued) !important;
|
||||
}
|
||||
|
||||
.apexcharts-tooltip-y-group {
|
||||
padding: 0 0 0 4px;
|
||||
font-weight: 600 !important;
|
||||
font-size: 10px !important;
|
||||
}
|
||||
|
||||
.apexcharts-tooltip-series-group {
|
||||
background-color: var(--background-normal) !important;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.apexcharts-tooltip-series-group:last-child {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
</style>
|
||||
87
app/src/panels/variable/index.ts
Normal file
87
app/src/panels/variable/index.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { definePanel } from '@directus/shared/utils';
|
||||
import PanelVariable from './panel-variable.vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { FIELD_TYPES_SELECT } from '@/constants';
|
||||
import { translate } from '@/utils/translate-object-values';
|
||||
import { getDefaultInterfaceForType } from '@/utils/get-default-interface-for-type';
|
||||
|
||||
export default definePanel({
|
||||
id: 'variable',
|
||||
name: '$t:panels.variable.name',
|
||||
description: '$t:panels.variable.description',
|
||||
icon: 'science',
|
||||
component: PanelVariable,
|
||||
variable: true,
|
||||
options: (panel) => {
|
||||
const { t } = useI18n();
|
||||
|
||||
return [
|
||||
{
|
||||
name: t('panels.variable.variable_key'),
|
||||
field: 'field',
|
||||
type: 'string',
|
||||
meta: {
|
||||
interface: 'input',
|
||||
width: 'full',
|
||||
options: {
|
||||
dbSafe: true,
|
||||
font: 'monospace',
|
||||
placeholder: t('interfaces.list.field_name_placeholder'),
|
||||
},
|
||||
},
|
||||
schema: null,
|
||||
},
|
||||
{
|
||||
name: t('type'),
|
||||
field: 'type',
|
||||
type: 'string',
|
||||
meta: {
|
||||
interface: 'select-dropdown',
|
||||
width: 'half',
|
||||
options: {
|
||||
choices: translate(FIELD_TYPES_SELECT),
|
||||
},
|
||||
},
|
||||
schema: null,
|
||||
},
|
||||
{
|
||||
name: t('default_value'),
|
||||
field: 'defaultValue',
|
||||
type: panel.options?.type,
|
||||
meta: {
|
||||
interface: panel.options?.type ? getDefaultInterfaceForType(panel.options.type) : 'input',
|
||||
readonly: !panel.options?.type,
|
||||
width: 'half',
|
||||
},
|
||||
schema: {},
|
||||
},
|
||||
{
|
||||
name: t('interface_label'),
|
||||
field: 'inter',
|
||||
type: 'string',
|
||||
meta: {
|
||||
interface: 'system-interface',
|
||||
width: 'half',
|
||||
options: {
|
||||
typeField: 'type',
|
||||
},
|
||||
},
|
||||
schema: null,
|
||||
},
|
||||
{
|
||||
name: t('options'),
|
||||
field: 'options',
|
||||
type: 'string',
|
||||
meta: {
|
||||
interface: 'system-interface-options',
|
||||
width: 'full',
|
||||
options: {
|
||||
interfaceField: 'inter',
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
minWidth: 12,
|
||||
minHeight: 6,
|
||||
});
|
||||
64
app/src/panels/variable/panel-variable.vue
Normal file
64
app/src/panels/variable/panel-variable.vue
Normal file
@@ -0,0 +1,64 @@
|
||||
<template>
|
||||
<div class="variable" :class="{ 'show-header': showHeader }">
|
||||
<component
|
||||
:is="`interface-${inter}`"
|
||||
v-bind="options"
|
||||
:value="value"
|
||||
:width="fieldWidth"
|
||||
:type="type"
|
||||
:field="field"
|
||||
@input="value = $event"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { Type } from '@directus/shared/types';
|
||||
import { computed } from 'vue';
|
||||
import { useInsightsStore } from '@/stores';
|
||||
|
||||
interface Props {
|
||||
type: Type;
|
||||
field: string;
|
||||
inter: string;
|
||||
dashboard: string;
|
||||
width: number;
|
||||
options?: Record<string, any>;
|
||||
showHeader?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), { options: () => ({}) });
|
||||
|
||||
const insightsStore = useInsightsStore();
|
||||
|
||||
const fieldWidth = computed(() => (props.width < 20 ? 'half' : 'full'));
|
||||
|
||||
const value = computed({
|
||||
get() {
|
||||
return insightsStore.getVariable(props.field);
|
||||
},
|
||||
set(val: any) {
|
||||
insightsStore.setVariable(props.field, val);
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scope>
|
||||
.variable {
|
||||
padding: 12px;
|
||||
|
||||
&.show-header {
|
||||
padding-top: 6px;
|
||||
}
|
||||
|
||||
display: grid;
|
||||
align-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
> * {
|
||||
grid-row: 1;
|
||||
grid-column: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,34 +1,471 @@
|
||||
import { Dashboard } from '../types';
|
||||
import api from '@/api';
|
||||
import { defineStore } from 'pinia';
|
||||
import { useUserStore, usePermissionsStore } from '@/stores';
|
||||
import { getPanels } from '@/panels';
|
||||
import { usePermissionsStore } from '@/stores';
|
||||
import { queryToGqlString } from '@/utils/query-to-gql-string';
|
||||
import { unexpectedError } from '@/utils/unexpected-error';
|
||||
import { Item, Panel } from '@directus/shared/types';
|
||||
import { getSimpleHash, toArray, applyOptionsData } from '@directus/shared/utils';
|
||||
import { AxiosResponse } from 'axios';
|
||||
import { assign, clone, get, isUndefined, mapKeys, omit, omitBy, pull, uniq } from 'lodash';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { acceptHMRUpdate, defineStore } from 'pinia';
|
||||
import { computed, reactive, ref, unref } from 'vue';
|
||||
import { Dashboard } from '../types';
|
||||
import escapeStringRegexp from 'escape-string-regexp';
|
||||
|
||||
export const useInsightsStore = defineStore({
|
||||
id: 'insightsStore',
|
||||
state: () => ({
|
||||
dashboards: [] as Dashboard[],
|
||||
}),
|
||||
actions: {
|
||||
async hydrate() {
|
||||
const userStore = useUserStore();
|
||||
const permissionsStore = usePermissionsStore();
|
||||
export type CreatePanel = Partial<Panel> &
|
||||
Pick<Panel, 'id' | 'width' | 'height' | 'position_x' | 'position_y' | 'type' | 'options'>;
|
||||
|
||||
if (userStore.isAdmin !== true && !permissionsStore.hasPermission('directus_dashboards', 'read')) {
|
||||
this.dashboards = [];
|
||||
} else {
|
||||
try {
|
||||
const response = await api.get<any>('/dashboards', {
|
||||
params: { limit: -1, fields: ['*', 'panels.*'], sort: ['name'] },
|
||||
});
|
||||
const MAX_CACHE_SIZE = 3; // Max number of dashboards to keep in cache at a time
|
||||
|
||||
this.dashboards = response.data.data;
|
||||
} catch {
|
||||
this.dashboards = [];
|
||||
export const useInsightsStore = defineStore('insightsStore', () => {
|
||||
/** All available dashboards in the platform */
|
||||
const dashboards = ref<Dashboard[]>([]);
|
||||
|
||||
/** All available panels */
|
||||
const panels = ref<Panel[]>([]);
|
||||
|
||||
/** Panels that are currently loading */
|
||||
const loading = ref<string[]>([]);
|
||||
|
||||
/** Panels that errored while fetching data */
|
||||
const errors = ref<{ [id: string]: Error }>({});
|
||||
|
||||
/** Cache/store for the panel data */
|
||||
const data = ref<{ [panel: string]: Item | Item[] }>({});
|
||||
|
||||
/** Runtime filter values */
|
||||
const variables = ref<{ [field: string]: any }>({});
|
||||
|
||||
/** Staged edits */
|
||||
const edits = reactive<{ create: CreatePanel[]; update: Partial<Panel>[]; delete: string[] }>({
|
||||
create: [],
|
||||
update: [],
|
||||
delete: [],
|
||||
});
|
||||
|
||||
const refreshIntervals = {} as { [dashboard: string]: number };
|
||||
|
||||
const saving = ref(false);
|
||||
|
||||
/** Last MAX_CACHE_SIZE dashboards that we've loaded into data. Used to purge caches once too much data is loaded */
|
||||
const lastLoaded: string[] = [];
|
||||
|
||||
/** If there's any unsaved staged changes */
|
||||
const hasEdits = computed(() => edits.create.length > 0 || edits.update.length > 0 || edits.delete.length > 0);
|
||||
|
||||
/** Raw panels modified to assign the edits */
|
||||
const panelsWithEdits = computed(() => {
|
||||
return [
|
||||
...unref(panels)
|
||||
.filter((panel) => edits.delete.includes(panel.id) === false)
|
||||
.map((panel) => {
|
||||
const updates = edits.update.find((updated) => updated.id === panel.id);
|
||||
|
||||
if (updates) {
|
||||
return assign({}, panel, updates);
|
||||
}
|
||||
|
||||
return panel;
|
||||
}),
|
||||
...edits.create,
|
||||
];
|
||||
});
|
||||
|
||||
const { panels: panelTypes } = getPanels();
|
||||
|
||||
return {
|
||||
dashboards,
|
||||
panels: panelsWithEdits,
|
||||
loading,
|
||||
errors,
|
||||
data,
|
||||
hasEdits,
|
||||
saving,
|
||||
edits,
|
||||
variables,
|
||||
hydrate,
|
||||
dehydrate,
|
||||
clearCache,
|
||||
clearEdits,
|
||||
getDashboard,
|
||||
getPanelsForDashboard,
|
||||
refresh,
|
||||
stagePanelCreate,
|
||||
stagePanelUpdate,
|
||||
stagePanelDuplicate,
|
||||
stagePanelDelete,
|
||||
saveChanges,
|
||||
refreshIntervals,
|
||||
getVariable,
|
||||
setVariable,
|
||||
};
|
||||
|
||||
async function hydrate() {
|
||||
const permissionsStore = usePermissionsStore();
|
||||
|
||||
if (
|
||||
permissionsStore.hasPermission('directus_dashboards', 'read') &&
|
||||
permissionsStore.hasPermission('directus_panels', 'read')
|
||||
) {
|
||||
try {
|
||||
const [dashboardsResponse, panelsResponse] = await Promise.all([
|
||||
api.get<any>('/dashboards', {
|
||||
params: { limit: -1, fields: ['*'], sort: ['name'] },
|
||||
}),
|
||||
api.get('/panels', { params: { limit: -1, fields: ['*'], sort: ['dashboard'] } }),
|
||||
]);
|
||||
dashboards.value = dashboardsResponse.data.data;
|
||||
panels.value = panelsResponse.data.data;
|
||||
} catch {
|
||||
dashboards.value = [];
|
||||
panels.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
const variableDefaults: Record<string, any> = {};
|
||||
|
||||
panels.value.forEach((panel) => {
|
||||
const panelType = unref(panelTypes).find((panelType) => panelType.id === panel.type);
|
||||
|
||||
if (panelType?.variable === true && panel.options?.field) {
|
||||
variableDefaults[panel.options.field] = panel.options?.defaultValue;
|
||||
}
|
||||
});
|
||||
|
||||
variables.value = variableDefaults;
|
||||
}
|
||||
|
||||
function dehydrate() {
|
||||
dashboards.value = [];
|
||||
panels.value = [];
|
||||
|
||||
clearCache();
|
||||
clearEdits();
|
||||
}
|
||||
|
||||
function clearCache() {
|
||||
loading.value = [];
|
||||
errors.value = {};
|
||||
data.value = {};
|
||||
}
|
||||
|
||||
function clearEdits() {
|
||||
edits.create = [];
|
||||
edits.update = [];
|
||||
edits.delete = [];
|
||||
}
|
||||
|
||||
function getDashboard(id: string) {
|
||||
return unref(dashboards).find((dashboard) => dashboard.id === id);
|
||||
}
|
||||
|
||||
function getPanelsForDashboard(dashboard: string) {
|
||||
return unref(panelsWithEdits).filter((panel) => panel.dashboard === dashboard);
|
||||
}
|
||||
|
||||
async function refresh(dashboard: string) {
|
||||
const panelsForDashboard = unref(panels).filter((panel) => panel.dashboard === dashboard);
|
||||
|
||||
await loadPanelData(panelsForDashboard);
|
||||
|
||||
if (lastLoaded.includes(dashboard) === false) {
|
||||
lastLoaded.push(dashboard);
|
||||
|
||||
if (lastLoaded.length > MAX_CACHE_SIZE) {
|
||||
const removed = lastLoaded.shift();
|
||||
const removedPanels = unref(panels)
|
||||
.filter((panel) => panel.dashboard === removed)
|
||||
.map(({ id }) => id);
|
||||
data.value = omit(data.value, ...removedPanels);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPanelData(
|
||||
panels: Pick<Panel, 'id' | 'options' | 'type'> | Pick<Panel, 'id' | 'options' | 'type'>[]
|
||||
) {
|
||||
panels = toArray(panels);
|
||||
|
||||
const queries = new Map();
|
||||
|
||||
for (const panel of panels) {
|
||||
const req = prepareQuery(panel);
|
||||
|
||||
if (!req) continue;
|
||||
|
||||
toArray(req).forEach(({ collection, query }, index) => {
|
||||
const key = getSimpleHash(panel.id + collection + JSON.stringify(query));
|
||||
queries.set(key, { panel: panel.id, collection, query, key, index, length: toArray(req).length });
|
||||
});
|
||||
}
|
||||
|
||||
loading.value = uniq([...loading.value, ...Array.from(queries.values()).map(({ panel }) => panel)]);
|
||||
|
||||
const gqlString = queryToGqlString(
|
||||
Array.from(queries.values())
|
||||
.filter(({ collection }) => {
|
||||
return collection.startsWith('directus_') === false;
|
||||
})
|
||||
.map(({ key, ...rest }) => ({ key: `query_${key}`, ...rest }))
|
||||
);
|
||||
|
||||
const systemGqlString = queryToGqlString(
|
||||
Array.from(queries.values())
|
||||
.filter(({ collection }) => {
|
||||
return collection.startsWith('directus_') === true;
|
||||
})
|
||||
.map(({ key, collection, ...rest }) => ({
|
||||
key: `query_${key}`,
|
||||
collection: collection.substring(9),
|
||||
...rest,
|
||||
}))
|
||||
);
|
||||
|
||||
try {
|
||||
const requests: Promise<AxiosResponse<any, any>>[] = [];
|
||||
|
||||
if (gqlString) requests.push(api.post(`/graphql`, { query: gqlString }));
|
||||
if (systemGqlString) requests.push(api.post(`/graphql/system`, { query: systemGqlString }));
|
||||
|
||||
const responses = await Promise.all(requests);
|
||||
|
||||
const results: { [panel: string]: Item | Item[] } = {};
|
||||
|
||||
for (const { data } of responses) {
|
||||
const result = mapKeys(data.data, (_, key) => key.substring('query_'.length));
|
||||
|
||||
for (const [key, data] of Object.entries(result)) {
|
||||
const { panel, length } = queries.get(key);
|
||||
if (length === 1) results[panel] = data;
|
||||
else if (!results[panel]) results[panel] = [data];
|
||||
else results[panel].push(data);
|
||||
}
|
||||
|
||||
if (Array.isArray(data.errors)) {
|
||||
setErrorsFromResponseData(data.errors);
|
||||
}
|
||||
}
|
||||
},
|
||||
async dehydrate() {
|
||||
this.$reset();
|
||||
},
|
||||
},
|
||||
|
||||
data.value = assign({}, data.value, results);
|
||||
|
||||
const succeededPanels = Object.keys(results);
|
||||
errors.value = omit(errors.value, ...succeededPanels);
|
||||
} catch (err: any) {
|
||||
/**
|
||||
* A thrown error means the request failed completely. This can happen for a wide variety
|
||||
* of reasons, but there's one common one we need to account for: misconfigured panels. A
|
||||
* GraphQL validation error will throw a 400 rather than a 200+partial data, so we need to
|
||||
* retry the request without the failed panels */
|
||||
|
||||
if (err.response.status === 400 && Array.isArray(err.response.data?.errors)) {
|
||||
const failedIDs = setErrorsFromResponseData(err.response.data.errors);
|
||||
|
||||
const panelsToTryAgain = panels.filter(({ id }) => failedIDs.includes(id) === false);
|
||||
|
||||
// Make sure we don't end in an infinite loop of retries
|
||||
if (panels.length !== panelsToTryAgain.length) {
|
||||
await loadPanelData(panelsToTryAgain);
|
||||
} else {
|
||||
unexpectedError(err);
|
||||
}
|
||||
} else {
|
||||
unexpectedError(err);
|
||||
}
|
||||
} finally {
|
||||
loading.value = pull(unref(loading), ...Array.from(queries.values()).map(({ panel }) => panel));
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the error objects based on the gqlError paths, returns the panel IDs of the panels that failed
|
||||
*/
|
||||
function setErrorsFromResponseData(responseErrors: any[]): string[] {
|
||||
const failedIDs: string[] = [];
|
||||
|
||||
for (const gqlError of responseErrors) {
|
||||
const queryKey = gqlError?.extensions?.graphqlErrors?.[0]?.path?.[0];
|
||||
if (!queryKey) continue;
|
||||
const panelID = queries.get(queryKey.substring('query_'.length))?.panel;
|
||||
if (!panelID) continue;
|
||||
failedIDs.push(panelID);
|
||||
errors.value = assign({}, errors.value, { [panelID]: gqlError });
|
||||
}
|
||||
|
||||
return failedIDs;
|
||||
}
|
||||
}
|
||||
|
||||
function prepareQuery(panel: Pick<Panel, 'options' | 'type'>) {
|
||||
const panelType = unref(panelTypes).find((panelType) => panelType.id === panel.type);
|
||||
return (
|
||||
panelType?.query?.(applyOptionsData(panel.options ?? {}, unref(variables), panelType.skipUndefinedKeys)) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function stagePanelCreate(panel: CreatePanel) {
|
||||
edits.create.push(panel);
|
||||
loadPanelData(panel);
|
||||
}
|
||||
|
||||
function stagePanelUpdate({ id, edits: panelEdits }: { id: string; edits: Partial<Panel> }) {
|
||||
panelEdits = omitBy(panelEdits, isUndefined);
|
||||
|
||||
const isNew = id.startsWith('_');
|
||||
const arr = isNew ? edits.create : edits.update;
|
||||
|
||||
/**
|
||||
* Check what the currently used data query is, so we can compare it to the new query later to
|
||||
* decide whether or not to reload the data
|
||||
*/
|
||||
let oldQuery;
|
||||
if ('options' in panelEdits) {
|
||||
// Edits not yet applied
|
||||
const panel = unref(panelsWithEdits).find((panel) => panel.id === id);
|
||||
|
||||
if (panel) {
|
||||
const panelType = unref(panelTypes).find((panelType) => panelType.id === panel.type)!;
|
||||
oldQuery = panelType.query?.(
|
||||
applyOptionsData(panel.options ?? {}, unref(variables), panelType.skipUndefinedKeys)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (arr.map(({ id }) => id).includes(id)) {
|
||||
const updatedArr = arr.map((currentEdit) => {
|
||||
if (currentEdit.id === id) {
|
||||
return assign({}, currentEdit, panelEdits);
|
||||
}
|
||||
|
||||
return currentEdit;
|
||||
});
|
||||
|
||||
if (isNew) edits.create = updatedArr as CreatePanel[];
|
||||
else edits.update = updatedArr;
|
||||
} else {
|
||||
arr.push({ id, ...panelEdits });
|
||||
}
|
||||
|
||||
// Reload data for panel if the query has changed
|
||||
if ('options' in panelEdits) {
|
||||
// This panel has the edits applied
|
||||
const panel = unref(panelsWithEdits).find((panel) => panel.id === id)!;
|
||||
const panelType = unref(panelTypes).find((panelType) => panelType.id === panel.type)!;
|
||||
const newQuery = panelType.query?.(
|
||||
applyOptionsData(panelEdits.options ?? {}, unref(variables), panelType.skipUndefinedKeys)
|
||||
);
|
||||
if (JSON.stringify(oldQuery) !== JSON.stringify(newQuery)) loadPanelData(panel);
|
||||
}
|
||||
}
|
||||
|
||||
function stagePanelDuplicate(panelKey: string, overrides?: Partial<Panel>) {
|
||||
const panel = unref(panelsWithEdits).find((panel) => panel.id === panelKey);
|
||||
if (!panel) return;
|
||||
|
||||
const newPanel = clone(panel);
|
||||
|
||||
newPanel.id = `_${nanoid()}`;
|
||||
newPanel.position_x = (newPanel.position_x ?? 0) + 2;
|
||||
newPanel.position_y = (newPanel.position_y ?? 0) + 2;
|
||||
|
||||
// In case width/height is totally unknown (which it shouldn't be) fallback to 4x4 as a last-resort
|
||||
newPanel.width ??= 4;
|
||||
newPanel.height ??= 4;
|
||||
|
||||
if (overrides) {
|
||||
assign(newPanel, overrides);
|
||||
}
|
||||
|
||||
stagePanelCreate(newPanel as CreatePanel);
|
||||
}
|
||||
|
||||
function stagePanelDelete(panelKey: string) {
|
||||
if (edits.create.some((created) => created.id === panelKey)) {
|
||||
edits.create = edits.create.filter((created) => created.id !== panelKey);
|
||||
return;
|
||||
}
|
||||
|
||||
edits.update = edits.update.filter((updated) => updated.id !== panelKey);
|
||||
edits.delete.push(panelKey);
|
||||
data.value = omit(data.value, panelKey);
|
||||
}
|
||||
|
||||
async function saveChanges() {
|
||||
saving.value = true;
|
||||
|
||||
try {
|
||||
const requests: Promise<AxiosResponse<any, any>>[] = [];
|
||||
|
||||
if (edits.create) {
|
||||
// Created edits might come with a temporary ID for editing. Make sure to submit to API without temp ID
|
||||
requests.push(
|
||||
api.post(
|
||||
`/panels`,
|
||||
edits.create.map((create) => omit(create, 'id'))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (edits.update) {
|
||||
requests.push(api.patch(`/panels`, edits.update));
|
||||
}
|
||||
|
||||
if (edits.delete) {
|
||||
requests.push(api.delete(`/panels`, { data: edits.delete }));
|
||||
}
|
||||
|
||||
await Promise.all(requests);
|
||||
await hydrate();
|
||||
|
||||
// Remove cached data for the newly created panels
|
||||
data.value = omit(data.value, ...edits.create.map(({ id }) => id));
|
||||
|
||||
// Fetch data for panels that now exist in the dashboard (from create) but haven't been fetched yet
|
||||
const panelsToLoad = unref(panelsWithEdits).filter(({ id }) => id in unref(data) === false);
|
||||
loadPanelData(panelsToLoad);
|
||||
|
||||
clearEdits();
|
||||
} catch (err: any) {
|
||||
unexpectedError(err);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function getVariable(field: string) {
|
||||
return get(unref(variables), field);
|
||||
}
|
||||
|
||||
function setVariable(field: string, value: unknown) {
|
||||
const newVariables = assign({}, variables.value, { [field]: value });
|
||||
|
||||
// Find all panels that are using this variable in their options
|
||||
const regex = new RegExp(`{{\\s*?${escapeStringRegexp(field)}\\s*?}}`);
|
||||
const needReload = unref(panelsWithEdits).filter((panel) => {
|
||||
if (panel.id in unref(data) === false) return false;
|
||||
|
||||
const optionsString = JSON.stringify(panel.options ?? {});
|
||||
const containsVariable = regex.test(optionsString);
|
||||
if (!containsVariable) return false;
|
||||
|
||||
const panelType = unref(panelTypes).find((panelType) => panelType.id === panel.type);
|
||||
if (!panelType) return false;
|
||||
const oldQuery = panelType.query?.(
|
||||
applyOptionsData(panel.options ?? {}, unref(variables), panelType.skipUndefinedKeys)
|
||||
);
|
||||
const newQuery = panelType.query?.(
|
||||
applyOptionsData(panel.options ?? {}, unref(newVariables), panelType.skipUndefinedKeys)
|
||||
);
|
||||
return JSON.stringify(oldQuery) !== JSON.stringify(newQuery);
|
||||
});
|
||||
|
||||
variables.value = newVariables;
|
||||
|
||||
if (needReload.length > 0) {
|
||||
loadPanelData(needReload);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (import.meta.hot) {
|
||||
import.meta.hot.accept(acceptHMRUpdate(useInsightsStore, import.meta.hot));
|
||||
}
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import { Panel } from '@directus/shared/types';
|
||||
|
||||
export type Dashboard = {
|
||||
id: string;
|
||||
name: string;
|
||||
note: string;
|
||||
icon: string;
|
||||
color: string;
|
||||
panels: Panel[];
|
||||
date_created: string;
|
||||
user_created: string;
|
||||
};
|
||||
|
||||
32
app/src/utils/get-groups.ts
Normal file
32
app/src/utils/get-groups.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
export function getGroups(precision: string, dateField: string) {
|
||||
let groups: string[] = [];
|
||||
|
||||
switch (precision || 'hour') {
|
||||
case 'year':
|
||||
groups = ['year'];
|
||||
break;
|
||||
case 'month':
|
||||
groups = ['year', 'month'];
|
||||
break;
|
||||
case 'week':
|
||||
groups = ['year', 'month', 'week'];
|
||||
break;
|
||||
case 'day':
|
||||
groups = ['year', 'month', 'day'];
|
||||
break;
|
||||
case 'hour':
|
||||
groups = ['year', 'month', 'day', 'hour'];
|
||||
break;
|
||||
case 'minute':
|
||||
groups = ['year', 'month', 'day', 'hour', 'minute'];
|
||||
break;
|
||||
case 'second':
|
||||
groups = ['year', 'month', 'day', 'hour', 'minute', 'second'];
|
||||
break;
|
||||
default:
|
||||
groups = ['year', 'month', 'day', 'hour'];
|
||||
break;
|
||||
}
|
||||
|
||||
return groups.map((datePart) => `${datePart}(${dateField})`);
|
||||
}
|
||||
61
app/src/utils/query-to-gql-string.ts
Normal file
61
app/src/utils/query-to-gql-string.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { useFieldsStore } from '@/stores';
|
||||
import { Query } from '@directus/shared/types';
|
||||
import { toArray } from '@directus/shared/utils';
|
||||
import { jsonToGraphQLQuery } from 'json-to-graphql-query';
|
||||
import { isEmpty, pick, set, omitBy, isUndefined } from 'lodash';
|
||||
|
||||
type QueryInfo = { collection: string; key: string; query: Query };
|
||||
|
||||
export function queryToGqlString(queries: QueryInfo | QueryInfo[]): string | null {
|
||||
if (!queries || isEmpty(queries)) return null;
|
||||
|
||||
const queryJSON: Record<string, any> = {
|
||||
query: {},
|
||||
};
|
||||
|
||||
for (const query of toArray(queries)) {
|
||||
queryJSON.query[query.key] = formatQuery(query);
|
||||
}
|
||||
|
||||
return jsonToGraphQLQuery(queryJSON);
|
||||
}
|
||||
|
||||
export function formatQuery({ collection, query }: QueryInfo): Record<string, any> {
|
||||
const queryKeysInArguments: (keyof Query)[] = ['limit', 'sort', 'filter', 'offset', 'page', 'search'];
|
||||
|
||||
const formattedQuery: Record<string, any> = {
|
||||
__args: omitBy(pick(query, ...queryKeysInArguments), isUndefined),
|
||||
__aliasFor: collection,
|
||||
};
|
||||
|
||||
const fields = query.fields ?? [useFieldsStore().getPrimaryKeyFieldForCollection(collection)!.field];
|
||||
|
||||
if (query?.aggregate && !isEmpty(query.aggregate)) {
|
||||
formattedQuery.__aliasFor = collection + '_aggregated';
|
||||
|
||||
for (const [aggregateFunc, fields] of Object.entries(query.aggregate)) {
|
||||
if (!formattedQuery[aggregateFunc]) {
|
||||
formattedQuery[aggregateFunc] = {};
|
||||
}
|
||||
|
||||
fields.forEach((field) => {
|
||||
formattedQuery[aggregateFunc][field] = true;
|
||||
});
|
||||
}
|
||||
|
||||
if (query.group) {
|
||||
formattedQuery.group = true;
|
||||
formattedQuery.__args.groupBy = query.group;
|
||||
}
|
||||
} else {
|
||||
for (const field of fields) {
|
||||
set(formattedQuery, field, true);
|
||||
}
|
||||
}
|
||||
|
||||
if (query.deep) {
|
||||
// TBD @TODO
|
||||
}
|
||||
|
||||
return formattedQuery;
|
||||
}
|
||||
Reference in New Issue
Block a user