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
2 changes: 1 addition & 1 deletion api/internal/queue/queue.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import (
amqp "github.com/rabbitmq/amqp091-go"
)

// Queue names (Plane-style: one queue per task type or shared).
// Queue names (one queue per task type or shared).
const (
QueueEmails = "devlane.emails"
QueueWebhooks = "devlane.webhooks"
Expand Down
8 changes: 4 additions & 4 deletions api/internal/redis/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import (
"github.com/redis/go-redis/v9"
)

// Cache key prefixes (Plane-style).
// Cache key prefixes.
const (
PrefixMagicLink = "magic_"
PrefixLock = "lock_"
Expand Down Expand Up @@ -68,7 +68,7 @@ func (c *Client) CacheSet(ctx context.Context, key string, v interface{}, ttl ti
return c.Client.Set(ctx, PrefixCache+key, data, ttl).Err()
}

// --- Lock (distributed lock, Plane-style for batch tasks) ---
// --- Lock (distributed lock for batch tasks) ---

// AcquireLock acquires a lock. Returns true if acquired, false if already held.
func (c *Client) AcquireLock(ctx context.Context, lockID string, expire time.Duration) (bool, error) {
Expand All @@ -85,7 +85,7 @@ func (c *Client) ReleaseLock(ctx context.Context, lockID string) error {
return c.Client.Del(ctx, PrefixLock+lockID).Err()
}

// --- Magic-link token (Plane-style: key = magic_<email>, value = JSON) ---
// --- Magic-link token (key = magic_<email>, value = JSON) ---

// MagicLinkData is stored in Redis for magic-link auth.
type MagicLinkData struct {
Expand Down Expand Up @@ -128,7 +128,7 @@ func (c *Client) DeleteMagicLink(ctx context.Context, email string) error {
return c.Client.Del(ctx, PrefixMagicLink+email).Err()
}

// --- Short-lived metadata (e.g. request origin per issue, Plane-style) ---
// --- Short-lived metadata (e.g. request origin per issue) ---

// SetRequestOrigin sets a short-lived value for an entity (e.g. issue_id -> origin).
func (c *Client) SetRequestOrigin(ctx context.Context, entityID, origin string, ttl time.Duration) error {
Expand Down
2 changes: 1 addition & 1 deletion api/internal/service/cycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import (
"github.com/google/uuid"
)

// computeCycleStatus derives status from start/end dates (Plane-style).
// computeCycleStatus derives status from start/end dates.
// draft: no dates; current: now in range; upcoming: start > now; completed: end < now.
func computeCycleStatus(start, end *time.Time) string {
now := time.Now()
Expand Down
229 changes: 210 additions & 19 deletions ui/src/components/layout/PageHeader.tsx

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions ui/src/components/layout/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,7 @@ const IconLayers = () => (
<path d="m22 12.65-9.17 4.16a2 2 0 0 1-1.66 0L2 12.65" />
</svg>
);
/** 2×2 grid — same glyph as project “Modules” nav (Plane-style module badge). */
/** 2×2 grid — same glyph as project “Modules” nav icon. */
const IconModuleGrid = ({ className }: { className?: string }) => (
<svg
viewBox="0 0 24 24"
Expand Down Expand Up @@ -789,7 +789,7 @@ export function Sidebar() {
});
};

// Auto-expand current project when navigating to its page (Plane-style: expand on nav, but allow manual collapse)
// Auto-expand current project when navigating to its page (expand on nav, but allow manual collapse)
useEffect(() => {
if (projectId) {
setExpandedProjectIds((prev) => new Set(prev).add(projectId));
Expand Down
235 changes: 235 additions & 0 deletions ui/src/components/project-issues/ProjectIssuesDisplayPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
import { type ReactNode, useState } from 'react';
import type { SavedViewGroupBy, SavedViewOrderBy } from '../../lib/projectSavedViewDisplay';
import {
ALL_SAVED_VIEW_DISPLAY_PROPERTIES,
SAVED_VIEW_DISPLAY_PROPERTY_LABELS,
} from '../../lib/projectSavedViewDisplay';
import type { ProjectIssuesDisplayState } from '../../lib/projectIssuesDisplay';

const IconChevronDown = () => (
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
aria-hidden
>
<path d="m6 9 6 6 6-6" />
</svg>
);

const IconCheck = () => (
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
aria-hidden
>
<path d="M20 6 9 17l-5-5" />
</svg>
);

type SectionId = 'properties' | 'group' | 'order';

const GROUP_OPTIONS: { value: SavedViewGroupBy; label: string }[] = [
{ value: 'states', label: 'States' },
{ value: 'priority', label: 'Priority' },
{ value: 'cycle', label: 'Cycle' },
{ value: 'module', label: 'Module' },
{ value: 'labels', label: 'Labels' },
{ value: 'assignees', label: 'Assignees' },
{ value: 'created_by', label: 'Created by' },
{ value: 'none', label: 'None' },
];

const ORDER_OPTIONS: { value: SavedViewOrderBy; label: string }[] = [
{ value: 'manual', label: 'Manual' },
{ value: 'last_created', label: 'Last created' },
{ value: 'last_updated', label: 'Last updated' },
{ value: 'start_date', label: 'Start date' },
{ value: 'due_date', label: 'Due date' },
{ value: 'priority', label: 'Priority' },
];

function CollapsibleSection(props: {
id: SectionId;
title: string;
expanded: boolean;
onToggle: (id: SectionId) => void;
children: ReactNode;
}) {
const { id, title, expanded, onToggle, children } = props;
return (
<div className="border-b border-(--border-subtle) last:border-b-0">
<button
type="button"
onClick={() => onToggle(id)}
className="flex w-full items-center justify-between gap-2 px-3 py-2 text-left text-[11px] font-semibold tracking-wide text-(--txt-secondary)"
>
<span>{title}</span>
<span
className={`text-(--txt-icon-tertiary) transition-transform ${expanded ? 'rotate-180' : ''}`}
>
<IconChevronDown />
</span>
</button>
{expanded ? <div className="px-2 pb-2">{children}</div> : null}
</div>
);
}

function RadioRow<T extends string>(props: {
selected: boolean;
value: T;
label: string;
onSelect: (v: T) => void;
}) {
const { selected, value, label, onSelect } = props;
return (
<button
type="button"
onClick={() => onSelect(value)}
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-[13px] text-(--txt-primary) hover:bg-(--bg-layer-1-hover) ${selected ? 'bg-(--bg-layer-1-hover)' : ''}`}
>
<span
className={`flex size-4 shrink-0 items-center justify-center rounded-full border-2 ${
selected
? 'border-(--brand-default) bg-(--brand-default) text-white'
: 'border-(--border-strong)'
}`}
>
{selected ? <IconCheck /> : null}
</span>
<span>{label}</span>
</button>
);
}

export interface ProjectIssuesDisplayPanelProps {
display: ProjectIssuesDisplayState;
setDisplay: React.Dispatch<React.SetStateAction<ProjectIssuesDisplayState>>;
}
Comment on lines +113 to +116
Copy link

Copilot AI Mar 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This file uses React.Dispatch / React.SetStateAction but doesn’t import React as a type. In this repo’s TS setup, the React namespace won’t be in scope automatically, so the file won’t compile. Import the types directly from react (recommended) or add a import type * as React from 'react'.

Copilot uses AI. Check for mistakes.

export function ProjectIssuesDisplayPanel({ display, setDisplay }: ProjectIssuesDisplayPanelProps) {
const [sections, setSections] = useState<Record<SectionId, boolean>>({
properties: true,
group: true,
order: true,
});

const toggleSection = (id: SectionId) => {
setSections((s) => ({ ...s, [id]: !s[id] }));
};

const toggleProperty = (id: (typeof ALL_SAVED_VIEW_DISPLAY_PROPERTIES)[number]) => {
setDisplay((prev) => {
const next = new Set(prev.displayProperties);
if (next.has(id)) next.delete(id);
else next.add(id);
return { ...prev, displayProperties: next };
});
};

return (
<div className="max-h-[min(70vh,560px)] overflow-y-auto py-1">
<CollapsibleSection
id="properties"
title="Display properties"
expanded={sections.properties}
onToggle={toggleSection}
>
<div className="flex flex-wrap gap-1.5">
{ALL_SAVED_VIEW_DISPLAY_PROPERTIES.map((prop) => {
const on = display.displayProperties.has(prop);
return (
<button
key={prop}
type="button"
onClick={() => toggleProperty(prop)}
className={`rounded-md border px-2 py-1 text-[12px] font-medium transition-colors ${
on
? 'border-(--brand-default) bg-(--brand-default) text-white'
: 'border-(--border-subtle) bg-(--bg-layer-1) text-(--txt-secondary) hover:bg-(--bg-layer-1-hover)'
}`}
>
{SAVED_VIEW_DISPLAY_PROPERTY_LABELS[prop]}
</button>
);
})}
</div>
</CollapsibleSection>

<CollapsibleSection
id="group"
title="Group by"
expanded={sections.group}
onToggle={toggleSection}
>
<div className="flex flex-col gap-0.5">
{GROUP_OPTIONS.map((opt) => (
<RadioRow
key={opt.value}
value={opt.value}
label={opt.label}
selected={display.groupBy === opt.value}
onSelect={(v) => setDisplay((p) => ({ ...p, groupBy: v }))}
/>
))}
</div>
</CollapsibleSection>

<CollapsibleSection
id="order"
title="Order by"
expanded={sections.order}
onToggle={toggleSection}
>
<div className="flex flex-col gap-0.5">
{ORDER_OPTIONS.map((opt) => (
<RadioRow
key={opt.value}
value={opt.value}
label={opt.label}
selected={display.orderBy === opt.value}
onSelect={(v) => setDisplay((p) => ({ ...p, orderBy: v }))}
/>
))}
</div>
<div className="mx-2 my-2 border-t border-(--border-subtle)" />
<label className="flex cursor-pointer items-center gap-2 px-2 py-1.5 text-[13px] text-(--txt-primary) hover:bg-(--bg-layer-1-hover)">
<input
type="checkbox"
className="size-3.5 rounded border-(--border-strong)"
checked={display.showSubWorkItems}
onChange={(e) =>
setDisplay((p) => ({
...p,
showSubWorkItems: e.target.checked,
}))
}
/>
Show sub-work items
</label>
<label className="flex cursor-pointer items-center gap-2 px-2 py-1.5 text-[13px] text-(--txt-primary) hover:bg-(--bg-layer-1-hover)">
<input
type="checkbox"
className="size-3.5 rounded border-(--border-strong)"
checked={display.showEmptyGroups}
onChange={(e) =>
setDisplay((p) => ({
...p,
showEmptyGroups: e.target.checked,
}))
}
/>
Show empty groups
</label>
</CollapsibleSection>
</div>
);
}
Loading
Loading