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>