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:
Rijk van Zanten
2022-06-27 15:26:42 -04:00
committed by GitHub
parent 6cba7fb91f
commit 32dd709778
91 changed files with 2259 additions and 1526 deletions

View File

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

View File

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

View File

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

View File

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