diff --git a/apps/api/internal/handler/view_test.go b/apps/api/internal/handler/view_test.go index 364b8685..8d76d53b 100644 --- a/apps/api/internal/handler/view_test.go +++ b/apps/api/internal/handler/view_test.go @@ -4,6 +4,7 @@ import ( "net/http" "testing" + "github.com/Devlaner/devlane/api/internal/model" "github.com/Devlaner/devlane/api/internal/testutil" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -47,6 +48,51 @@ func TestView_CRUD(t *testing.T) { require.Equal(t, http.StatusNoContent, rr5.Code) } +// A saved view round-trips its filters + display settings through the backend so +// they are shared across users/devices, and only the owner may update them. This +// is the contract the ViewDetailPage "Save changes" action relies on. Covers #173. +func TestView_PersistsFiltersAndDisplaySettings(t *testing.T) { + ts := testutil.NewTestServer(t) + w := testutil.SeedWorld(t, ts.DB) + base := "/api/workspaces/" + w.Workspace.Slug + "/views/" + + rr := ts.POST(base, map[string]any{"name": "Board"}, w.Session) + require.Equal(t, http.StatusCreated, rr.Code, "body=%s", rr.Body.String()) + id, _ := testutil.MustJSONMap(t, rr)["id"].(string) + require.NotEmpty(t, id) + + // The owner saves filters + display settings (the shape the UI sends). + patch := map[string]any{ + "filters": map[string]any{"priority": "high,urgent"}, + "display_filters": map[string]any{"groupBy": "priority", "orderBy": "due_date"}, + "display_properties": map[string]any{ + "displayProperties": []string{"id", "state", "assignee"}, + }, + } + rr2 := ts.PATCH(base+id+"/", patch, w.Session) + require.Equal(t, http.StatusOK, rr2.Code, "body=%s", rr2.Body.String()) + + // GET echoes them back unchanged so another device can reconstruct the view. + rr3 := ts.GET(base+id+"/", w.Session) + require.Equal(t, http.StatusOK, rr3.Code) + got := testutil.MustJSONMap(t, rr3) + filters, _ := got["filters"].(map[string]any) + require.Equal(t, "high,urgent", filters["priority"]) + df, _ := got["display_filters"].(map[string]any) + require.Equal(t, "priority", df["groupBy"]) + require.Equal(t, "due_date", df["orderBy"]) + dp, _ := got["display_properties"].(map[string]any) + props, _ := dp["displayProperties"].([]any) + require.Len(t, props, 3) + + // A workspace member who does not own the view cannot update it. + other := testutil.CreateUser(t, ts.DB) + testutil.AddWorkspaceMember(t, ts.DB, w.Workspace.ID, other.ID, model.RoleMember) + otherSession := testutil.LoginAs(t, ts.DB, other) + rr4 := ts.PATCH(base+id+"/", map[string]any{"name": "Hijack"}, otherSession) + require.Equal(t, http.StatusNotFound, rr4.Code, "non-owner must not update a view") +} + func TestView_Favorites_DualVariantRoutes(t *testing.T) { // Router lines 332-337 register both /favorite and /favorite/ variants on // purpose. This test asserts BOTH paths reach the same handler. diff --git a/apps/web/src/lib/projectSavedViewDisplay.ts b/apps/web/src/lib/projectSavedViewDisplay.ts index 388d78f4..82ff0f57 100644 --- a/apps/web/src/lib/projectSavedViewDisplay.ts +++ b/apps/web/src/lib/projectSavedViewDisplay.ts @@ -152,6 +152,46 @@ export function serializeSettings(s: SavedViewDisplaySettings): string { }); } +/** + * Split display settings into the backend's two JSON columns: `display_properties` + * carries the visible columns, `display_filters` carries grouping/ordering. This is + * the shape written by the saved-view "Save changes" action and read back by + * {@link parseSavedViewDisplayFromRecords}. + */ +export function savedViewDisplayToRecords(s: SavedViewDisplaySettings): { + displayFilters: Record; + displayProperties: Record; +} { + return { + displayFilters: { + groupBy: s.groupBy, + orderBy: s.orderBy, + orderDirection: s.orderDirection, + showSubWorkItems: s.showSubWorkItems, + }, + displayProperties: { + displayProperties: [...s.displayProperties], + }, + }; +} + +/** + * Rebuild display settings from a saved view's `display_filters` + `display_properties` + * records. Returns null when the view has no stored display settings, so callers can + * fall back to localStorage/defaults. Reuses {@link parsePersistedSavedViewDisplay}, so + * unknown or partial values are validated and defaulted the same way as the local cache. + */ +export function parseSavedViewDisplayFromRecords( + displayFilters: Record | null | undefined, + displayProperties: Record | null | undefined, +): SavedViewDisplaySettings | null { + const hasFilters = Boolean(displayFilters) && Object.keys(displayFilters ?? {}).length > 0; + const hasProps = Boolean(displayProperties) && Object.keys(displayProperties ?? {}).length > 0; + if (!hasFilters && !hasProps) return null; + const combined = { ...(displayFilters ?? {}), ...(displayProperties ?? {}) }; + return parsePersistedSavedViewDisplay(JSON.stringify(combined)); +} + export const SAVED_VIEW_DISPLAY_PROPERTY_LABELS: Record = { id: 'ID', assignee: 'Assignee', diff --git a/apps/web/src/pages/ViewDetailPage.tsx b/apps/web/src/pages/ViewDetailPage.tsx index 704b3608..8d4540b5 100644 --- a/apps/web/src/pages/ViewDetailPage.tsx +++ b/apps/web/src/pages/ViewDetailPage.tsx @@ -1,10 +1,11 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { Link, useParams, useSearchParams } from 'react-router-dom'; import { Badge, Avatar, Button } from '../components/ui'; import { CreateWorkItemModal } from '../components/CreateWorkItemModal'; import { ProjectSavedViewActiveFilters } from '../components/project-saved-view/ProjectSavedViewActiveFilters'; import { useProjectSavedViewDisplay } from '../contexts/ProjectSavedViewDisplayContext'; import { useWorkspaceViewsState } from '../contexts/WorkspaceViewsStateContext'; +import { useAuth } from '../contexts/AuthContext'; import { workspaceService } from '../services/workspaceService'; import { projectService } from '../services/projectService'; import { issueService } from '../services/issueService'; @@ -26,9 +27,16 @@ import type { } from '../api/types'; import type { Priority } from '../types'; import type { SavedViewDisplayPropertyId } from '../lib/projectSavedViewDisplay'; +import { + savedViewDisplayToRecords, + parseSavedViewDisplayFromRecords, +} from '../lib/projectSavedViewDisplay'; import { sortIssuesByOrder } from '../lib/issueListGroupAndSort'; import { getImageUrl } from '../lib/utils'; -import { parseWorkspaceViewFiltersFromSearchParams } from '../types/workspaceViewFilters'; +import { + parseWorkspaceViewFiltersFromSearchParams, + workspaceViewFiltersToSearchParams, +} from '../types/workspaceViewFilters'; import { useDocumentTitle } from '../hooks/useDocumentTitle'; const priorityVariant: Record = { @@ -145,7 +153,8 @@ function pushUniq(arr: string[], id: string) { } export function ViewDetailPage() { - const { settings } = useProjectSavedViewDisplay(); + const { settings, setSettings } = useProjectSavedViewDisplay(); + const { user } = useAuth(); const { filters: workspaceViewFilters, setFilters: setWorkspaceViewFilters } = useWorkspaceViewsState(); const { workspaceSlug, projectId, viewId } = useParams<{ @@ -168,6 +177,11 @@ export function ViewDetailPage() { const [loading, setLoading] = useState(true); const [issuesLoading, setIssuesLoading] = useState(false); const [error, setError] = useState(null); + const [saving, setSaving] = useState(false); + const [saveState, setSaveState] = useState<'idle' | 'saved' | 'error'>('idle'); + // Seed the display context from the view record once per view id (below), so + // re-setting `view` after a save doesn't clobber the current in-memory settings. + const seededViewId = useRef(null); useDocumentTitle(loading ? 'View' : (view?.name ?? 'View')); @@ -230,6 +244,24 @@ export function ViewDetailPage() { setWorkspaceViewFilters(next); }, [setWorkspaceViewFilters, view, viewId]); + // Load persisted display settings (group/order/columns) from the view record, + // so a saved view is shared across users and devices rather than device-local. + // Seed once per view id; if the view has no stored settings, keep the local + // (localStorage/default) settings the display context already provides. + useEffect(() => { + if (!view) return; + if (seededViewId.current === view.id) return; + seededViewId.current = view.id; + const parsed = parseSavedViewDisplayFromRecords(view.display_filters, view.display_properties); + if (parsed) setSettings(parsed); + }, [view, setSettings]); + + // Any change to the filters or display settings means there are unsaved edits, + // so drop the "Saved" acknowledgement until the next explicit save. + useEffect(() => { + setSaveState('idle'); + }, [workspaceViewFilters, settings]); + useEffect(() => { if (!workspaceSlug || !projectId) return; let cancelled = false; @@ -740,6 +772,29 @@ export function ViewDetailPage() { } }; + // Persist the current filters + display settings back onto the saved view so they + // are shared and survive across devices. Only the view's owner may update it. + const handleSaveView = async () => { + if (!workspaceSlug || !viewId) return; + setSaving(true); + try { + const { displayFilters, displayProperties } = savedViewDisplayToRecords(settings); + await viewService.update(workspaceSlug, viewId, { + filters: workspaceViewFiltersToSearchParams(workspaceViewFilters), + display_filters: displayFilters, + display_properties: displayProperties, + }); + // Don't re-set `view` here: the just-saved values already live in + // workspaceViewFilters + settings, and replacing `view` would re-run the + // filters-sync effect and clear this "Saved" acknowledgement immediately. + setSaveState('saved'); + } catch { + setSaveState('error'); + } finally { + setSaving(false); + } + }; + if (loading) { return (
@@ -809,6 +864,18 @@ export function ViewDetailPage() {

{view.description}

) : null}
+ {user && view.owned_by_id === user.id ? ( +
+ {saveState === 'error' ? ( + Couldn’t save. Try again. + ) : saveState === 'saved' ? ( + Saved + ) : null} + +
+ ) : null}