feat: metric config

This commit is contained in:
DarkPhoenix2704
2025-07-02 18:39:25 +05:30
parent ad07ac855b
commit 9f4cfad3a4
12 changed files with 388 additions and 155 deletions

1
.gitignore vendored
View File

@@ -71,7 +71,6 @@ local.properties
# Cloud9 IDE
# =========
.c9/
data/
mongod
# Visual Studio

View File

@@ -8,50 +8,57 @@ const { activeDashboard } = storeToRefs(dashboardStore)
const getDefaultConfig = (widgetType: WidgetTypes, type?: ChartTypes) => {
switch (widgetType) {
case 'metric':
case WidgetTypes.METRIC:
return {
component: {
title: 'Number Widget',
description: '',
dataSource: {
type: 'model',
fk_model_id: '',
},
appearance: {
fontSize: 'large',
textColor: '',
backgroundColor: '',
},
source: {
tableId: '',
viewId: '',
type: 'all_records',
metric: {
aggregation: 'count',
columnId: '',
filters: [],
},
}
default:
case WidgetTypes.CHART:
return {
chartType: type,
component: {
title: 'Chart Widget',
description: '',
dataSource: {
type: 'model',
fk_model_id: '',
},
source: {
tableId: '',
viewId: '',
type: 'all_records',
aggregation: 'count',
columnId: '',
filters: [],
xAxis: {
column_id: '',
label: 'X Axis',
},
yAxis: [
{
column_id: '',
aggregation: 'count',
label: 'Y Axis',
},
],
}
case WidgetTypes.TEXT:
return {
content: 'Enter your text here...',
format: 'plain',
}
default:
return {}
}
}
const createWidget = async (widgetType: WidgetTypes, type?: ChartTypes) => {
if (!activeDashboard.value?.id) return
const getWidgetTitle = (widgetType: WidgetTypes, chartType?: ChartTypes) => {
if (widgetType === WidgetTypes.CHART && chartType) {
return `${chartType.charAt(0).toUpperCase() + chartType.slice(1)} Chart`
}
return `${widgetType.charAt(0).toUpperCase() + widgetType.slice(1)}`
}
const newWidget: Partial<WidgetType> = {
title: `${type} Widget`,
title: getWidgetTitle(widgetType, type),
type: widgetType,
position: {
x: 0,

View File

@@ -1,23 +1,8 @@
<script setup lang="ts">
import MetricsWidgetConfig from './Widgets/Metrics/Config.vue'
const widgetStore = useWidgetStore()
const dashboardStore = useDashboardStore()
const { selectedWidget } = storeToRefs(widgetStore)
const { activeDashboard } = storeToRefs(dashboardStore)
// Handle config updates
const handleConfigUpdate = async (config: any) => {
if (selectedWidget.value && activeDashboard.value?.id) {
await widgetStore.updateWidget(activeDashboard.value.id, selectedWidget.value.id!, { config })
}
}
// Close editor
const closeEditor = () => {
selectedWidget.value = null
}
// Get config component based on widget type
const getConfigComponent = () => {
if (!selectedWidget.value) return null
@@ -25,7 +10,6 @@ const getConfigComponent = () => {
case 'metric':
return MetricsWidgetConfig
case 'chart':
// Will be implemented later for chart widgets
return null
default:
return null
@@ -38,7 +22,7 @@ const getConfigComponent = () => {
v-if="selectedWidget"
class="widget-editor-panel w-80 bg-white border-l border-nc-content-gray-300 h-full overflow-hidden flex flex-col"
>
<component :is="getConfigComponent()" :widget="selectedWidget" @update:config="handleConfigUpdate" />
<component :is="getConfigComponent()" :widget="selectedWidget" />
</div>
</template>

View File

@@ -11,8 +11,15 @@ const chartLabel = computed(() => {
return WidgetChartLabelMap[selectedWidget.value?.type as WidgetTypes]
})
const { updateWidget } = useWidgetStore()
const { activeDashboard } = storeToRefs(useDashboardStore())
const handleConfigUpdate = async (config: any) => {
console.log('handleConfigUpdate', config)
if (selectedWidget.value && activeDashboard.value?.id) {
await updateWidget(activeDashboard.value.id, selectedWidget.value.id, {
config: { ...selectedWidget.value.config, ...config },
})
}
}
</script>

View File

@@ -0,0 +1,91 @@
<script setup lang="ts">
import TabbedSelect from '../TabbedSelect.vue'
const emit = defineEmits<{
'update:aggregation': [aggregation: any]
}>()
const { selectedWidget } = storeToRefs(useWidgetStore())
const selectedValue = ref(selectedWidget.value?.config?.metric?.aggregation === 'count' ? 'count' : 'summary')
const selectedAggregationType = ref(selectedWidget.value?.config?.metric?.aggregation || 'count')
const selectedFieldId = ref(selectedWidget.value?.config?.metric?.column_id || '')
const modelId = computed(() => selectedWidget.value?.config?.dataSource?.fk_model_id || '')
const aggregationMap = {
count: 'Record Count',
summary: 'Field Summary',
} as const
const aggregationOptions = [
{ value: 'distinct', label: 'Distinct' },
{ value: 'sum', label: 'Sum' },
{ value: 'avg', label: 'Average' },
{ value: 'median', label: 'Median' },
{ value: 'min', label: 'Minimum' },
{ value: 'max', label: 'Maximum' },
]
const handleChange = (type: 'field' | 'aggregation') => {
const aggregation = {
type: selectedValue.value,
}
if (type === 'field') {
aggregation.aggregation = null
}
if (type === 'aggregation') {
aggregation.column_id = selectedFieldId.value
aggregation.aggregation = selectedAggregationType.value
}
emit('update:aggregation', aggregation)
}
watch(selectedValue, () => {
const aggregation = {
aggregation: selectedValue.value,
}
if (selectedValue.value === 'count') {
aggregation.column_id = null
aggregation.aggregation = null
} else if (selectedValue.value === 'summary') {
aggregation.column_id = selectedFieldId.value
aggregation.aggregation = selectedAggregationType.value
}
emit('update:aggregation', aggregation)
})
</script>
<template>
<TabbedSelect v-model:model-value="selectedValue" :values="['count', 'summary']">
<template #default="{ value }">
{{ aggregationMap[value] }}
</template>
</TabbedSelect>
<div v-if="selectedValue === 'summary'" class="flex gap-2 flex-1 min-w-0">
<div class="flex flex-col gap-2 flex-1 min-w-0">
<label>Field</label>
<NSelectField v-model:value="selectedFieldId" v-model:table-id="modelId" @update:value="handleChange('field')" />
</div>
<div class="flex flex-col gap-2 flex-1 min-w-0">
<label>Type</label>
<a-select
v-model:value="selectedAggregationType"
:options="aggregationOptions"
class="nc-select-shadow"
@update:value="handleChange('aggregation')"
>
<template #suffixIcon>
<GeneralIcon icon="arrowDown" class="text-gray-700" />
</template>
</a-select>
</div>
</div>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,164 @@
<script setup lang="ts">
import GroupedSettings from '../GroupedSettings.vue'
const emit = defineEmits<{
'update:source': [source: any]
}>()
const { selectedWidget } = storeToRefs(useWidgetStore())
const isConditionDropdownOpen = ref(false)
const selectedDataSourceType = ref(selectedWidget.value?.config?.dataSource?.type || 'model')
const selectedModelId = ref(selectedWidget.value?.config?.dataSource?.fk_model_id || '')
const selectedViewId = ref(selectedWidget.value?.config?.dataSource?.fk_view_id || '')
const filters = ref([])
const updateDataSource = () => {
const dataSource = { type: selectedDataSourceType.value }
if (selectedDataSourceType.value === 'model') {
if (selectedModelId.value) {
dataSource.fk_model_id = selectedModelId.value
}
} else if (selectedDataSourceType.value === 'view') {
if (selectedModelId.value) {
dataSource.fk_model_id = selectedModelId.value
}
if (selectedViewId.value) {
dataSource.fk_view_id = selectedViewId.value
}
} else if (selectedDataSourceType.value === 'filter') {
if (selectedModelId.value) {
dataSource.fk_model_id = selectedModelId.value
}
}
console.log(dataSource)
emit('update:source', dataSource)
}
const onDataSourceTypeChange = (newValue) => {
if (newValue !== 'view') {
selectedViewId.value = ''
}
updateDataSource()
}
const onDataChange = (type: 'model' | 'view') => {
if (type === 'model') {
selectedViewId.value = ''
}
updateDataSource()
}
</script>
<template>
<GroupedSettings title="Source">
<div class="flex flex-col gap-2 flex-1 min-w-0">
<label>Table</label>
<NSelectTable v-model:value="selectedModelId" @update:value="onDataChange('model')" />
</div>
<div class="flex flex-col gap-2 flex-1 min-w-0">
<label>Records</label>
<a-radio-group
v-model:value="selectedDataSourceType"
class="record-filter-type w-full"
@update:value="onDataSourceTypeChange"
>
<a-radio value="model">All Records</a-radio>
<a-radio value="view">Records from a view</a-radio>
<a-radio value="filter">Specific records</a-radio>
</a-radio-group>
</div>
<div v-if="selectedDataSourceType === 'view'" class="flex flex-col gap-2 flex-1 min-w-0">
<label>View</label>
<NSelectView v-model:value="selectedViewId" :table-id="selectedModelId" @update:value="onDataChange('view')" />
</div>
<div v-if="selectedDataSourceType === 'filter'" class="flex flex-col gap-2 flex-1 min-w-0">
<label>Conditions</label>
<NcDropdown
v-model:visible="isConditionDropdownOpen"
placement="bottomLeft"
overlay-class-name="nc-datasource-conditions-dropdown"
>
<div
class="h-9 border-1 rounded-lg py-1 px-3 flex items-center justify-between gap-2 !min-w-[170px] transition-all cursor-pointer select-none text-sm"
:class="{
'!border-brand-500 shadow-selected': isConditionDropdownOpen,
'border-gray-200': !isConditionDropdownOpen,
'bg-[#F0F3FF]': filters.length,
}"
>
<div
class="nc-datasource-conditions-count flex-1"
:class="{
'text-brand-500 ': filters.length,
}"
>
{{ filters.length ? `${filters.length} condition${filters.length !== 1 ? 's' : ''}` : 'No conditions' }}
</div>
<GeneralIcon
icon="settings"
class="flex-none w-4 h-4"
:class="{
'text-brand-500 ': filters.length,
}"
/>
</div>
<template #overlay>
<div
class="nc-datasource-conditions-dropdown-container"
:class="{
'py-2': !filters.length,
}"
>
<SmartsheetToolbarColumnFilter
ref="fieldVisibilityRef"
:value="filters"
class="w-full"
:auto-save="true"
data-testid="nc-filter-menu"
:show-loading="false"
/>
</div>
</template>
</NcDropdown>
</div>
</GroupedSettings>
</template>
<style scoped lang="scss">
.record-filter-type {
:deep(.ant-radio-input:focus + .ant-radio-inner) {
box-shadow: none !important;
}
:deep(.ant-radio-wrapper) {
> span {
@apply text-nc-content-gray leading-5;
}
@apply flex py-2 m-0;
.ant-radio-checked .ant-radio-inner {
@apply !bg-nc-fill-primary !border-nc-fill-primary;
&::after {
@apply bg-nc-bg-default;
width: 12px;
height: 12px;
margin-top: -6px;
margin-left: -6px;
}
}
&:first-child {
@apply rounded-tl-lg rounded-tr-lg;
}
&:last-child {
@apply border-t-0 rounded-bl-lg rounded-br-lg;
}
}
}
</style>

View File

@@ -0,0 +1,38 @@
<script setup lang="ts">
import GroupedSettings from '../GroupedSettings.vue'
const emit = defineEmits<{
'update:widget': [updates: any]
}>()
const { selectedWidget } = useWidgetStore()
const widgetData = reactive({
title: selectedWidget?.title || '',
description: selectedWidget?.description || '',
})
watch(widgetData, () => {
emit('update:widget', widgetData)
})
</script>
<template>
<GroupedSettings title="Text">
<div class="flex flex-col gap-2 flex-1 min-w-0">
<label>Title</label>
<a-input v-model:value="widgetData.title" class="nc-input-sm nc-input-shadow" placeholder="Title" />
</div>
<div class="flex flex-col gap-2 flex-1 min-w-0">
<label>Description</label>
<a-textarea
v-model:value="widgetData.description"
class="nc-input-sm nc-input-text-area nc-input-shadow px-3 !text-gray-800 max-h-[150px] min-h-[100px]"
placeholder="Description"
/>
</div>
</GroupedSettings>
</template>
<style scoped lang="scss"></style>

View File

@@ -1,27 +1,45 @@
<script setup lang="ts">
import type { WidgetType } from 'nocodb-sdk'
import GroupedSettings from '../Common/GroupedSettings.vue'
interface Props {
widget: WidgetType
const widgetStore = useWidgetStore()
const { selectedWidget } = storeToRefs(widgetStore)
const { updateWidget } = useWidgetStore()
const { activeDashboard } = storeToRefs(useDashboardStore())
const handleConfigUpdate = async (type: string, updates: any) => {
if (type === 'text') {
await updateWidget(activeDashboard.value.id, selectedWidget.value.id, updates)
} else if (type === 'dataSource') {
await updateWidget(activeDashboard.value.id, selectedWidget.value.id, {
config: {
...selectedWidget.value.config,
dataSource: {
...selectedWidget.value.config.dataSource,
...updates,
},
},
})
} else if (type === 'metric') {
await updateWidget(activeDashboard.value.id, selectedWidget.value.id, {
config: {
...selectedWidget.value.config,
metric: {
...selectedWidget.value.config.metric,
...updates,
},
},
})
}
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:config': [config: any]
}>()
// Watch for changes and emit updates
watchEffect(() => {})
</script>
<template>
<SmartsheetDashboardWidgetsCommonConfig>
<template #data>
<SmartsheetDashboardWidgetsCommonDataText />
<SmartsheetDashboardWidgetsCommonDataSource />
<SmartsheetDashboardWidgetsCommonDataText @update:widget="handleConfigUpdate('text', $event)" />
<SmartsheetDashboardWidgetsCommonDataSource @update:source="handleConfigUpdate('dataSource', $event)" />
<GroupedSettings title="Display">
<SmartsheetDashboardWidgetsCommonDataAggregation />
<SmartsheetDashboardWidgetsCommonDataAggregation @update:aggregation="handleConfigUpdate('metric', $event)" />
</GroupedSettings>
</template>
</SmartsheetDashboardWidgetsCommonConfig>

View File

@@ -5,8 +5,6 @@ const { isEditingDashboard } = storeToRefs(useDashboardStore())
<template>
<div v-if="isEditingDashboard" class="flex gap-2 items-center justify-center">
<NcButton type="secondary" size="small" @click="isEditingDashboard = false"> Cancel </NcButton>
<NcButton type="secondary" class="!text-nc-content-brand" size="small"> Save changes </NcButton>
</div>
<div v-else class="flex gap-2 items-center justify-center">

View File

@@ -22,86 +22,6 @@ export const useWidgetStore = defineStore('widget', () => {
const selectedWidget = ref<WidgetType | null>(null)
// Create placeholder data for testing
const createPlaceholderWidgets = (dashboardId: string): WidgetType[] => {
return [
{
id: 'widget-1',
title: 'Total Records',
type: 'metric',
fk_dashboard_id: dashboardId,
position: {
x: 0,
y: 0,
w: 2,
h: 2,
},
config: {
component: {
title: 'Total Records',
description: 'Total number of records in the database',
},
appearance: {
fontSize: 'large',
textColor: '#1f2937',
backgroundColor: '',
},
source: {
type: 'all_records',
aggregation: 'count',
viewId: '',
columnId: '',
filters: [],
},
display: {
showLabel: true,
showComparison: true,
comparisonPeriod: 'previous_period',
},
},
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
},
{
id: 'widget-2',
title: 'Average Revenue',
type: 'metric',
fk_dashboard_id: dashboardId,
position: {
x: 2,
y: 0,
w: 2,
h: 2,
},
config: {
component: {
title: 'Average Revenue',
description: 'Average revenue per customer',
},
appearance: {
fontSize: 'large',
textColor: '#059669',
backgroundColor: '#f0fdf4',
},
source: {
type: 'all_records',
aggregation: 'avg',
viewId: '',
columnId: 'revenue',
filters: [],
},
display: {
showLabel: true,
showComparison: false,
comparisonPeriod: 'previous_period',
},
},
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
},
]
}
// Actions
const loadWidgets = async ({ dashboardId, force = false }: { dashboardId: string; force?: boolean }) => {
if (!activeWorkspaceId.value || !openedProject.value?.id) {
@@ -183,7 +103,7 @@ export const useWidgetStore = defineStore('widget', () => {
}
const updateWidget = async (
dashboardId: string,
dashboardId = activeDashboardId.value,
widgetId: string,
updates: Partial<WidgetType>,
options?: {
@@ -221,6 +141,10 @@ export const useWidgetStore = defineStore('widget', () => {
widgets.value.set(dashboardId, dashboardWidgets)
}
if (selectedWidget.value?.id === widgetId) {
selectedWidget.value = updated as unknown as WidgetType
}
return updated
} catch (e) {
console.error(e)

View File

@@ -83,11 +83,10 @@ export interface TableWidgetConfig {
export interface MetricWidgetConfig {
dataSource?: WidgetDataSource;
metric: {
type: 'count' | 'summary';
column_id?: string;
aggregation: 'sum' | 'avg' | 'count' | 'min' | 'max';
label?: string;
};
filters?: any[];
}
export interface TextWidgetConfig {

View File

@@ -86,13 +86,14 @@ export default class Widget implements IWidget {
for (let widget of widgetsList) {
widget = prepareForResponse(widget, ['config', 'meta', 'position']);
}
widgetsList.sort(
(a, b) =>
(a.order != null ? a.order : Infinity) -
(b.order != null ? b.order : Infinity),
);
await NocoCache.setList(CacheScope.WIDGET, [dashboardId], widgetsList);
}
widgetsList.sort(
(a, b) =>
(a.order != null ? a.order : Infinity) -
(b.order != null ? b.order : Infinity),
);
return widgetsList?.map((w) => new Widget(w));
}
@@ -118,18 +119,18 @@ export default class Widget implements IWidget {
insertObj = prepareForDb(insertObj, ['config', 'meta', 'position']);
const { id } = await ncMeta.metaInsert2(
const insertRes = await ncMeta.metaInsert2(
context.workspace_id,
context.base_id,
MetaTable.WIDGETS,
insertObj,
);
return Widget.get(context, id, ncMeta).then(async (widget) => {
return Widget.get(context, insertRes.id, ncMeta).then(async (widget) => {
await NocoCache.appendToList(
CacheScope.WIDGET,
[widget.fk_dashboard_id],
`${CacheScope.WIDGET}:${id}`,
`${CacheScope.WIDGET}:${insertRes.id}`,
);
return widget;
});
@@ -162,7 +163,10 @@ export default class Widget implements IWidget {
widgetId,
);
await NocoCache.update(`${CacheScope.WIDGET}:${widgetId}`, updateObj);
await NocoCache.update(
`${CacheScope.WIDGET}:${widgetId}`,
prepareForResponse(updateObj, ['config', 'meta', 'position']),
);
return await this.get(context, widgetId);
}