mirror of
https://github.com/formbricks/formbricks.git
synced 2026-03-05 00:48:03 -06:00
feedback
This commit is contained in:
@@ -691,6 +691,7 @@
|
||||
"no_data_available": "No data available",
|
||||
"no_data_returned": "No data returned from query",
|
||||
"no_data_returned_for_chart": "No data returned for chart",
|
||||
"no_grouping": "None (filter only)",
|
||||
"no_valid_data_to_display": "No valid data to display",
|
||||
"not_contains": "not contains",
|
||||
"not_equals": "not equals",
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
parseQueryToState,
|
||||
} from "@/modules/ee/analysis/lib/query-builder";
|
||||
import { formatCubeColumnHeader } from "@/modules/ee/analysis/lib/schema-definition";
|
||||
import type { AnalyticsResponse, TChartDataRow, TCubeQuery } from "@/modules/ee/analysis/types/analysis";
|
||||
import type { AnalyticsResponse, TChartDataRow } from "@/modules/ee/analysis/types/analysis";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { LoadingSpinner } from "@/modules/ui/components/loading-spinner";
|
||||
|
||||
@@ -38,7 +38,7 @@ const AVAILABLE_CHART_TYPES = CHART_TYPES.filter((type) => !["table", "map", "sc
|
||||
interface AdvancedChartBuilderProps {
|
||||
environmentId: string;
|
||||
initialChartType?: string;
|
||||
initialQuery?: TCubeQuery;
|
||||
initialQuery?: TChartQuery;
|
||||
hidePreview?: boolean;
|
||||
onChartGenerated?: (data: AnalyticsResponse) => void;
|
||||
onSave?: (chartId: string) => void;
|
||||
@@ -113,7 +113,7 @@ export function AdvancedChartBuilder({
|
||||
|
||||
const [state, dispatch] = useReducer(chartBuilderReducer, getInitialState());
|
||||
const [chartData, setChartData] = useState<TChartDataRow[] | null>(null);
|
||||
const [query, setQuery] = useState<TCubeQuery | null>(initialQuery || null);
|
||||
const [query, setQuery] = useState<TChartQuery | null>(initialQuery || null);
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -79,7 +79,7 @@ export function ChartsList({ charts, environmentId }: Readonly<ChartsListProps>)
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-1 my-auto hidden whitespace-nowrap text-center text-sm text-slate-500 sm:block">
|
||||
<div className="ph-no-capture text-slate-900">{chart.creator.name || "-"}</div>
|
||||
<div className="ph-no-capture text-slate-900">{chart.creator?.name ?? "-"}</div>
|
||||
</div>
|
||||
<div className="col-span-1 my-auto hidden whitespace-normal text-center text-sm text-slate-500 sm:block">
|
||||
<div className="ph-no-capture text-slate-900">
|
||||
|
||||
@@ -6,7 +6,7 @@ import { AIQuerySection } from "@/modules/ee/analysis/charts/components/ai-query
|
||||
import { ChartDialogFooterWithModals } from "@/modules/ee/analysis/charts/components/chart-dialog-footer-with-modals";
|
||||
import { ChartPreview } from "@/modules/ee/analysis/charts/components/chart-preview";
|
||||
import { ManualChartBuilder } from "@/modules/ee/analysis/charts/components/manual-chart-builder";
|
||||
import { AnalyticsResponse, TCubeQuery } from "@/modules/ee/analysis/types/analysis";
|
||||
import { AnalyticsResponse } from "@/modules/ee/analysis/types/analysis";
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
@@ -112,7 +112,7 @@ export function CreateChartView({
|
||||
<AdvancedChartBuilder
|
||||
environmentId={environmentId}
|
||||
initialChartType={selectedChartType || chartData?.chartType || ""}
|
||||
initialQuery={chartData?.query as TCubeQuery | undefined}
|
||||
initialQuery={chartData?.query}
|
||||
hidePreview={true}
|
||||
onChartGenerated={handleAdvancedChartGenerated}
|
||||
onSave={onAdvancedBuilderSave}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { AdvancedChartBuilder } from "@/modules/ee/analysis/charts/components/ad
|
||||
import { ChartDialogFooterWithModals } from "@/modules/ee/analysis/charts/components/chart-dialog-footer-with-modals";
|
||||
import { ChartPreview } from "@/modules/ee/analysis/charts/components/chart-preview";
|
||||
import { ManualChartBuilder } from "@/modules/ee/analysis/charts/components/manual-chart-builder";
|
||||
import { AnalyticsResponse, TCubeQuery } from "@/modules/ee/analysis/types/analysis";
|
||||
import { AnalyticsResponse } from "@/modules/ee/analysis/types/analysis";
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
@@ -89,7 +89,7 @@ export function EditChartView({
|
||||
<AdvancedChartBuilder
|
||||
environmentId={environmentId}
|
||||
initialChartType={selectedChartType || chartData.chartType || ""}
|
||||
initialQuery={chartData.query as TCubeQuery | undefined}
|
||||
initialQuery={chartData.query}
|
||||
hidePreview={true}
|
||||
onChartGenerated={onChartGenerated}
|
||||
onSave={onAdvancedBuilderSave}
|
||||
|
||||
@@ -69,8 +69,9 @@ export function TimeDimensionPanel({
|
||||
}
|
||||
};
|
||||
|
||||
const handleGranularityChange = (granularity: TimeDimensionConfig["granularity"]) => {
|
||||
const handleGranularityChange = (value: string) => {
|
||||
if (timeDimension) {
|
||||
const granularity = value === "none" ? undefined : (value as TimeDimensionConfig["granularity"]);
|
||||
onTimeDimensionChange({ ...timeDimension, granularity });
|
||||
}
|
||||
};
|
||||
@@ -129,13 +130,12 @@ export function TimeDimensionPanel({
|
||||
<label className="text-sm font-medium text-gray-700">
|
||||
{t("environments.analysis.charts.granularity")}
|
||||
</label>
|
||||
<Select
|
||||
value={timeDimension.granularity}
|
||||
onValueChange={(value) => handleGranularityChange(value as TimeDimensionConfig["granularity"])}>
|
||||
<Select value={timeDimension.granularity ?? "none"} onValueChange={handleGranularityChange}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">{t("environments.analysis.charts.no_grouping")}</SelectItem>
|
||||
{TIME_GRANULARITIES.map((gran) => (
|
||||
<SelectItem key={gran} value={gran}>
|
||||
{gran.charAt(0).toUpperCase() + gran.slice(1)}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import "server-only";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
@@ -229,7 +228,7 @@ export const getChart = async (chartId: string, projectId: string): Promise<TCha
|
||||
* Fetches all charts for the given environment (for list/dashboard UI).
|
||||
* Uses getEnvironmentAuth for access check and enriches with creator names.
|
||||
*/
|
||||
export const getCharts = reactCache(async (environmentId: string): Promise<TChartWithCreator[]> => {
|
||||
export const getCharts = async (environmentId: string): Promise<TChartWithCreator[]> => {
|
||||
try {
|
||||
const { project } = await getEnvironmentAuth(environmentId);
|
||||
|
||||
@@ -241,12 +240,7 @@ export const getCharts = reactCache(async (environmentId: string): Promise<TChar
|
||||
creator: { select: { name: true } },
|
||||
},
|
||||
});
|
||||
return charts.map((chart) => ({
|
||||
...chart,
|
||||
creator: {
|
||||
name: chart.creator?.name ?? null,
|
||||
},
|
||||
}));
|
||||
return charts;
|
||||
} catch (error) {
|
||||
if (error instanceof ResourceNotFoundError) {
|
||||
throw error;
|
||||
@@ -256,4 +250,4 @@ export const getCharts = reactCache(async (environmentId: string): Promise<TChar
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Query builder utility to construct Cube.js queries from chart builder state.
|
||||
*/
|
||||
import { TCubeFilter, TCubeQuery, TTimeDimension } from "../types/analysis";
|
||||
import { TChartQuery, TCubeFilter, TMemberFilter, TTimeDimension } from "@formbricks/types/dashboard";
|
||||
|
||||
export interface CustomMeasure {
|
||||
id?: string;
|
||||
@@ -14,14 +14,14 @@ export type TFilterFieldType = "string" | "number" | "time";
|
||||
|
||||
export interface FilterRow {
|
||||
field: string;
|
||||
operator: TCubeFilter["operator"];
|
||||
operator: TMemberFilter["operator"];
|
||||
values: string[] | number[] | null;
|
||||
}
|
||||
|
||||
export interface TimeDimensionConfig {
|
||||
dimension: string;
|
||||
granularity: "hour" | "day" | "week" | "month" | "quarter" | "year";
|
||||
dateRange: string | [Date, Date];
|
||||
granularity?: "second" | "minute" | "hour" | "day" | "week" | "month" | "quarter" | "year";
|
||||
dateRange?: string | [Date, Date];
|
||||
}
|
||||
|
||||
export interface ChartBuilderState {
|
||||
@@ -36,11 +36,22 @@ export interface ChartBuilderState {
|
||||
orderBy?: { field: string; direction: "asc" | "desc" };
|
||||
}
|
||||
|
||||
function buildMemberFilter(f: FilterRow): TMemberFilter {
|
||||
const filter: TMemberFilter = {
|
||||
member: f.field,
|
||||
operator: f.operator,
|
||||
};
|
||||
if (f.operator !== "set" && f.operator !== "notSet" && f.values) {
|
||||
filter.values = f.values.map(String);
|
||||
}
|
||||
return filter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a Cube.js query from chart builder state.
|
||||
*/
|
||||
export function buildCubeQuery(config: ChartBuilderState): TCubeQuery {
|
||||
const query: TCubeQuery = {
|
||||
export function buildCubeQuery(config: ChartBuilderState): TChartQuery {
|
||||
const query: TChartQuery = {
|
||||
measures: [...config.selectedMeasures],
|
||||
};
|
||||
|
||||
@@ -51,9 +62,12 @@ export function buildCubeQuery(config: ChartBuilderState): TCubeQuery {
|
||||
if (config.timeDimension) {
|
||||
const timeDim: TTimeDimension = {
|
||||
dimension: config.timeDimension.dimension,
|
||||
granularity: config.timeDimension.granularity,
|
||||
};
|
||||
|
||||
if (config.timeDimension.granularity) {
|
||||
timeDim.granularity = config.timeDimension.granularity;
|
||||
}
|
||||
|
||||
if (typeof config.timeDimension.dateRange === "string") {
|
||||
timeDim.dateRange = config.timeDimension.dateRange;
|
||||
} else if (Array.isArray(config.timeDimension.dateRange)) {
|
||||
@@ -71,27 +85,27 @@ export function buildCubeQuery(config: ChartBuilderState): TCubeQuery {
|
||||
}
|
||||
|
||||
if (config.filters.length > 0) {
|
||||
query.filters = config.filters.map((f) => {
|
||||
const filter: TCubeFilter = {
|
||||
member: f.field,
|
||||
operator: f.operator,
|
||||
};
|
||||
const memberFilters = config.filters.map(buildMemberFilter);
|
||||
|
||||
if (f.operator !== "set" && f.operator !== "notSet" && f.values) {
|
||||
filter.values = f.values.map(String);
|
||||
}
|
||||
|
||||
return filter;
|
||||
});
|
||||
if (config.filterLogic === "or") {
|
||||
query.filters = [{ or: memberFilters } as TCubeFilter];
|
||||
} else {
|
||||
query.filters = memberFilters;
|
||||
}
|
||||
}
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
function isMemberFilter(f: TCubeFilter): f is TMemberFilter {
|
||||
return "member" in f;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a Cube.js query back into ChartBuilderState.
|
||||
* Preserves absent granularity / dateRange instead of injecting defaults.
|
||||
*/
|
||||
export function parseQueryToState(query: TCubeQuery, chartType?: string): Partial<ChartBuilderState> {
|
||||
export function parseQueryToState(query: TChartQuery, chartType?: string): Partial<ChartBuilderState> {
|
||||
const state: Partial<ChartBuilderState> = {
|
||||
chartType: chartType || "",
|
||||
selectedMeasures: query.measures || [],
|
||||
@@ -103,75 +117,38 @@ export function parseQueryToState(query: TCubeQuery, chartType?: string): Partia
|
||||
};
|
||||
|
||||
if (query.filters && query.filters.length > 0) {
|
||||
state.filters = query.filters.map((f) => ({
|
||||
field: f.member,
|
||||
operator: f.operator,
|
||||
values: f.values || null,
|
||||
}));
|
||||
const first = query.filters[0];
|
||||
|
||||
if (!isMemberFilter(first) && "or" in first && query.filters.length === 1) {
|
||||
state.filterLogic = "or";
|
||||
state.filters = (first.or as TMemberFilter[]).map((f) => ({
|
||||
field: f.member,
|
||||
operator: f.operator,
|
||||
values: f.values || null,
|
||||
}));
|
||||
} else {
|
||||
state.filterLogic = "and";
|
||||
state.filters = query.filters.filter(isMemberFilter).map((f) => ({
|
||||
field: f.member,
|
||||
operator: f.operator,
|
||||
values: f.values || null,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
if (query.timeDimensions && query.timeDimensions.length > 0) {
|
||||
const timeDim = query.timeDimensions[0];
|
||||
state.timeDimension = {
|
||||
const config: TimeDimensionConfig = {
|
||||
dimension: timeDim.dimension,
|
||||
granularity: timeDim.granularity || "day",
|
||||
dateRange: (timeDim.dateRange || "last 30 days") as TimeDimensionConfig["dateRange"],
|
||||
};
|
||||
if (timeDim.granularity) {
|
||||
config.granularity = timeDim.granularity;
|
||||
}
|
||||
if (timeDim.dateRange) {
|
||||
config.dateRange = timeDim.dateRange as TimeDimensionConfig["dateRange"];
|
||||
}
|
||||
state.timeDimension = config;
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert date preset string to date range.
|
||||
*/
|
||||
export function getDateRangeFromPreset(preset: string): [Date, Date] | null {
|
||||
const now = new Date();
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
|
||||
switch (preset) {
|
||||
case "today": {
|
||||
const tomorrow = new Date(today);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
return [today, tomorrow];
|
||||
}
|
||||
case "yesterday": {
|
||||
const yesterday = new Date(today);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
return [yesterday, today];
|
||||
}
|
||||
case "last 7 days": {
|
||||
const sevenDaysAgo = new Date(today);
|
||||
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
|
||||
return [sevenDaysAgo, today];
|
||||
}
|
||||
case "last 30 days": {
|
||||
const thirtyDaysAgo = new Date(today);
|
||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||
return [thirtyDaysAgo, today];
|
||||
}
|
||||
case "this month": {
|
||||
const firstDay = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const lastDay = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
||||
return [firstDay, lastDay];
|
||||
}
|
||||
case "last month": {
|
||||
const firstDayLastMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1);
|
||||
const firstDayThisMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
return [firstDayLastMonth, firstDayThisMonth];
|
||||
}
|
||||
case "this quarter": {
|
||||
const quarter = Math.floor(now.getMonth() / 3);
|
||||
const firstDay = new Date(now.getFullYear(), quarter * 3, 1);
|
||||
const lastDay = new Date(now.getFullYear(), (quarter + 1) * 3, 1);
|
||||
return [firstDay, lastDay];
|
||||
}
|
||||
case "this year": {
|
||||
const firstDay = new Date(now.getFullYear(), 0, 1);
|
||||
const lastDay = new Date(now.getFullYear() + 1, 0, 1);
|
||||
return [firstDay, lastDay];
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,9 +39,11 @@ export const ZChart = z.object({
|
||||
export type TChart = z.infer<typeof ZChart>;
|
||||
|
||||
export const ZChartWithCreator = ZChart.extend({
|
||||
creator: z.object({
|
||||
name: z.string().nullable(),
|
||||
}),
|
||||
creator: z
|
||||
.object({
|
||||
name: z.string().nullable(),
|
||||
})
|
||||
.nullable(),
|
||||
});
|
||||
export type TChartWithCreator = z.infer<typeof ZChartWithCreator>;
|
||||
|
||||
@@ -96,28 +98,6 @@ export type TAddWidgetInput = z.infer<typeof ZAddWidgetInput>;
|
||||
export const ZApiChartType = z.enum(["bar", "line", "donut", "kpi", "area", "pie", "big_number"]);
|
||||
export type TApiChartType = z.infer<typeof ZApiChartType>;
|
||||
|
||||
export const ZTimeDimension = z.object({
|
||||
dimension: z.string(),
|
||||
granularity: z.enum(["hour", "day", "week", "month", "quarter", "year"]).optional(),
|
||||
dateRange: z.union([z.string(), z.tuple([z.string(), z.string()])]).optional(),
|
||||
});
|
||||
export type TTimeDimension = z.infer<typeof ZTimeDimension>;
|
||||
|
||||
export const ZCubeFilter = z.object({
|
||||
member: z.string(),
|
||||
operator: z.string(),
|
||||
values: z.array(z.string()).optional(),
|
||||
});
|
||||
export type TCubeFilter = z.infer<typeof ZCubeFilter>;
|
||||
|
||||
export const ZCubeQuery = z.object({
|
||||
measures: z.array(z.string()),
|
||||
dimensions: z.array(z.string()).optional(),
|
||||
timeDimensions: z.array(ZTimeDimension).optional(),
|
||||
filters: z.array(ZCubeFilter).optional(),
|
||||
});
|
||||
export type TCubeQuery = z.infer<typeof ZCubeQuery>;
|
||||
|
||||
/** Row from Cube.js tablePivot - keys are measure/dimension names, values are primitives */
|
||||
export type TChartDataRow = Record<string, string | number | null | boolean | undefined>;
|
||||
|
||||
|
||||
@@ -10,26 +10,30 @@ const ZCubeTimeDimension = z.object({
|
||||
granularity: z.enum(["second", "minute", "hour", "day", "week", "month", "quarter", "year"]).optional(),
|
||||
});
|
||||
|
||||
export type TTimeDimension = z.infer<typeof ZCubeTimeDimension>;
|
||||
|
||||
const ZCubeMemberFilter = z.object({
|
||||
member: z.string(),
|
||||
operator: z.string(),
|
||||
values: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
type TCubeFilter = z.infer<typeof ZCubeMemberFilter> | { and: TCubeFilter[] } | { or: TCubeFilter[] };
|
||||
export type TMemberFilter = z.infer<typeof ZCubeMemberFilter>;
|
||||
|
||||
const ZCubeFilter: z.ZodType<TCubeFilter> = z.union([
|
||||
const ZCubeLogicalFilter: z.ZodType = z.union([
|
||||
ZCubeMemberFilter,
|
||||
z.object({ and: z.lazy(() => z.array(ZCubeFilter)) }),
|
||||
z.object({ or: z.lazy(() => z.array(ZCubeFilter)) }),
|
||||
z.object({ and: z.array(z.lazy(() => ZCubeLogicalFilter)) }),
|
||||
z.object({ or: z.array(z.lazy(() => ZCubeLogicalFilter)) }),
|
||||
]);
|
||||
|
||||
export type TCubeFilter = TMemberFilter | { and: TCubeFilter[] } | { or: TCubeFilter[] };
|
||||
|
||||
export const ZChartQuery = z.object({
|
||||
measures: z.array(z.string()).optional(),
|
||||
dimensions: z.array(z.string()).optional(),
|
||||
segments: z.array(z.string()).optional(),
|
||||
timeDimensions: z.array(ZCubeTimeDimension).optional(),
|
||||
filters: z.array(ZCubeFilter).optional(),
|
||||
filters: z.array(ZCubeLogicalFilter).optional(),
|
||||
order: z
|
||||
.union([z.array(z.tuple([z.string(), z.enum(["asc", "desc"])])), z.record(z.enum(["asc", "desc"]))])
|
||||
.optional(),
|
||||
|
||||
Reference in New Issue
Block a user