From 9495da9fa7ddd78d71592d226ea01b352ba17afa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20S=C3=A1nchez?= Date: Wed, 30 Sep 2026 16:40:41 -0600 Subject: [PATCH 1/3] feat[backend](dashboards): added filters on dashboards --- backend/database/migrations.go | 1 + backend/modules/audit/domain/event_type.go | 6 + .../dashboards/connectors/repository.go | 7 + .../modules/dashboards/connectors/usecase.go | 9 ++ backend/modules/dashboards/domain/filters.go | 31 ++++ .../dashboards/dto/dashboard_filter.go | 34 +++++ .../dashboards/handler/dashboard_filter.go | 142 ++++++++++++++++++ backend/modules/dashboards/module.go | 13 ++ .../repository/dashboard_filters.pg.go | 55 +++++++ backend/modules/dashboards/routes.go | 7 + .../dashboards/usecase/dashboard_filter.go | 77 ++++++++++ 11 files changed, 382 insertions(+) create mode 100644 backend/modules/dashboards/domain/filters.go create mode 100644 backend/modules/dashboards/dto/dashboard_filter.go create mode 100644 backend/modules/dashboards/handler/dashboard_filter.go create mode 100644 backend/modules/dashboards/repository/dashboard_filters.pg.go create mode 100644 backend/modules/dashboards/usecase/dashboard_filter.go diff --git a/backend/database/migrations.go b/backend/database/migrations.go index 37efec677..38c5bac21 100644 --- a/backend/database/migrations.go +++ b/backend/database/migrations.go @@ -65,6 +65,7 @@ func Models() []any { datasources_domain.Datasource{}, dashboards_domain.Dashboard{}, dashboards_domain.Visualization{}, + dashboards_domain.DashboardFilter{}, loganalyzer_domain.SavedQuery{}, adaudit_domain.ADUser{}, } diff --git a/backend/modules/audit/domain/event_type.go b/backend/modules/audit/domain/event_type.go index 5f2861dc3..a585d870e 100644 --- a/backend/modules/audit/domain/event_type.go +++ b/backend/modules/audit/domain/event_type.go @@ -163,6 +163,12 @@ const ( VISUALIZATION_UPDATE_SUCCESS ApplicationEventType = "VISUALIZATION_UPDATE_SUCCESS" VISUALIZATION_DELETE_ATTEMPT ApplicationEventType = "VISUALIZATION_DELETE_ATTEMPT" VISUALIZATION_DELETE_SUCCESS ApplicationEventType = "VISUALIZATION_DELETE_SUCCESS" + DASHBOARD_FILTER_CREATE_ATTEMPT ApplicationEventType = "DASHBOARD_FILTER_CREATE_ATTEMPT" + DASHBOARD_FILTER_CREATE_SUCCESS ApplicationEventType = "DASHBOARD_FILTER_CREATE_SUCCESS" + DASHBOARD_FILTER_UPDATE_ATTEMPT ApplicationEventType = "DASHBOARD_FILTER_UPDATE_ATTEMPT" + DASHBOARD_FILTER_UPDATE_SUCCESS ApplicationEventType = "DASHBOARD_FILTER_UPDATE_SUCCESS" + DASHBOARD_FILTER_DELETE_ATTEMPT ApplicationEventType = "DASHBOARD_FILTER_DELETE_ATTEMPT" + DASHBOARD_FILTER_DELETE_SUCCESS ApplicationEventType = "DASHBOARD_FILTER_DELETE_SUCCESS" DASHBOARD_LAYOUT_CREATE_ATTEMPT ApplicationEventType = "DASHBOARD_LAYOUT_CREATE_ATTEMPT" DASHBOARD_LAYOUT_CREATE_SUCCESS ApplicationEventType = "DASHBOARD_LAYOUT_CREATE_SUCCESS" DASHBOARD_LAYOUT_UPDATE_ATTEMPT ApplicationEventType = "DASHBOARD_LAYOUT_UPDATE_ATTEMPT" diff --git a/backend/modules/dashboards/connectors/repository.go b/backend/modules/dashboards/connectors/repository.go index 2ed6201ad..d521fda20 100644 --- a/backend/modules/dashboards/connectors/repository.go +++ b/backend/modules/dashboards/connectors/repository.go @@ -22,3 +22,10 @@ type VisualizationRepository interface { List(ctx context.Context, f dto.VisualizationFilter) ([]domain.Visualization, int64, error) Delete(ctx context.Context, id uuid.UUID) error } + +type DashboardFilterRepository interface { + Save(ctx context.Context, v *domain.DashboardFilter) error + FindByID(ctx context.Context, id uuid.UUID) (*domain.DashboardFilter, error) + List(ctx context.Context, f dto.DashboardFilterFilter) ([]domain.DashboardFilter, int64, error) + Delete(ctx context.Context, id uuid.UUID) error +} diff --git a/backend/modules/dashboards/connectors/usecase.go b/backend/modules/dashboards/connectors/usecase.go index 8967b7397..507053a82 100644 --- a/backend/modules/dashboards/connectors/usecase.go +++ b/backend/modules/dashboards/connectors/usecase.go @@ -24,3 +24,12 @@ type VisualizationUsecase interface { List(ctx context.Context, f dto.VisualizationFilter) ([]domain.Visualization, int64, error) Delete(ctx context.Context, id uuid.UUID) error } + + +type DashboardFilterUsecase interface { + Create(ctx context.Context, v *domain.DashboardFilter, user string) (*domain.DashboardFilter, error) + Update(ctx context.Context, v *domain.DashboardFilter, user string) (*domain.DashboardFilter, error) + List(ctx context.Context, f dto.DashboardFilterFilter) ([]domain.DashboardFilter, int64, error) + Delete(ctx context.Context, id uuid.UUID) error +} + diff --git a/backend/modules/dashboards/domain/filters.go b/backend/modules/dashboards/domain/filters.go new file mode 100644 index 000000000..b776a0a86 --- /dev/null +++ b/backend/modules/dashboards/domain/filters.go @@ -0,0 +1,31 @@ +package domain + +import ( + "time" + + "github.com/google/uuid" +) + + +type DashboardFilterType = string + +const ( + DashboardFilterMultiple DashboardFilterType = "multiple" + DashboardFilterSearchable DashboardFilterType = "searchable" +) + +type DashboardFilter struct { + ID uuid.UUID `gorm:"column:id;type:uuid;primaryKey;default:gen_random_uuid()" json:"id"` + TenantID uuid.UUID `gorm:"column:tenant_id;type:uuid;not null;index" json:"-"` + DashboardID uuid.UUID `gorm:"column:dashboard_id;type:uuid;not null;index" json:"dashboardId"` + DataSet string `gorm:"column:data_set;not null" json:"data_set"` + Field string `gorm:"column:field;not null" json:"field"` + Type DashboardFilterType `gorm:"column:type;not null" json:"type"` + Label string `gorm:"column:label" json:"label"` + PlaceHolder string `gorm:"column:place_holder" json:"place_holder"` + CreatedDate time.Time `gorm:"column:created_date" json:"createdDate"` + ModifiedDate time.Time `gorm:"column:modified_date" json:"modifiedDate"` +} + +func (DashboardFilter) TableName() string { return "dashboard_filter" } + diff --git a/backend/modules/dashboards/dto/dashboard_filter.go b/backend/modules/dashboards/dto/dashboard_filter.go new file mode 100644 index 000000000..5081bd5a4 --- /dev/null +++ b/backend/modules/dashboards/dto/dashboard_filter.go @@ -0,0 +1,34 @@ + +package dto + +import ( + "github.com/google/uuid" + "github.com/utmstack/utmstack/backend/pkg/database" +) + +type DashboardFilterFilter struct { + DashboardID *uuid.UUID + database.Params +} + + +// the wire form of the filter. gin cannot bind a +// uuid.UUID from a query string (it is a [16]byte), so the id arrives as text +// and is parsed here. +type DashboardFilterQuery struct { + DashboardID string `form:"dashboardId"` + database.Params +} + +func (q DashboardFilterQuery) Filter() (DashboardFilterFilter, error) { + f := DashboardFilterFilter{Params: q.Params} + if q.DashboardID == "" { + return f, nil + } + id, err := uuid.Parse(q.DashboardID) + if err != nil { + return f, err + } + f.DashboardID = &id + return f, nil +} diff --git a/backend/modules/dashboards/handler/dashboard_filter.go b/backend/modules/dashboards/handler/dashboard_filter.go new file mode 100644 index 000000000..5f6bf73ee --- /dev/null +++ b/backend/modules/dashboards/handler/dashboard_filter.go @@ -0,0 +1,142 @@ +package handler + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/utmstack/utmstack/backend/modules/audit" + audit_connectors "github.com/utmstack/utmstack/backend/modules/audit/connectors" + audit_domain "github.com/utmstack/utmstack/backend/modules/audit/domain" + "github.com/utmstack/utmstack/backend/modules/dashboards/connectors" + "github.com/utmstack/utmstack/backend/modules/dashboards/domain" + "github.com/utmstack/utmstack/backend/modules/dashboards/dto" +) + +type DashboardFilterHandler struct { + uc connectors.DashboardFilterUsecase +} + +func NewDashboardFilterHandler(uc connectors.DashboardFilterUsecase) *DashboardFilterHandler { + return &DashboardFilterHandler{uc: uc} +} + +// Create godoc +// +// @Summary Create a dashbaord filter +// @Description Creates a custom dashboard filter. +// @Tags DashboardFilters +// @Security BearerAuth +// @Accept json +// @Produce json +// @Param input body domain.DashboardFilter true "DashboardFilter to create" +// @Success 201 {object} domain.DashboardFilter +// @Failure 400 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /filter [post] +func (h *DashboardFilterHandler) Create(c *gin.Context) { + var v domain.DashboardFilter + if err := c.ShouldBindJSON(&v); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + res, err := h.uc.Create(c.Request.Context(), &v, currentUser(c)) + resID := "" + if res != nil { + resID = res.ID.String() + } + audit.Record(c, audit_connectors.Event{Action: "dashboard_filter.create", ResourceType: "dashboard_filter", ResourceID: resID}, + audit_domain.DASHBOARD_FILTER_CREATE_ATTEMPT, audit_domain.DASHBOARD_FILTER_CREATE_SUCCESS, err) + if err != nil { + writeError(c, err) + return + } + c.JSON(http.StatusCreated, res) +} + +// Update godoc +// +// @Summary Update a dashbaord filter +// @Description Updates an existing dashboard filter +// @Tags DashboardFilters +// @Security BearerAuth +// @Accept json +// @Produce json +// @Param input body domain.DashboardFilter true "DashboardFilter to update" +// @Success 200 {object} domain.DashboardFilter +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /filter [put] +func (h *DashboardFilterHandler) Update(c *gin.Context) { + var v domain.DashboardFilter + if err := c.ShouldBindJSON(&v); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + res, err := h.uc.Update(c.Request.Context(), &v, currentUser(c)) + audit.Record(c, audit_connectors.Event{Action: "dashboard_filter.update", ResourceType: "dashboard_filter", ResourceID: v.ID.String()}, + audit_domain.DASHBOARD_FILTER_UPDATE_ATTEMPT, audit_domain.DASHBOARD_FILTER_UPDATE_SUCCESS, err) + if err != nil { + writeError(c, err) + return + } + c.JSON(http.StatusOK, res) +} + +// List godoc +// +// @Summary List dashbaord filters +// @Description Lists dashboard filter. +// @Tags DashboardFilters +// @Security BearerAuth +// @Produce json +// @Param dashboardId query int false "Filter by dashboard id" +// @Param page query int false "Page (0-based)" +// @Param size query int false "Page size" +// @Success 200 {array} domain.DashboardFilter +// @Header 200 {string} X-Total-Count "Total records" +// @Failure 500 {object} map[string]string +// @Router /filters [get] +func (h *DashboardFilterHandler) List(c *gin.Context) { + var q dto.DashboardFilterQuery + if err := c.ShouldBindQuery(&q); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + f, err := q.Filter() + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "dashboardId must be a uuid"}) + return + } + items, total, err := h.uc.List(c.Request.Context(), f) + if err != nil { + writeError(c, err) + return + } + writeList(c, items, total) +} + + +// Delete godoc +// +// @Summary Delete a dashbaord filter by id +// @Tags DashboardFilters +// @Security BearerAuth +// @Param id path int true "DashboardFilter id" +// @Success 200 "Deleted" +// @Failure 500 {object} map[string]string +// @Router /filter/{id} [delete] +func (h *DashboardFilterHandler) Delete(c *gin.Context) { + id, ok := pathID(c) + if !ok { + return + } + err := h.uc.Delete(c.Request.Context(), id) + audit.Record(c, audit_connectors.Event{Action: "visualization.delete", ResourceType: "visualization", ResourceID: id.String()}, + audit_domain.VISUALIZATION_DELETE_ATTEMPT, audit_domain.VISUALIZATION_DELETE_SUCCESS, err) + if err != nil { + writeError(c, err) + return + } + c.Status(http.StatusOK) +} diff --git a/backend/modules/dashboards/module.go b/backend/modules/dashboards/module.go index 0125454ce..51abeb9f5 100644 --- a/backend/modules/dashboards/module.go +++ b/backend/modules/dashboards/module.go @@ -17,17 +17,21 @@ type Module struct { queryHandler *handler.QueryHandler dashboardHandler *handler.DashboardHandler visualizationHandler *handler.VisualizationHandler + dashboardFilterHandler *handler.DashboardFilterHandler dashboardUC connectors.DashboardUsecase visualizationUC connectors.VisualizationUsecase + dashboardFilterUC connectors.DashboardFilterUsecase dashboardBootstrap *repository.DashboardBootstrap } func NewModule(db *gorm.DB, events usecase.Reader) *Module { dashRepo := repository.NewDashboardRepository(db) vizRepo := repository.NewVisualizationRepository(db) + filterRepo := repository.NewDashboardFilterRepository(db) dashUC := usecase.NewDashboardUsecase(dashRepo) vizUC := usecase.NewVisualizationUsecase(vizRepo) + filtUC := usecase.NewDashboardFilterUsecase(filterRepo) dashboardBootstrap := repository.NewDashboardBootstrap( env.String(repository.DashboardsSrcDirEnv, repository.DefaultDashboardsSrcDir, false), db) @@ -35,9 +39,11 @@ func NewModule(db *gorm.DB, events usecase.Reader) *Module { m := &Module{ dashboardHandler: handler.NewDashboardHandler(dashUC), visualizationHandler: handler.NewVisualizationHandler(vizUC), + dashboardFilterHandler: handler.NewDashboardFilterHandler(filtUC) , dashboardUC: dashUC, visualizationUC: vizUC, dashboardBootstrap: dashboardBootstrap, + dashboardFilterUC: filtUC, } if events != nil { m.queryHandler = handler.NewQueryHandler(usecase.NewQueryService(events)) @@ -54,10 +60,17 @@ func (m *Module) Start(ctx context.Context) { } func (m *Module) GetDashboardHandler() *handler.DashboardHandler { return m.dashboardHandler } + func (m *Module) GetVisualizationHandler() *handler.VisualizationHandler { return m.visualizationHandler } +func (m *Module) GetDashboardFilterHandler() *handler.DashboardFilterHandler { + return m.dashboardFilterHandler +} + + + func (m *Module) GetDashboardUsecase() connectors.DashboardUsecase { return m.dashboardUC } func (m *Module) GetVisualizationUsecase() connectors.VisualizationUsecase { return m.visualizationUC diff --git a/backend/modules/dashboards/repository/dashboard_filters.pg.go b/backend/modules/dashboards/repository/dashboard_filters.pg.go new file mode 100644 index 000000000..746484604 --- /dev/null +++ b/backend/modules/dashboards/repository/dashboard_filters.pg.go @@ -0,0 +1,55 @@ +package repository + +import ( + "context" + "errors" + + "github.com/utmstack/utmstack/backend/modules/dashboards/connectors" + "github.com/utmstack/utmstack/backend/modules/dashboards/domain" + "github.com/utmstack/utmstack/backend/modules/dashboards/dto" + "gorm.io/gorm" + + "github.com/google/uuid" +) + +type pgDashboardFilterRepository struct{ db *gorm.DB } + +func NewDashboardFilterRepository(db *gorm.DB) connectors.DashboardFilterRepository { + return &pgDashboardFilterRepository{db: db} +} + +func (r *pgDashboardFilterRepository) Save(ctx context.Context, v *domain.DashboardFilter) error { + return r.db.WithContext(ctx).Save(v).Error +} + +func (r *pgDashboardFilterRepository) FindByID(ctx context.Context, id uuid.UUID) (*domain.DashboardFilter, error) { + var v domain.DashboardFilter + err := r.db.WithContext(ctx).First(&v, id).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + if err != nil { + return nil, err + } + return &v, nil +} + +func (r *pgDashboardFilterRepository) List(ctx context.Context, f dto.DashboardFilterFilter) ([]domain.DashboardFilter, int64, error) { + q := r.db.WithContext(ctx).Model(&domain.DashboardFilter{}) + if f.DashboardID != nil { + q = q.Where("dashboard_id = ?", *f.DashboardID) + } + var total int64 + if err := q.Count(&total).Error; err != nil { + return nil, 0, err + } + var items []domain.DashboardFilter + if err := q.Order("id ASC").Offset(f.Offset()).Limit(f.Limit()).Find(&items).Error; err != nil { + return nil, 0, err + } + return items, total, nil +} + +func (r *pgDashboardFilterRepository) Delete(ctx context.Context, id uuid.UUID) error { + return r.db.WithContext(ctx).Delete(&domain.DashboardFilter{}, id).Error +} diff --git a/backend/modules/dashboards/routes.go b/backend/modules/dashboards/routes.go index 509ef7f04..343990381 100644 --- a/backend/modules/dashboards/routes.go +++ b/backend/modules/dashboards/routes.go @@ -8,6 +8,7 @@ import ( func RegisterRoutes(api *gin.RouterGroup, m *Module, userAuth gin.HandlerFunc) { dh := m.GetDashboardHandler() vh := m.GetVisualizationHandler() + fh := m.GetDashboardFilterHandler() read := middleware.RequirePermission("dashboards.read") write := middleware.RequirePermission("dashboards.write") @@ -19,6 +20,12 @@ func RegisterRoutes(api *gin.RouterGroup, m *Module, userAuth gin.HandlerFunc) { d.GET("/:id", read, dh.GetByID) d.DELETE("/:id", write, dh.Delete) + f := d.Group("/filters", userAuth) + f.POST("", write, fh.Create) + f.PUT("", write, fh.Update) + f.GET("", read, fh.List) + f.DELETE("/:id", write, fh.Delete) + v := api.Group("/visualizations", userAuth) v.POST("", write, vh.Create) v.PUT("", write, vh.Update) diff --git a/backend/modules/dashboards/usecase/dashboard_filter.go b/backend/modules/dashboards/usecase/dashboard_filter.go new file mode 100644 index 000000000..60a024298 --- /dev/null +++ b/backend/modules/dashboards/usecase/dashboard_filter.go @@ -0,0 +1,77 @@ +package usecase + +import ( + "context" + "time" + + "github.com/utmstack/utmstack/backend/modules/dashboards/connectors" + "github.com/utmstack/utmstack/backend/modules/dashboards/domain" + "github.com/utmstack/utmstack/backend/modules/dashboards/dto" + + "github.com/google/uuid" +) + +type dashboardFilterUsecase struct { + repo connectors.DashboardFilterRepository +} + +func NewDashboardFilterUsecase(repo connectors.DashboardFilterRepository) connectors.DashboardFilterUsecase { + return &dashboardFilterUsecase{repo: repo} +} + +func (u *dashboardFilterUsecase) Create(ctx context.Context, v *domain.DashboardFilter, user string) (*domain.DashboardFilter, error) { + if v.ID != uuid.Nil { + return nil, domain.ErrIDForbidden + } + if v.DashboardID == uuid.Nil { + return nil, domain.ErrDashboardIDRequired + } + + now := time.Now().UTC() + v.CreatedDate = now + v.ModifiedDate = now + if err := u.repo.Save(ctx, v); err != nil { + return nil, err + } + return v, nil +} + +func (u *dashboardFilterUsecase) Update(ctx context.Context, v *domain.DashboardFilter, user string) (*domain.DashboardFilter, error) { + if v.ID == uuid.Nil { + return nil, domain.ErrIDRequired + } + + existing, err := u.repo.FindByID(ctx, v.ID) + if err != nil { + return nil, err + } + if existing == nil { + return nil, domain.ErrNotFound + } + v.CreatedDate = existing.CreatedDate + // A dashboardFilter can't move to a different tenant — it's not reusable. + v.TenantID = existing.TenantID + // A dashboardFilter can't move to a different dashboard — it's not reusable. + v.DashboardID = existing.DashboardID + v.ModifiedDate = time.Now().UTC() + if err := u.repo.Save(ctx, v); err != nil { + return nil, err + } + return v, nil +} + +func (u *dashboardFilterUsecase) List(ctx context.Context, f dto.DashboardFilterFilter) ([]domain.DashboardFilter, int64, error) { + return u.repo.List(ctx, f) +} + +func (u *dashboardFilterUsecase) Delete(ctx context.Context, id uuid.UUID) error { + existing, err := u.repo.FindByID(ctx, id) + if err != nil { + return err + } + if existing == nil { + return domain.ErrNotFound + } + return u.repo.Delete(ctx, id) +} + From a39363003b71c2bef48d71c19d50d06767c598f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20S=C3=A1nchez?= Date: Wed, 30 Sep 2026 16:43:06 -0600 Subject: [PATCH 2/3] fix[frontend](dashboards): added dashboard filter support --- .../dashboard/hooks/useDashboardFilters.ts | 43 ++++++++ .../dashboard/pages/DashboardPage.tsx | 97 +++++++++++-------- .../service/dashboard-filters.service.ts | 42 ++++++++ .../src/features/dashboard/types/index.ts | 36 +++++++ 4 files changed, 180 insertions(+), 38 deletions(-) create mode 100644 frontend/src/features/dashboard/hooks/useDashboardFilters.ts create mode 100644 frontend/src/features/dashboard/service/dashboard-filters.service.ts diff --git a/frontend/src/features/dashboard/hooks/useDashboardFilters.ts b/frontend/src/features/dashboard/hooks/useDashboardFilters.ts new file mode 100644 index 000000000..a9a9e8e93 --- /dev/null +++ b/frontend/src/features/dashboard/hooks/useDashboardFilters.ts @@ -0,0 +1,43 @@ +import { useMemo } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { createDashboardFiltersService } from '@/features/dashboard/service/dashboard-filters.service' +import type { DashboardFilterCreateInput, DashboardFilterUpdateInput } from '@/features/dashboard/types' + +export const DASHBOARD_FILTERS_QUERY_KEYS = { + all: ['dashboardFilters'] as const, + list: (dashboardId: string) => [...DASHBOARD_FILTERS_QUERY_KEYS.all, 'list', dashboardId] as const, +} + +export function useDashboardFilters(dashboardId: string | null) { + const queryClient = useQueryClient() + const service = useMemo(() => createDashboardFiltersService(), []) + + const list = useQuery({ + queryKey: DASHBOARD_FILTERS_QUERY_KEYS.list(dashboardId ?? ''), + queryFn: () => service.listDashboardFilters({ dashboardId: dashboardId!, page: 0, size: 500 }), + enabled: !!dashboardId, + }) + + const invalidate = () => { + if (dashboardId) { + queryClient.invalidateQueries({ queryKey: DASHBOARD_FILTERS_QUERY_KEYS.list(dashboardId) }) + } + } + + const createFilter = useMutation({ + mutationFn: (data: DashboardFilterCreateInput) => service.createDashboardFilter(data), + onSuccess: invalidate, + }) + + const updateFilter = useMutation({ + mutationFn: (data: DashboardFilterUpdateInput) => service.updateDashboardFilter(data), + onSuccess: invalidate, + }) + + const deleteFilter = useMutation({ + mutationFn: (id: string) => service.deleteDashboardFilter(id), + onSuccess: invalidate, + }) + + return { list, createFilter, updateFilter, deleteFilter } +} diff --git a/frontend/src/features/dashboard/pages/DashboardPage.tsx b/frontend/src/features/dashboard/pages/DashboardPage.tsx index 4e69fa548..2356551a9 100644 --- a/frontend/src/features/dashboard/pages/DashboardPage.tsx +++ b/frontend/src/features/dashboard/pages/DashboardPage.tsx @@ -20,6 +20,7 @@ import { useVisualizationMutations, } from "@/features/dashboard/hooks/useVisualizations"; import { useDashboardEditor } from "@/features/dashboard/hooks/useDashboardEditor"; +import { useDashboardFilters } from "@/features/dashboard/hooks/useDashboardFilters"; import { DashboardGrid } from "@/features/dashboard/components/DashboardGrid"; import { DashboardEditorBar } from "@/features/dashboard/components/DashboardEditorBar"; import { DashboardFormDialog } from "@/features/dashboard/components/DashboardFormDialog"; @@ -45,6 +46,8 @@ import { import type { Dashboard, DashboardFilterChip, + DashboardFilter, + DashboardFilterCreateInput, FilterType, GridLayoutItem, } from "@/features/dashboard/types"; @@ -126,9 +129,11 @@ export function DashboardPage() { const initialItems = useMemo(() => toGridItems(vizItems), [vizItems]); const editor = useDashboardEditor(initialItems); + const { list: filterList, createFilter, updateFilter, deleteFilter } = useDashboardFilters(selectedId); + const chips = useMemo( - () => parseChipConfig(selectedDashboard.data?.filters), - [selectedDashboard.data?.filters], + () => (filterList.data?.data ?? []).map(filterToChip), + [filterList.data], ); // Reset chip *values* whenever the dashboard changes; chip *config* persists. @@ -328,33 +333,38 @@ export function DashboardPage() { setSelectedId(id); }; - const handleSaveFilters = (next: DashboardFilterChip[]) => { + const handleSaveFilters = async (next: DashboardFilterChip[]) => { const target = selectedDashboard.data; if (!target) return; - dashboards.updateDashboard.mutate( - { - id: target.id, - name: target.name, - description: target.description, - config: target.config, - filters: JSON.stringify(next), - }, - { - onSuccess: () => { - toast.success(t("dashboards.toast.filtersSaved")); - // Drop any values whose chip was removed/renamed. - const validIds = new Set(next.map((c) => c.id)); - setChipValues((prev) => { - const out: ChipValueMap = {}; - for (const k of Object.keys(prev)) - if (validIds.has(k)) out[k] = prev[k]; - return out; - }); - }, - onError: (err) => - toast.error(err.message ?? t("dashboards.toast.filtersSaveFailed")), - }, - ); + try { + const existing = filterList.data?.data ?? []; + const existingIds = new Set(existing.map((f) => f.id)); + const nextIds = new Set(next.map((c) => c.id)); + + // Delete removed chips. + for (const f of existing) { + if (!nextIds.has(f.id)) await deleteFilter.mutateAsync(f.id); + } + // Create or update. + for (const chip of next) { + if (existingIds.has(chip.id)) { + await updateFilter.mutateAsync({ id: chip.id, ...chipToFilterInput(chip, target.id) }); + } else { + await createFilter.mutateAsync(chipToFilterInput(chip, target.id)); + } + } + + toast.success(t("dashboards.toast.filtersSaved")); + // Drop any values whose chip was removed/renamed. + setChipValues((prev) => { + const out: ChipValueMap = {}; + for (const k of Object.keys(prev)) + if (nextIds.has(k)) out[k] = prev[k]; + return out; + }); + } catch (err) { + toast.error((err as Error).message ?? t("dashboards.toast.filtersSaveFailed")); + } }; const backToList = () => { @@ -454,7 +464,7 @@ export function DashboardPage() { values={chipValues} onChange={setChipValues} editable={editor.editing} - savingChips={dashboards.updateDashboard.isPending} + savingChips={createFilter.isPending || updateFilter.isPending || deleteFilter.isPending} onSaveChips={handleSaveFilters} /> )} @@ -554,16 +564,27 @@ export function DashboardPage() { export default DashboardPage; -function parseChipConfig( - json: string | undefined | null, -): DashboardFilterChip[] { - if (!json) return []; - try { - const parsed = JSON.parse(json); - return Array.isArray(parsed) ? (parsed as DashboardFilterChip[]) : []; - } catch { - return []; - } +function filterToChip(f: DashboardFilter): DashboardFilterChip { + return { + id: f.id, + field: f.field, + label: f.label || f.field, + placeholder: f.place_holder || undefined, + dataset: f.data_set, + multiple: f.type === "multiple", + searchable: f.type === "searchable", + }; +} + +function chipToFilterInput(chip: DashboardFilterChip, dashboardId: string): DashboardFilterCreateInput { + return { + dashboardId, + data_set: chip.dataset, + field: chip.field, + type: chip.multiple ? "multiple" : "searchable", + label: chip.label, + place_holder: chip.placeholder || "", + }; } function chipsToFilters( diff --git a/frontend/src/features/dashboard/service/dashboard-filters.service.ts b/frontend/src/features/dashboard/service/dashboard-filters.service.ts new file mode 100644 index 000000000..d83ae7155 --- /dev/null +++ b/frontend/src/features/dashboard/service/dashboard-filters.service.ts @@ -0,0 +1,42 @@ +import { createApiClient, type Paged } from '@/shared/lib/api-client' +import type { + DashboardFilter, + DashboardFilterCreateInput, + DashboardFilterListParams, + DashboardFilterUpdateInput, +} from '@/features/dashboard/types' + +const BASE_URL = '/dashboards/filters' + +function buildQuery(params: DashboardFilterListParams): string { + const p = new URLSearchParams() + if (params.dashboardId != null) p.set('dashboardId', String(params.dashboardId)) + if (params.page != null) p.set('page', String(params.page)) + if (params.size != null) p.set('size', String(params.size)) + const q = p.toString() + return q ? `?${q}` : '' +} + +export interface DashboardFiltersService { + listDashboardFilters(params?: DashboardFilterListParams): Promise> + getDashboardFilter(id: string): Promise + createDashboardFilter(data: DashboardFilterCreateInput): Promise + updateDashboardFilter(data: DashboardFilterUpdateInput): Promise + deleteDashboardFilter(id: string): Promise +} + +export function createDashboardFiltersService(baseUrl?: string): DashboardFiltersService { + const api = createApiClient(baseUrl) + + return { + listDashboardFilters: (params = {}) => api.getPaged(`${BASE_URL}${buildQuery(params)}`), + + getDashboardFilter: (id: string) => api.get(`${BASE_URL}/${id}`), + + createDashboardFilter: (data: DashboardFilterCreateInput) => api.post(BASE_URL, data), + + updateDashboardFilter: (data: DashboardFilterUpdateInput) => api.put(BASE_URL, data), + + deleteDashboardFilter: (id: string) => api.delete(BASE_URL + '/' + id), + } +} diff --git a/frontend/src/features/dashboard/types/index.ts b/frontend/src/features/dashboard/types/index.ts index 561737a6e..c6898e5d7 100644 --- a/frontend/src/features/dashboard/types/index.ts +++ b/frontend/src/features/dashboard/types/index.ts @@ -132,6 +132,42 @@ export interface FilterType { value: unknown } +// Persistent chip config stored on dashboard.filters (JSON blob). Describes a +// single dropdown in the dashboard filter bar; the *value* the user picks is +// session-only (not persisted). +export type DashboardFilterType = 'multiple' | 'searchable' + +export interface DashboardFilter { + id: string + dashboardId: string + data_set: string + field: string + type: DashboardFilterType + label?: string + place_holder?: string + createdDate?: string + modifiedDate?: string +} + +export interface DashboardFilterCreateInput { + dashboardId: string + data_set: string + field: string + type: DashboardFilterType + label?: string + place_holder?: string +} + +export interface DashboardFilterUpdateInput extends DashboardFilterCreateInput { + id: string +} + +export interface DashboardFilterListParams { + dashboardId?: string + page?: number + size?: number +} + // Persistent chip config stored on dashboard.filters (JSON blob). Describes a // single dropdown in the dashboard filter bar; the *value* the user picks is // session-only (not persisted). From 6b5d6a034305bd5c2bd4b8d87438c2f67097a925 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20S=C3=A1nchez?= Date: Wed, 30 Sep 2026 16:52:52 -0600 Subject: [PATCH 3/3] fix[frontend](dashboards): added toast for every dashboard filter operation --- .../features/dashboard/pages/DashboardPage.tsx | 15 +++++++++++++-- frontend/src/shared/i18n/locales/de.json | 8 +++++++- frontend/src/shared/i18n/locales/en.json | 8 +++++++- frontend/src/shared/i18n/locales/es.json | 8 +++++++- frontend/src/shared/i18n/locales/fr.json | 8 +++++++- frontend/src/shared/i18n/locales/it.json | 8 +++++++- frontend/src/shared/i18n/locales/pt.json | 8 +++++++- frontend/src/shared/i18n/locales/ru.json | 8 +++++++- 8 files changed, 62 insertions(+), 9 deletions(-) diff --git a/frontend/src/features/dashboard/pages/DashboardPage.tsx b/frontend/src/features/dashboard/pages/DashboardPage.tsx index 2356551a9..078e30ee9 100644 --- a/frontend/src/features/dashboard/pages/DashboardPage.tsx +++ b/frontend/src/features/dashboard/pages/DashboardPage.tsx @@ -341,20 +341,31 @@ export function DashboardPage() { const existingIds = new Set(existing.map((f) => f.id)); const nextIds = new Set(next.map((c) => c.id)); + let created = 0; + let updated = 0; + let deleted = 0; + // Delete removed chips. for (const f of existing) { - if (!nextIds.has(f.id)) await deleteFilter.mutateAsync(f.id); + if (!nextIds.has(f.id)) { + await deleteFilter.mutateAsync(f.id); + deleted++; + } } // Create or update. for (const chip of next) { if (existingIds.has(chip.id)) { await updateFilter.mutateAsync({ id: chip.id, ...chipToFilterInput(chip, target.id) }); + updated++; } else { await createFilter.mutateAsync(chipToFilterInput(chip, target.id)); + created++; } } - toast.success(t("dashboards.toast.filtersSaved")); + if (created > 0) toast.success(t("dashboards.toast.filterCreated")); + if (updated > 0) toast.success(t("dashboards.toast.filterUpdated")); + if (deleted > 0) toast.success(t("dashboards.toast.filterDeleted")); // Drop any values whose chip was removed/renamed. setChipValues((prev) => { const out: ChipValueMap = {}; diff --git a/frontend/src/shared/i18n/locales/de.json b/frontend/src/shared/i18n/locales/de.json index 59ecfba25..c9769a9a1 100644 --- a/frontend/src/shared/i18n/locales/de.json +++ b/frontend/src/shared/i18n/locales/de.json @@ -549,7 +549,13 @@ "visualizationUpdated": "Widget aktualisiert.", "visualizationUpdateFailed": "Widget konnte nicht aktualisiert werden.", "visualizationDeleted": "Widget gelöscht.", - "visualizationDeleteFailed": "Widget konnte nicht gelöscht werden." + "visualizationDeleteFailed": "Widget konnte nicht gelöscht werden.", + "filterCreated": "Filter erstellt.", + "filterUpdated": "Filter aktualisiert.", + "filterDeleted": "Filter gelöscht.", + "filterCreateFailed": "Filter konnte nicht erstellt werden.", + "filterUpdateFailed": "Filter konnte nicht aktualisiert werden.", + "filterDeleteFailed": "Filter konnte nicht gelöscht werden." }, "newDashboard": { "title": "Neues Dashboard", diff --git a/frontend/src/shared/i18n/locales/en.json b/frontend/src/shared/i18n/locales/en.json index 3bcb4e99e..679927070 100644 --- a/frontend/src/shared/i18n/locales/en.json +++ b/frontend/src/shared/i18n/locales/en.json @@ -552,7 +552,13 @@ "visualizationDeleted": "Widget deleted.", "visualizationDeleteFailed": "Could not delete widget.", "filtersSaved": "Filters saved.", - "filtersSaveFailed": "Could not save filters." + "filtersSaveFailed": "Could not save filters.", + "filterCreated": "Filter created.", + "filterUpdated": "Filter updated.", + "filterDeleted": "Filter deleted.", + "filterCreateFailed": "Could not create filter.", + "filterUpdateFailed": "Could not update filter.", + "filterDeleteFailed": "Could not delete filter." }, "filters": { "chooseValue": "Choose…", diff --git a/frontend/src/shared/i18n/locales/es.json b/frontend/src/shared/i18n/locales/es.json index e58d5ee91..f5305f84e 100644 --- a/frontend/src/shared/i18n/locales/es.json +++ b/frontend/src/shared/i18n/locales/es.json @@ -549,7 +549,13 @@ "visualizationUpdated": "Widget actualizado.", "visualizationUpdateFailed": "No se pudo actualizar el widget.", "visualizationDeleted": "Widget eliminado.", - "visualizationDeleteFailed": "No se pudo eliminar el widget." + "visualizationDeleteFailed": "No se pudo eliminar el widget.", + "filterCreated": "Filtro creado.", + "filterUpdated": "Filtro actualizado.", + "filterDeleted": "Filtro eliminado.", + "filterCreateFailed": "No se pudo crear el filtro.", + "filterUpdateFailed": "No se pudo actualizar el filtro.", + "filterDeleteFailed": "No se pudo eliminar el filtro." }, "newDashboard": { "title": "Nuevo panel", diff --git a/frontend/src/shared/i18n/locales/fr.json b/frontend/src/shared/i18n/locales/fr.json index b7942cbf6..4993e0a20 100644 --- a/frontend/src/shared/i18n/locales/fr.json +++ b/frontend/src/shared/i18n/locales/fr.json @@ -549,7 +549,13 @@ "visualizationUpdated": "Widget mis à jour.", "visualizationUpdateFailed": "Impossible de mettre à jour le widget.", "visualizationDeleted": "Widget supprimé.", - "visualizationDeleteFailed": "Impossible de supprimer le widget." + "visualizationDeleteFailed": "Impossible de supprimer le widget.", + "filterCreated": "Filtre créé.", + "filterUpdated": "Filtre mis à jour.", + "filterDeleted": "Filtre supprimé.", + "filterCreateFailed": "Echec de la creation du filtre.", + "filterUpdateFailed": "Echec de la mise a jour du filtre.", + "filterDeleteFailed": "Echec de la suppression du filtre." }, "newDashboard": { "title": "Nouveau tableau de bord", diff --git a/frontend/src/shared/i18n/locales/it.json b/frontend/src/shared/i18n/locales/it.json index 42db5c728..206af5368 100644 --- a/frontend/src/shared/i18n/locales/it.json +++ b/frontend/src/shared/i18n/locales/it.json @@ -549,7 +549,13 @@ "visualizationUpdated": "Widget aggiornato.", "visualizationUpdateFailed": "Impossibile aggiornare il widget.", "visualizationDeleted": "Widget eliminato.", - "visualizationDeleteFailed": "Impossibile eliminare il widget." + "visualizationDeleteFailed": "Impossibile eliminare il widget.", + "filterCreated": "Filtro creato.", + "filterUpdated": "Filtro aggiornato.", + "filterDeleted": "Filtro eliminato.", + "filterCreateFailed": "Impossibile creare il filtro.", + "filterUpdateFailed": "Impossibile aggiornare il filtro.", + "filterDeleteFailed": "Impossibile eliminare il filtro." }, "newDashboard": { "title": "Nuova dashboard", diff --git a/frontend/src/shared/i18n/locales/pt.json b/frontend/src/shared/i18n/locales/pt.json index aa8d37fd8..f74777cf3 100644 --- a/frontend/src/shared/i18n/locales/pt.json +++ b/frontend/src/shared/i18n/locales/pt.json @@ -549,7 +549,13 @@ "visualizationUpdated": "Widget atualizado.", "visualizationUpdateFailed": "Não foi possível atualizar o widget.", "visualizationDeleted": "Widget excluído.", - "visualizationDeleteFailed": "Não foi possível excluir o widget." + "visualizationDeleteFailed": "Não foi possível excluir o widget.", + "filterCreated": "Filtro criado.", + "filterUpdated": "Filtro atualizado.", + "filterDeleted": "Filtro excluído.", + "filterCreateFailed": "Não foi possível criar o filtro.", + "filterUpdateFailed": "Não foi possível atualizar o filtro.", + "filterDeleteFailed": "Não foi possível excluir o filtro." }, "newDashboard": { "title": "Novo painel", diff --git a/frontend/src/shared/i18n/locales/ru.json b/frontend/src/shared/i18n/locales/ru.json index 129734227..113e81164 100644 --- a/frontend/src/shared/i18n/locales/ru.json +++ b/frontend/src/shared/i18n/locales/ru.json @@ -551,7 +551,13 @@ "visualizationUpdated": "Виджет обновлён.", "visualizationUpdateFailed": "Не удалось обновить виджет.", "visualizationDeleted": "Виджет удалён.", - "visualizationDeleteFailed": "Не удалось удалить виджет." + "visualizationDeleteFailed": "Не удалось удалить виджет.", + "filterCreated": "Фильтр создан.", + "filterUpdated": "Фильтр обновлён.", + "filterDeleted": "Фильтр удалён.", + "filterCreateFailed": "Не удалось создать фильтр.", + "filterUpdateFailed": "Не удалось обновить фильтр.", + "filterDeleteFailed": "Не удалось удалить фильтр." }, "newDashboard": { "title": "Новая панель",