From 500c7f5eb85ef10e0778cf5403b5a610af8b2807 Mon Sep 17 00:00:00 2001 From: Lanre Adelowo Date: Fri, 21 Feb 2025 14:18:26 +0100 Subject: [PATCH 01/15] handle case where a dashboard might not have charts inside of them --- web/ui/src/app/(main)/dashboards/[slug]/page.tsx | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/web/ui/src/app/(main)/dashboards/[slug]/page.tsx b/web/ui/src/app/(main)/dashboards/[slug]/page.tsx index fc8f7cda..34dfdd47 100644 --- a/web/ui/src/app/(main)/dashboards/[slug]/page.tsx +++ b/web/ui/src/app/(main)/dashboards/[slug]/page.tsx @@ -376,9 +376,18 @@ export default function DashboardPage() {
- {dashboardData.charts.map((chart) => ( - - ))} + {!dashboardData?.charts || dashboardData.charts.length === 0 ? ( +
+ +

No charts yet

+

Get started by adding your first chart to this dashboard.

+ +
+ ) : ( + dashboardData.charts.map((chart) => ( + + )) + )}
); From c83b4ba7f860b28757bb86afa5355546c3478a5f Mon Sep 17 00:00:00 2001 From: Lanre Adelowo Date: Fri, 21 Feb 2025 14:23:05 +0100 Subject: [PATCH 02/15] invalidate query immediately a new chart is added to the dashboard --- web/ui/src/app/(main)/dashboards/[slug]/page.tsx | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/web/ui/src/app/(main)/dashboards/[slug]/page.tsx b/web/ui/src/app/(main)/dashboards/[slug]/page.tsx index 34dfdd47..3a8339e3 100644 --- a/web/ui/src/app/(main)/dashboards/[slug]/page.tsx +++ b/web/ui/src/app/(main)/dashboards/[slug]/page.tsx @@ -43,7 +43,7 @@ import { CommandItem, CommandList, } from "@/components/ui/command"; -import { useQuery, useMutation } from "@tanstack/react-query"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import client from "@/lib/client"; import { LIST_CHARTS, DASHBOARD_DETAIL } from "@/lib/query-constants"; import type { @@ -182,7 +182,9 @@ function ChartCard({ chart }: { chart: MalakDashboardChart }) { export default function DashboardPage() { const params = useParams(); - const dashboardId = params.slug as string; + const dashboardID = params.slug as string; + + const queryClient = useQueryClient(); const [isOpen, setIsOpen] = useState(false); const [isPopoverOpen, setIsPopoverOpen] = useState(false); @@ -190,9 +192,9 @@ export default function DashboardPage() { const [selectedChartLabel, setSelectedChartLabel] = useState(""); const { data: dashboardData, isLoading: isLoadingDashboard } = useQuery({ - queryKey: [DASHBOARD_DETAIL, dashboardId], + queryKey: [DASHBOARD_DETAIL, dashboardID], queryFn: async () => { - const response = await client.dashboards.dashboardsDetail(dashboardId); + const response = await client.dashboards.dashboardsDetail(dashboardID); return response.data; }, }); @@ -208,15 +210,17 @@ export default function DashboardPage() { const addChartMutation = useMutation({ mutationFn: async (chartReference: string) => { - const response = await client.dashboards.chartsUpdate(dashboardId, { + const response = await client.dashboards.chartsUpdate(dashboardID, { chart_reference: chartReference }); return response.data; }, onSuccess: (data) => { + queryClient.invalidateQueries({ queryKey: [DASHBOARD_DETAIL, dashboardID] }); setSelectedChart(""); setSelectedChartLabel(""); setIsOpen(false); + toast.success(data.message); }, onError: (err: AxiosError): void => { From 5d50b1a0033398df197c591c4adf71f896fef399 Mon Sep 17 00:00:00 2001 From: Lanre Adelowo Date: Fri, 21 Feb 2025 14:48:41 +0100 Subject: [PATCH 03/15] implement http endpoint --- integration.go | 1 + internal/datastore/postgres/integration.go | 15 +++ mocks/integration.go | 15 +++ server/dashboard.go | 62 +++++++++ server/http.go | 3 + server/response.go | 5 + swagger/docs.go | 125 ++++++++++++++++++ swagger/swagger.json | 141 +++++++++++++++++++++ swagger/swagger.yaml | 90 +++++++++++++ web/ui/src/client/Api.ts | 42 ++++++ 10 files changed, 499 insertions(+) diff --git a/integration.go b/integration.go index 4224e519..647da822 100644 --- a/integration.go +++ b/integration.go @@ -188,4 +188,5 @@ type IntegrationRepository interface { AddDataPoint(context.Context, *WorkspaceIntegration, []IntegrationDataValues) error ListCharts(context.Context, uuid.UUID) ([]IntegrationChart, error) GetChart(context.Context, FetchChartOptions) (IntegrationChart, error) + GetDataPoints(context.Context, IntegrationChart) ([]IntegrationDataPoint, error) } diff --git a/internal/datastore/postgres/integration.go b/internal/datastore/postgres/integration.go index dfd8fa80..0a5a65a3 100644 --- a/internal/datastore/postgres/integration.go +++ b/internal/datastore/postgres/integration.go @@ -274,3 +274,18 @@ func (i *integrationRepo) GetChart(ctx context.Context, return chart, err } + +func (i *integrationRepo) GetDataPoints(ctx context.Context, + chart malak.IntegrationChart) ([]malak.IntegrationDataPoint, error) { + + ctx, cancelFn := withContext(ctx) + defer cancelFn() + + dataValues := make([]malak.IntegrationDataPoint, 0) + + return dataValues, i.inner.NewSelect(). + Model(&dataValues). + Where("integration_chart_id = ?", chart.ID). + Order("created_at ASC"). + Scan(ctx) +} diff --git a/mocks/integration.go b/mocks/integration.go index 05792fee..fdc3b6e4 100644 --- a/mocks/integration.go +++ b/mocks/integration.go @@ -210,6 +210,21 @@ func (mr *MockIntegrationRepositoryMockRecorder) GetChart(arg0, arg1 any) *gomoc return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChart", reflect.TypeOf((*MockIntegrationRepository)(nil).GetChart), arg0, arg1) } +// GetDataPoints mocks base method. +func (m *MockIntegrationRepository) GetDataPoints(arg0 context.Context, arg1 malak.IntegrationChart) ([]malak.IntegrationDataPoint, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetDataPoints", arg0, arg1) + ret0, _ := ret[0].([]malak.IntegrationDataPoint) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetDataPoints indicates an expected call of GetDataPoints. +func (mr *MockIntegrationRepositoryMockRecorder) GetDataPoints(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetDataPoints", reflect.TypeOf((*MockIntegrationRepository)(nil).GetDataPoints), arg0, arg1) +} + // List mocks base method. func (m *MockIntegrationRepository) List(arg0 context.Context, arg1 *malak.Workspace) ([]malak.WorkspaceIntegration, error) { m.ctrl.T.Helper() diff --git a/server/dashboard.go b/server/dashboard.go index abbbda87..ee30052b 100644 --- a/server/dashboard.go +++ b/server/dashboard.go @@ -368,3 +368,65 @@ func (d *dashboardHandler) fetchDashboard( Charts: charts, }, StatusSuccess } + +// @Summary fetch charting data +// @Tags dashboards +// @Accept json +// @Produce json +// @Param reference path string required "chart unique reference.. e.g integration_chart_km31C.e6xV" +// @Success 200 {object} listChartDataPointsResponse +// @Failure 400 {object} APIStatus +// @Failure 401 {object} APIStatus +// @Failure 404 {object} APIStatus +// @Failure 500 {object} APIStatus +// @Router /dashboards/charts/{reference} [GET] +func (d *dashboardHandler) fetchChartingData( + ctx context.Context, + span trace.Span, + logger *zap.Logger, + w http.ResponseWriter, + r *http.Request) (render.Renderer, Status) { + + logger.Debug("fetch charting data") + + workspace := getWorkspaceFromContext(r.Context()) + + ref := chi.URLParam(r, "reference") + + if hermes.IsStringEmpty(ref) { + return newAPIStatus(http.StatusBadRequest, "reference required"), StatusFailed + } + + chart, err := d.integrationRepo.GetChart(ctx, malak.FetchChartOptions{ + WorkspaceID: workspace.ID, + Reference: malak.Reference(ref), + }) + if err != nil { + logger.Error("could not fetch chart", zap.Error(err)) + status := http.StatusInternalServerError + msg := "an error occurred while fetching chart" + + if errors.Is(err, malak.ErrChartNotFound) { + status = http.StatusNotFound + msg = err.Error() + } + + return newAPIStatus(status, msg), StatusFailed + } + + dataPoints, err := d.integrationRepo.GetDataPoints(ctx, chart) + if err != nil { + + logger.Error("could not charting data", + zap.Error(err)) + + return newAPIStatus( + http.StatusInternalServerError, + "could not fetch charting data"), StatusFailed + } + + return listChartDataPointsResponse{ + APIStatus: newAPIStatus(http.StatusOK, "datapoints fetched"), + DataPoints: dataPoints, + }, StatusSuccess +} diff --git a/server/http.go b/server/http.go index 03ef107d..1febe8f0 100644 --- a/server/http.go +++ b/server/http.go @@ -394,6 +394,9 @@ func buildRoutes( r.Get("/charts", WrapMalakHTTPHandler(logger, dashHandler.listAllCharts, cfg, "dashboards.list.charts")) + r.Get("/charts/{reference}", + WrapMalakHTTPHandler(logger, dashHandler.fetchChartingData, cfg, "dashboards.charts.datapoints")) + r.Get("/{reference}", WrapMalakHTTPHandler(logger, dashHandler.fetchDashboard, cfg, "dashboards.fetch")) diff --git a/server/response.go b/server/response.go index b1242976..68701d23 100644 --- a/server/response.go +++ b/server/response.go @@ -82,6 +82,11 @@ type listIntegrationChartsResponse struct { APIStatus } +type listChartDataPointsResponse struct { + DataPoints []malak.IntegrationDataPoint `json:"data_points,omitempty" validate:"required"` + APIStatus +} + type listDashboardChartsResponse struct { Charts []malak.DashboardChart `json:"charts,omitempty" validate:"required"` Dashboard malak.Dashboard `json:"dashboard,omitempty" validate:"required"` diff --git a/swagger/docs.go b/swagger/docs.go index 35e5671f..0d7b10e5 100644 --- a/swagger/docs.go +++ b/swagger/docs.go @@ -822,6 +822,61 @@ const docTemplate = `{ } } }, + "/dashboards/charts/{reference}": { + "get": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "dashboards" + ], + "summary": "fetch charting data", + "parameters": [ + { + "type": "string", + "description": "chart unique reference.. e.g integration_chart_km31C.e6xV", + "name": "reference", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/server.listChartDataPointsResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/server.APIStatus" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/server.APIStatus" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/server.APIStatus" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/server.APIStatus" + } + } + } + } + }, "/dashboards/{reference}": { "get": { "consumes": [ @@ -3294,6 +3349,58 @@ const docTemplate = `{ "IntegrationChartTypePie" ] }, + "malak.IntegrationDataPoint": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "data_point_type": { + "$ref": "#/definitions/malak.IntegrationDataPointType" + }, + "id": { + "type": "string" + }, + "integration_chart_id": { + "type": "string" + }, + "metadata": { + "$ref": "#/definitions/malak.IntegrationDataPointMetadata" + }, + "point_name": { + "type": "string" + }, + "point_value": { + "type": "integer" + }, + "reference": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "workspace_id": { + "type": "string" + }, + "workspace_integration_id": { + "type": "string" + } + } + }, + "malak.IntegrationDataPointMetadata": { + "type": "object" + }, + "malak.IntegrationDataPointType": { + "type": "string", + "enum": [ + "currency", + "others" + ], + "x-enum-varnames": [ + "IntegrationDataPointTypeCurrency", + "IntegrationDataPointTypeOthers" + ] + }, "malak.IntegrationMetadata": { "type": "object", "properties": { @@ -4259,6 +4366,24 @@ const docTemplate = `{ } } }, + "server.listChartDataPointsResponse": { + "type": "object", + "required": [ + "data_points", + "message" + ], + "properties": { + "data_points": { + "type": "array", + "items": { + "$ref": "#/definitions/malak.IntegrationDataPoint" + } + }, + "message": { + "type": "string" + } + } + }, "server.listContactsResponse": { "type": "object", "required": [ diff --git a/swagger/swagger.json b/swagger/swagger.json index 5979aa9b..74e3ee87 100644 --- a/swagger/swagger.json +++ b/swagger/swagger.json @@ -481,6 +481,58 @@ "IntegrationChartTypePie" ] }, + "malak.IntegrationDataPoint": { + "properties": { + "created_at": { + "type": "string" + }, + "data_point_type": { + "$ref": "#/components/schemas/malak.IntegrationDataPointType" + }, + "id": { + "type": "string" + }, + "integration_chart_id": { + "type": "string" + }, + "metadata": { + "$ref": "#/components/schemas/malak.IntegrationDataPointMetadata" + }, + "point_name": { + "type": "string" + }, + "point_value": { + "type": "integer" + }, + "reference": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "workspace_id": { + "type": "string" + }, + "workspace_integration_id": { + "type": "string" + } + }, + "type": "object" + }, + "malak.IntegrationDataPointMetadata": { + "type": "object" + }, + "malak.IntegrationDataPointType": { + "enum": [ + "currency", + "others" + ], + "type": "string", + "x-enum-varnames": [ + "IntegrationDataPointTypeCurrency", + "IntegrationDataPointTypeOthers" + ] + }, "malak.IntegrationMetadata": { "properties": { "endpoint": { @@ -1446,6 +1498,24 @@ ], "type": "object" }, + "server.listChartDataPointsResponse": { + "properties": { + "data_points": { + "items": { + "$ref": "#/components/schemas/malak.IntegrationDataPoint" + }, + "type": "array" + }, + "message": { + "type": "string" + } + }, + "required": [ + "data_points", + "message" + ], + "type": "object" + }, "server.listContactsResponse": { "properties": { "contacts": { @@ -2786,6 +2856,77 @@ ] } }, + "/dashboards/charts/{reference}": { + "get": { + "parameters": [ + { + "description": "chart unique reference.. e.g integration_chart_km31C.e6xV", + "in": "path", + "name": "reference", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/server.listChartDataPointsResponse" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/server.APIStatus" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/server.APIStatus" + } + } + }, + "description": "Unauthorized" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/server.APIStatus" + } + } + }, + "description": "Not Found" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/server.APIStatus" + } + } + }, + "description": "Internal Server Error" + } + }, + "summary": "fetch charting data", + "tags": [ + "dashboards" + ] + } + }, "/dashboards/{reference}": { "get": { "parameters": [ diff --git a/swagger/swagger.yaml b/swagger/swagger.yaml index b1525280..0759c25e 100644 --- a/swagger/swagger.yaml +++ b/swagger/swagger.yaml @@ -324,6 +324,41 @@ components: x-enum-varnames: - IntegrationChartTypeBar - IntegrationChartTypePie + malak.IntegrationDataPoint: + properties: + created_at: + type: string + data_point_type: + $ref: '#/components/schemas/malak.IntegrationDataPointType' + id: + type: string + integration_chart_id: + type: string + metadata: + $ref: '#/components/schemas/malak.IntegrationDataPointMetadata' + point_name: + type: string + point_value: + type: integer + reference: + type: string + updated_at: + type: string + workspace_id: + type: string + workspace_integration_id: + type: string + type: object + malak.IntegrationDataPointMetadata: + type: object + malak.IntegrationDataPointType: + enum: + - currency + - others + type: string + x-enum-varnames: + - IntegrationDataPointTypeCurrency + - IntegrationDataPointTypeOthers malak.IntegrationMetadata: properties: endpoint: @@ -981,6 +1016,18 @@ components: - message - workspace type: object + server.listChartDataPointsResponse: + properties: + data_points: + items: + $ref: '#/components/schemas/malak.IntegrationDataPoint' + type: array + message: + type: string + required: + - data_points + - message + type: object server.listContactsResponse: properties: contacts: @@ -1911,6 +1958,49 @@ paths: summary: List charts tags: - dashboards + /dashboards/charts/{reference}: + get: + parameters: + - description: chart unique reference.. e.g integration_chart_km31C.e6xV + in: path + name: reference + required: true + schema: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/server.listChartDataPointsResponse' + description: OK + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/server.APIStatus' + description: Bad Request + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/server.APIStatus' + description: Unauthorized + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/server.APIStatus' + description: Not Found + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/server.APIStatus' + description: Internal Server Error + summary: fetch charting data + tags: + - dashboards /decks: get: responses: diff --git a/web/ui/src/client/Api.ts b/web/ui/src/client/Api.ts index 0c49ab8d..8b4f1b71 100644 --- a/web/ui/src/client/Api.ts +++ b/web/ui/src/client/Api.ts @@ -199,6 +199,27 @@ export enum MalakIntegrationChartType { IntegrationChartTypePie = "pie", } +export interface MalakIntegrationDataPoint { + created_at?: string; + data_point_type?: MalakIntegrationDataPointType; + id?: string; + integration_chart_id?: string; + metadata?: MalakIntegrationDataPointMetadata; + point_name?: string; + point_value?: number; + reference?: string; + updated_at?: string; + workspace_id?: string; + workspace_integration_id?: string; +} + +export type MalakIntegrationDataPointMetadata = object; + +export enum MalakIntegrationDataPointType { + IntegrationDataPointTypeCurrency = "currency", + IntegrationDataPointTypeOthers = "others", +} + export interface MalakIntegrationMetadata { endpoint?: string; } @@ -570,6 +591,11 @@ export interface ServerFetchWorkspaceResponse { workspace: MalakWorkspace; } +export interface ServerListChartDataPointsResponse { + data_points: MalakIntegrationDataPoint[]; + message: string; +} + export interface ServerListContactsResponse { contacts: MalakContact[]; message: string; @@ -1100,6 +1126,22 @@ export class Api extends HttpClient + this.request({ + path: `/dashboards/charts/${reference}`, + method: "GET", + format: "json", + ...params, + }), }; decks = { /** From be26f6e931d462b60cb685ac095d9115f1c4fb79 Mon Sep 17 00:00:00 2001 From: Lanre Adelowo Date: Fri, 21 Feb 2025 15:09:26 +0100 Subject: [PATCH 04/15] fix ui and queries --- integration.go | 7 +- internal/datastore/postgres/integration.go | 1 + internal/integrations/mercury/mercury.go | 8 +- .../src/app/(main)/dashboards/[slug]/page.tsx | 98 +++++++++---------- web/ui/src/lib/query-constants.ts | 3 +- 5 files changed, 58 insertions(+), 59 deletions(-) diff --git a/integration.go b/integration.go index 647da822..99f7cfe0 100644 --- a/integration.go +++ b/integration.go @@ -130,9 +130,10 @@ type IntegrationDataValues struct { // InternalName + ProviderID search in db // We cannot use only InternalName becasue some integrations // like mercury have the same InternalName twice ( each account has a savings and checkings which we track) - InternalName IntegrationChartInternalNameType - ProviderID string - Data IntegrationDataPoint + InternalName IntegrationChartInternalNameType + UserFacingName string + ProviderID string + Data IntegrationDataPoint } type IntegrationChartValues struct { diff --git a/internal/datastore/postgres/integration.go b/internal/datastore/postgres/integration.go index 0a5a65a3..34434081 100644 --- a/internal/datastore/postgres/integration.go +++ b/internal/datastore/postgres/integration.go @@ -212,6 +212,7 @@ func (i *integrationRepo) AddDataPoint(ctx context.Context, Model(&chart). Where("workspace_integration_id = ?", workspaceIntegration.ID). Where("workspace_id = ?", workspaceIntegration.WorkspaceID). + Where("user_facing_name = ?", value.UserFacingName). Where("internal_name = ?", value.InternalName) if !hermes.IsStringEmpty(value.ProviderID) { diff --git a/internal/integrations/mercury/mercury.go b/internal/integrations/mercury/mercury.go index 82e06135..241faf29 100644 --- a/internal/integrations/mercury/mercury.go +++ b/internal/integrations/mercury/mercury.go @@ -185,8 +185,9 @@ func (m *mercuryClient) Data(ctx context.Context, for _, account := range response.Accounts { dataPoints = append(dataPoints, malak.IntegrationDataValues{ - InternalName: malak.IntegrationChartInternalNameTypeMercuryAccount, - ProviderID: account.ID, + InternalName: malak.IntegrationChartInternalNameTypeMercuryAccount, + ProviderID: account.ID, + UserFacingName: account.Name, Data: malak.IntegrationDataPoint{ DataPointType: malak.IntegrationDataPointTypeCurrency, WorkspaceIntegrationID: opts.IntegrationID, @@ -237,7 +238,8 @@ func (m *mercuryClient) Data(ctx context.Context, } dataPoints = append(dataPoints, malak.IntegrationDataValues{ - InternalName: malak.IntegrationChartInternalNameTypeMercuryAccountTransaction, + InternalName: malak.IntegrationChartInternalNameTypeMercuryAccountTransaction, + UserFacingName: "Transactions count for " + account.Name, Data: malak.IntegrationDataPoint{ DataPointType: malak.IntegrationDataPointTypeOthers, WorkspaceIntegrationID: opts.IntegrationID, diff --git a/web/ui/src/app/(main)/dashboards/[slug]/page.tsx b/web/ui/src/app/(main)/dashboards/[slug]/page.tsx index 3a8339e3..9d620545 100644 --- a/web/ui/src/app/(main)/dashboards/[slug]/page.tsx +++ b/web/ui/src/app/(main)/dashboards/[slug]/page.tsx @@ -45,7 +45,7 @@ import { } from "@/components/ui/command"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import client from "@/lib/client"; -import { LIST_CHARTS, DASHBOARD_DETAIL } from "@/lib/query-constants"; +import { LIST_CHARTS, DASHBOARD_DETAIL, FETCH_CHART_DATA_POINTS } from "@/lib/query-constants"; import type { ServerAPIStatus, ServerListIntegrationChartsResponse, ServerListDashboardChartsResponse, MalakDashboardChart @@ -53,49 +53,17 @@ import type { import { toast } from "sonner"; import { AxiosError } from "axios"; -// Mock data for bar charts -const revenueData = [ - { month: "Day 1", revenue: 2400 }, - { month: "Day 2", revenue: 1398 }, - { month: "Day 3", revenue: 9800 }, - { month: "Day 4", revenue: 3908 }, - { month: "Day 5", revenue: 4800 }, - { month: "Day 6", revenue: 3800 }, - { month: "Day 7", revenue: 5200 }, - { month: "Day 8", revenue: 4100 }, - { month: "Day 9", revenue: 6300 }, - { month: "Day 10", revenue: 5400 }, - { month: "Day 11", revenue: 4700 }, - { month: "Day 12", revenue: 3900 }, - { month: "Day 13", revenue: 5600 }, - { month: "Day 14", revenue: 4800 }, - { month: "Day 15", revenue: 6100 }, - { month: "Day 16", revenue: 5300 }, - { month: "Day 17", revenue: 4500 }, - { month: "Day 18", revenue: 3700 }, - { month: "Day 19", revenue: 5900 }, - { month: "Day 20", revenue: 4200 }, - { month: "Day 21", revenue: 6400 }, - { month: "Day 22", revenue: 5500 }, - { month: "Day 23", revenue: 4600 }, - { month: "Day 24", revenue: 3800 }, - { month: "Day 25", revenue: 5700 }, - { month: "Day 26", revenue: 4900 }, - { month: "Day 27", revenue: 6200 }, - { month: "Day 28", revenue: 5100 }, - { month: "Day 29", revenue: 4300 }, - { month: "Day 30", revenue: 3600 } -]; - -// Mock data for pie charts -const costData = [ - { name: "Infrastructure", value: 400, color: "#0088FE" }, - { name: "Marketing", value: 300, color: "#00C49F" }, - { name: "Development", value: 500, color: "#FFBB28" }, - { name: "Operations", value: 200, color: "#FF8042" }, -]; - function ChartCard({ chart }: { chart: MalakDashboardChart }) { + const { data: chartData, isLoading: isLoadingChartData, error } = useQuery({ + queryKey: [FETCH_CHART_DATA_POINTS, chart.chart?.reference], + queryFn: async () => { + if (!chart.chart?.reference) return null; + const response = await client.dashboards.chartsDetail(chart.chart.reference); + return response.data; + }, + enabled: !!chart.chart?.reference, + }); + const getChartIcon = (type: string) => { switch (type) { case "bar": @@ -107,15 +75,25 @@ function ChartCard({ chart }: { chart: MalakDashboardChart }) { } }; - const getChartData = (chart: MalakDashboardChart) => { - // TODO: Replace with real data from the chart's data source - return chart.chart?.chart_type === "bar" ? revenueData : costData; + const formatChartData = (dataPoints: any[] | undefined) => { + if (!dataPoints) return []; + + // Transform data points into the format expected by recharts + return dataPoints.map(point => ({ + name: point.point_name, + value: point.point_value, + // For bar charts, use a consistent key name + revenue: point.point_value, + })); }; if (!chart.chart) { return null; } + const formattedData = formatChartData(chartData?.data_points); + const hasNoData = !formattedData || formattedData.length === 0; + return (
@@ -142,11 +120,27 @@ function ChartCard({ chart }: { chart: MalakDashboardChart }) {
- {chart.chart.chart_type === "bar" ? ( + {isLoadingChartData ? ( +
+ +
+ ) : error ? ( +
+ +

Failed to load chart data

+

Please try again later

+
+ ) : hasNoData ? ( +
+ +

No data available

+

Check back later for updates

+
+ ) : chart.chart.chart_type === "bar" ? ( - - + + @@ -158,7 +152,7 @@ function ChartCard({ chart }: { chart: MalakDashboardChart }) { - {costData.map((entry, index) => ( - + {formattedData.map((entry, index) => ( + ))} diff --git a/web/ui/src/lib/query-constants.ts b/web/ui/src/lib/query-constants.ts index d7945960..044b6fd2 100644 --- a/web/ui/src/lib/query-constants.ts +++ b/web/ui/src/lib/query-constants.ts @@ -33,5 +33,6 @@ export const UPDATE_INTEGRATION_SETTINGS = 'UPDATE_INTEGRATION_SETTINGS' as cons export const DISABLE_INTEGRATION = 'DISABLE_INTEGRATION' as const; export const CREATE_DASHBOARD = 'CREATE_DASHBOARD' as const; export const LIST_DASHBOARDS = "LIST_DASHBOARDS" as const; -export const LIST_CHARTS = "LIST_CHARTS"; +export const LIST_CHARTS = "LIST_CHARTS" as const; export const DASHBOARD_DETAIL = "DASHBOARD_DETAIL" as const; +export const FETCH_CHART_DATA_POINTS = "FETCH_CHART_DATA_POINTS" as const; From b4b1237bfc1e30ace22b6020df7c153abc6595be Mon Sep 17 00:00:00 2001 From: Lanre Adelowo Date: Fri, 21 Feb 2025 18:12:34 +0100 Subject: [PATCH 05/15] fix chart --- internal/integrations/brex/brex.go | 8 +- .../src/app/(main)/dashboards/[slug]/page.tsx | 80 +++++++++++++------ 2 files changed, 60 insertions(+), 28 deletions(-) diff --git a/internal/integrations/brex/brex.go b/internal/integrations/brex/brex.go index 4bdb76df..88a22b50 100644 --- a/internal/integrations/brex/brex.go +++ b/internal/integrations/brex/brex.go @@ -305,8 +305,9 @@ func (m *brexClient) Data(ctx context.Context, for _, account := range accountsResponse.Items { dataPoints = append(dataPoints, malak.IntegrationDataValues{ - InternalName: malak.IntegrationChartInternalNameTypeBrexAccount, - ProviderID: account.ID, + InternalName: malak.IntegrationChartInternalNameTypeBrexAccount, + ProviderID: account.ID, + UserFacingName: account.Name, Data: malak.IntegrationDataPoint{ DataPointType: malak.IntegrationDataPointTypeCurrency, WorkspaceIntegrationID: opts.IntegrationID, @@ -327,7 +328,8 @@ func (m *brexClient) Data(ctx context.Context, } dataPoints = append(dataPoints, malak.IntegrationDataValues{ - InternalName: malak.IntegrationChartInternalNameTypeBrexAccountTransaction, + InternalName: malak.IntegrationChartInternalNameTypeBrexAccountTransaction, + UserFacingName: "Transactions count for " + account.Name, Data: malak.IntegrationDataPoint{ DataPointType: malak.IntegrationDataPointTypeOthers, WorkspaceIntegrationID: opts.IntegrationID, diff --git a/web/ui/src/app/(main)/dashboards/[slug]/page.tsx b/web/ui/src/app/(main)/dashboards/[slug]/page.tsx index 9d620545..ce63f0dd 100644 --- a/web/ui/src/app/(main)/dashboards/[slug]/page.tsx +++ b/web/ui/src/app/(main)/dashboards/[slug]/page.tsx @@ -48,7 +48,7 @@ import client from "@/lib/client"; import { LIST_CHARTS, DASHBOARD_DETAIL, FETCH_CHART_DATA_POINTS } from "@/lib/query-constants"; import type { ServerAPIStatus, ServerListIntegrationChartsResponse, - ServerListDashboardChartsResponse, MalakDashboardChart + ServerListDashboardChartsResponse, MalakDashboardChart, MalakIntegrationDataPoint } from "@/client/Api"; import { toast } from "sonner"; import { AxiosError } from "axios"; @@ -64,7 +64,7 @@ function ChartCard({ chart }: { chart: MalakDashboardChart }) { enabled: !!chart.chart?.reference, }); - const getChartIcon = (type: string) => { + const getChartIcon = (type: string | undefined) => { switch (type) { case "bar": return ; @@ -75,16 +75,23 @@ function ChartCard({ chart }: { chart: MalakDashboardChart }) { } }; - const formatChartData = (dataPoints: any[] | undefined) => { + const formatChartData = (dataPoints: MalakIntegrationDataPoint[] | undefined): Array<{ + name: string; + value: number; + }> => { if (!dataPoints) return []; // Transform data points into the format expected by recharts - return dataPoints.map(point => ({ - name: point.point_name, - value: point.point_value, - // For bar charts, use a consistent key name - revenue: point.point_value, - })); + return dataPoints.map(point => { + const value = point.data_point_type === 'currency' + ? (point.point_value || 0) / 100 + : point.point_value || 0; + + return { + name: point.point_name || '', + value, + }; + }); }; if (!chart.chart) { @@ -92,6 +99,29 @@ function ChartCard({ chart }: { chart: MalakDashboardChart }) { } const formattedData = formatChartData(chartData?.data_points); + + if (isLoadingChartData) { + return ( + +
+ +
+
+ ); + } + + if (error) { + return ( + +
+ +

Failed to load chart data

+

Please try again later

+
+
+ ); + } + const hasNoData = !formattedData || formattedData.length === 0; return ( @@ -99,11 +129,11 @@ function ChartCard({ chart }: { chart: MalakDashboardChart }) {
- {getChartIcon(chart.chart.chart_type || "bar")} + {getChartIcon(chart.chart.chart_type)}

{chart.chart.user_facing_name}

-

{chart.chart.internal_name}

+
@@ -120,17 +150,7 @@ function ChartCard({ chart }: { chart: MalakDashboardChart }) {
- {isLoadingChartData ? ( -
- -
- ) : error ? ( -
- -

Failed to load chart data

-

Please try again later

-
- ) : hasNoData ? ( + {hasNoData ? (

No data available

@@ -142,8 +162,13 @@ function ChartCard({ chart }: { chart: MalakDashboardChart }) { - - + { + if (chartData?.data_points?.[0]?.data_point_type === 'currency') { + return [`$${value.toFixed(2)}`, 'Value']; + } + return [value, 'Value']; + }} /> + @@ -164,7 +189,12 @@ function ChartCard({ chart }: { chart: MalakDashboardChart }) { ))} - + { + if (chartData?.data_points?.[0]?.data_point_type === 'currency') { + return [`$${value.toFixed(2)}`, 'Value']; + } + return [value, 'Value']; + }} /> From 4845b00e2490bbf069369e5d0289b7a05268dd4b Mon Sep 17 00:00:00 2001 From: Lanre Adelowo Date: Fri, 21 Feb 2025 18:14:07 +0100 Subject: [PATCH 06/15] fix --- web/ui/src/app/(main)/dashboards/[slug]/page.tsx | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/web/ui/src/app/(main)/dashboards/[slug]/page.tsx b/web/ui/src/app/(main)/dashboards/[slug]/page.tsx index ce63f0dd..9ded01df 100644 --- a/web/ui/src/app/(main)/dashboards/[slug]/page.tsx +++ b/web/ui/src/app/(main)/dashboards/[slug]/page.tsx @@ -80,10 +80,10 @@ function ChartCard({ chart }: { chart: MalakDashboardChart }) { value: number; }> => { if (!dataPoints) return []; - + // Transform data points into the format expected by recharts return dataPoints.map(point => { - const value = point.data_point_type === 'currency' + const value = point.data_point_type === 'currency' ? (point.point_value || 0) / 100 : point.point_value || 0; @@ -132,8 +132,7 @@ function ChartCard({ chart }: { chart: MalakDashboardChart }) { {getChartIcon(chart.chart.chart_type)}
-

{chart.chart.user_facing_name}

- +

{chart.chart.user_facing_name}

@@ -143,9 +142,7 @@ function ChartCard({ chart }: { chart: MalakDashboardChart }) { - Edit Chart - Duplicate - Delete + Remove from dashboard
From cfaf9e4a073e074175302165905edb9e0835f3d2 Mon Sep 17 00:00:00 2001 From: Lanre Adelowo Date: Fri, 21 Feb 2025 18:44:18 +0100 Subject: [PATCH 07/15] implement basic drag and drop for users to be able to arrabge as they like --- web/ui/bun.lockb | Bin 463877 -> 469570 bytes web/ui/package.json | 4 + .../src/app/(main)/dashboards/[slug]/page.tsx | 259 +++++++++++++----- .../dashboards/[slug]/styles.module.css | 21 ++ 4 files changed, 213 insertions(+), 71 deletions(-) create mode 100644 web/ui/src/app/(main)/dashboards/[slug]/styles.module.css diff --git a/web/ui/bun.lockb b/web/ui/bun.lockb index cde4a638992fabade107d2d13d9809b104fa9dc6..69de268ac0bbf28b292ddf3a9c1cfc6551260709 100755 GIT binary patch delta 94478 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%0Ay@P91~8%&c9+(50jzcBkUe917R{izM3=~cK-2* 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 DlW~_p delta 90686 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 => { - if (!dataPoints) return []; - - // Transform data points into the format expected by recharts - return dataPoints.map(point => { - const value = point.data_point_type === 'currency' - ? (point.point_value || 0) / 100 - : point.point_value || 0; - - return { - name: point.point_name || '', - value, - }; - }); - }; + const formatChartData = + (dataPoints: MalakIntegrationDataPoint[] | undefined): Array<{ + name: string; + value: number; + }> => { + if (!dataPoints) return []; + + return dataPoints.map(point => { + const value = point.data_point_type === 'currency' + ? (point.point_value || 0) / 100 + : point.point_value || 0; + + return { + name: point.point_name || '', + value, + }; + }); + }; if (!chart.chart) { return null; @@ -125,7 +142,7 @@ function ChartCard({ chart }: { chart: MalakDashboardChart }) { const hasNoData = !formattedData || formattedData.length === 0; return ( - +
@@ -155,45 +172,50 @@ function ChartCard({ chart }: { chart: MalakDashboardChart }) {
) : chart.chart.chart_type === "bar" ? ( - - - - - { - if (chartData?.data_points?.[0]?.data_point_type === 'currency') { - return [`$${value.toFixed(2)}`, 'Value']; - } - return [value, 'Value']; - }} /> - - - + + + + { + if (chartData?.data_points?.[0]?.data_point_type === 'currency') { + return [`$${value.toFixed(2)}`, 'Value']; + } + return [value, 'Value']; + }} /> + + ) : ( - - - `${name} ${(percent * 100).toFixed(0)}%`} - outerRadius={60} - dataKey="value" - > - {formattedData.map((entry, index) => ( - - ))} - - { - if (chartData?.data_points?.[0]?.data_point_type === 'currency') { - return [`$${value.toFixed(2)}`, 'Value']; - } - return [value, 'Value']; - }} /> - - + + `${name} ${(percent * 100).toFixed(0)}%`} + outerRadius={60} + dataKey="value" + > + {formattedData.map((entry, index) => ( + + ))} + + { + if (chartData?.data_points?.[0]?.data_point_type === 'currency') { + return [`$${value.toFixed(2)}`, 'Value']; + } + return [value, 'Value']; + }} /> + )}
@@ -201,6 +223,42 @@ function ChartCard({ chart }: { chart: MalakDashboardChart }) { ); } +function SortableChartCard({ chart }: { chart: MalakDashboardChart }) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: chart.reference || '' }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + zIndex: isDragging ? 50 : undefined, + position: 'relative' as const, + opacity: isDragging ? 0.5 : undefined, + }; + + return ( +
+
+ +
+ +
+ ); +} + export default function DashboardPage() { const params = useParams(); const dashboardID = params.slug as string; @@ -261,6 +319,48 @@ export default function DashboardPage() { addChartMutation.mutate(selectedChart); }; + const [charts, setCharts] = useState([]); + + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 8, // 8px movement required before drag starts + }, + }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }) + ); + + useEffect(() => { + if (dashboardData?.charts) { + setCharts(dashboardData.charts); + } + }, [dashboardData?.charts]); + + function handleDragEnd(event: any) { + const { active, over } = event; + + if (active.id !== over?.id) { + setCharts((items) => { + const oldIndex = items.findIndex((item) => item.reference === active.id); + const newIndex = items.findIndex((item) => item.reference === over.id); + const newItems = arrayMove(items, oldIndex, newIndex); + + // Log the new positions + const positions = newItems.map((item, index) => ({ + chart_id: item.reference, + index + })); + console.log('New chart positions:', positions); + + return newItems; + }); + + // TODO: Call API to update chart positions + } + } + if (isLoadingDashboard) { return (
@@ -400,20 +500,37 @@ export default function DashboardPage() {
-
- {!dashboardData?.charts || dashboardData.charts.length === 0 ? ( -
- -

No charts yet

-

Get started by adding your first chart to this dashboard.

- -
- ) : ( - dashboardData.charts.map((chart) => ( - - )) - )} -
+ { + // Add haptic feedback on mobile + if (window.navigator.vibrate) { + window.navigator.vibrate(100); + } + }} + > +
+ {!charts || charts.length === 0 ? ( +
+ +

No charts yet

+

Get started by adding your first chart to this dashboard.

+ +
+ ) : ( + chart.reference || '')} + strategy={rectSortingStrategy} + > + {charts.map((chart) => ( + + ))} + + )} +
+
); } diff --git a/web/ui/src/app/(main)/dashboards/[slug]/styles.module.css b/web/ui/src/app/(main)/dashboards/[slug]/styles.module.css new file mode 100644 index 00000000..458fee24 --- /dev/null +++ b/web/ui/src/app/(main)/dashboards/[slug]/styles.module.css @@ -0,0 +1,21 @@ +.sortableChart { + transition: transform 200ms cubic-bezier(0.2, 0, 0, 1); +} + +.sortableChartDragging { + z-index: 50; + transition: none; + animation: pulse 1.5s infinite; +} + +@keyframes pulse { + 0% { + box-shadow: 0 0 0 0 rgba(0, 0, 0, 0.1); + } + 70% { + box-shadow: 0 0 0 10px rgba(0, 0, 0, 0); + } + 100% { + box-shadow: 0 0 0 0 rgba(0, 0, 0, 0); + } +} \ No newline at end of file From 07a5c0e5bc4b2abfd3a1f266e9ec55cd6bef86f4 Mon Sep 17 00:00:00 2001 From: Lanre Adelowo Date: Fri, 21 Feb 2025 18:54:57 +0100 Subject: [PATCH 08/15] all card sector should be draggable --- .../src/app/(main)/dashboards/[slug]/page.tsx | 29 +++++++++---------- 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/web/ui/src/app/(main)/dashboards/[slug]/page.tsx b/web/ui/src/app/(main)/dashboards/[slug]/page.tsx index 77f748db..c1eb7ced 100644 --- a/web/ui/src/app/(main)/dashboards/[slug]/page.tsx +++ b/web/ui/src/app/(main)/dashboards/[slug]/page.tsx @@ -152,16 +152,6 @@ function ChartCard({ chart }: { chart: MalakDashboardChart }) {

{chart.chart.user_facing_name}

- - - - - - Remove from dashboard - -
{hasNoData ? ( @@ -246,13 +236,20 @@ function SortableChartCard({ chart }: { chart: MalakDashboardChart }) { ref={setNodeRef} style={style} {...attributes} - className={`touch-none ${styles.sortableChart} ${isDragging ? styles.sortableChartDragging : ''}`} + {...listeners} + className={`touch-none ${styles.sortableChart} ${isDragging ? styles.sortableChartDragging : ''} cursor-grab active:cursor-grabbing`} > -
- +
+ + + + + + Remove from dashboard + +
From ca74ff581b752306452ade8e0308d89409caa56d Mon Sep 17 00:00:00 2001 From: Lanre Adelowo Date: Fri, 21 Feb 2025 19:55:34 +0100 Subject: [PATCH 09/15] fetch positioning of charts --- dashboard.go | 12 ++ internal/datastore/postgres/dashboard.go | 46 ++++++++ ...1182318_create_dashboard_ordering.down.sql | 1 + ...221182318_create_dashboard_ordering.up.sql | 8 ++ mocks/dashboard.go | 30 +++++ server/dashboard.go | 48 ++++++-- server/response.go | 5 +- swagger/docs.go | 26 ++++- swagger/swagger.json | 26 ++++- swagger/swagger.yaml | 16 +++ .../src/app/(main)/dashboards/[slug]/page.tsx | 104 ++++++++++-------- web/ui/src/client/Api.ts | 8 ++ 12 files changed, 270 insertions(+), 60 deletions(-) create mode 100644 internal/datastore/postgres/migrations/20250221182318_create_dashboard_ordering.down.sql create mode 100644 internal/datastore/postgres/migrations/20250221182318_create_dashboard_ordering.up.sql diff --git a/dashboard.go b/dashboard.go index 15134c43..955af5a3 100644 --- a/dashboard.go +++ b/dashboard.go @@ -47,6 +47,15 @@ type DashboardChart struct { bun.BaseModel `json:"-"` } +type DashboardChartPosition struct { + ID uuid.UUID `bun:"type:uuid,default:uuid_generate_v4(),pk" json:"id,omitempty"` + DashboardID uuid.UUID `json:"dashboard_id,omitempty"` + ChartID uuid.UUID `json:"chart_id,omitempty"` + OrderIndex int64 `json:"order_index,omitempty"` + + bun.BaseModel `json:"-"` +} + type ListDashboardOptions struct { Paginator Paginator WorkspaceID uuid.UUID @@ -68,4 +77,7 @@ type DashboardRepository interface { AddChart(context.Context, *DashboardChart) error List(context.Context, ListDashboardOptions) ([]Dashboard, int64, error) GetCharts(context.Context, FetchDashboardChartsOption) ([]DashboardChart, error) + + UpdateDashboardPositions(context.Context, []DashboardChartPosition) error + GetDashboardPositions(context.Context, uuid.UUID) ([]DashboardChartPosition, error) } diff --git a/internal/datastore/postgres/dashboard.go b/internal/datastore/postgres/dashboard.go index 2490860c..84e1c5b2 100644 --- a/internal/datastore/postgres/dashboard.go +++ b/internal/datastore/postgres/dashboard.go @@ -7,6 +7,7 @@ import ( "time" "github.com/ayinke-llc/malak" + "github.com/google/uuid" "github.com/uptrace/bun" ) @@ -128,3 +129,48 @@ func (d *dashboardRepo) GetCharts(ctx context.Context, return charts, err } + +func (d *dashboardRepo) GetDashboardPositions(ctx context.Context, + dashboardID uuid.UUID) ([]malak.DashboardChartPosition, error) { + + ctx, cancelFn := withContext(ctx) + defer cancelFn() + + positions := make([]malak.DashboardChartPosition, 0) + + err := d.inner.NewSelect(). + Model(&positions). + Where("dashboard_id = ?", dashboardID). + Scan(ctx) + + return positions, err +} + +func (d *dashboardRepo) UpdateDashboardPositions(ctx context.Context, + positions []malak.DashboardChartPosition) error { + + ctx, cancelFn := withContext(ctx) + defer cancelFn() + + return d.inner.RunInTx(ctx, &sql.TxOptions{}, + func(ctx context.Context, tx bun.Tx) error { + + dashboardIDs := []uuid.UUID{} + + for _, v := range positions { + dashboardIDs = append(dashboardIDs, v.DashboardID) + } + + _, err := tx.NewDelete(). + Model(new(malak.DashboardChartPosition)). + Where("dashboard_id IN (?)", bun.In(dashboardIDs)). + Exec(ctx) + if err != nil { + return err + } + + _, err = tx.NewInsert().Model(&positions). + Exec(ctx) + return err + }) +} diff --git a/internal/datastore/postgres/migrations/20250221182318_create_dashboard_ordering.down.sql b/internal/datastore/postgres/migrations/20250221182318_create_dashboard_ordering.down.sql new file mode 100644 index 00000000..7038d408 --- /dev/null +++ b/internal/datastore/postgres/migrations/20250221182318_create_dashboard_ordering.down.sql @@ -0,0 +1 @@ +DROP TABLE dashboard_chart_positions; diff --git a/internal/datastore/postgres/migrations/20250221182318_create_dashboard_ordering.up.sql b/internal/datastore/postgres/migrations/20250221182318_create_dashboard_ordering.up.sql new file mode 100644 index 00000000..5ed922dc --- /dev/null +++ b/internal/datastore/postgres/migrations/20250221182318_create_dashboard_ordering.up.sql @@ -0,0 +1,8 @@ +CREATE TABLE dashboard_chart_positions ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + dashboard_id uuid NOT NULL REFERENCES dashboards(id), + chart_id uuid NOT NULL REFERENCES dashboard_charts(id), + order_index INT NOT NULL +); + +ALTER TABLE dashboard_chart_positions ADD CONSTRAINT one_position_per_chart UNIQUE (dashboard_id, chart_id, order_index); diff --git a/mocks/dashboard.go b/mocks/dashboard.go index 26490c43..b76122f8 100644 --- a/mocks/dashboard.go +++ b/mocks/dashboard.go @@ -14,6 +14,7 @@ import ( reflect "reflect" malak "github.com/ayinke-llc/malak" + uuid "github.com/google/uuid" gomock "go.uber.org/mock/gomock" ) @@ -99,6 +100,21 @@ func (mr *MockDashboardRepositoryMockRecorder) GetCharts(arg0, arg1 any) *gomock return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetCharts", reflect.TypeOf((*MockDashboardRepository)(nil).GetCharts), arg0, arg1) } +// GetDashboardPositions mocks base method. +func (m *MockDashboardRepository) GetDashboardPositions(arg0 context.Context, arg1 uuid.UUID) ([]malak.DashboardChartPosition, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetDashboardPositions", arg0, arg1) + ret0, _ := ret[0].([]malak.DashboardChartPosition) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetDashboardPositions indicates an expected call of GetDashboardPositions. +func (mr *MockDashboardRepositoryMockRecorder) GetDashboardPositions(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetDashboardPositions", reflect.TypeOf((*MockDashboardRepository)(nil).GetDashboardPositions), arg0, arg1) +} + // List mocks base method. func (m *MockDashboardRepository) List(arg0 context.Context, arg1 malak.ListDashboardOptions) ([]malak.Dashboard, int64, error) { m.ctrl.T.Helper() @@ -114,3 +130,17 @@ func (mr *MockDashboardRepositoryMockRecorder) List(arg0, arg1 any) *gomock.Call mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockDashboardRepository)(nil).List), arg0, arg1) } + +// UpdateDashboardPositions mocks base method. +func (m *MockDashboardRepository) UpdateDashboardPositions(arg0 context.Context, arg1 []malak.DashboardChartPosition) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateDashboardPositions", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateDashboardPositions indicates an expected call of UpdateDashboardPositions. +func (mr *MockDashboardRepositoryMockRecorder) UpdateDashboardPositions(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateDashboardPositions", reflect.TypeOf((*MockDashboardRepository)(nil).UpdateDashboardPositions), arg0, arg1) +} diff --git a/server/dashboard.go b/server/dashboard.go index ee30052b..1ce766f2 100644 --- a/server/dashboard.go +++ b/server/dashboard.go @@ -13,6 +13,7 @@ import ( "github.com/microcosm-cc/bluemonday" "go.opentelemetry.io/otel/trace" "go.uber.org/zap" + "golang.org/x/sync/errgroup" ) type dashboardHandler struct { @@ -348,24 +349,53 @@ func (d *dashboardHandler) fetchDashboard( return newAPIStatus(status, msg), StatusFailed } - charts, err := d.dashboardRepo.GetCharts(ctx, malak.FetchDashboardChartsOption{ - WorkspaceID: workspace.ID, - DashboardID: dashboard.ID, + var g errgroup.Group + + var charts []malak.DashboardChart + var positions []malak.DashboardChartPosition + + g.Go(func() error { + + charts, err = d.dashboardRepo.GetCharts(ctx, malak.FetchDashboardChartsOption{ + WorkspaceID: workspace.ID, + DashboardID: dashboard.ID, + }) + if err != nil { + + logger.Error("could not list dashboard charts", + zap.Error(err)) + + return errors.New("could not list dashboard charts") + } + + return nil }) - if err != nil { - logger.Error("could not list dashboard charts", - zap.Error(err)) + g.Go(func() error { - return newAPIStatus( - http.StatusInternalServerError, - "could not list dashboard charts"), StatusFailed + positions, err = d.dashboardRepo.GetDashboardPositions(ctx, dashboard.ID) + + if err != nil { + + logger.Error("could not list dashboard positions", + zap.Error(err)) + + return errors.New("could not list dashboard positions") + } + + return nil + }) + + if err := g.Wait(); err != nil { + return newAPIStatus(http.StatusInternalServerError, err.Error()), + StatusFailed } return listDashboardChartsResponse{ APIStatus: newAPIStatus(http.StatusOK, "dashboards fetched"), Dashboard: dashboard, Charts: charts, + Positions: positions, }, StatusSuccess } diff --git a/server/response.go b/server/response.go index 68701d23..540db46b 100644 --- a/server/response.go +++ b/server/response.go @@ -88,8 +88,9 @@ type listChartDataPointsResponse struct { } type listDashboardChartsResponse struct { - Charts []malak.DashboardChart `json:"charts,omitempty" validate:"required"` - Dashboard malak.Dashboard `json:"dashboard,omitempty" validate:"required"` + Charts []malak.DashboardChart `json:"charts,omitempty" validate:"required"` + Positions []malak.DashboardChartPosition `json:"positions,omitempty" validate:"required"` + Dashboard malak.Dashboard `json:"dashboard,omitempty" validate:"required"` APIStatus } diff --git a/swagger/docs.go b/swagger/docs.go index 0d7b10e5..94f0143d 100644 --- a/swagger/docs.go +++ b/swagger/docs.go @@ -3163,6 +3163,23 @@ const docTemplate = `{ } } }, + "malak.DashboardChartPosition": { + "type": "object", + "properties": { + "chart_id": { + "type": "string" + }, + "dashboard_id": { + "type": "string" + }, + "id": { + "type": "string" + }, + "order_index": { + "type": "integer" + } + } + }, "malak.Deck": { "type": "object", "properties": { @@ -4411,7 +4428,8 @@ const docTemplate = `{ "required": [ "charts", "dashboard", - "message" + "message", + "positions" ], "properties": { "charts": { @@ -4425,6 +4443,12 @@ const docTemplate = `{ }, "message": { "type": "string" + }, + "positions": { + "type": "array", + "items": { + "$ref": "#/definitions/malak.DashboardChartPosition" + } } } }, diff --git a/swagger/swagger.json b/swagger/swagger.json index 74e3ee87..70f950a4 100644 --- a/swagger/swagger.json +++ b/swagger/swagger.json @@ -295,6 +295,23 @@ }, "type": "object" }, + "malak.DashboardChartPosition": { + "properties": { + "chart_id": { + "type": "string" + }, + "dashboard_id": { + "type": "string" + }, + "id": { + "type": "string" + }, + "order_index": { + "type": "integer" + } + }, + "type": "object" + }, "malak.Deck": { "properties": { "created_at": { @@ -1551,12 +1568,19 @@ }, "message": { "type": "string" + }, + "positions": { + "items": { + "$ref": "#/components/schemas/malak.DashboardChartPosition" + }, + "type": "array" } }, "required": [ "charts", "dashboard", - "message" + "message", + "positions" ], "type": "object" }, diff --git a/swagger/swagger.yaml b/swagger/swagger.yaml index 0759c25e..e48088b1 100644 --- a/swagger/swagger.yaml +++ b/swagger/swagger.yaml @@ -199,6 +199,17 @@ components: workspace_integration_id: type: string type: object + malak.DashboardChartPosition: + properties: + chart_id: + type: string + dashboard_id: + type: string + id: + type: string + order_index: + type: integer + type: object malak.Deck: properties: created_at: @@ -1053,10 +1064,15 @@ components: $ref: '#/components/schemas/malak.Dashboard' message: type: string + positions: + items: + $ref: '#/components/schemas/malak.DashboardChartPosition' + type: array required: - charts - dashboard - message + - positions type: object server.listDashboardResponse: properties: diff --git a/web/ui/src/app/(main)/dashboards/[slug]/page.tsx b/web/ui/src/app/(main)/dashboards/[slug]/page.tsx index c1eb7ced..850565cd 100644 --- a/web/ui/src/app/(main)/dashboards/[slug]/page.tsx +++ b/web/ui/src/app/(main)/dashboards/[slug]/page.tsx @@ -1,60 +1,51 @@ "use client"; +import type { + MalakDashboardChart, MalakIntegrationDataPoint, + ServerAPIStatus, + ServerListDashboardChartsResponse, + ServerListIntegrationChartsResponse +} from "@/client/Api"; +import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; -import { - RiBarChart2Line, RiPieChartLine, RiSettings4Line, - RiArrowDownSLine, RiLoader4Line, RiDragMove2Line -} from "@remixicon/react"; -import { useParams } from "next/navigation"; -import { - Bar, BarChart, ResponsiveContainer, - XAxis, YAxis, Tooltip, PieChart, - Pie, Cell -} from "recharts"; import { ChartContainer } from "@/components/ui/chart"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { Label } from "@/components/ui/label"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; import { Sheet, SheetContent, SheetDescription, + SheetFooter, SheetHeader, SheetTitle, SheetTrigger, - SheetFooter, } from "@/components/ui/sheet"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; -import { useState, useEffect } from "react"; -import { Button } from "@/components/ui/button"; -import { Label } from "@/components/ui/label"; -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, -} from "@/components/ui/command"; -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import client from "@/lib/client"; -import { LIST_CHARTS, DASHBOARD_DETAIL, FETCH_CHART_DATA_POINTS } from "@/lib/query-constants"; -import type { - ServerAPIStatus, ServerListIntegrationChartsResponse, - ServerListDashboardChartsResponse, MalakDashboardChart, MalakIntegrationDataPoint -} from "@/client/Api"; -import { toast } from "sonner"; -import { AxiosError } from "axios"; import { - DndContext, + DASHBOARD_DETAIL, FETCH_CHART_DATA_POINTS, + LIST_CHARTS +} from "@/lib/query-constants"; +import { closestCenter, + DndContext, KeyboardSensor, PointerSensor, useSensor, @@ -62,12 +53,31 @@ import { } from '@dnd-kit/core'; import { arrayMove, + rectSortingStrategy, SortableContext, sortableKeyboardCoordinates, useSortable, - rectSortingStrategy, } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; +import { + RiArrowDownSLine, + RiBarChart2Line, + RiLoader4Line, + RiPieChartLine, RiSettings4Line +} from "@remixicon/react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { AxiosError } from "axios"; +import { useParams } from "next/navigation"; +import { useEffect, useState } from "react"; +import { + Bar, BarChart, + Cell, + Pie, + PieChart, + Tooltip, + XAxis, YAxis +} from "recharts"; +import { toast } from "sonner"; import styles from "./styles.module.css"; function ChartCard({ chart }: { chart: MalakDashboardChart }) { @@ -162,10 +172,10 @@ function ChartCard({ chart }: { chart: MalakDashboardChart }) {
) : chart.chart.chart_type === "bar" ? ( - @@ -181,8 +191,8 @@ function ChartCard({ chart }: { chart: MalakDashboardChart }) { ) : ( - @@ -232,9 +242,9 @@ function SortableChartCard({ chart }: { chart: MalakDashboardChart }) { }; return ( -
([]); - + const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { diff --git a/web/ui/src/client/Api.ts b/web/ui/src/client/Api.ts index 8b4f1b71..e666da80 100644 --- a/web/ui/src/client/Api.ts +++ b/web/ui/src/client/Api.ts @@ -127,6 +127,13 @@ export interface MalakDashboardChart { workspace_integration_id?: string; } +export interface MalakDashboardChartPosition { + chart_id?: string; + dashboard_id?: string; + id?: string; + order_index?: number; +} + export interface MalakDeck { created_at?: string; created_by?: string; @@ -606,6 +613,7 @@ export interface ServerListDashboardChartsResponse { charts: MalakDashboardChart[]; dashboard: MalakDashboard; message: string; + positions: MalakDashboardChartPosition[]; } export interface ServerListDashboardResponse { From 273d63d48c017b34ac30b4b801bcb8be96245e34 Mon Sep 17 00:00:00 2001 From: Lanre Adelowo Date: Fri, 21 Feb 2025 20:24:30 +0100 Subject: [PATCH 10/15] fix api connection with updating charts --- dashboard.go | 2 +- internal/datastore/postgres/dashboard.go | 9 +- mocks/dashboard.go | 8 +- server/auth.go | 4 +- server/contact.go | 20 +- server/dashboard.go | 96 +++++++- server/deck.go | 18 +- server/http.go | 3 + server/update.go | 8 +- server/update_actions.go | 10 +- server/update_analytics.go | 2 +- server/update_image.go | 2 +- server/update_send.go | 4 +- server/workspace.go | 12 +- server/workspace_integration.go | 10 +- swagger/docs.go | 192 +++++++++++----- swagger/swagger.json | 211 +++++++++++++----- swagger/swagger.yaml | 172 +++++++++----- .../src/app/(main)/dashboards/[slug]/page.tsx | 30 ++- web/ui/src/client/Api.ts | 177 +++++++-------- 20 files changed, 664 insertions(+), 326 deletions(-) diff --git a/dashboard.go b/dashboard.go index 955af5a3..3ef556b1 100644 --- a/dashboard.go +++ b/dashboard.go @@ -78,6 +78,6 @@ type DashboardRepository interface { List(context.Context, ListDashboardOptions) ([]Dashboard, int64, error) GetCharts(context.Context, FetchDashboardChartsOption) ([]DashboardChart, error) - UpdateDashboardPositions(context.Context, []DashboardChartPosition) error + UpdateDashboardPositions(context.Context, uuid.UUID, []DashboardChartPosition) error GetDashboardPositions(context.Context, uuid.UUID) ([]DashboardChartPosition, error) } diff --git a/internal/datastore/postgres/dashboard.go b/internal/datastore/postgres/dashboard.go index 84e1c5b2..cc1721ab 100644 --- a/internal/datastore/postgres/dashboard.go +++ b/internal/datastore/postgres/dashboard.go @@ -147,6 +147,7 @@ func (d *dashboardRepo) GetDashboardPositions(ctx context.Context, } func (d *dashboardRepo) UpdateDashboardPositions(ctx context.Context, + dashboardID uuid.UUID, positions []malak.DashboardChartPosition) error { ctx, cancelFn := withContext(ctx) @@ -155,15 +156,9 @@ func (d *dashboardRepo) UpdateDashboardPositions(ctx context.Context, return d.inner.RunInTx(ctx, &sql.TxOptions{}, func(ctx context.Context, tx bun.Tx) error { - dashboardIDs := []uuid.UUID{} - - for _, v := range positions { - dashboardIDs = append(dashboardIDs, v.DashboardID) - } - _, err := tx.NewDelete(). Model(new(malak.DashboardChartPosition)). - Where("dashboard_id IN (?)", bun.In(dashboardIDs)). + Where("dashboard_id = ?", dashboardID). Exec(ctx) if err != nil { return err diff --git a/mocks/dashboard.go b/mocks/dashboard.go index b76122f8..17ec6825 100644 --- a/mocks/dashboard.go +++ b/mocks/dashboard.go @@ -132,15 +132,15 @@ func (mr *MockDashboardRepositoryMockRecorder) List(arg0, arg1 any) *gomock.Call } // UpdateDashboardPositions mocks base method. -func (m *MockDashboardRepository) UpdateDashboardPositions(arg0 context.Context, arg1 []malak.DashboardChartPosition) error { +func (m *MockDashboardRepository) UpdateDashboardPositions(arg0 context.Context, arg1 uuid.UUID, arg2 []malak.DashboardChartPosition) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "UpdateDashboardPositions", arg0, arg1) + ret := m.ctrl.Call(m, "UpdateDashboardPositions", arg0, arg1, arg2) ret0, _ := ret[0].(error) return ret0 } // UpdateDashboardPositions indicates an expected call of UpdateDashboardPositions. -func (mr *MockDashboardRepositoryMockRecorder) UpdateDashboardPositions(arg0, arg1 any) *gomock.Call { +func (mr *MockDashboardRepositoryMockRecorder) UpdateDashboardPositions(arg0, arg1, arg2 any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateDashboardPositions", reflect.TypeOf((*MockDashboardRepository)(nil).UpdateDashboardPositions), arg0, arg1) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateDashboardPositions", reflect.TypeOf((*MockDashboardRepository)(nil).UpdateDashboardPositions), arg0, arg1, arg2) } diff --git a/server/auth.go b/server/auth.go index 78060aa5..82ee864c 100644 --- a/server/auth.go +++ b/server/auth.go @@ -43,7 +43,7 @@ func (a *authenticateUserRequest) Validate() error { return nil } -// @Summary Sign in with a social login provider +// @Description Sign in with a social login provider // @Tags auth // @Accept json // @Produce json @@ -155,7 +155,7 @@ func (a *authHandler) Login( return resp, StatusSuccess } -// @Summary Fetch current user. This api should also double as a token validation api +// @Description Fetch current user. This api should also double as a token validation api // @Tags user // @Accept json // @Produce json diff --git a/server/contact.go b/server/contact.go index 525d7266..3e0d129c 100644 --- a/server/contact.go +++ b/server/contact.go @@ -63,7 +63,7 @@ func (c *createContactRequest) Validate() error { return nil } -// @Summary Creates a new contact +// @Description Creates a new contact // @Tags contacts // @Accept json // @Produce json @@ -148,7 +148,7 @@ func (c *createContactListRequest) Validate() error { return nil } -// @Summary Create a new contact list +// @Description Create a new contact list // @Tags contacts // @id createContactList // @Accept json @@ -202,7 +202,7 @@ func (c *contactHandler) createContactList( }, StatusSuccess } -// @Summary List all created contact lists +// @Description List all created contact lists // @Tags contacts // @id fetchContactLists // @Accept json @@ -261,7 +261,7 @@ func (c *contactHandler) fetchContactLists( }, StatusSuccess } -// @Summary Edit a contact list +// @Description Edit a contact list // @Tags contacts // @id editContactList // @Accept json @@ -329,7 +329,7 @@ func (c *contactHandler) editContactList( }, StatusSuccess } -// @Summary delete a contact list +// @Description delete a contact list // @Tags contacts // @id deleteContactList // @Accept json @@ -394,7 +394,7 @@ func (c *addContactToListRequest) Validate() error { return nil } -// @Summary add a new contact to a list +// @Description add a new contact to a list // @Tags contacts // @id addEmailToContactList // @Accept json @@ -479,7 +479,7 @@ func (c *contactHandler) addUserToContactList( return newAPIStatus(http.StatusCreated, "list was successfully updated with contact"), StatusSuccess } -// @Summary list your contacts +// @Description list your contacts // @Tags contacts // @Accept json // @Produce json @@ -532,7 +532,7 @@ func (c *contactHandler) list( }, StatusSuccess } -// @Summary fetch a contact by reference +// @Description fetch a contact by reference // @Tags contacts // @Accept json // @Produce json @@ -601,7 +601,7 @@ func (c *contactHandler) fetchContact( }, StatusSuccess } -// @Summary delete a contact +// @Description delete a contact // @Tags contacts // @id deleteContact // @Accept json @@ -731,7 +731,7 @@ func (c *editContactRequest) Validate() error { return nil } -// @Summary edit a contact +// @Description edit a contact // @Tags contacts // @Accept json // @Produce json diff --git a/server/dashboard.go b/server/dashboard.go index 1ce766f2..bc72bf49 100644 --- a/server/dashboard.go +++ b/server/dashboard.go @@ -10,6 +10,7 @@ import ( "github.com/ayinke-llc/malak/config" "github.com/go-chi/chi/v5" "github.com/go-chi/render" + "github.com/google/uuid" "github.com/microcosm-cc/bluemonday" "go.opentelemetry.io/otel/trace" "go.uber.org/zap" @@ -55,7 +56,7 @@ func (c *createDashboardRequest) Validate() error { return nil } -// @Summary create a new dashboard +// @Description create a new dashboard // @Tags dashboards // @Accept json // @Produce json @@ -107,7 +108,7 @@ func (d *dashboardHandler) create( }, StatusSuccess } -// @Summary List dashboards +// @Description List dashboards // @Tags dashboards // @Accept json // @Produce json @@ -159,7 +160,7 @@ func (d *dashboardHandler) list( }, StatusSuccess } -// @Summary List charts +// @Description List charts // @Tags dashboards // @Accept json // @Produce json @@ -211,7 +212,7 @@ func (c *addChartToDashboardRequest) Validate() error { return nil } -// @Summary add a chart to a dashboard +// @Description add a chart to a dashboard // @Tags dashboards // @Accept json // @Produce json @@ -304,7 +305,7 @@ func (d *dashboardHandler) addChart( StatusSuccess } -// @Summary fetch dashboard +// @Description fetch dashboard // @Tags dashboards // @Accept json // @Produce json @@ -399,7 +400,7 @@ func (d *dashboardHandler) fetchDashboard( }, StatusSuccess } -// @Summary fetch charting data +// @Description fetch charting data // @Tags dashboards // @Accept json // @Produce json @@ -460,3 +461,86 @@ func (d *dashboardHandler) fetchChartingData( DataPoints: dataPoints, }, StatusSuccess } + +type updateDashboardPositionsRequest struct { + Positions []struct { + ChartID uuid.UUID `json:"chart_id,omitempty" validate:"required"` + Index int64 `json:"index,omitempty" validate:"required"` + } `json:"positions,omitempty" validate:"required"` + GenericRequest +} + +// @Description update dashboard chart positioning +// @Tags dashboards +// @Accept json +// @Produce json +// @Param message body updateDashboardPositionsRequest true "dashboard chart positions" @Param reference path string required "dashboard unique reference.. e.g dashboard_22" @Success 200 {object} APIStatus +// @Failure 400 {object} APIStatus +// @Failure 401 {object} APIStatus +// @Failure 404 {object} APIStatus +// @Failure 500 {object} APIStatus +// @Router /dashboards/{reference}/positions [POST] +func (d *dashboardHandler) updateDashboardPositions( + ctx context.Context, + span trace.Span, + logger *zap.Logger, + w http.ResponseWriter, + r *http.Request) (render.Renderer, Status) { + + logger.Debug("updating dashboard chart positions") + + workspace := getWorkspaceFromContext(r.Context()) + + ref := chi.URLParam(r, "reference") + + if hermes.IsStringEmpty(ref) { + return newAPIStatus(http.StatusBadRequest, "reference required"), StatusFailed + } + + req := new(updateDashboardPositionsRequest) + + if err := render.Bind(r, req); err != nil { + return newAPIStatus(http.StatusBadRequest, "invalid request body"), StatusFailed + } + + dashboard, err := d.dashboardRepo.Get(ctx, malak.FetchDashboardOption{ + Reference: malak.Reference(ref), + WorkspaceID: workspace.ID, + }) + if err != nil { + logger.Error("could not fetch dashboard", zap.Error(err)) + status := http.StatusInternalServerError + msg := "an error occurred while fetching dashboard" + + if errors.Is(err, malak.ErrDashboardNotFound) { + status = http.StatusNotFound + msg = err.Error() + } + + return newAPIStatus(status, msg), StatusFailed + } + + // No validation here but leaving it to the db layer since we have relationship + // mapping guarantees + positions := make([]malak.DashboardChartPosition, 0, len(req.Positions)) + + for _, v := range req.Positions { + positions = append(positions, malak.DashboardChartPosition{ + OrderIndex: v.Index, + ChartID: v.ChartID, + DashboardID: dashboard.ID, + }) + } + + if err := d.dashboardRepo.UpdateDashboardPositions(ctx, dashboard.ID, positions); err != nil { + + logger.Error("could not update dashboard positions", + zap.Error(err)) + + return newAPIStatus( + http.StatusInternalServerError, + "could not update dashboard positions"), StatusFailed + } + + return newAPIStatus(http.StatusOK, "datapoints fetched"), StatusSuccess +} diff --git a/server/deck.go b/server/deck.go index 9d214222..7d88f161 100644 --- a/server/deck.go +++ b/server/deck.go @@ -45,7 +45,7 @@ func hashURL(rawURL string) (string, error) { return "deck-" + fmt.Sprintf("%x", hasher.Sum64()), nil } -// @Summary Upload a deck +// @Description Upload a deck // @Tags decks // @id uploadDeck // @Accept json @@ -148,7 +148,7 @@ func (c *createDeckRequest) Validate() error { return nil } -// @Summary Creates a new deck +// @Description Creates a new deck // @Tags decks // @Accept json // @Produce json @@ -249,7 +249,7 @@ func (d *deckHandler) Create( }, StatusSuccess } -// @Summary list all decks. No pagination +// @Description list all decks. No pagination // @Tags decks // @Accept json // @Produce json @@ -283,7 +283,7 @@ func (d *deckHandler) List( }, StatusSuccess } -// @Summary delete a deck +// @Description delete a deck // @Tags decks // @Accept json // @Produce json @@ -336,7 +336,7 @@ func (d *deckHandler) Delete( return newAPIStatus(http.StatusOK, "deleted your deck"), StatusSuccess } -// @Summary fetch a deck +// @Description fetch a deck // @Tags decks // @Accept json // @Produce json @@ -407,7 +407,7 @@ func (u *updateDeckPreferencesRequest) Validate() error { return nil } -// @Summary update a deck preferences +// @Description update a deck preferences // @Tags decks // @Accept json // @Produce json @@ -482,7 +482,7 @@ func (d *deckHandler) updatePreferences( }, StatusSuccess } -// @Summary toggle archive status of a deck +// @Description toggle archive status of a deck // @Tags decks // @Accept json // @Produce json @@ -538,7 +538,7 @@ func (d *deckHandler) toggleArchive( }, StatusSuccess } -// @Summary toggle pinned status of a deck +// @Description toggle pinned status of a deck // @Tags decks // @Accept json // @Produce json @@ -594,7 +594,7 @@ func (d *deckHandler) togglePinned( }, StatusSuccess } -// @Summary public api to fetch a deck +// @Description public api to fetch a deck // @Tags decks-viewer // @Accept json // @Produce json diff --git a/server/http.go b/server/http.go index 1febe8f0..75634a5b 100644 --- a/server/http.go +++ b/server/http.go @@ -400,6 +400,9 @@ func buildRoutes( r.Get("/{reference}", WrapMalakHTTPHandler(logger, dashHandler.fetchDashboard, cfg, "dashboards.fetch")) + r.Post("/{reference}/positions", + WrapMalakHTTPHandler(logger, dashHandler.updateDashboardPositions, cfg, "dashboards.positions.update")) + r.Put("/{reference}/charts", WrapMalakHTTPHandler(logger, dashHandler.addChart, cfg, "dashboards.charts.add")) }) diff --git a/server/update.go b/server/update.go index 568e726f..342dd9a4 100644 --- a/server/update.go +++ b/server/update.go @@ -50,7 +50,7 @@ func (c *createUpdateContent) Validate() error { return nil } -// @Summary Create a new update +// @Description Create a new update // @Tags updates // @Accept json // @Produce json @@ -142,7 +142,7 @@ func (u *updatesHandler) create( }, StatusSuccess } -// @Summary List updates +// @Description List updates // @Tags updates // @Accept json // @Produce json @@ -269,7 +269,7 @@ type BlockContentItem struct { Attrs map[string]interface{} `json:"attrs,omitempty"` } -// @Summary Update a specific update +// @Description Update a specific update // @Tags updates // @id updateContent // @Accept json @@ -335,7 +335,7 @@ func (u *updatesHandler) update( "updates stored"), StatusSuccess } -// @Summary List pinned updates +// @Description List pinned updates // @Tags updates // @Accept json // @Produce json diff --git a/server/update_actions.go b/server/update_actions.go index ad8d518d..8cce9d7e 100644 --- a/server/update_actions.go +++ b/server/update_actions.go @@ -14,7 +14,7 @@ import ( "go.uber.org/zap" ) -// @Summary Duplicate a specific update +// @Description Duplicate a specific update // @Tags updates // @id duplicateUpdate // @Accept json @@ -80,7 +80,7 @@ func (u *updatesHandler) duplicate( } // @Tags updates -// @Summary Delete a specific update +// @Description Delete a specific update // @id deleteUpdate // @Accept json // @Produce json @@ -134,7 +134,7 @@ func (u *updatesHandler) delete( } // @Tags updates -// @Summary Toggle pinned status a specific update +// @Description Toggle pinned status a specific update // @id toggleUpdatePin // @Accept json // @Produce json @@ -195,7 +195,7 @@ func (u *updatesHandler) togglePinned( } // @Tags updates -// @Summary Fetch a specific update +// @Description Fetch a specific update // @id fetchUpdate // @Accept json // @Produce json @@ -243,7 +243,7 @@ func (u *updatesHandler) fetchUpdate( } // @Tags updates -// @Summary Fetch a specific update +// @Description Fetch a specific update // @Id reactPost // @Accept json // @Produce json diff --git a/server/update_analytics.go b/server/update_analytics.go index 895fe752..5b4d3b97 100644 --- a/server/update_analytics.go +++ b/server/update_analytics.go @@ -16,7 +16,7 @@ import ( ) // @Tags updates -// @Summary Fetch analytics for a specific update +// @Description Fetch analytics for a specific update // @id fetchUpdateAnalytics // @Accept json // @Produce json diff --git a/server/update_image.go b/server/update_image.go index 9b336d50..72d3574b 100644 --- a/server/update_image.go +++ b/server/update_image.go @@ -11,7 +11,7 @@ import ( "go.uber.org/zap" ) -// @Summary Upload an image +// @Description Upload an image // @Tags images // @id uploadImage // @Accept json diff --git a/server/update_send.go b/server/update_send.go index 08020f92..6770d4d2 100644 --- a/server/update_send.go +++ b/server/update_send.go @@ -38,7 +38,7 @@ func (p *previewUpdateRequest) Validate() error { } // @Tags updates -// @Summary Send preview of an update +// @Description Send preview of an update // @id previewUpdate // @Accept json // @Produce json @@ -190,7 +190,7 @@ func (s *sendUpdateRequest) Validate() error { } // @Tags updates -// @Summary Send an update to real users +// @Description Send an update to real users // @id sendUpdate // @Accept json // @Produce json diff --git a/server/workspace.go b/server/workspace.go index 895a8c86..1db62cfa 100644 --- a/server/workspace.go +++ b/server/workspace.go @@ -55,7 +55,7 @@ func (c *createWorkspaceRequest) Validate() error { return nil } -// @Summary Create a new workspace +// @Description Create a new workspace // @Tags workspace // @Accept json // @Produce json @@ -136,7 +136,7 @@ func (wo *workspaceHandler) createWorkspace( }, StatusSuccess } -// @Summary Switch current workspace +// @Description Switch current workspace // @Tags workspace // @Accept json // @Produce json @@ -247,7 +247,7 @@ func (u *updateWorkspaceRequest) Validate() error { return nil } -// @Summary update workspace details +// @Description update workspace details // @Tags workspace // @Accept json // @Produce json @@ -320,7 +320,7 @@ func (wo *workspaceHandler) updateWorkspace( }, StatusSuccess } -// @Summary fetch workspace preferences +// @Description fetch workspace preferences // @Tags workspace // @Accept json // @Produce json @@ -378,7 +378,7 @@ func (u *updatePreferencesRequest) Make(current *malak.Preference) *malak.Prefer return current } -// @Summary update workspace preferences +// @Description update workspace preferences // @Tags workspace // @Accept json // @Produce json @@ -429,7 +429,7 @@ func (wo *workspaceHandler) updatePreferences( }, StatusSuccess } -// @Summary get billing portal +// @Description get billing portal // @Tags billing // @Accept json // @Produce json diff --git a/server/workspace_integration.go b/server/workspace_integration.go index ef493069..fc3b0029 100644 --- a/server/workspace_integration.go +++ b/server/workspace_integration.go @@ -17,7 +17,7 @@ import ( "go.uber.org/zap" ) -// @Summary fetch workspace preferences +// @Description fetch workspace preferences // @Tags integrations // @Accept json // @Produce json @@ -66,7 +66,7 @@ func (t *testAPIIntegrationRequest) Validate() error { return nil } -// @Summary test an api key is valid and can reach the integration +// @Description test an api key is valid and can reach the integration // @Tags integrations // @Accept json // @Produce json @@ -154,7 +154,7 @@ func (wo *workspaceHandler) pingIntegration( StatusSuccess } -// @Summary enable integration +// @Description enable integration // @Tags integrations // @Accept json // @Produce json @@ -274,7 +274,7 @@ func (wo *workspaceHandler) enableIntegration( // TODO: maybe have just one endpoint for updateAPIKeyForIntegration and enableIntegration? // They are pretty much the same except for validation criterias. // -// @Summary update integration api key +// @Description update integration api key // @Tags integrations // @Accept json // @Produce json @@ -390,7 +390,7 @@ func (wo *workspaceHandler) updateAPIKeyForIntegration( StatusSuccess } -// @Summary disable integration +// @Description disable integration // @Tags integrations // @Accept json // @Produce json diff --git a/swagger/docs.go b/swagger/docs.go index 94f0143d..7b4838fc 100644 --- a/swagger/docs.go +++ b/swagger/docs.go @@ -20,6 +20,7 @@ const docTemplate = `{ "paths": { "/auth/connect/{provider}": { "post": { + "description": "Sign in with a social login provider", "consumes": [ "application/json" ], @@ -29,7 +30,6 @@ const docTemplate = `{ "tags": [ "auth" ], - "summary": "Sign in with a social login provider", "parameters": [ { "description": "auth exchange data", @@ -84,6 +84,7 @@ const docTemplate = `{ }, "/contacts": { "get": { + "description": "list your contacts", "consumes": [ "application/json" ], @@ -93,7 +94,6 @@ const docTemplate = `{ "tags": [ "contacts" ], - "summary": "list your contacts", "parameters": [ { "type": "integer", @@ -142,6 +142,7 @@ const docTemplate = `{ } }, "post": { + "description": "Creates a new contact", "consumes": [ "application/json" ], @@ -151,7 +152,6 @@ const docTemplate = `{ "tags": [ "contacts" ], - "summary": "Creates a new contact", "parameters": [ { "description": "contact request body", @@ -199,6 +199,7 @@ const docTemplate = `{ }, "/contacts/lists": { "get": { + "description": "List all created contact lists", "consumes": [ "application/json" ], @@ -208,7 +209,6 @@ const docTemplate = `{ "tags": [ "contacts" ], - "summary": "List all created contact lists", "operationId": "fetchContactLists", "parameters": [ { @@ -252,6 +252,7 @@ const docTemplate = `{ } }, "post": { + "description": "Create a new contact list", "consumes": [ "application/json" ], @@ -261,7 +262,6 @@ const docTemplate = `{ "tags": [ "contacts" ], - "summary": "Create a new contact list", "operationId": "createContactList", "parameters": [ { @@ -310,6 +310,7 @@ const docTemplate = `{ }, "/contacts/lists/{reference}": { "put": { + "description": "Edit a contact list", "consumes": [ "application/json" ], @@ -319,7 +320,6 @@ const docTemplate = `{ "tags": [ "contacts" ], - "summary": "Edit a contact list", "operationId": "editContactList", "parameters": [ { @@ -373,6 +373,7 @@ const docTemplate = `{ } }, "post": { + "description": "add a new contact to a list", "consumes": [ "application/json" ], @@ -382,7 +383,6 @@ const docTemplate = `{ "tags": [ "contacts" ], - "summary": "add a new contact to a list", "operationId": "addEmailToContactList", "parameters": [ { @@ -436,6 +436,7 @@ const docTemplate = `{ } }, "delete": { + "description": "delete a contact list", "consumes": [ "application/json" ], @@ -445,7 +446,6 @@ const docTemplate = `{ "tags": [ "contacts" ], - "summary": "delete a contact list", "operationId": "deleteContactList", "parameters": [ { @@ -492,6 +492,7 @@ const docTemplate = `{ }, "/contacts/{reference}": { "get": { + "description": "fetch a contact by reference", "consumes": [ "application/json" ], @@ -501,7 +502,6 @@ const docTemplate = `{ "tags": [ "contacts" ], - "summary": "fetch a contact by reference", "parameters": [ { "type": "string", @@ -545,6 +545,7 @@ const docTemplate = `{ } }, "put": { + "description": "edit a contact", "consumes": [ "application/json" ], @@ -554,7 +555,6 @@ const docTemplate = `{ "tags": [ "contacts" ], - "summary": "edit a contact", "parameters": [ { "description": "contact request body", @@ -607,6 +607,7 @@ const docTemplate = `{ } }, "delete": { + "description": "delete a contact", "consumes": [ "application/json" ], @@ -616,7 +617,6 @@ const docTemplate = `{ "tags": [ "contacts" ], - "summary": "delete a contact", "operationId": "deleteContact", "parameters": [ { @@ -663,6 +663,7 @@ const docTemplate = `{ }, "/dashboards": { "get": { + "description": "List dashboards", "consumes": [ "application/json" ], @@ -672,7 +673,6 @@ const docTemplate = `{ "tags": [ "dashboards" ], - "summary": "List dashboards", "parameters": [ { "type": "integer", @@ -721,6 +721,7 @@ const docTemplate = `{ } }, "post": { + "description": "create a new dashboard", "consumes": [ "application/json" ], @@ -730,7 +731,6 @@ const docTemplate = `{ "tags": [ "dashboards" ], - "summary": "create a new dashboard", "parameters": [ { "description": "dashboard request body", @@ -778,6 +778,7 @@ const docTemplate = `{ }, "/dashboards/charts": { "get": { + "description": "List charts", "consumes": [ "application/json" ], @@ -787,7 +788,6 @@ const docTemplate = `{ "tags": [ "dashboards" ], - "summary": "List charts", "responses": { "200": { "description": "OK", @@ -824,6 +824,7 @@ const docTemplate = `{ }, "/dashboards/charts/{reference}": { "get": { + "description": "fetch charting data", "consumes": [ "application/json" ], @@ -833,7 +834,6 @@ const docTemplate = `{ "tags": [ "dashboards" ], - "summary": "fetch charting data", "parameters": [ { "type": "string", @@ -879,6 +879,7 @@ const docTemplate = `{ }, "/dashboards/{reference}": { "get": { + "description": "fetch dashboard", "consumes": [ "application/json" ], @@ -888,7 +889,6 @@ const docTemplate = `{ "tags": [ "dashboards" ], - "summary": "fetch dashboard", "parameters": [ { "type": "string", @@ -934,6 +934,7 @@ const docTemplate = `{ }, "/dashboards/{reference}/charts": { "put": { + "description": "add a chart to a dashboard", "consumes": [ "application/json" ], @@ -943,7 +944,6 @@ const docTemplate = `{ "tags": [ "dashboards" ], - "summary": "add a chart to a dashboard", "parameters": [ { "description": "dashboard request chart data", @@ -996,8 +996,73 @@ const docTemplate = `{ } } }, + "/dashboards/{reference}/positions": { + "post": { + "description": "update dashboard chart positioning", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "dashboards" + ], + "parameters": [ + { + "description": "dashboard chart positions", + "name": "message", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/server.updateDashboardPositionsRequest" + } + }, + { + "type": "string", + "description": "dashboard unique reference.. e.g dashboard_22", + "name": "reference", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/server.APIStatus" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/server.APIStatus" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/server.APIStatus" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/server.APIStatus" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/server.APIStatus" + } + } + } + } + }, "/decks": { "get": { + "description": "list all decks. No pagination", "consumes": [ "application/json" ], @@ -1007,7 +1072,6 @@ const docTemplate = `{ "tags": [ "decks" ], - "summary": "list all decks. No pagination", "responses": { "200": { "description": "OK", @@ -1042,6 +1106,7 @@ const docTemplate = `{ } }, "post": { + "description": "Creates a new deck", "consumes": [ "application/json" ], @@ -1051,7 +1116,6 @@ const docTemplate = `{ "tags": [ "decks" ], - "summary": "Creates a new deck", "parameters": [ { "description": "deck request body", @@ -1099,6 +1163,7 @@ const docTemplate = `{ }, "/decks/{reference}": { "get": { + "description": "fetch a deck", "consumes": [ "application/json" ], @@ -1108,7 +1173,6 @@ const docTemplate = `{ "tags": [ "decks" ], - "summary": "fetch a deck", "parameters": [ { "type": "string", @@ -1152,6 +1216,7 @@ const docTemplate = `{ } }, "delete": { + "description": "delete a deck", "consumes": [ "application/json" ], @@ -1161,7 +1226,6 @@ const docTemplate = `{ "tags": [ "decks" ], - "summary": "delete a deck", "parameters": [ { "type": "string", @@ -1207,6 +1271,7 @@ const docTemplate = `{ }, "/decks/{reference}/archive": { "post": { + "description": "toggle archive status of a deck", "consumes": [ "application/json" ], @@ -1216,7 +1281,6 @@ const docTemplate = `{ "tags": [ "decks" ], - "summary": "toggle archive status of a deck", "operationId": "toggleArchive", "parameters": [ { @@ -1263,6 +1327,7 @@ const docTemplate = `{ }, "/decks/{reference}/pin": { "post": { + "description": "toggle pinned status of a deck", "consumes": [ "application/json" ], @@ -1272,7 +1337,6 @@ const docTemplate = `{ "tags": [ "decks" ], - "summary": "toggle pinned status of a deck", "operationId": "togglePin", "parameters": [ { @@ -1319,6 +1383,7 @@ const docTemplate = `{ }, "/decks/{reference}/preferences": { "put": { + "description": "update a deck preferences", "consumes": [ "application/json" ], @@ -1328,7 +1393,6 @@ const docTemplate = `{ "tags": [ "decks" ], - "summary": "update a deck preferences", "parameters": [ { "type": "string", @@ -1383,6 +1447,7 @@ const docTemplate = `{ }, "/public/decks/{reference}": { "get": { + "description": "public api to fetch a deck", "consumes": [ "application/json" ], @@ -1392,7 +1457,6 @@ const docTemplate = `{ "tags": [ "decks-viewer" ], - "summary": "public api to fetch a deck", "parameters": [ { "type": "string", @@ -1438,6 +1502,7 @@ const docTemplate = `{ }, "/updates/react": { "get": { + "description": "Fetch a specific update", "consumes": [ "application/json" ], @@ -1447,7 +1512,6 @@ const docTemplate = `{ "tags": [ "updates" ], - "summary": "Fetch a specific update", "operationId": "reactPost", "parameters": [ { @@ -1501,6 +1565,7 @@ const docTemplate = `{ }, "/uploads/decks": { "post": { + "description": "Upload a deck", "consumes": [ "application/json" ], @@ -1510,7 +1575,6 @@ const docTemplate = `{ "tags": [ "decks" ], - "summary": "Upload a deck", "operationId": "uploadDeck", "parameters": [ { @@ -1557,6 +1621,7 @@ const docTemplate = `{ }, "/uploads/images": { "post": { + "description": "Upload an image", "consumes": [ "application/json" ], @@ -1566,7 +1631,6 @@ const docTemplate = `{ "tags": [ "images" ], - "summary": "Upload an image", "operationId": "uploadImage", "parameters": [ { @@ -1613,6 +1677,7 @@ const docTemplate = `{ }, "/user": { "get": { + "description": "Fetch current user. This api should also double as a token validation api", "consumes": [ "application/json" ], @@ -1622,7 +1687,6 @@ const docTemplate = `{ "tags": [ "user" ], - "summary": "Fetch current user. This api should also double as a token validation api", "responses": { "200": { "description": "OK", @@ -1659,6 +1723,7 @@ const docTemplate = `{ }, "/workspaces": { "post": { + "description": "Create a new workspace", "consumes": [ "application/json" ], @@ -1668,7 +1733,6 @@ const docTemplate = `{ "tags": [ "workspace" ], - "summary": "Create a new workspace", "parameters": [ { "description": "request body to create a workspace", @@ -1714,6 +1778,7 @@ const docTemplate = `{ } }, "patch": { + "description": "update workspace details", "consumes": [ "application/json" ], @@ -1723,7 +1788,6 @@ const docTemplate = `{ "tags": [ "workspace" ], - "summary": "update workspace details", "parameters": [ { "description": "request body to create a workspace", @@ -1771,6 +1835,7 @@ const docTemplate = `{ }, "/workspaces/billing": { "post": { + "description": "get billing portal", "consumes": [ "application/json" ], @@ -1780,7 +1845,6 @@ const docTemplate = `{ "tags": [ "billing" ], - "summary": "get billing portal", "responses": { "200": { "description": "OK", @@ -1817,6 +1881,7 @@ const docTemplate = `{ }, "/workspaces/integrations": { "get": { + "description": "fetch workspace preferences", "consumes": [ "application/json" ], @@ -1826,7 +1891,6 @@ const docTemplate = `{ "tags": [ "integrations" ], - "summary": "fetch workspace preferences", "responses": { "200": { "description": "OK", @@ -1863,6 +1927,7 @@ const docTemplate = `{ }, "/workspaces/integrations/{reference}": { "put": { + "description": "update integration api key", "consumes": [ "application/json" ], @@ -1872,7 +1937,6 @@ const docTemplate = `{ "tags": [ "integrations" ], - "summary": "update integration api key", "parameters": [ { "description": "request body", @@ -1918,6 +1982,7 @@ const docTemplate = `{ } }, "post": { + "description": "enable integration", "consumes": [ "application/json" ], @@ -1927,7 +1992,6 @@ const docTemplate = `{ "tags": [ "integrations" ], - "summary": "enable integration", "parameters": [ { "description": "request body", @@ -1973,6 +2037,7 @@ const docTemplate = `{ } }, "delete": { + "description": "disable integration", "consumes": [ "application/json" ], @@ -1982,7 +2047,6 @@ const docTemplate = `{ "tags": [ "integrations" ], - "summary": "disable integration", "responses": { "200": { "description": "OK", @@ -2019,6 +2083,7 @@ const docTemplate = `{ }, "/workspaces/integrations/{reference}/ping": { "post": { + "description": "test an api key is valid and can reach the integration", "consumes": [ "application/json" ], @@ -2028,7 +2093,6 @@ const docTemplate = `{ "tags": [ "integrations" ], - "summary": "test an api key is valid and can reach the integration", "parameters": [ { "description": "request body to test an integration", @@ -2076,6 +2140,7 @@ const docTemplate = `{ }, "/workspaces/preferences": { "get": { + "description": "fetch workspace preferences", "consumes": [ "application/json" ], @@ -2085,7 +2150,6 @@ const docTemplate = `{ "tags": [ "workspace" ], - "summary": "fetch workspace preferences", "responses": { "200": { "description": "OK", @@ -2120,6 +2184,7 @@ const docTemplate = `{ } }, "put": { + "description": "update workspace preferences", "consumes": [ "application/json" ], @@ -2129,7 +2194,6 @@ const docTemplate = `{ "tags": [ "workspace" ], - "summary": "update workspace preferences", "parameters": [ { "description": "request body to updare a workspace preference", @@ -2177,6 +2241,7 @@ const docTemplate = `{ }, "/workspaces/switch/{reference}": { "post": { + "description": "Switch current workspace", "consumes": [ "application/json" ], @@ -2186,7 +2251,6 @@ const docTemplate = `{ "tags": [ "workspace" ], - "summary": "Switch current workspace", "operationId": "switchworkspace", "parameters": [ { @@ -2233,6 +2297,7 @@ const docTemplate = `{ }, "/workspaces/updates": { "get": { + "description": "List updates", "consumes": [ "application/json" ], @@ -2242,7 +2307,6 @@ const docTemplate = `{ "tags": [ "updates" ], - "summary": "List updates", "parameters": [ { "type": "integer", @@ -2297,6 +2361,7 @@ const docTemplate = `{ } }, "post": { + "description": "Create a new update", "consumes": [ "application/json" ], @@ -2306,7 +2371,6 @@ const docTemplate = `{ "tags": [ "updates" ], - "summary": "Create a new update", "parameters": [ { "description": "update content body", @@ -2354,6 +2418,7 @@ const docTemplate = `{ }, "/workspaces/updates/pins": { "get": { + "description": "List pinned updates", "consumes": [ "application/json" ], @@ -2363,7 +2428,6 @@ const docTemplate = `{ "tags": [ "updates" ], - "summary": "List pinned updates", "responses": { "200": { "description": "OK", @@ -2400,6 +2464,7 @@ const docTemplate = `{ }, "/workspaces/updates/{reference}": { "get": { + "description": "Fetch a specific update", "consumes": [ "application/json" ], @@ -2409,7 +2474,6 @@ const docTemplate = `{ "tags": [ "updates" ], - "summary": "Fetch a specific update", "operationId": "fetchUpdate", "parameters": [ { @@ -2454,6 +2518,7 @@ const docTemplate = `{ } }, "put": { + "description": "Update a specific update", "consumes": [ "application/json" ], @@ -2463,7 +2528,6 @@ const docTemplate = `{ "tags": [ "updates" ], - "summary": "Update a specific update", "operationId": "updateContent", "parameters": [ { @@ -2517,6 +2581,7 @@ const docTemplate = `{ } }, "post": { + "description": "Send an update to real users", "consumes": [ "application/json" ], @@ -2526,7 +2591,6 @@ const docTemplate = `{ "tags": [ "updates" ], - "summary": "Send an update to real users", "operationId": "sendUpdate", "parameters": [ { @@ -2580,6 +2644,7 @@ const docTemplate = `{ } }, "delete": { + "description": "Delete a specific update", "consumes": [ "application/json" ], @@ -2589,7 +2654,6 @@ const docTemplate = `{ "tags": [ "updates" ], - "summary": "Delete a specific update", "operationId": "deleteUpdate", "parameters": [ { @@ -2636,6 +2700,7 @@ const docTemplate = `{ }, "/workspaces/updates/{reference}/analytics": { "get": { + "description": "Fetch analytics for a specific update", "consumes": [ "application/json" ], @@ -2645,7 +2710,6 @@ const docTemplate = `{ "tags": [ "updates" ], - "summary": "Fetch analytics for a specific update", "operationId": "fetchUpdateAnalytics", "parameters": [ { @@ -2692,6 +2756,7 @@ const docTemplate = `{ }, "/workspaces/updates/{reference}/duplicate": { "post": { + "description": "Duplicate a specific update", "consumes": [ "application/json" ], @@ -2701,7 +2766,6 @@ const docTemplate = `{ "tags": [ "updates" ], - "summary": "Duplicate a specific update", "operationId": "duplicateUpdate", "parameters": [ { @@ -2748,6 +2812,7 @@ const docTemplate = `{ }, "/workspaces/updates/{reference}/pin": { "post": { + "description": "Toggle pinned status a specific update", "consumes": [ "application/json" ], @@ -2757,7 +2822,6 @@ const docTemplate = `{ "tags": [ "updates" ], - "summary": "Toggle pinned status a specific update", "operationId": "toggleUpdatePin", "parameters": [ { @@ -2804,6 +2868,7 @@ const docTemplate = `{ }, "/workspaces/updates/{reference}/preview": { "post": { + "description": "Send preview of an update", "consumes": [ "application/json" ], @@ -2813,7 +2878,6 @@ const docTemplate = `{ "tags": [ "updates" ], - "summary": "Send preview of an update", "operationId": "previewUpdate", "parameters": [ { @@ -4613,6 +4677,32 @@ const docTemplate = `{ } } }, + "server.updateDashboardPositionsRequest": { + "type": "object", + "required": [ + "positions" + ], + "properties": { + "positions": { + "type": "array", + "items": { + "type": "object", + "required": [ + "chart_id", + "index" + ], + "properties": { + "chart_id": { + "type": "string" + }, + "index": { + "type": "integer" + } + } + } + } + } + }, "server.updateDeckPreferencesRequest": { "type": "object", "properties": { diff --git a/swagger/swagger.json b/swagger/swagger.json index 70f950a4..7e96b8ae 100644 --- a/swagger/swagger.json +++ b/swagger/swagger.json @@ -1745,6 +1745,32 @@ ], "type": "object" }, + "server.updateDashboardPositionsRequest": { + "properties": { + "positions": { + "items": { + "properties": { + "chart_id": { + "type": "string" + }, + "index": { + "type": "integer" + } + }, + "required": [ + "chart_id", + "index" + ], + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "positions" + ], + "type": "object" + }, "server.updateDeckPreferencesRequest": { "properties": { "enable_downloading": { @@ -1843,6 +1869,7 @@ "paths": { "/auth/connect/{provider}": { "post": { + "description": "Sign in with a social login provider", "parameters": [ { "description": "oauth2 provider", @@ -1918,7 +1945,6 @@ "description": "Internal Server Error" } }, - "summary": "Sign in with a social login provider", "tags": [ "auth" ] @@ -1926,6 +1952,7 @@ }, "/contacts": { "get": { + "description": "list your contacts", "parameters": [ { "description": "Page to query data from. Defaults to 1", @@ -1996,12 +2023,12 @@ "description": "Internal Server Error" } }, - "summary": "list your contacts", "tags": [ "contacts" ] }, "post": { + "description": "Creates a new contact", "requestBody": { "content": { "application/json": { @@ -2066,7 +2093,6 @@ "description": "Internal Server Error" } }, - "summary": "Creates a new contact", "tags": [ "contacts" ] @@ -2074,6 +2100,7 @@ }, "/contacts/lists": { "get": { + "description": "List all created contact lists", "operationId": "fetchContactLists", "parameters": [ { @@ -2137,12 +2164,12 @@ "description": "Internal Server Error" } }, - "summary": "List all created contact lists", "tags": [ "contacts" ] }, "post": { + "description": "Create a new contact list", "operationId": "createContactList", "requestBody": { "content": { @@ -2208,7 +2235,6 @@ "description": "Internal Server Error" } }, - "summary": "Create a new contact list", "tags": [ "contacts" ] @@ -2216,6 +2242,7 @@ }, "/contacts/lists/{reference}": { "delete": { + "description": "delete a contact list", "operationId": "deleteContactList", "parameters": [ { @@ -2280,12 +2307,12 @@ "description": "Internal Server Error" } }, - "summary": "delete a contact list", "tags": [ "contacts" ] }, "post": { + "description": "add a new contact to a list", "operationId": "addEmailToContactList", "parameters": [ { @@ -2362,12 +2389,12 @@ "description": "Internal Server Error" } }, - "summary": "add a new contact to a list", "tags": [ "contacts" ] }, "put": { + "description": "Edit a contact list", "operationId": "editContactList", "parameters": [ { @@ -2444,7 +2471,6 @@ "description": "Internal Server Error" } }, - "summary": "Edit a contact list", "tags": [ "contacts" ] @@ -2452,6 +2478,7 @@ }, "/contacts/{reference}": { "delete": { + "description": "delete a contact", "operationId": "deleteContact", "parameters": [ { @@ -2516,12 +2543,12 @@ "description": "Internal Server Error" } }, - "summary": "delete a contact", "tags": [ "contacts" ] }, "get": { + "description": "fetch a contact by reference", "parameters": [ { "description": "contact unique reference.. e.g contact_", @@ -2585,12 +2612,12 @@ "description": "Internal Server Error" } }, - "summary": "fetch a contact by reference", "tags": [ "contacts" ] }, "put": { + "description": "edit a contact", "parameters": [ { "description": "contact unique reference.. e.g contact_", @@ -2666,7 +2693,6 @@ "description": "Internal Server Error" } }, - "summary": "edit a contact", "tags": [ "contacts" ] @@ -2674,6 +2700,7 @@ }, "/dashboards": { "get": { + "description": "List dashboards", "parameters": [ { "description": "Page to query data from. Defaults to 1", @@ -2744,12 +2771,12 @@ "description": "Internal Server Error" } }, - "summary": "List dashboards", "tags": [ "dashboards" ] }, "post": { + "description": "create a new dashboard", "requestBody": { "content": { "application/json": { @@ -2814,7 +2841,6 @@ "description": "Internal Server Error" } }, - "summary": "create a new dashboard", "tags": [ "dashboards" ] @@ -2822,6 +2848,7 @@ }, "/dashboards/charts": { "get": { + "description": "List charts", "responses": { "200": { "content": { @@ -2874,7 +2901,6 @@ "description": "Internal Server Error" } }, - "summary": "List charts", "tags": [ "dashboards" ] @@ -2882,6 +2908,7 @@ }, "/dashboards/charts/{reference}": { "get": { + "description": "fetch charting data", "parameters": [ { "description": "chart unique reference.. e.g integration_chart_km31C.e6xV", @@ -2945,7 +2972,6 @@ "description": "Internal Server Error" } }, - "summary": "fetch charting data", "tags": [ "dashboards" ] @@ -2953,6 +2979,7 @@ }, "/dashboards/{reference}": { "get": { + "description": "fetch dashboard", "parameters": [ { "description": "dashboard unique reference.. e.g dashboard_", @@ -3016,7 +3043,6 @@ "description": "Internal Server Error" } }, - "summary": "fetch dashboard", "tags": [ "dashboards" ] @@ -3024,6 +3050,7 @@ }, "/dashboards/{reference}/charts": { "put": { + "description": "add a chart to a dashboard", "parameters": [ { "description": "dashboard unique reference.. e.g dashboard_", @@ -3099,7 +3126,89 @@ "description": "Internal Server Error" } }, - "summary": "add a chart to a dashboard", + "tags": [ + "dashboards" + ] + } + }, + "/dashboards/{reference}/positions": { + "post": { + "description": "update dashboard chart positioning", + "parameters": [ + { + "description": "dashboard unique reference.. e.g dashboard_22", + "in": "path", + "name": "reference", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/server.updateDashboardPositionsRequest" + } + } + }, + "description": "dashboard chart positions", + "required": true, + "x-originalParamName": "message" + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/server.APIStatus" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/server.APIStatus" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/server.APIStatus" + } + } + }, + "description": "Unauthorized" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/server.APIStatus" + } + } + }, + "description": "Not Found" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/server.APIStatus" + } + } + }, + "description": "Internal Server Error" + } + }, "tags": [ "dashboards" ] @@ -3107,6 +3216,7 @@ }, "/decks": { "get": { + "description": "list all decks. No pagination", "responses": { "200": { "content": { @@ -3159,12 +3269,12 @@ "description": "Internal Server Error" } }, - "summary": "list all decks. No pagination", "tags": [ "decks" ] }, "post": { + "description": "Creates a new deck", "requestBody": { "content": { "application/json": { @@ -3229,7 +3339,6 @@ "description": "Internal Server Error" } }, - "summary": "Creates a new deck", "tags": [ "decks" ] @@ -3237,6 +3346,7 @@ }, "/decks/{reference}": { "delete": { + "description": "delete a deck", "parameters": [ { "description": "deck unique reference.. e.g deck_", @@ -3300,12 +3410,12 @@ "description": "Internal Server Error" } }, - "summary": "delete a deck", "tags": [ "decks" ] }, "get": { + "description": "fetch a deck", "parameters": [ { "description": "deck unique reference.. e.g deck_", @@ -3369,7 +3479,6 @@ "description": "Internal Server Error" } }, - "summary": "fetch a deck", "tags": [ "decks" ] @@ -3377,6 +3486,7 @@ }, "/decks/{reference}/archive": { "post": { + "description": "toggle archive status of a deck", "operationId": "toggleArchive", "parameters": [ { @@ -3441,7 +3551,6 @@ "description": "Internal Server Error" } }, - "summary": "toggle archive status of a deck", "tags": [ "decks" ] @@ -3449,6 +3558,7 @@ }, "/decks/{reference}/pin": { "post": { + "description": "toggle pinned status of a deck", "operationId": "togglePin", "parameters": [ { @@ -3513,7 +3623,6 @@ "description": "Internal Server Error" } }, - "summary": "toggle pinned status of a deck", "tags": [ "decks" ] @@ -3521,6 +3630,7 @@ }, "/decks/{reference}/preferences": { "put": { + "description": "update a deck preferences", "parameters": [ { "description": "deck unique reference.. e.g deck_", @@ -3596,7 +3706,6 @@ "description": "Internal Server Error" } }, - "summary": "update a deck preferences", "tags": [ "decks" ] @@ -3604,6 +3713,7 @@ }, "/public/decks/{reference}": { "get": { + "description": "public api to fetch a deck", "parameters": [ { "description": "deck unique reference.. e.g deck_", @@ -3667,7 +3777,6 @@ "description": "Internal Server Error" } }, - "summary": "public api to fetch a deck", "tags": [ "decks-viewer" ] @@ -3675,6 +3784,7 @@ }, "/updates/react": { "get": { + "description": "Fetch a specific update", "operationId": "reactPost", "parameters": [ { @@ -3748,7 +3858,6 @@ "description": "Internal Server Error" } }, - "summary": "Fetch a specific update", "tags": [ "updates" ] @@ -3756,6 +3865,7 @@ }, "/uploads/decks": { "post": { + "description": "Upload a deck", "operationId": "uploadDeck", "requestBody": { "content": { @@ -3829,7 +3939,6 @@ "description": "Internal Server Error" } }, - "summary": "Upload a deck", "tags": [ "decks" ] @@ -3837,6 +3946,7 @@ }, "/uploads/images": { "post": { + "description": "Upload an image", "operationId": "uploadImage", "requestBody": { "content": { @@ -3910,7 +4020,6 @@ "description": "Internal Server Error" } }, - "summary": "Upload an image", "tags": [ "images" ] @@ -3918,6 +4027,7 @@ }, "/user": { "get": { + "description": "Fetch current user. This api should also double as a token validation api", "responses": { "200": { "content": { @@ -3970,7 +4080,6 @@ "description": "Internal Server Error" } }, - "summary": "Fetch current user. This api should also double as a token validation api", "tags": [ "user" ] @@ -3978,6 +4087,7 @@ }, "/workspaces": { "patch": { + "description": "update workspace details", "requestBody": { "content": { "application/json": { @@ -4042,12 +4152,12 @@ "description": "Internal Server Error" } }, - "summary": "update workspace details", "tags": [ "workspace" ] }, "post": { + "description": "Create a new workspace", "requestBody": { "content": { "application/json": { @@ -4112,7 +4222,6 @@ "description": "Internal Server Error" } }, - "summary": "Create a new workspace", "tags": [ "workspace" ] @@ -4120,6 +4229,7 @@ }, "/workspaces/billing": { "post": { + "description": "get billing portal", "responses": { "200": { "content": { @@ -4172,7 +4282,6 @@ "description": "Internal Server Error" } }, - "summary": "get billing portal", "tags": [ "billing" ] @@ -4180,6 +4289,7 @@ }, "/workspaces/integrations": { "get": { + "description": "fetch workspace preferences", "responses": { "200": { "content": { @@ -4232,7 +4342,6 @@ "description": "Internal Server Error" } }, - "summary": "fetch workspace preferences", "tags": [ "integrations" ] @@ -4240,6 +4349,7 @@ }, "/workspaces/integrations/{reference}": { "delete": { + "description": "disable integration", "responses": { "200": { "content": { @@ -4292,12 +4402,12 @@ "description": "Internal Server Error" } }, - "summary": "disable integration", "tags": [ "integrations" ] }, "post": { + "description": "enable integration", "requestBody": { "content": { "application/json": { @@ -4362,12 +4472,12 @@ "description": "Internal Server Error" } }, - "summary": "enable integration", "tags": [ "integrations" ] }, "put": { + "description": "update integration api key", "requestBody": { "content": { "application/json": { @@ -4432,7 +4542,6 @@ "description": "Internal Server Error" } }, - "summary": "update integration api key", "tags": [ "integrations" ] @@ -4440,6 +4549,7 @@ }, "/workspaces/integrations/{reference}/ping": { "post": { + "description": "test an api key is valid and can reach the integration", "requestBody": { "content": { "application/json": { @@ -4504,7 +4614,6 @@ "description": "Internal Server Error" } }, - "summary": "test an api key is valid and can reach the integration", "tags": [ "integrations" ] @@ -4512,6 +4621,7 @@ }, "/workspaces/preferences": { "get": { + "description": "fetch workspace preferences", "responses": { "200": { "content": { @@ -4564,12 +4674,12 @@ "description": "Internal Server Error" } }, - "summary": "fetch workspace preferences", "tags": [ "workspace" ] }, "put": { + "description": "update workspace preferences", "requestBody": { "content": { "application/json": { @@ -4634,7 +4744,6 @@ "description": "Internal Server Error" } }, - "summary": "update workspace preferences", "tags": [ "workspace" ] @@ -4642,6 +4751,7 @@ }, "/workspaces/switch/{reference}": { "post": { + "description": "Switch current workspace", "operationId": "switchworkspace", "parameters": [ { @@ -4706,7 +4816,6 @@ "description": "Internal Server Error" } }, - "summary": "Switch current workspace", "tags": [ "workspace" ] @@ -4714,6 +4823,7 @@ }, "/workspaces/updates": { "get": { + "description": "List updates", "parameters": [ { "description": "Page to query data from. Defaults to 1", @@ -4792,12 +4902,12 @@ "description": "Internal Server Error" } }, - "summary": "List updates", "tags": [ "updates" ] }, "post": { + "description": "Create a new update", "requestBody": { "content": { "application/json": { @@ -4862,7 +4972,6 @@ "description": "Internal Server Error" } }, - "summary": "Create a new update", "tags": [ "updates" ] @@ -4870,6 +4979,7 @@ }, "/workspaces/updates/pins": { "get": { + "description": "List pinned updates", "responses": { "200": { "content": { @@ -4922,7 +5032,6 @@ "description": "Internal Server Error" } }, - "summary": "List pinned updates", "tags": [ "updates" ] @@ -4930,6 +5039,7 @@ }, "/workspaces/updates/{reference}": { "delete": { + "description": "Delete a specific update", "operationId": "deleteUpdate", "parameters": [ { @@ -4994,12 +5104,12 @@ "description": "Internal Server Error" } }, - "summary": "Delete a specific update", "tags": [ "updates" ] }, "get": { + "description": "Fetch a specific update", "operationId": "fetchUpdate", "parameters": [ { @@ -5064,12 +5174,12 @@ "description": "Internal Server Error" } }, - "summary": "Fetch a specific update", "tags": [ "updates" ] }, "post": { + "description": "Send an update to real users", "operationId": "sendUpdate", "parameters": [ { @@ -5146,12 +5256,12 @@ "description": "Internal Server Error" } }, - "summary": "Send an update to real users", "tags": [ "updates" ] }, "put": { + "description": "Update a specific update", "operationId": "updateContent", "parameters": [ { @@ -5228,7 +5338,6 @@ "description": "Internal Server Error" } }, - "summary": "Update a specific update", "tags": [ "updates" ] @@ -5236,6 +5345,7 @@ }, "/workspaces/updates/{reference}/analytics": { "get": { + "description": "Fetch analytics for a specific update", "operationId": "fetchUpdateAnalytics", "parameters": [ { @@ -5300,7 +5410,6 @@ "description": "Internal Server Error" } }, - "summary": "Fetch analytics for a specific update", "tags": [ "updates" ] @@ -5308,6 +5417,7 @@ }, "/workspaces/updates/{reference}/duplicate": { "post": { + "description": "Duplicate a specific update", "operationId": "duplicateUpdate", "parameters": [ { @@ -5372,7 +5482,6 @@ "description": "Internal Server Error" } }, - "summary": "Duplicate a specific update", "tags": [ "updates" ] @@ -5380,6 +5489,7 @@ }, "/workspaces/updates/{reference}/pin": { "post": { + "description": "Toggle pinned status a specific update", "operationId": "toggleUpdatePin", "parameters": [ { @@ -5444,7 +5554,6 @@ "description": "Internal Server Error" } }, - "summary": "Toggle pinned status a specific update", "tags": [ "updates" ] @@ -5452,6 +5561,7 @@ }, "/workspaces/updates/{reference}/preview": { "post": { + "description": "Send preview of an update", "operationId": "previewUpdate", "parameters": [ { @@ -5528,7 +5638,6 @@ "description": "Internal Server Error" } }, - "summary": "Send preview of an update", "tags": [ "updates" ] diff --git a/swagger/swagger.yaml b/swagger/swagger.yaml index e48088b1..8a3e6882 100644 --- a/swagger/swagger.yaml +++ b/swagger/swagger.yaml @@ -1181,6 +1181,23 @@ components: required: - api_key type: object + server.updateDashboardPositionsRequest: + properties: + positions: + items: + properties: + chart_id: + type: string + index: + type: integer + required: + - chart_id + - index + type: object + type: array + required: + - positions + type: object server.updateDeckPreferencesRequest: properties: enable_downloading: @@ -1246,6 +1263,7 @@ openapi: 3.0.3 paths: /auth/connect/{provider}: post: + description: Sign in with a social login provider parameters: - description: oauth2 provider in: path @@ -1292,11 +1310,11 @@ paths: schema: $ref: '#/components/schemas/server.APIStatus' description: Internal Server Error - summary: Sign in with a social login provider tags: - auth /contacts: get: + description: list your contacts parameters: - description: Page to query data from. Defaults to 1 in: query @@ -1339,10 +1357,10 @@ paths: schema: $ref: '#/components/schemas/server.APIStatus' description: Internal Server Error - summary: list your contacts tags: - contacts post: + description: Creates a new contact requestBody: content: application/json: @@ -1382,11 +1400,11 @@ paths: schema: $ref: '#/components/schemas/server.APIStatus' description: Internal Server Error - summary: Creates a new contact tags: - contacts /contacts/{reference}: delete: + description: delete a contact operationId: deleteContact parameters: - description: contact unique reference.. e.g contact_ @@ -1426,10 +1444,10 @@ paths: schema: $ref: '#/components/schemas/server.APIStatus' description: Internal Server Error - summary: delete a contact tags: - contacts get: + description: fetch a contact by reference parameters: - description: contact unique reference.. e.g contact_ in: path @@ -1468,10 +1486,10 @@ paths: schema: $ref: '#/components/schemas/server.APIStatus' description: Internal Server Error - summary: fetch a contact by reference tags: - contacts put: + description: edit a contact parameters: - description: contact unique reference.. e.g contact_ in: path @@ -1518,11 +1536,11 @@ paths: schema: $ref: '#/components/schemas/server.APIStatus' description: Internal Server Error - summary: edit a contact tags: - contacts /contacts/lists: get: + description: List all created contact lists operationId: fetchContactLists parameters: - description: show emails inside the list @@ -1561,10 +1579,10 @@ paths: schema: $ref: '#/components/schemas/server.APIStatus' description: Internal Server Error - summary: List all created contact lists tags: - contacts post: + description: Create a new contact list operationId: createContactList requestBody: content: @@ -1605,11 +1623,11 @@ paths: schema: $ref: '#/components/schemas/server.APIStatus' description: Internal Server Error - summary: Create a new contact list tags: - contacts /contacts/lists/{reference}: delete: + description: delete a contact list operationId: deleteContactList parameters: - description: list unique reference.. e.g list_ @@ -1649,10 +1667,10 @@ paths: schema: $ref: '#/components/schemas/server.APIStatus' description: Internal Server Error - summary: delete a contact list tags: - contacts post: + description: add a new contact to a list operationId: addEmailToContactList parameters: - description: list unique reference.. e.g list_ @@ -1700,10 +1718,10 @@ paths: schema: $ref: '#/components/schemas/server.APIStatus' description: Internal Server Error - summary: add a new contact to a list tags: - contacts put: + description: Edit a contact list operationId: editContactList parameters: - description: list unique reference.. e.g list_ @@ -1751,11 +1769,11 @@ paths: schema: $ref: '#/components/schemas/server.APIStatus' description: Internal Server Error - summary: Edit a contact list tags: - contacts /dashboards: get: + description: List dashboards parameters: - description: Page to query data from. Defaults to 1 in: query @@ -1798,10 +1816,10 @@ paths: schema: $ref: '#/components/schemas/server.APIStatus' description: Internal Server Error - summary: List dashboards tags: - dashboards post: + description: create a new dashboard requestBody: content: application/json: @@ -1841,11 +1859,11 @@ paths: schema: $ref: '#/components/schemas/server.APIStatus' description: Internal Server Error - summary: create a new dashboard tags: - dashboards /dashboards/{reference}: get: + description: fetch dashboard parameters: - description: dashboard unique reference.. e.g dashboard_ in: path @@ -1884,11 +1902,11 @@ paths: schema: $ref: '#/components/schemas/server.APIStatus' description: Internal Server Error - summary: fetch dashboard tags: - dashboards /dashboards/{reference}/charts: put: + description: add a chart to a dashboard parameters: - description: dashboard unique reference.. e.g dashboard_ in: path @@ -1935,11 +1953,62 @@ paths: schema: $ref: '#/components/schemas/server.APIStatus' description: Internal Server Error - summary: add a chart to a dashboard + tags: + - dashboards + /dashboards/{reference}/positions: + post: + description: update dashboard chart positioning + parameters: + - description: dashboard unique reference.. e.g dashboard_22 + in: path + name: reference + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/server.updateDashboardPositionsRequest' + description: dashboard chart positions + required: true + x-originalParamName: message + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/server.APIStatus' + description: OK + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/server.APIStatus' + description: Bad Request + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/server.APIStatus' + description: Unauthorized + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/server.APIStatus' + description: Not Found + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/server.APIStatus' + description: Internal Server Error tags: - dashboards /dashboards/charts: get: + description: List charts responses: "200": content: @@ -1971,11 +2040,11 @@ paths: schema: $ref: '#/components/schemas/server.APIStatus' description: Internal Server Error - summary: List charts tags: - dashboards /dashboards/charts/{reference}: get: + description: fetch charting data parameters: - description: chart unique reference.. e.g integration_chart_km31C.e6xV in: path @@ -2014,11 +2083,11 @@ paths: schema: $ref: '#/components/schemas/server.APIStatus' description: Internal Server Error - summary: fetch charting data tags: - dashboards /decks: get: + description: list all decks. No pagination responses: "200": content: @@ -2050,10 +2119,10 @@ paths: schema: $ref: '#/components/schemas/server.APIStatus' description: Internal Server Error - summary: list all decks. No pagination tags: - decks post: + description: Creates a new deck requestBody: content: application/json: @@ -2093,11 +2162,11 @@ paths: schema: $ref: '#/components/schemas/server.APIStatus' description: Internal Server Error - summary: Creates a new deck tags: - decks /decks/{reference}: delete: + description: delete a deck parameters: - description: deck unique reference.. e.g deck_ in: path @@ -2136,10 +2205,10 @@ paths: schema: $ref: '#/components/schemas/server.APIStatus' description: Internal Server Error - summary: delete a deck tags: - decks get: + description: fetch a deck parameters: - description: deck unique reference.. e.g deck_ in: path @@ -2178,11 +2247,11 @@ paths: schema: $ref: '#/components/schemas/server.APIStatus' description: Internal Server Error - summary: fetch a deck tags: - decks /decks/{reference}/archive: post: + description: toggle archive status of a deck operationId: toggleArchive parameters: - description: deck unique reference.. e.g deck_ @@ -2222,11 +2291,11 @@ paths: schema: $ref: '#/components/schemas/server.APIStatus' description: Internal Server Error - summary: toggle archive status of a deck tags: - decks /decks/{reference}/pin: post: + description: toggle pinned status of a deck operationId: togglePin parameters: - description: deck unique reference.. e.g deck_ @@ -2266,11 +2335,11 @@ paths: schema: $ref: '#/components/schemas/server.APIStatus' description: Internal Server Error - summary: toggle pinned status of a deck tags: - decks /decks/{reference}/preferences: put: + description: update a deck preferences parameters: - description: deck unique reference.. e.g deck_ in: path @@ -2317,11 +2386,11 @@ paths: schema: $ref: '#/components/schemas/server.APIStatus' description: Internal Server Error - summary: update a deck preferences tags: - decks /public/decks/{reference}: get: + description: public api to fetch a deck parameters: - description: deck unique reference.. e.g deck_ in: path @@ -2360,11 +2429,11 @@ paths: schema: $ref: '#/components/schemas/server.APIStatus' description: Internal Server Error - summary: public api to fetch a deck tags: - decks-viewer /updates/react: get: + description: Fetch a specific update operationId: reactPost parameters: - description: provider type @@ -2410,11 +2479,11 @@ paths: schema: $ref: '#/components/schemas/server.APIStatus' description: Internal Server Error - summary: Fetch a specific update tags: - updates /uploads/decks: post: + description: Upload a deck operationId: uploadDeck requestBody: content: @@ -2460,11 +2529,11 @@ paths: schema: $ref: '#/components/schemas/server.APIStatus' description: Internal Server Error - summary: Upload a deck tags: - decks /uploads/images: post: + description: Upload an image operationId: uploadImage requestBody: content: @@ -2510,11 +2579,12 @@ paths: schema: $ref: '#/components/schemas/server.APIStatus' description: Internal Server Error - summary: Upload an image tags: - images /user: get: + description: Fetch current user. This api should also double as a token validation + api responses: "200": content: @@ -2546,12 +2616,11 @@ paths: schema: $ref: '#/components/schemas/server.APIStatus' description: Internal Server Error - summary: Fetch current user. This api should also double as a token validation - api tags: - user /workspaces: patch: + description: update workspace details requestBody: content: application/json: @@ -2591,10 +2660,10 @@ paths: schema: $ref: '#/components/schemas/server.APIStatus' description: Internal Server Error - summary: update workspace details tags: - workspace post: + description: Create a new workspace requestBody: content: application/json: @@ -2634,11 +2703,11 @@ paths: schema: $ref: '#/components/schemas/server.APIStatus' description: Internal Server Error - summary: Create a new workspace tags: - workspace /workspaces/billing: post: + description: get billing portal responses: "200": content: @@ -2670,11 +2739,11 @@ paths: schema: $ref: '#/components/schemas/server.APIStatus' description: Internal Server Error - summary: get billing portal tags: - billing /workspaces/integrations: get: + description: fetch workspace preferences responses: "200": content: @@ -2706,11 +2775,11 @@ paths: schema: $ref: '#/components/schemas/server.APIStatus' description: Internal Server Error - summary: fetch workspace preferences tags: - integrations /workspaces/integrations/{reference}: delete: + description: disable integration responses: "200": content: @@ -2742,10 +2811,10 @@ paths: schema: $ref: '#/components/schemas/server.APIStatus' description: Internal Server Error - summary: disable integration tags: - integrations post: + description: enable integration requestBody: content: application/json: @@ -2785,10 +2854,10 @@ paths: schema: $ref: '#/components/schemas/server.APIStatus' description: Internal Server Error - summary: enable integration tags: - integrations put: + description: update integration api key requestBody: content: application/json: @@ -2828,11 +2897,11 @@ paths: schema: $ref: '#/components/schemas/server.APIStatus' description: Internal Server Error - summary: update integration api key tags: - integrations /workspaces/integrations/{reference}/ping: post: + description: test an api key is valid and can reach the integration requestBody: content: application/json: @@ -2872,11 +2941,11 @@ paths: schema: $ref: '#/components/schemas/server.APIStatus' description: Internal Server Error - summary: test an api key is valid and can reach the integration tags: - integrations /workspaces/preferences: get: + description: fetch workspace preferences responses: "200": content: @@ -2908,10 +2977,10 @@ paths: schema: $ref: '#/components/schemas/server.APIStatus' description: Internal Server Error - summary: fetch workspace preferences tags: - workspace put: + description: update workspace preferences requestBody: content: application/json: @@ -2951,11 +3020,11 @@ paths: schema: $ref: '#/components/schemas/server.APIStatus' description: Internal Server Error - summary: update workspace preferences tags: - workspace /workspaces/switch/{reference}: post: + description: Switch current workspace operationId: switchworkspace parameters: - description: Workspace unique reference.. e.g update_ @@ -2995,11 +3064,11 @@ paths: schema: $ref: '#/components/schemas/server.APIStatus' description: Internal Server Error - summary: Switch current workspace tags: - workspace /workspaces/updates: get: + description: List updates parameters: - description: Page to query data from. Defaults to 1 in: query @@ -3047,10 +3116,10 @@ paths: schema: $ref: '#/components/schemas/server.APIStatus' description: Internal Server Error - summary: List updates tags: - updates post: + description: Create a new update requestBody: content: application/json: @@ -3090,11 +3159,11 @@ paths: schema: $ref: '#/components/schemas/server.APIStatus' description: Internal Server Error - summary: Create a new update tags: - updates /workspaces/updates/{reference}: delete: + description: Delete a specific update operationId: deleteUpdate parameters: - description: update unique reference.. e.g update_ @@ -3134,10 +3203,10 @@ paths: schema: $ref: '#/components/schemas/server.APIStatus' description: Internal Server Error - summary: Delete a specific update tags: - updates get: + description: Fetch a specific update operationId: fetchUpdate parameters: - description: update unique reference.. e.g update_ @@ -3177,10 +3246,10 @@ paths: schema: $ref: '#/components/schemas/server.APIStatus' description: Internal Server Error - summary: Fetch a specific update tags: - updates post: + description: Send an update to real users operationId: sendUpdate parameters: - description: update unique reference.. e.g update_ @@ -3228,10 +3297,10 @@ paths: schema: $ref: '#/components/schemas/server.APIStatus' description: Internal Server Error - summary: Send an update to real users tags: - updates put: + description: Update a specific update operationId: updateContent parameters: - description: update unique reference.. e.g update_ @@ -3279,11 +3348,11 @@ paths: schema: $ref: '#/components/schemas/server.APIStatus' description: Internal Server Error - summary: Update a specific update tags: - updates /workspaces/updates/{reference}/analytics: get: + description: Fetch analytics for a specific update operationId: fetchUpdateAnalytics parameters: - description: update unique reference.. e.g update_ @@ -3323,11 +3392,11 @@ paths: schema: $ref: '#/components/schemas/server.APIStatus' description: Internal Server Error - summary: Fetch analytics for a specific update tags: - updates /workspaces/updates/{reference}/duplicate: post: + description: Duplicate a specific update operationId: duplicateUpdate parameters: - description: update unique reference.. e.g update_ @@ -3367,11 +3436,11 @@ paths: schema: $ref: '#/components/schemas/server.APIStatus' description: Internal Server Error - summary: Duplicate a specific update tags: - updates /workspaces/updates/{reference}/pin: post: + description: Toggle pinned status a specific update operationId: toggleUpdatePin parameters: - description: update unique reference.. e.g update_ @@ -3411,11 +3480,11 @@ paths: schema: $ref: '#/components/schemas/server.APIStatus' description: Internal Server Error - summary: Toggle pinned status a specific update tags: - updates /workspaces/updates/{reference}/preview: post: + description: Send preview of an update operationId: previewUpdate parameters: - description: update unique reference.. e.g update_ @@ -3463,11 +3532,11 @@ paths: schema: $ref: '#/components/schemas/server.APIStatus' description: Internal Server Error - summary: Send preview of an update tags: - updates /workspaces/updates/pins: get: + description: List pinned updates responses: "200": content: @@ -3499,7 +3568,6 @@ paths: schema: $ref: '#/components/schemas/server.APIStatus' description: Internal Server Error - summary: List pinned updates tags: - updates servers: diff --git a/web/ui/src/app/(main)/dashboards/[slug]/page.tsx b/web/ui/src/app/(main)/dashboards/[slug]/page.tsx index 850565cd..711ead4d 100644 --- a/web/ui/src/app/(main)/dashboards/[slug]/page.tsx +++ b/web/ui/src/app/(main)/dashboards/[slug]/page.tsx @@ -4,7 +4,7 @@ import type { MalakDashboardChart, MalakIntegrationDataPoint, ServerAPIStatus, ServerListDashboardChartsResponse, - ServerListIntegrationChartsResponse + ServerListIntegrationChartsResponse, } from "@/client/Api"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; @@ -314,6 +314,24 @@ export default function DashboardPage() { } }); + const updatePositionsMutation = useMutation({ + mutationFn: async (positions: { chart_id: string; index: number }[]) => { + const response = await client.dashboards.positionsCreate(dashboardID, { positions }); + return response.data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [DASHBOARD_DETAIL, dashboardID] }); + toast.success("chart positions updated") + }, + onError: (err: AxiosError) => { + toast.error(err?.response?.data?.message || "Failed to update chart positions"); + // Revert to the previous state on error + if (dashboardData?.charts) { + setCharts(dashboardData.charts); + } + } + }); + const barCharts = chartsData?.charts?.filter(chart => chart.chart_type === "bar") ?? []; const pieCharts = chartsData?.charts?.filter(chart => chart.chart_type === "pie") ?? []; @@ -331,7 +349,7 @@ export default function DashboardPage() { const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { - distance: 8, // 8px movement required before drag starts + distance: 8, // 8px movement is required before drag starts }, }), useSensor(KeyboardSensor, { @@ -354,17 +372,15 @@ export default function DashboardPage() { const newIndex = items.findIndex((item) => item.reference === over.id); const newItems = arrayMove(items, oldIndex, newIndex); - // Log the new positions const positions = newItems.map((item, index) => ({ - chart_id: item.reference, + chart_id: item.id || '', index })); - console.log('New chart positions:', positions); + + updatePositionsMutation.mutate(positions); return newItems; }); - - // TODO: Call API to update chart positions } } diff --git a/web/ui/src/client/Api.ts b/web/ui/src/client/Api.ts index e666da80..e59e8501 100644 --- a/web/ui/src/client/Api.ts +++ b/web/ui/src/client/Api.ts @@ -666,6 +666,13 @@ export interface ServerTestAPIIntegrationRequest { api_key: string; } +export interface ServerUpdateDashboardPositionsRequest { + positions: { + chart_id: string; + index: number; + }[]; +} + export interface ServerUpdateDeckPreferencesRequest { enable_downloading?: boolean; password_protection?: { @@ -837,11 +844,10 @@ export class HttpClient { export class Api extends HttpClient { auth = { /** - * No description + * @description Sign in with a social login provider * * @tags auth * @name ConnectCreate - * @summary Sign in with a social login provider * @request POST:/auth/connect/{provider} */ connectCreate: (provider: string, data: ServerAuthenticateUserRequest, params: RequestParams = {}) => @@ -856,11 +862,10 @@ export class Api extends HttpClient extends HttpClient @@ -899,11 +903,10 @@ export class Api extends HttpClient @@ -915,11 +918,10 @@ export class Api extends HttpClient @@ -931,11 +933,10 @@ export class Api extends HttpClient @@ -949,11 +950,10 @@ export class Api extends HttpClient extends HttpClient @@ -990,11 +989,10 @@ export class Api extends HttpClient @@ -1006,11 +1004,10 @@ export class Api extends HttpClient @@ -1024,11 +1021,10 @@ export class Api extends HttpClient @@ -1043,11 +1039,10 @@ export class Api extends HttpClient extends HttpClient @@ -1086,11 +1080,10 @@ export class Api extends HttpClient @@ -1102,11 +1095,10 @@ export class Api extends HttpClient @@ -1120,11 +1112,27 @@ export class Api extends HttpClient + this.request({ + path: `/dashboards/${reference}/positions`, + method: "POST", + body: data, + type: ContentType.Json, + format: "json", + ...params, + }), + + /** + * @description List charts * * @tags dashboards * @name ChartsList - * @summary List charts * @request GET:/dashboards/charts */ chartsList: (params: RequestParams = {}) => @@ -1136,11 +1144,10 @@ export class Api extends HttpClient @@ -1153,11 +1160,10 @@ export class Api extends HttpClient @@ -1169,11 +1175,10 @@ export class Api extends HttpClient @@ -1187,11 +1192,10 @@ export class Api extends HttpClient @@ -1203,11 +1207,10 @@ export class Api extends HttpClient @@ -1219,11 +1222,10 @@ export class Api extends HttpClient @@ -1235,11 +1237,10 @@ export class Api extends HttpClient @@ -1251,11 +1252,10 @@ export class Api extends HttpClient @@ -1270,11 +1270,10 @@ export class Api extends HttpClient @@ -1287,11 +1286,10 @@ export class Api extends HttpClient extends HttpClient extends HttpClient extends HttpClient @@ -1385,11 +1380,10 @@ export class Api extends HttpClient @@ -1403,11 +1397,10 @@ export class Api extends HttpClient @@ -1421,11 +1414,10 @@ export class Api extends HttpClient @@ -1437,11 +1429,10 @@ export class Api extends HttpClient @@ -1453,11 +1444,10 @@ export class Api extends HttpClient @@ -1469,11 +1459,10 @@ export class Api extends HttpClient @@ -1487,11 +1476,10 @@ export class Api extends HttpClient @@ -1505,11 +1493,10 @@ export class Api extends HttpClient @@ -1523,11 +1510,10 @@ export class Api extends HttpClient @@ -1539,11 +1525,10 @@ export class Api extends HttpClient @@ -1557,11 +1542,10 @@ export class Api extends HttpClient @@ -1573,11 +1557,10 @@ export class Api extends HttpClient extends HttpClient @@ -1618,11 +1600,10 @@ export class Api extends HttpClient @@ -1634,11 +1615,10 @@ export class Api extends HttpClient @@ -1650,11 +1630,10 @@ export class Api extends HttpClient @@ -1668,11 +1647,10 @@ export class Api extends HttpClient @@ -1686,11 +1664,10 @@ export class Api extends HttpClient @@ -1702,11 +1679,10 @@ export class Api extends HttpClient @@ -1718,11 +1694,10 @@ export class Api extends HttpClient @@ -1734,11 +1709,10 @@ export class Api extends HttpClient @@ -1752,11 +1726,10 @@ export class Api extends HttpClient From faa12284a1ca48f0ea021c34d13628074c344dd2 Mon Sep 17 00:00:00 2001 From: Lanre Adelowo Date: Fri, 21 Feb 2025 20:28:43 +0100 Subject: [PATCH 11/15] use callback --- .../src/app/(main)/dashboards/[slug]/page.tsx | 43 +++++++++++++------ 1 file changed, 29 insertions(+), 14 deletions(-) diff --git a/web/ui/src/app/(main)/dashboards/[slug]/page.tsx b/web/ui/src/app/(main)/dashboards/[slug]/page.tsx index 711ead4d..b6a08b09 100644 --- a/web/ui/src/app/(main)/dashboards/[slug]/page.tsx +++ b/web/ui/src/app/(main)/dashboards/[slug]/page.tsx @@ -68,7 +68,7 @@ import { import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { AxiosError } from "axios"; import { useParams } from "next/navigation"; -import { useEffect, useState } from "react"; +import { useEffect, useState, useRef, useCallback } from "react"; import { Bar, BarChart, Cell, @@ -345,6 +345,7 @@ export default function DashboardPage() { }; const [charts, setCharts] = useState([]); + const isUpdatingRef = useRef(false); const sensors = useSensors( useSensor(PointerSensor, { @@ -363,25 +364,39 @@ export default function DashboardPage() { } }, [dashboardData?.charts]); + const updatePositionsDebounced = useCallback( + (positions: { chart_id: string; index: number }[]) => { + if (isUpdatingRef.current) return; + isUpdatingRef.current = true; + + setTimeout(() => { + updatePositionsMutation.mutate(positions); + isUpdatingRef.current = false; + }, 100); + }, + [updatePositionsMutation] + ); + function handleDragEnd(event: any) { const { active, over } = event; - if (active.id !== over?.id) { - setCharts((items) => { - const oldIndex = items.findIndex((item) => item.reference === active.id); - const newIndex = items.findIndex((item) => item.reference === over.id); - const newItems = arrayMove(items, oldIndex, newIndex); + if (!active || !over || active.id === over.id) { + return; + } - const positions = newItems.map((item, index) => ({ - chart_id: item.id || '', - index - })); + setCharts((items) => { + const oldIndex = items.findIndex((item) => item.reference === active.id); + const newIndex = items.findIndex((item) => item.reference === over.id); + const newItems = arrayMove(items, oldIndex, newIndex); - updatePositionsMutation.mutate(positions); + const positions = newItems.map((item, index) => ({ + chart_id: item.id || '', + index + })); - return newItems; - }); - } + updatePositionsDebounced(positions); + return newItems; + }); } if (isLoadingDashboard) { From 6c1afb3c33f0bb74078a0a4dfa1a772f8f333ee1 Mon Sep 17 00:00:00 2001 From: Lanre Adelowo Date: Fri, 21 Feb 2025 20:32:19 +0100 Subject: [PATCH 12/15] fix positioning --- web/ui/src/app/(main)/dashboards/[slug]/page.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/web/ui/src/app/(main)/dashboards/[slug]/page.tsx b/web/ui/src/app/(main)/dashboards/[slug]/page.tsx index b6a08b09..d045ee2a 100644 --- a/web/ui/src/app/(main)/dashboards/[slug]/page.tsx +++ b/web/ui/src/app/(main)/dashboards/[slug]/page.tsx @@ -360,9 +360,15 @@ export default function DashboardPage() { useEffect(() => { if (dashboardData?.charts) { - setCharts(dashboardData.charts); + // Sort charts based on their positions if available + const sortedCharts = [...dashboardData.charts].sort((a, b) => { + const posA = dashboardData.positions?.find(p => p.chart_id === a.id)?.order_index ?? 0; + const posB = dashboardData.positions?.find(p => p.chart_id === b.id)?.order_index ?? 0; + return posA - posB; + }); + setCharts(sortedCharts); } - }, [dashboardData?.charts]); + }, [dashboardData?.charts, dashboardData?.positions]); const updatePositionsDebounced = useCallback( (positions: { chart_id: string; index: number }[]) => { From 95e6cfe9e4cdf11b44eedba058bce4ec31785cfe Mon Sep 17 00:00:00 2001 From: Lanre Adelowo Date: Fri, 21 Feb 2025 21:38:29 +0100 Subject: [PATCH 13/15] add tests --- internal/datastore/postgres/dashboard.go | 27 +- internal/datastore/postgres/dashboard_test.go | 398 ++++++++++++------ server/dashboard_test.go | 284 +++++++++++++ .../chart_not_found.golden | 1 + .../error_fetching_chart.golden | 1 + .../error_fetching_data_points.golden | 1 + .../successfully_fetched_chart_data.golden | 1 + ...sfully_fetched_dashboard_and_charts.golden | 2 +- .../dashboard_not_found.golden | 1 + .../error_fetching_dashboard.golden | 1 + .../error_updating_positions.golden | 1 + .../successfully_updated_positions.golden | 1 + swagger/docs.go | 13 - swagger/swagger.json | 21 - swagger/swagger.yaml | 13 - 15 files changed, 596 insertions(+), 170 deletions(-) create mode 100644 server/testdata/TestDashboardHandler_FetchChartingData/chart_not_found.golden create mode 100644 server/testdata/TestDashboardHandler_FetchChartingData/error_fetching_chart.golden create mode 100644 server/testdata/TestDashboardHandler_FetchChartingData/error_fetching_data_points.golden create mode 100644 server/testdata/TestDashboardHandler_FetchChartingData/successfully_fetched_chart_data.golden create mode 100644 server/testdata/TestDashboardHandler_UpdateDashboardPositions/dashboard_not_found.golden create mode 100644 server/testdata/TestDashboardHandler_UpdateDashboardPositions/error_fetching_dashboard.golden create mode 100644 server/testdata/TestDashboardHandler_UpdateDashboardPositions/error_updating_positions.golden create mode 100644 server/testdata/TestDashboardHandler_UpdateDashboardPositions/successfully_updated_positions.golden diff --git a/internal/datastore/postgres/dashboard.go b/internal/datastore/postgres/dashboard.go index cc1721ab..19cf1741 100644 --- a/internal/datastore/postgres/dashboard.go +++ b/internal/datastore/postgres/dashboard.go @@ -27,6 +27,10 @@ func (d *dashboardRepo) Create(ctx context.Context, ctx, cancelFn := withContext(ctx) defer cancelFn() + if dashboard.Title == "" { + return errors.New("dashboard title is required") + } + return d.inner.RunInTx(ctx, &sql.TxOptions{}, func(ctx context.Context, tx bun.Tx) error { @@ -155,8 +159,19 @@ func (d *dashboardRepo) UpdateDashboardPositions(ctx context.Context, return d.inner.RunInTx(ctx, &sql.TxOptions{}, func(ctx context.Context, tx bun.Tx) error { + // First check if dashboard exists + exists, err := tx.NewSelect(). + Model((*malak.Dashboard)(nil)). + Where("id = ?", dashboardID). + Exists(ctx) + if err != nil { + return err + } + if !exists { + return malak.ErrDashboardNotFound + } - _, err := tx.NewDelete(). + _, err = tx.NewDelete(). Model(new(malak.DashboardChartPosition)). Where("dashboard_id = ?", dashboardID). Exec(ctx) @@ -164,8 +179,12 @@ func (d *dashboardRepo) UpdateDashboardPositions(ctx context.Context, return err } - _, err = tx.NewInsert().Model(&positions). - Exec(ctx) - return err + if len(positions) > 0 { + _, err = tx.NewInsert().Model(&positions). + Exec(ctx) + return err + } + + return nil }) } diff --git a/internal/datastore/postgres/dashboard_test.go b/internal/datastore/postgres/dashboard_test.go index 3c54bbe8..f9d22abb 100644 --- a/internal/datastore/postgres/dashboard_test.go +++ b/internal/datastore/postgres/dashboard_test.go @@ -20,17 +20,56 @@ func TestDashboard_Create(t *testing.T) { }) require.NoError(t, err) - dashboard := &malak.Dashboard{ - WorkspaceID: workspace.ID, - Reference: malak.NewReferenceGenerator().Generate(malak.EntityTypeDashboard), - Title: "Test Dashboard", - Description: "Test Dashboard Description", + tests := []struct { + name string + dashboard *malak.Dashboard + expectError bool + }{ + { + name: "valid dashboard", + dashboard: &malak.Dashboard{ + WorkspaceID: workspace.ID, + Reference: malak.NewReferenceGenerator().Generate(malak.EntityTypeDashboard), + Title: "Test Dashboard", + Description: "Test Dashboard Description", + }, + expectError: false, + }, + { + name: "dashboard with empty title", + dashboard: &malak.Dashboard{ + WorkspaceID: workspace.ID, + Reference: malak.NewReferenceGenerator().Generate(malak.EntityTypeDashboard), + Description: "Test Dashboard Description", + }, + expectError: true, + }, + { + name: "dashboard with invalid workspace ID", + dashboard: &malak.Dashboard{ + WorkspaceID: uuid.New(), + Reference: malak.NewReferenceGenerator().Generate(malak.EntityTypeDashboard), + Title: "Test Dashboard", + Description: "Test Dashboard Description", + }, + expectError: true, + }, } - err = dashboardRepo.Create(t.Context(), dashboard) - require.NoError(t, err) - require.NotEmpty(t, dashboard.ID) - require.Equal(t, int64(0), dashboard.ChartCount) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := dashboardRepo.Create(t.Context(), tt.dashboard) + if tt.expectError { + require.Error(t, err) + } else { + require.NoError(t, err) + require.NotEmpty(t, tt.dashboard.ID) + require.Equal(t, int64(0), tt.dashboard.ChartCount) + require.False(t, tt.dashboard.CreatedAt.IsZero()) + require.False(t, tt.dashboard.UpdatedAt.IsZero()) + } + }) + } } func TestDashboard_AddChart(t *testing.T) { @@ -87,27 +126,66 @@ func TestDashboard_AddChart(t *testing.T) { err = dashboardRepo.Create(t.Context(), dashboard) require.NoError(t, err) - require.Equal(t, int64(0), dashboard.ChartCount) - - chart := &malak.DashboardChart{ - WorkspaceIntegrationID: workspaceIntegration.ID, - ChartID: createdChart.ID, - Reference: malak.NewReferenceGenerator().Generate(malak.EntityTypeDashboardChart), - WorkspaceID: workspace.ID, - DashboardID: dashboard.ID, + + tests := []struct { + name string + chart *malak.DashboardChart + expectError bool + }{ + { + name: "valid chart", + chart: &malak.DashboardChart{ + WorkspaceIntegrationID: workspaceIntegration.ID, + ChartID: createdChart.ID, + Reference: malak.NewReferenceGenerator().Generate(malak.EntityTypeDashboardChart), + WorkspaceID: workspace.ID, + DashboardID: dashboard.ID, + }, + expectError: false, + }, + { + name: "invalid workspace integration ID", + chart: &malak.DashboardChart{ + WorkspaceIntegrationID: uuid.New(), + ChartID: createdChart.ID, + Reference: malak.NewReferenceGenerator().Generate(malak.EntityTypeDashboardChart), + WorkspaceID: workspace.ID, + DashboardID: dashboard.ID, + }, + expectError: true, + }, + { + name: "invalid chart ID", + chart: &malak.DashboardChart{ + WorkspaceIntegrationID: workspaceIntegration.ID, + ChartID: uuid.New(), + Reference: malak.NewReferenceGenerator().Generate(malak.EntityTypeDashboardChart), + WorkspaceID: workspace.ID, + DashboardID: dashboard.ID, + }, + expectError: true, + }, } - err = dashboardRepo.AddChart(t.Context(), chart) - require.NoError(t, err) - require.NotEmpty(t, chart.ID) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := dashboardRepo.AddChart(t.Context(), tt.chart) + if tt.expectError { + require.Error(t, err) + } else { + require.NoError(t, err) + require.NotEmpty(t, tt.chart.ID) - // verify chart count was incremented - updatedDashboard, err := dashboardRepo.Get(t.Context(), malak.FetchDashboardOption{ - WorkspaceID: workspace.ID, - Reference: dashboard.Reference, - }) - require.NoError(t, err) - require.Equal(t, int64(1), updatedDashboard.ChartCount) + // Verify chart count was incremented + updatedDashboard, err := dashboardRepo.Get(t.Context(), malak.FetchDashboardOption{ + WorkspaceID: workspace.ID, + Reference: dashboard.Reference, + }) + require.NoError(t, err) + require.Equal(t, int64(1), updatedDashboard.ChartCount) + } + }) + } } func TestDashboard_Get(t *testing.T) { @@ -133,9 +211,10 @@ func TestDashboard_Get(t *testing.T) { require.NoError(t, err) tests := []struct { - name string - opts malak.FetchDashboardOption - expectedError error + name string + opts malak.FetchDashboardOption + expectError bool + errorType error }{ { name: "existing dashboard", @@ -143,7 +222,7 @@ func TestDashboard_Get(t *testing.T) { WorkspaceID: workspace.ID, Reference: dashboard.Reference, }, - expectedError: nil, + expectError: false, }, { name: "non-existent dashboard", @@ -151,7 +230,8 @@ func TestDashboard_Get(t *testing.T) { WorkspaceID: workspace.ID, Reference: malak.NewReferenceGenerator().Generate(malak.EntityTypeDashboard), }, - expectedError: malak.ErrDashboardNotFound, + expectError: true, + errorType: malak.ErrDashboardNotFound, }, { name: "wrong workspace", @@ -159,15 +239,19 @@ func TestDashboard_Get(t *testing.T) { WorkspaceID: uuid.New(), Reference: dashboard.Reference, }, - expectedError: malak.ErrDashboardNotFound, + expectError: true, + errorType: malak.ErrDashboardNotFound, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result, err := dashboardRepo.Get(t.Context(), tt.opts) - if tt.expectedError != nil { - require.ErrorIs(t, err, tt.expectedError) + if tt.expectError { + require.Error(t, err) + if tt.errorType != nil { + require.ErrorIs(t, err, tt.errorType) + } } else { require.NoError(t, err) require.Equal(t, tt.opts.WorkspaceID, result.WorkspaceID) @@ -239,37 +323,26 @@ func TestDashboard_GetCharts(t *testing.T) { err = dashboardRepo.Create(t.Context(), dashboard) require.NoError(t, err) - // Add multiple charts - charts := []*malak.DashboardChart{ - { - WorkspaceIntegrationID: workspaceIntegration.ID, - ChartID: createdCharts[0].ID, - Reference: malak.NewReferenceGenerator().Generate(malak.EntityTypeDashboardChart), - WorkspaceID: workspace.ID, - DashboardID: dashboard.ID, - }, - { + // Add charts to dashboard + for _, chart := range createdCharts { + dashboardChart := &malak.DashboardChart{ WorkspaceIntegrationID: workspaceIntegration.ID, - ChartID: createdCharts[1].ID, + ChartID: chart.ID, Reference: malak.NewReferenceGenerator().Generate(malak.EntityTypeDashboardChart), WorkspaceID: workspace.ID, DashboardID: dashboard.ID, - }, - } - - for _, chart := range charts { - err = dashboardRepo.AddChart(t.Context(), chart) + } + err = dashboardRepo.AddChart(t.Context(), dashboardChart) require.NoError(t, err) } - // Test getting charts tests := []struct { name string opts malak.FetchDashboardChartsOption expectedCount int }{ { - name: "existing dashboard charts", + name: "get existing charts", opts: malak.FetchDashboardChartsOption{ WorkspaceID: workspace.ID, DashboardID: dashboard.ID, @@ -296,17 +369,39 @@ func TestDashboard_GetCharts(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - results, err := dashboardRepo.GetCharts(t.Context(), tt.opts) + charts, err := dashboardRepo.GetCharts(t.Context(), tt.opts) require.NoError(t, err) - require.Equal(t, tt.expectedCount, len(results)) + require.Len(t, charts, tt.expectedCount) if tt.expectedCount > 0 { - for _, result := range results { - require.Equal(t, tt.opts.WorkspaceID, result.WorkspaceID) - require.Equal(t, tt.opts.DashboardID, result.DashboardID) - require.NotEmpty(t, result.ID) - require.NotEmpty(t, result.Reference) + for _, chart := range charts { + require.NotNil(t, chart.IntegrationChart) + require.NotEmpty(t, chart.IntegrationChart.UserFacingName) + require.NotEmpty(t, chart.IntegrationChart.InternalName) + require.NotEmpty(t, chart.IntegrationChart.ChartType) + require.Equal(t, workspaceIntegration.ID, chart.IntegrationChart.WorkspaceIntegrationID) + require.Equal(t, workspace.ID, chart.IntegrationChart.WorkspaceID) + } + + // Verify specific chart data + foundAccountBalance := false + foundTransactionHistory := false + + for _, chart := range charts { + switch chart.IntegrationChart.InternalName { + case malak.IntegrationChartInternalNameTypeMercuryAccount: + foundAccountBalance = true + require.Equal(t, "Account Balance", chart.IntegrationChart.UserFacingName) + require.Equal(t, malak.IntegrationChartTypeBar, chart.IntegrationChart.ChartType) + case malak.IntegrationChartInternalNameTypeMercuryAccountTransaction: + foundTransactionHistory = true + require.Equal(t, "Transaction History", chart.IntegrationChart.UserFacingName) + require.Equal(t, malak.IntegrationChartTypeBar, chart.IntegrationChart.ChartType) + } } + + require.True(t, foundAccountBalance, "Account Balance chart not found") + require.True(t, foundTransactionHistory, "Transaction History chart not found") } }) } @@ -319,13 +414,6 @@ func TestDashboard_List(t *testing.T) { dashboardRepo := NewDashboardRepo(client) workspaceRepo := NewWorkspaceRepository(client) - // Clean up any existing dashboards first - _, err := client.NewDelete(). - Table("dashboards"). - Where("1=1"). - Exec(t.Context()) - require.NoError(t, err) - workspace1, err := workspaceRepo.Get(t.Context(), &malak.FindWorkspaceOptions{ ID: uuid.MustParse("a4ae79a2-9b76-40d7-b5a1-661e60a02cb0"), }) @@ -336,6 +424,11 @@ func TestDashboard_List(t *testing.T) { }) require.NoError(t, err) + // Clean up existing dashboards + _, err = client.NewDelete().Model((*malak.Dashboard)(nil)).Where("1=1").Exec(t.Context()) + require.NoError(t, err) + + // Create test dashboards dashboards1 := []*malak.Dashboard{ { WorkspaceID: workspace1.ID, @@ -375,13 +468,11 @@ func TestDashboard_List(t *testing.T) { for _, d := range dashboards1 { err = dashboardRepo.Create(t.Context(), d) require.NoError(t, err) - require.NotEmpty(t, d.ID) } for _, d := range dashboards2 { err = dashboardRepo.Create(t.Context(), d) require.NoError(t, err) - require.NotEmpty(t, d.ID) } tests := []struct { @@ -438,6 +529,18 @@ func TestDashboard_List(t *testing.T) { expectedCount: 2, totalCount: 2, }, + { + name: "non-existent workspace", + opts: malak.ListDashboardOptions{ + WorkspaceID: uuid.New(), + Paginator: malak.Paginator{ + Page: 1, + PerPage: 10, + }, + }, + expectedCount: 0, + totalCount: 0, + }, } for _, tt := range tests { @@ -447,25 +550,22 @@ func TestDashboard_List(t *testing.T) { require.Equal(t, tt.expectedCount, len(results)) require.Equal(t, tt.totalCount, total) - for _, result := range results { - require.Equal(t, tt.opts.WorkspaceID, result.WorkspaceID) + if tt.expectedCount > 0 { + for _, result := range results { + require.Equal(t, tt.opts.WorkspaceID, result.WorkspaceID) + require.NotEmpty(t, result.ID) + require.NotEmpty(t, result.Reference) + require.NotEmpty(t, result.Title) + require.NotEmpty(t, result.Description) + require.False(t, result.CreatedAt.IsZero()) + require.False(t, result.UpdatedAt.IsZero()) + } } }) } - - nonExistentResults, total, err := dashboardRepo.List(t.Context(), malak.ListDashboardOptions{ - WorkspaceID: uuid.New(), - Paginator: malak.Paginator{ - Page: 1, - PerPage: 10, - }, - }) - require.NoError(t, err) - require.Equal(t, 0, len(nonExistentResults)) - require.Equal(t, int64(0), total) } -func TestDashboard_GetChartsWithIntegration(t *testing.T) { +func TestDashboard_UpdatePositions(t *testing.T) { client, teardownFunc := setupDatabase(t) defer teardownFunc() @@ -478,6 +578,7 @@ func TestDashboard_GetChartsWithIntegration(t *testing.T) { }) require.NoError(t, err) + // Create integration integration := &malak.Integration{ IntegrationName: "Mercury", Reference: malak.NewReferenceGenerator().Generate(malak.EntityTypeIntegration), @@ -489,11 +590,13 @@ func TestDashboard_GetChartsWithIntegration(t *testing.T) { err = integrationRepo.Create(t.Context(), integration) require.NoError(t, err) + // Get workspace integration integrations, err := integrationRepo.List(t.Context(), workspace) require.NoError(t, err) require.Len(t, integrations, 1) workspaceIntegration := integrations[0] + // Create integration charts chartValues := []malak.IntegrationChartValues{ { UserFacingName: "Account Balance", @@ -511,6 +614,7 @@ func TestDashboard_GetChartsWithIntegration(t *testing.T) { err = integrationRepo.CreateCharts(t.Context(), &workspaceIntegration, chartValues) require.NoError(t, err) + // Get created charts charts, err := integrationRepo.ListCharts(t.Context(), workspace.ID) require.NoError(t, err) require.Len(t, charts, 2) @@ -518,12 +622,15 @@ func TestDashboard_GetChartsWithIntegration(t *testing.T) { dashboard := &malak.Dashboard{ WorkspaceID: workspace.ID, Reference: malak.NewReferenceGenerator().Generate(malak.EntityTypeDashboard), - Title: "Mercury Dashboard", - Description: "Mercury Banking Dashboard", + Title: "Test Dashboard", + Description: "Test Dashboard Description", } + err = dashboardRepo.Create(t.Context(), dashboard) require.NoError(t, err) + // Create dashboard charts + dashboardCharts := make([]*malak.DashboardChart, 0, len(charts)) for _, chart := range charts { dashboardChart := &malak.DashboardChart{ WorkspaceIntegrationID: workspaceIntegration.ID, @@ -534,42 +641,97 @@ func TestDashboard_GetChartsWithIntegration(t *testing.T) { } err = dashboardRepo.AddChart(t.Context(), dashboardChart) require.NoError(t, err) + dashboardCharts = append(dashboardCharts, dashboardChart) } - dashboardCharts, err := dashboardRepo.GetCharts(t.Context(), malak.FetchDashboardChartsOption{ - WorkspaceID: workspace.ID, - DashboardID: dashboard.ID, - }) - require.NoError(t, err) - require.Len(t, dashboardCharts, 2) - - // verify integration chart data is loaded - for _, dashboardChart := range dashboardCharts { - require.NotNil(t, dashboardChart.IntegrationChart) - require.NotEmpty(t, dashboardChart.IntegrationChart.UserFacingName) - require.NotEmpty(t, dashboardChart.IntegrationChart.InternalName) - require.NotEmpty(t, dashboardChart.IntegrationChart.ChartType) - require.Equal(t, workspaceIntegration.ID, dashboardChart.IntegrationChart.WorkspaceIntegrationID) - require.Equal(t, workspace.ID, dashboardChart.IntegrationChart.WorkspaceID) + tests := []struct { + name string + positions []malak.DashboardChartPosition + expectedCount int + expectedOrder []int64 + expectError bool + dashboardID uuid.UUID + }{ + { + name: "update with valid positions", + positions: []malak.DashboardChartPosition{ + { + DashboardID: dashboard.ID, + ChartID: dashboardCharts[0].ID, + OrderIndex: 1, + }, + { + DashboardID: dashboard.ID, + ChartID: dashboardCharts[1].ID, + OrderIndex: 2, + }, + }, + expectedCount: 2, + expectedOrder: []int64{1, 2}, + dashboardID: dashboard.ID, + }, + { + name: "update with empty positions", + positions: []malak.DashboardChartPosition{}, + expectedCount: 0, + expectedOrder: []int64{}, + dashboardID: dashboard.ID, + }, + { + name: "update with reversed order", + positions: []malak.DashboardChartPosition{ + { + DashboardID: dashboard.ID, + ChartID: dashboardCharts[1].ID, + OrderIndex: 1, + }, + { + DashboardID: dashboard.ID, + ChartID: dashboardCharts[0].ID, + OrderIndex: 2, + }, + }, + expectedCount: 2, + expectedOrder: []int64{1, 2}, + dashboardID: dashboard.ID, + }, + { + name: "update with invalid dashboard ID", + positions: []malak.DashboardChartPosition{ + { + DashboardID: uuid.New(), + ChartID: dashboardCharts[0].ID, + OrderIndex: 1, + }, + }, + expectedCount: 0, + expectedOrder: []int64{}, + expectError: true, + dashboardID: uuid.New(), + }, } - // vErify specific chart data - foundAccountBalance := false - foundTransactionHistory := false - - for _, dashboardChart := range dashboardCharts { - switch dashboardChart.IntegrationChart.InternalName { - case malak.IntegrationChartInternalNameTypeMercuryAccount: - foundAccountBalance = true - require.Equal(t, "Account Balance", dashboardChart.IntegrationChart.UserFacingName) - require.Equal(t, malak.IntegrationChartTypeBar, dashboardChart.IntegrationChart.ChartType) - case malak.IntegrationChartInternalNameTypeMercuryAccountTransaction: - foundTransactionHistory = true - require.Equal(t, "Transaction History", dashboardChart.IntegrationChart.UserFacingName) - require.Equal(t, malak.IntegrationChartTypeBar, dashboardChart.IntegrationChart.ChartType) - } - } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Update positions + err := dashboardRepo.UpdateDashboardPositions(t.Context(), tt.dashboardID, tt.positions) + if tt.expectError { + require.Error(t, err) + return + } + require.NoError(t, err) - require.True(t, foundAccountBalance, "Account Balance chart not found") - require.True(t, foundTransactionHistory, "Transaction History chart not found") + // Verify positions were updated + savedPositions, err := dashboardRepo.GetDashboardPositions(t.Context(), tt.dashboardID) + require.NoError(t, err) + require.Len(t, savedPositions, tt.expectedCount) + + // Verify position order if there are expected positions + if tt.expectedCount > 0 { + for i, expectedOrder := range tt.expectedOrder { + require.Equal(t, expectedOrder, savedPositions[i].OrderIndex) + } + } + }) + } } diff --git a/server/dashboard_test.go b/server/dashboard_test.go index 4a11f1f6..129fd5ab 100644 --- a/server/dashboard_test.go +++ b/server/dashboard_test.go @@ -11,6 +11,7 @@ import ( "github.com/ayinke-llc/malak" malak_mocks "github.com/ayinke-llc/malak/mocks" "github.com/go-chi/chi/v5" + "github.com/google/uuid" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" ) @@ -532,6 +533,10 @@ func generateFetchDashboardRequest() []struct { dashboard.EXPECT().GetCharts(gomock.Any(), gomock.Any()). Times(1). Return(nil, errors.New("error fetching charts")) + + dashboard.EXPECT().GetDashboardPositions(gomock.Any(), workspaceID). + Times(1). + Return([]malak.DashboardChartPosition{}, nil) }, expectedStatusCode: http.StatusInternalServerError, }, @@ -561,6 +566,17 @@ func generateFetchDashboardRequest() []struct { ChartID: workspaceID, }, }, nil) + + dashboard.EXPECT().GetDashboardPositions(gomock.Any(), workspaceID). + Times(1). + Return([]malak.DashboardChartPosition{ + { + ID: workspaceID, + DashboardID: workspaceID, + ChartID: workspaceID, + OrderIndex: 1, + }, + }, nil) }, expectedStatusCode: http.StatusOK, }, @@ -601,3 +617,271 @@ func TestDashboardHandler_FetchDashboard(t *testing.T) { }) } } + +func generateFetchChartingDataRequest() []struct { + name string + mockFn func(integration *malak_mocks.MockIntegrationRepository) + expectedStatusCode int +} { + return []struct { + name string + mockFn func(integration *malak_mocks.MockIntegrationRepository) + expectedStatusCode int + }{ + { + name: "chart not found", + mockFn: func(integration *malak_mocks.MockIntegrationRepository) { + integration.EXPECT().GetChart(gomock.Any(), gomock.Any()). + Times(1). + Return(malak.IntegrationChart{}, malak.ErrChartNotFound) + }, + expectedStatusCode: http.StatusNotFound, + }, + { + name: "error fetching chart", + mockFn: func(integration *malak_mocks.MockIntegrationRepository) { + integration.EXPECT().GetChart(gomock.Any(), gomock.Any()). + Times(1). + Return(malak.IntegrationChart{}, errors.New("error fetching chart")) + }, + expectedStatusCode: http.StatusInternalServerError, + }, + { + name: "error fetching data points", + mockFn: func(integration *malak_mocks.MockIntegrationRepository) { + integration.EXPECT().GetChart(gomock.Any(), gomock.Any()). + Times(1). + Return(malak.IntegrationChart{ + ID: workspaceID, + WorkspaceIntegrationID: workspaceID, + Reference: "CHART_123", + WorkspaceID: workspaceID, + }, nil) + + integration.EXPECT().GetDataPoints(gomock.Any(), gomock.Any()). + Times(1). + Return(nil, errors.New("error fetching data points")) + }, + expectedStatusCode: http.StatusInternalServerError, + }, + { + name: "successfully fetched chart data", + mockFn: func(integration *malak_mocks.MockIntegrationRepository) { + integration.EXPECT().GetChart(gomock.Any(), gomock.Any()). + Times(1). + Return(malak.IntegrationChart{ + ID: workspaceID, + WorkspaceIntegrationID: workspaceID, + Reference: "CHART_123", + WorkspaceID: workspaceID, + }, nil) + + integration.EXPECT().GetDataPoints(gomock.Any(), gomock.Any()). + Times(1). + Return([]malak.IntegrationDataPoint{ + { + ID: workspaceID, + WorkspaceIntegrationID: workspaceID, + WorkspaceID: workspaceID, + IntegrationChartID: workspaceID, + Reference: "datapoint_123", + PointName: "Test Point", + PointValue: 100, + DataPointType: malak.IntegrationDataPointTypeCurrency, + Metadata: malak.IntegrationDataPointMetadata{}, + }, + }, nil) + }, + expectedStatusCode: http.StatusOK, + }, + } +} + +func TestDashboardHandler_FetchChartingData(t *testing.T) { + for _, v := range generateFetchChartingDataRequest() { + t.Run(v.name, func(t *testing.T) { + controller := gomock.NewController(t) + defer controller.Finish() + + integrationRepo := malak_mocks.NewMockIntegrationRepository(controller) + v.mockFn(integrationRepo) + + h := &dashboardHandler{ + integrationRepo: integrationRepo, + cfg: getConfig(), + } + + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/dashboards/charts/CHART_123", nil) + req.Header.Add("Content-Type", "application/json") + + req = req.WithContext(writeUserToCtx(req.Context(), &malak.User{})) + req = req.WithContext(writeWorkspaceToCtx(req.Context(), &malak.Workspace{ID: workspaceID})) + + router := chi.NewRouter() + router.Get("/dashboards/charts/{reference}", WrapMalakHTTPHandler(getLogger(t), + h.fetchChartingData, + getConfig(), "dashboards.fetch_charting_data").ServeHTTP) + + router.ServeHTTP(rr, req) + + require.Equal(t, v.expectedStatusCode, rr.Code) + verifyMatch(t, rr) + }) + } +} + +func generateUpdateDashboardPositionsRequest() []struct { + name string + mockFn func(dashboard *malak_mocks.MockDashboardRepository) + expectedStatusCode int + req updateDashboardPositionsRequest +} { + return []struct { + name string + mockFn func(dashboard *malak_mocks.MockDashboardRepository) + expectedStatusCode int + req updateDashboardPositionsRequest + }{ + { + name: "dashboard not found", + mockFn: func(dashboard *malak_mocks.MockDashboardRepository) { + dashboard.EXPECT().Get(gomock.Any(), gomock.Any()). + Times(1). + Return(malak.Dashboard{}, malak.ErrDashboardNotFound) + }, + expectedStatusCode: http.StatusNotFound, + req: updateDashboardPositionsRequest{ + Positions: []struct { + ChartID uuid.UUID `json:"chart_id,omitempty" validate:"required"` + Index int64 `json:"index,omitempty" validate:"required"` + }{ + { + ChartID: workspaceID, + Index: 1, + }, + }, + }, + }, + { + name: "error fetching dashboard", + mockFn: func(dashboard *malak_mocks.MockDashboardRepository) { + dashboard.EXPECT().Get(gomock.Any(), gomock.Any()). + Times(1). + Return(malak.Dashboard{}, errors.New("error fetching dashboard")) + }, + expectedStatusCode: http.StatusInternalServerError, + req: updateDashboardPositionsRequest{ + Positions: []struct { + ChartID uuid.UUID `json:"chart_id,omitempty" validate:"required"` + Index int64 `json:"index,omitempty" validate:"required"` + }{ + { + ChartID: workspaceID, + Index: 1, + }, + }, + }, + }, + { + name: "error updating positions", + mockFn: func(dashboard *malak_mocks.MockDashboardRepository) { + dashboard.EXPECT().Get(gomock.Any(), gomock.Any()). + Times(1). + Return(malak.Dashboard{ + ID: workspaceID, + Title: "Test Dashboard", + Description: "Test description", + Reference: "DASH_123", + ChartCount: 1, + WorkspaceID: workspaceID, + }, nil) + + dashboard.EXPECT().UpdateDashboardPositions(gomock.Any(), gomock.Any(), gomock.Any()). + Times(1). + Return(errors.New("error updating positions")) + }, + expectedStatusCode: http.StatusInternalServerError, + req: updateDashboardPositionsRequest{ + Positions: []struct { + ChartID uuid.UUID `json:"chart_id,omitempty" validate:"required"` + Index int64 `json:"index,omitempty" validate:"required"` + }{ + { + ChartID: workspaceID, + Index: 1, + }, + }, + }, + }, + { + name: "successfully updated positions", + mockFn: func(dashboard *malak_mocks.MockDashboardRepository) { + dashboard.EXPECT().Get(gomock.Any(), gomock.Any()). + Times(1). + Return(malak.Dashboard{ + ID: workspaceID, + Title: "Test Dashboard", + Description: "Test description", + Reference: "DASH_123", + ChartCount: 1, + WorkspaceID: workspaceID, + }, nil) + + dashboard.EXPECT().UpdateDashboardPositions(gomock.Any(), gomock.Any(), gomock.Any()). + Times(1). + Return(nil) + }, + expectedStatusCode: http.StatusOK, + req: updateDashboardPositionsRequest{ + Positions: []struct { + ChartID uuid.UUID `json:"chart_id,omitempty" validate:"required"` + Index int64 `json:"index,omitempty" validate:"required"` + }{ + { + ChartID: workspaceID, + Index: 1, + }, + }, + }, + }, + } +} + +func TestDashboardHandler_UpdateDashboardPositions(t *testing.T) { + for _, v := range generateUpdateDashboardPositionsRequest() { + t.Run(v.name, func(t *testing.T) { + controller := gomock.NewController(t) + defer controller.Finish() + + dashboardRepo := malak_mocks.NewMockDashboardRepository(controller) + v.mockFn(dashboardRepo) + + h := &dashboardHandler{ + dashboardRepo: dashboardRepo, + cfg: getConfig(), + } + + var b = bytes.NewBuffer(nil) + err := json.NewEncoder(b).Encode(v.req) + require.NoError(t, err) + + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/dashboards/DASH_123/positions", b) + req.Header.Add("Content-Type", "application/json") + + req = req.WithContext(writeUserToCtx(req.Context(), &malak.User{})) + req = req.WithContext(writeWorkspaceToCtx(req.Context(), &malak.Workspace{ID: workspaceID})) + + router := chi.NewRouter() + router.Post("/dashboards/{reference}/positions", WrapMalakHTTPHandler(getLogger(t), + h.updateDashboardPositions, + getConfig(), "dashboards.update_positions").ServeHTTP) + + router.ServeHTTP(rr, req) + + require.Equal(t, v.expectedStatusCode, rr.Code) + verifyMatch(t, rr) + }) + } +} diff --git a/server/testdata/TestDashboardHandler_FetchChartingData/chart_not_found.golden b/server/testdata/TestDashboardHandler_FetchChartingData/chart_not_found.golden new file mode 100644 index 00000000..0bd6a132 --- /dev/null +++ b/server/testdata/TestDashboardHandler_FetchChartingData/chart_not_found.golden @@ -0,0 +1 @@ +{"message":"chart not found"} diff --git a/server/testdata/TestDashboardHandler_FetchChartingData/error_fetching_chart.golden b/server/testdata/TestDashboardHandler_FetchChartingData/error_fetching_chart.golden new file mode 100644 index 00000000..03c0463b --- /dev/null +++ b/server/testdata/TestDashboardHandler_FetchChartingData/error_fetching_chart.golden @@ -0,0 +1 @@ +{"message":"an error occurred while fetching chart"} diff --git a/server/testdata/TestDashboardHandler_FetchChartingData/error_fetching_data_points.golden b/server/testdata/TestDashboardHandler_FetchChartingData/error_fetching_data_points.golden new file mode 100644 index 00000000..c10b3b3d --- /dev/null +++ b/server/testdata/TestDashboardHandler_FetchChartingData/error_fetching_data_points.golden @@ -0,0 +1 @@ +{"message":"could not fetch charting data"} diff --git a/server/testdata/TestDashboardHandler_FetchChartingData/successfully_fetched_chart_data.golden b/server/testdata/TestDashboardHandler_FetchChartingData/successfully_fetched_chart_data.golden new file mode 100644 index 00000000..f870de00 --- /dev/null +++ b/server/testdata/TestDashboardHandler_FetchChartingData/successfully_fetched_chart_data.golden @@ -0,0 +1 @@ +{"data_points":[{"id":"8ce0f580-4d6d-429e-9d0e-a78eb99f62c2","workspace_integration_id":"8ce0f580-4d6d-429e-9d0e-a78eb99f62c2","workspace_id":"8ce0f580-4d6d-429e-9d0e-a78eb99f62c2","integration_chart_id":"8ce0f580-4d6d-429e-9d0e-a78eb99f62c2","reference":"datapoint_123","point_name":"Test Point","point_value":100,"data_point_type":"currency","metadata":{},"created_at":"0001-01-01T00:00:00Z","updated_at":"0001-01-01T00:00:00Z"}],"message":"datapoints fetched"} diff --git a/server/testdata/TestDashboardHandler_FetchDashboard/successfully_fetched_dashboard_and_charts.golden b/server/testdata/TestDashboardHandler_FetchDashboard/successfully_fetched_dashboard_and_charts.golden index 71958a13..040deefc 100644 --- a/server/testdata/TestDashboardHandler_FetchDashboard/successfully_fetched_dashboard_and_charts.golden +++ b/server/testdata/TestDashboardHandler_FetchDashboard/successfully_fetched_dashboard_and_charts.golden @@ -1 +1 @@ -{"charts":[{"id":"8ce0f580-4d6d-429e-9d0e-a78eb99f62c2","workspace_integration_id":"8ce0f580-4d6d-429e-9d0e-a78eb99f62c2","reference":"DASHCHART_123","workspace_id":"8ce0f580-4d6d-429e-9d0e-a78eb99f62c2","dashboard_id":"8ce0f580-4d6d-429e-9d0e-a78eb99f62c2","chart_id":"8ce0f580-4d6d-429e-9d0e-a78eb99f62c2","created_at":"0001-01-01T00:00:00Z","updated_at":"0001-01-01T00:00:00Z"}],"dashboard":{"id":"8ce0f580-4d6d-429e-9d0e-a78eb99f62c2","reference":"DASH_123","description":"Test description","title":"Test Dashboard","workspace_id":"8ce0f580-4d6d-429e-9d0e-a78eb99f62c2","chart_count":1,"created_at":"0001-01-01T00:00:00Z","updated_at":"0001-01-01T00:00:00Z"},"message":"dashboards fetched"} +{"charts":[{"id":"8ce0f580-4d6d-429e-9d0e-a78eb99f62c2","workspace_integration_id":"8ce0f580-4d6d-429e-9d0e-a78eb99f62c2","reference":"DASHCHART_123","workspace_id":"8ce0f580-4d6d-429e-9d0e-a78eb99f62c2","dashboard_id":"8ce0f580-4d6d-429e-9d0e-a78eb99f62c2","chart_id":"8ce0f580-4d6d-429e-9d0e-a78eb99f62c2","created_at":"0001-01-01T00:00:00Z","updated_at":"0001-01-01T00:00:00Z"}],"positions":[{"id":"8ce0f580-4d6d-429e-9d0e-a78eb99f62c2","dashboard_id":"8ce0f580-4d6d-429e-9d0e-a78eb99f62c2","chart_id":"8ce0f580-4d6d-429e-9d0e-a78eb99f62c2","order_index":1}],"dashboard":{"id":"8ce0f580-4d6d-429e-9d0e-a78eb99f62c2","reference":"DASH_123","description":"Test description","title":"Test Dashboard","workspace_id":"8ce0f580-4d6d-429e-9d0e-a78eb99f62c2","chart_count":1,"created_at":"0001-01-01T00:00:00Z","updated_at":"0001-01-01T00:00:00Z"},"message":"dashboards fetched"} diff --git a/server/testdata/TestDashboardHandler_UpdateDashboardPositions/dashboard_not_found.golden b/server/testdata/TestDashboardHandler_UpdateDashboardPositions/dashboard_not_found.golden new file mode 100644 index 00000000..b01f1398 --- /dev/null +++ b/server/testdata/TestDashboardHandler_UpdateDashboardPositions/dashboard_not_found.golden @@ -0,0 +1 @@ +{"message":"dashboard not found"} diff --git a/server/testdata/TestDashboardHandler_UpdateDashboardPositions/error_fetching_dashboard.golden b/server/testdata/TestDashboardHandler_UpdateDashboardPositions/error_fetching_dashboard.golden new file mode 100644 index 00000000..be76c74b --- /dev/null +++ b/server/testdata/TestDashboardHandler_UpdateDashboardPositions/error_fetching_dashboard.golden @@ -0,0 +1 @@ +{"message":"an error occurred while fetching dashboard"} diff --git a/server/testdata/TestDashboardHandler_UpdateDashboardPositions/error_updating_positions.golden b/server/testdata/TestDashboardHandler_UpdateDashboardPositions/error_updating_positions.golden new file mode 100644 index 00000000..be0ef579 --- /dev/null +++ b/server/testdata/TestDashboardHandler_UpdateDashboardPositions/error_updating_positions.golden @@ -0,0 +1 @@ +{"message":"could not update dashboard positions"} diff --git a/server/testdata/TestDashboardHandler_UpdateDashboardPositions/successfully_updated_positions.golden b/server/testdata/TestDashboardHandler_UpdateDashboardPositions/successfully_updated_positions.golden new file mode 100644 index 00000000..fdfb4daf --- /dev/null +++ b/server/testdata/TestDashboardHandler_UpdateDashboardPositions/successfully_updated_positions.golden @@ -0,0 +1 @@ +{"message":"datapoints fetched"} diff --git a/swagger/docs.go b/swagger/docs.go index 7b4838fc..ce91a2ff 100644 --- a/swagger/docs.go +++ b/swagger/docs.go @@ -1017,22 +1017,9 @@ const docTemplate = `{ "schema": { "$ref": "#/definitions/server.updateDashboardPositionsRequest" } - }, - { - "type": "string", - "description": "dashboard unique reference.. e.g dashboard_22", - "name": "reference", - "in": "path", - "required": true } ], "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/server.APIStatus" - } - }, "400": { "description": "Bad Request", "schema": { diff --git a/swagger/swagger.json b/swagger/swagger.json index 7e96b8ae..d5804a30 100644 --- a/swagger/swagger.json +++ b/swagger/swagger.json @@ -3134,17 +3134,6 @@ "/dashboards/{reference}/positions": { "post": { "description": "update dashboard chart positioning", - "parameters": [ - { - "description": "dashboard unique reference.. e.g dashboard_22", - "in": "path", - "name": "reference", - "required": true, - "schema": { - "type": "string" - } - } - ], "requestBody": { "content": { "application/json": { @@ -3158,16 +3147,6 @@ "x-originalParamName": "message" }, "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/server.APIStatus" - } - } - }, - "description": "OK" - }, "400": { "content": { "application/json": { diff --git a/swagger/swagger.yaml b/swagger/swagger.yaml index 8a3e6882..56a06ff4 100644 --- a/swagger/swagger.yaml +++ b/swagger/swagger.yaml @@ -1958,13 +1958,6 @@ paths: /dashboards/{reference}/positions: post: description: update dashboard chart positioning - parameters: - - description: dashboard unique reference.. e.g dashboard_22 - in: path - name: reference - required: true - schema: - type: string requestBody: content: application/json: @@ -1974,12 +1967,6 @@ paths: required: true x-originalParamName: message responses: - "200": - content: - application/json: - schema: - $ref: '#/components/schemas/server.APIStatus' - description: OK "400": content: application/json: From 67b5c77486749c36fc4d27b45d343fe6dd47e177 Mon Sep 17 00:00:00 2001 From: Lanre Adelowo Date: Fri, 21 Feb 2025 21:52:01 +0100 Subject: [PATCH 14/15] fix test --- internal/datastore/postgres/integration_test.go | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/internal/datastore/postgres/integration_test.go b/internal/datastore/postgres/integration_test.go index 28a7c56d..b706d353 100644 --- a/internal/datastore/postgres/integration_test.go +++ b/internal/datastore/postgres/integration_test.go @@ -338,8 +338,9 @@ func TestIntegration_AddDataPoint(t *testing.T) { dataPoints := []malak.IntegrationDataValues{ { - InternalName: "revenue_chart", - ProviderID: "stripe_revenue", + UserFacingName: "Revenue Chart", + InternalName: "revenue_chart", + ProviderID: "stripe_revenue", Data: malak.IntegrationDataPoint{ PointName: malak.GetTodayFormatted(), PointValue: 10050, // 100.50 * 100 to store as integer cents @@ -354,8 +355,9 @@ func TestIntegration_AddDataPoint(t *testing.T) { invalidDataPoints := []malak.IntegrationDataValues{ { - InternalName: "non_existent_chart", - ProviderID: "stripe_revenue", + UserFacingName: "Non Existent Chart", + InternalName: "non_existent_chart", + ProviderID: "stripe_revenue", Data: malak.IntegrationDataPoint{ PointName: malak.GetTodayFormatted(), PointValue: 20000, // 200.00 * 100 to store as integer cents @@ -596,8 +598,9 @@ func TestIntegration_AddDataPointErrors(t *testing.T) { // Try to add data point for non-existent chart dataPoints := []malak.IntegrationDataValues{ { - InternalName: malak.IntegrationChartInternalNameTypeMercuryAccount, - ProviderID: "account_123", + UserFacingName: "Balance", + InternalName: malak.IntegrationChartInternalNameTypeMercuryAccount, + ProviderID: "account_123", Data: malak.IntegrationDataPoint{ PointName: "Balance", PointValue: 1000, From 97c550f3f2753480c2b073d6ef54c231f5c3c85f Mon Sep 17 00:00:00 2001 From: Lanre Adelowo Date: Fri, 21 Feb 2025 22:03:22 +0100 Subject: [PATCH 15/15] update tests --- .../datastore/postgres/integration_test.go | 117 ++++++++++++++++++ server/dashboard_test.go | 24 ++++ .../error_fetching_dashboard_positions.golden | 1 + 3 files changed, 142 insertions(+) create mode 100644 server/testdata/TestDashboardHandler_FetchDashboard/error_fetching_dashboard_positions.golden diff --git a/internal/datastore/postgres/integration_test.go b/internal/datastore/postgres/integration_test.go index b706d353..6d977084 100644 --- a/internal/datastore/postgres/integration_test.go +++ b/internal/datastore/postgres/integration_test.go @@ -650,3 +650,120 @@ func TestIntegration_ListChartsErrors(t *testing.T) { require.NoError(t, err) require.Empty(t, charts) } + +func TestIntegration_GetDataPoints(t *testing.T) { + client, teardownFunc := setupDatabase(t) + defer teardownFunc() + + integrationRepo := NewIntegrationRepo(client) + repo := NewWorkspaceRepository(client) + + workspace, err := repo.Get(t.Context(), &malak.FindWorkspaceOptions{ + ID: uuid.MustParse("a4ae79a2-9b76-40d7-b5a1-661e60a02cb0"), + }) + require.NoError(t, err) + + // Create integration + integration := &malak.Integration{ + IntegrationName: "Mercury", + Reference: malak.NewReferenceGenerator().Generate(malak.EntityTypeIntegration), + Description: "Mercury Banking Integration", + IsEnabled: true, + IntegrationType: malak.IntegrationTypeOauth2, + LogoURL: "https://mercury.com/logo.png", + } + err = integrationRepo.Create(t.Context(), integration) + require.NoError(t, err) + + integrations, err := integrationRepo.List(t.Context(), workspace) + require.NoError(t, err) + require.Len(t, integrations, 1) + workspaceIntegration := integrations[0] + + // Create chart + chartValues := []malak.IntegrationChartValues{ + { + UserFacingName: "Account Balance", + InternalName: malak.IntegrationChartInternalNameTypeMercuryAccount, + ProviderID: "account_123", + ChartType: malak.IntegrationChartTypeBar, + }, + } + err = integrationRepo.CreateCharts(t.Context(), &workspaceIntegration, chartValues) + require.NoError(t, err) + + // Get the created chart + charts, err := integrationRepo.ListCharts(t.Context(), workspace.ID) + require.NoError(t, err) + require.Len(t, charts, 1) + chart := charts[0] + + // Initially there should be no data points + dataPoints, err := integrationRepo.GetDataPoints(t.Context(), chart) + require.NoError(t, err) + require.Empty(t, dataPoints) + + // Add data points + dataPointValues := []malak.IntegrationDataValues{ + { + UserFacingName: "Account Balance", + InternalName: malak.IntegrationChartInternalNameTypeMercuryAccount, + ProviderID: "account_123", + Data: malak.IntegrationDataPoint{ + PointName: "Day 1", + PointValue: 10000, // $100.00 + DataPointType: malak.IntegrationDataPointTypeCurrency, + Metadata: malak.IntegrationDataPointMetadata{}, + }, + }, + } + err = integrationRepo.AddDataPoint(t.Context(), &workspaceIntegration, dataPointValues) + require.NoError(t, err) + + // Add another data point + dataPointValues = []malak.IntegrationDataValues{ + { + UserFacingName: "Account Balance", + InternalName: malak.IntegrationChartInternalNameTypeMercuryAccount, + ProviderID: "account_123", + Data: malak.IntegrationDataPoint{ + PointName: "Day 2", + PointValue: 20000, // $200.00 + DataPointType: malak.IntegrationDataPointTypeCurrency, + Metadata: malak.IntegrationDataPointMetadata{}, + }, + }, + } + err = integrationRepo.AddDataPoint(t.Context(), &workspaceIntegration, dataPointValues) + require.NoError(t, err) + + // Verify data points are returned in order + dataPoints, err = integrationRepo.GetDataPoints(t.Context(), chart) + require.NoError(t, err) + require.Len(t, dataPoints, 2) + + // Verify data points are ordered by creation date + require.Equal(t, int64(10000), dataPoints[0].PointValue) + require.Equal(t, "Day 1", dataPoints[0].PointName) + require.Equal(t, int64(20000), dataPoints[1].PointValue) + require.Equal(t, "Day 2", dataPoints[1].PointName) + + // Verify data point fields + for _, dp := range dataPoints { + require.NotEmpty(t, dp.ID) + require.Equal(t, workspaceIntegration.ID, dp.WorkspaceIntegrationID) + require.Equal(t, workspace.ID, dp.WorkspaceID) + require.Equal(t, chart.ID, dp.IntegrationChartID) + require.NotEmpty(t, dp.Reference) + require.Equal(t, malak.IntegrationDataPointTypeCurrency, dp.DataPointType) + require.NotZero(t, dp.CreatedAt) + require.NotZero(t, dp.UpdatedAt) + } + + // Test with non-existent chart ID + nonExistentChart := chart + nonExistentChart.ID = uuid.New() + dataPoints, err = integrationRepo.GetDataPoints(t.Context(), nonExistentChart) + require.NoError(t, err) + require.Empty(t, dataPoints) +} diff --git a/server/dashboard_test.go b/server/dashboard_test.go index 129fd5ab..222f92d9 100644 --- a/server/dashboard_test.go +++ b/server/dashboard_test.go @@ -516,6 +516,30 @@ func generateFetchDashboardRequest() []struct { }, expectedStatusCode: http.StatusInternalServerError, }, + { + name: "error fetching dashboard positions", + mockFn: func(dashboard *malak_mocks.MockDashboardRepository) { + dashboard.EXPECT().Get(gomock.Any(), gomock.Any()). + Times(1). + Return(malak.Dashboard{ + ID: workspaceID, + Title: "Test Dashboard", + Description: "Test description", + Reference: "DASH_123", + ChartCount: 0, + WorkspaceID: workspaceID, + }, nil) + + dashboard.EXPECT().GetCharts(gomock.Any(), gomock.Any()). + Times(1). + Return([]malak.DashboardChart{}, nil) + + dashboard.EXPECT().GetDashboardPositions(gomock.Any(), workspaceID). + Times(1). + Return(nil, errors.New("error fetching dashboard positions")) + }, + expectedStatusCode: http.StatusInternalServerError, + }, { name: "error fetching dashboard charts", mockFn: func(dashboard *malak_mocks.MockDashboardRepository) { diff --git a/server/testdata/TestDashboardHandler_FetchDashboard/error_fetching_dashboard_positions.golden b/server/testdata/TestDashboardHandler_FetchDashboard/error_fetching_dashboard_positions.golden new file mode 100644 index 00000000..065c691c --- /dev/null +++ b/server/testdata/TestDashboardHandler_FetchDashboard/error_fetching_dashboard_positions.golden @@ -0,0 +1 @@ +{"message":"could not list dashboard positions"}