Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions apps/api/internal/handler/view_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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.
Expand Down
40 changes: 40 additions & 0 deletions apps/web/src/lib/projectSavedViewDisplay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
displayProperties: Record<string, unknown>;
} {
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<string, unknown> | null | undefined,
displayProperties: Record<string, unknown> | 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<SavedViewDisplayPropertyId, string> = {
id: 'ID',
assignee: 'Assignee',
Expand Down
73 changes: 70 additions & 3 deletions apps/web/src/pages/ViewDetailPage.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<Priority, 'danger' | 'warning' | 'default' | 'neutral'> = {
Expand Down Expand Up @@ -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<{
Expand All @@ -168,6 +177,11 @@ export function ViewDetailPage() {
const [loading, setLoading] = useState(true);
const [issuesLoading, setIssuesLoading] = useState(false);
const [error, setError] = useState<string | null>(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<string | null>(null);

useDocumentTitle(loading ? 'View' : (view?.name ?? 'View'));

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 (
<div className="flex items-center justify-center p-8 text-sm text-(--txt-tertiary)">
Expand Down Expand Up @@ -809,6 +864,18 @@ export function ViewDetailPage() {
<p className="mt-0.5 truncate text-xs text-(--txt-secondary)">{view.description}</p>
) : null}
</div>
{user && view.owned_by_id === user.id ? (
<div className="flex shrink-0 items-center gap-2">
{saveState === 'error' ? (
<span className="text-xs text-(--txt-danger-primary)">Couldn’t save. Try again.</span>
) : saveState === 'saved' ? (
<span className="text-xs text-(--txt-tertiary)">Saved</span>
) : null}
<Button size="sm" variant="secondary" onClick={handleSaveView} disabled={saving}>
{saving ? 'Saving…' : 'Save changes'}
</Button>
</div>
) : null}
</div>
<ProjectSavedViewActiveFilters
members={members}
Expand Down
Loading