directus-pie-panel

Pie panel directus extension for non-technical users
git clone git://git.finwo.net/misc/directus-pie-panel
Log | Files | Refs

panel.vue (7340B)


      1 <script lang="ts">
      2 import { useApi, useStores } from '@directus/extensions-sdk';
      3 import formatTitle from '@directus/format-title';
      4 import { abbreviateNumber } from '@directus/utils';
      5 import ApexCharts from 'apexcharts';
      6 import { get } from 'lodash';
      7 import { defineComponent, onMounted, onUnmounted, ref, watch } from 'vue';
      8 import { useI18n } from 'vue-i18n';
      9 
     10 const FALLBACK_PALETTE = [
     11 	'#6644FF',
     12 	'#3B82F6',
     13 	'#10B981',
     14 	'#F59E0B',
     15 	'#EF4444',
     16 	'#8B5CF6',
     17 	'#EC4899',
     18 	'#14B8A6',
     19 	'#F97316',
     20 	'#6366F1',
     21 ];
     22 
     23 export default defineComponent({
     24 	props: {
     25 		showHeader: {
     26 			type: Boolean,
     27 			default: false,
     28 		},
     29 		width: {
     30 			type: Number,
     31 			default: 12,
     32 		},
     33 		height: {
     34 			type: Number,
     35 			default: 8,
     36 		},
     37 		collection: {
     38 			type: String,
     39 			default: null,
     40 		},
     41 		filter: {
     42 			type: Object,
     43 			// eslint-disable-next-line vue/require-valid-default-prop
     44 			default: {},
     45 		},
     46 		dataValues: {
     47 			type: String,
     48 			default: null,
     49 		},
     50 		dataLabels: {
     51 			type: String,
     52 			default: null,
     53 		},
     54 		colors: {
     55 			type: Array,
     56 			default: () => [],
     57 		},
     58 		labelGrouping: {
     59 			type: String,
     60 			default: 'sum',
     61 		},
     62 		sortDirection: {
     63 			type: String,
     64 			default: 'desc',
     65 		},
     66 		showLegend: {
     67 			type: Boolean,
     68 			default: true,
     69 		},
     70 		legendPosition: {
     71 			type: String,
     72 			default: 'bottom',
     73 		},
     74 		showDataLabel: {
     75 			type: Boolean,
     76 			default: true,
     77 		},
     78 	},
     79 	setup(props) {
     80 		const { t } = useI18n();
     81 		const api = useApi();
     82 		const { usePermissionsStore } = useStores();
     83 		const { hasPermission } = usePermissionsStore();
     84 		const canRead = hasPermission(props.collection, 'read');
     85 		const hasError = ref<boolean>(false);
     86 
     87 		const errorResponse = ref<Record<string, string>>({
     88 			title: '',
     89 			message: '',
     90 		});
     91 
     92 		const isLoading = ref<boolean>(true);
     93 
     94 		const chartEl = ref();
     95 		const chart = ref<ApexCharts>();
     96 
     97 		onMounted(setUpChart);
     98 
     99 		watch(
    100 			[
    101 				() => props.collection,
    102 				() => props.filter,
    103 				() => props.dataValues,
    104 				() => props.dataLabels,
    105 				() => props.colors,
    106 				() => props.width,
    107 				() => props.height,
    108 				() => props.labelGrouping,
    109 				() => props.sortDirection,
    110 				() => props.showLegend,
    111 				() => props.legendPosition,
    112 				() => props.showDataLabel,
    113 			],
    114 			() => {
    115 				chart.value?.destroy();
    116 				setUpChart();
    117 			},
    118 		);
    119 
    120 		onUnmounted(() => {
    121 			chart.value?.destroy();
    122 		});
    123 
    124 		function colorFor(rawLabel: string): string {
    125 			const rules: Array<Record<string, string>> = Array.isArray(props.colors) ? props.colors : [];
    126 			const needle = String(rawLabel).trim();
    127 			for (const rule of rules) {
    128 				if (String(rule.label).trim() === needle) {
    129 					return rule.color;
    130 				}
    131 			}
    132 			return '';
    133 		}
    134 
    135 		async function setUpChart() {
    136 			if (!props.dataValues || !props.dataLabels)
    137 				return;
    138 
    139 			const categories = ref<Array<string>>([]);
    140 			const rawLabels = ref<Array<string>>([]);
    141 			const category_data = ref<Array<number>>([]);
    142 
    143 			try {
    144 				const response = await api.get(`/items/${props.collection}`, {
    145 					params: {
    146 						limit: '-1',
    147 						filter: {
    148 							_and: [
    149 								props.filter,
    150 								{
    151 									[props.dataLabels]: { _nempty: true },
    152 								},
    153 							],
    154 						},
    155 						aggregate: {
    156 							[props.labelGrouping]: props.dataValues,
    157 						},
    158 						groupBy: [props.dataLabels],
    159 					},
    160 				});
    161 
    162 				const data: Array<Record<string, any>> = response.data.data.sort((a: Record<string, any>, b: Record<string, any>) => props.sortDirection === 'desc'
    163 					? b[props.labelGrouping].value - a[props.labelGrouping].value
    164 					: a[props.labelGrouping].value - b[props.labelGrouping].value,
    165 				);
    166 
    167 				data.forEach((item: Record<string, any>) => {
    168 					rawLabels.value.push(get(item, props.dataLabels));
    169 					categories.value.push(formatTitle(get(item, props.dataLabels)));
    170 					const y_value = get(item[props.labelGrouping], props.dataValues);
    171 					category_data.value.push(y_value === y_value * 1 ? y_value * 1 : y_value);
    172 				});
    173 
    174 				const chartColors = rawLabels.value.map((raw, i) => colorFor(raw) || FALLBACK_PALETTE[i % FALLBACK_PALETTE.length]);
    175 
    176 				const borderColor = 'var(--theme--border-color-subdued)';
    177 
    178 				chart.value = new ApexCharts(chartEl.value, {
    179 					chart: {
    180 						type: 'donut',
    181 						animation: {
    182 							enabled: false,
    183 						},
    184 						height: '100%',
    185 						width: '100%',
    186 						dropShadow: {
    187 							enabled: false,
    188 						},
    189 						toolbar: {
    190 							show: false,
    191 						},
    192 						fontFamily: 'var(--theme--fonts--sans--font-family)',
    193 						foreColor: 'var(--theme--foreground-subdued)',
    194 						background: 'transparent',
    195 					},
    196 					series: category_data.value,
    197 					labels: categories.value,
    198 					colors: chartColors,
    199 					plotOptions: {
    200 						pie: {
    201 							donut: {
    202 								size: '65%',
    203 								labels: {
    204 									show: true,
    205 									name: {
    206 										show: true,
    207 									},
    208 									value: {
    209 										show: true,
    210 										formatter(val: string) {
    211 											return abbreviateNumber(Number(val), 1);
    212 										},
    213 									},
    214 									total: {
    215 										show: true,
    216 										label: t('total'),
    217 										fontFamily: 'var(--theme--fonts--sans--font-family)',
    218 										formatter(w: Record<string, any>) {
    219 											return abbreviateNumber(w.globals.seriesTotals.reduce((a: number, b: number) => a + b, 0), 1);
    220 										},
    221 									},
    222 								},
    223 							},
    224 							expandOnClick: false,
    225 						},
    226 					},
    227 					dataLabels: {
    228 						enabled: props.showDataLabel,
    229 						formatter(val: number) {
    230 							return abbreviateNumber(val, 1);
    231 						},
    232 						dropShadow: {
    233 							enabled: false,
    234 						},
    235 					},
    236 					legend: {
    237 						show: props.showLegend,
    238 						position: props.legendPosition,
    239 						markers: {
    240 							shape: 'circle',
    241 						},
    242 						labels: {
    243 							colors: 'var(--theme--foreground-subdued)',
    244 						},
    245 					},
    246 					tooltip: {
    247 						enabled: true,
    248 						marker: {
    249 							show: true,
    250 						},
    251 						y: {
    252 							formatter(val: number) {
    253 								return abbreviateNumber(val, 1);
    254 							},
    255 						},
    256 					},
    257 					stroke: {
    258 						colors: ['var(--theme--background-normal)'],
    259 						width: 2,
    260 					},
    261 					grid: {
    262 						borderColor,
    263 						padding: {
    264 							top: 0,
    265 							bottom: 0,
    266 							left: 0,
    267 							right: 0,
    268 						},
    269 					},
    270 				});
    271 
    272 				chart.value.render();
    273 				isLoading.value = false;
    274 			}
    275 			catch (error: any) {
    276 				errorResponse.value.title = error.code || 'UNKNOWN';
    277 				errorResponse.value.message = error.message || t('errors.UNKNOWN');
    278 				hasError.value = true;
    279 				isLoading.value = false;
    280 			}
    281 		}
    282 
    283 		return {
    284 			t,
    285 			isLoading,
    286 			chartEl,
    287 
    288 			// Errors
    289 			hasError,
    290 			errorResponse,
    291 
    292 			// Permission
    293 			canRead,
    294 		};
    295 	},
    296 });
    297 </script>
    298 
    299 <template>
    300 	<div class="pie-chart" :class="{ 'has-header': showHeader }">
    301 		<v-info v-if="!collection" type="danger" icon="error" center title="No Collection Selected" />
    302 		<v-info v-else-if="!dataValues || !dataLabels" type="warning" icon="warning" center title="Both Value and Label fields must be selected" />
    303 		<v-info v-else-if="!canRead" type="danger" icon="error" center title="Forbidden">
    304 			You do not have permissions to see this table
    305 		</v-info>
    306 		<v-info v-else-if="hasError" type="danger" icon="error" :title="errorResponse?.title">
    307 			{{ errorResponse?.message }}
    308 		</v-info>
    309 		<VProgressCircular v-else-if="isLoading" indeterminate />
    310 		<div ref="chartEl" />
    311 	</div>
    312 </template>
    313 
    314 <style scoped>
    315 .pie-chart {
    316 	height: 100%;
    317 	padding: 12px;
    318 }
    319 
    320 .pie-chart.has-header {
    321 	padding: 0 12px;
    322 }
    323 </style>