mirror of
https://github.com/stablyai/orca.git
synced 2026-10-06 08:02:28 +00:00
fix: address review findings (#4853)
This commit is contained in:
@@ -13,6 +13,7 @@ import {
|
||||
getIssueComments
|
||||
} from '../linear/issues'
|
||||
import {
|
||||
createProject,
|
||||
getCustomView,
|
||||
getProject,
|
||||
listCustomViewIssues,
|
||||
@@ -56,6 +57,29 @@ function normalizeCustomViewModel(value: unknown): LinearCustomViewModel {
|
||||
return value
|
||||
}
|
||||
|
||||
function normalizeIdList(value: unknown, fieldName: string): string[] | undefined {
|
||||
if (value === undefined) {
|
||||
return undefined
|
||||
}
|
||||
if (
|
||||
!Array.isArray(value) ||
|
||||
!value.every((id): id is string => typeof id === 'string' && Boolean(id.trim()))
|
||||
) {
|
||||
throw new Error(`Invalid ${fieldName}`)
|
||||
}
|
||||
return value.map((id) => id.trim())
|
||||
}
|
||||
|
||||
function normalizeOptionalDate(value: unknown, fieldName: string): string | undefined {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return undefined
|
||||
}
|
||||
if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value.trim())) {
|
||||
throw new Error(`Invalid ${fieldName}`)
|
||||
}
|
||||
return value.trim()
|
||||
}
|
||||
|
||||
export function registerLinearHandlers(): void {
|
||||
ipcMain.handle('linear:connect', async (_event, args: { apiKey: string }) => {
|
||||
if (typeof args?.apiKey !== 'string' || !args.apiKey.trim()) {
|
||||
@@ -284,6 +308,72 @@ export function registerLinearHandlers(): void {
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
'linear:createProject',
|
||||
async (
|
||||
_event,
|
||||
args: {
|
||||
name: string
|
||||
description?: string
|
||||
content?: string
|
||||
teamIds?: string[]
|
||||
leadId?: string | null
|
||||
memberIds?: string[]
|
||||
labelIds?: string[]
|
||||
priority?: number
|
||||
startDate?: string
|
||||
targetDate?: string
|
||||
workspaceId?: string
|
||||
}
|
||||
) => {
|
||||
if (typeof args?.name !== 'string' || !args.name.trim()) {
|
||||
return { ok: false, error: 'Project name is required' }
|
||||
}
|
||||
let teamIds: string[]
|
||||
try {
|
||||
teamIds = normalizeIdList(args.teamIds, 'team IDs') ?? []
|
||||
} catch (error) {
|
||||
return { ok: false, error: error instanceof Error ? error.message : 'Invalid team IDs' }
|
||||
}
|
||||
if (teamIds.length === 0) {
|
||||
return { ok: false, error: 'At least one team is required' }
|
||||
}
|
||||
if (
|
||||
args.priority !== undefined &&
|
||||
(!Number.isInteger(args.priority) || args.priority < 0 || args.priority > 4)
|
||||
) {
|
||||
return { ok: false, error: 'Invalid priority' }
|
||||
}
|
||||
let memberIds: string[] | undefined
|
||||
let labelIds: string[] | undefined
|
||||
let startDate: string | undefined
|
||||
let targetDate: string | undefined
|
||||
try {
|
||||
memberIds = normalizeIdList(args.memberIds, 'member IDs')
|
||||
labelIds = normalizeIdList(args.labelIds, 'label IDs')
|
||||
startDate = normalizeOptionalDate(args.startDate, 'start date')
|
||||
targetDate = normalizeOptionalDate(args.targetDate, 'target date')
|
||||
} catch (error) {
|
||||
return { ok: false, error: error instanceof Error ? error.message : 'Invalid project' }
|
||||
}
|
||||
return createProject(
|
||||
{
|
||||
name: args.name.trim(),
|
||||
description: args.description?.trim() || undefined,
|
||||
content: args.content?.trim() || undefined,
|
||||
teamIds,
|
||||
leadId: normalizeWorkspaceId(args.leadId),
|
||||
memberIds,
|
||||
labelIds,
|
||||
priority: typeof args.priority === 'number' ? args.priority : undefined,
|
||||
startDate,
|
||||
targetDate
|
||||
},
|
||||
normalizeWorkspaceId(args.workspaceId)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
'linear:getProject',
|
||||
async (_event, args: { id: string; workspaceId?: string; force?: boolean }) => {
|
||||
|
||||
@@ -210,6 +210,69 @@ describe('Linear project queries', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('creates a project with team metadata and maps the created project', async () => {
|
||||
rawRequest.mockResolvedValueOnce({
|
||||
data: {
|
||||
projectCreate: {
|
||||
success: true,
|
||||
project: {
|
||||
...rawProject('project-1'),
|
||||
description: 'Summary',
|
||||
content: 'Brief',
|
||||
priority: 2,
|
||||
targetDate: '2026-08-01',
|
||||
teams: { nodes: [{ id: 'team-1', name: 'Team', key: 'TM' }] }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
const { createProject } = await import('./projects')
|
||||
|
||||
const result = await createProject(
|
||||
{
|
||||
name: 'Roadmap',
|
||||
description: 'Summary',
|
||||
content: 'Brief',
|
||||
teamIds: ['team-1'],
|
||||
leadId: 'user-1',
|
||||
memberIds: ['user-1', 'user-2'],
|
||||
labelIds: ['label-1'],
|
||||
priority: 2,
|
||||
startDate: '2026-07-01',
|
||||
targetDate: '2026-08-01'
|
||||
},
|
||||
'workspace-1'
|
||||
)
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
project: {
|
||||
id: 'project-1',
|
||||
name: 'project-1',
|
||||
workspaceId: 'workspace-1',
|
||||
description: 'Summary',
|
||||
content: 'Brief',
|
||||
priority: 2,
|
||||
targetDate: '2026-08-01',
|
||||
teams: [{ id: 'team-1', name: 'Team', key: 'TM' }]
|
||||
}
|
||||
})
|
||||
expect(rawRequest.mock.calls[0]?.[1]).toEqual({
|
||||
input: {
|
||||
name: 'Roadmap',
|
||||
description: 'Summary',
|
||||
content: 'Brief',
|
||||
teamIds: ['team-1'],
|
||||
leadId: 'user-1',
|
||||
memberIds: ['user-1', 'user-2'],
|
||||
labelIds: ['label-1'],
|
||||
priority: 2,
|
||||
startDate: '2026-07-01',
|
||||
targetDate: '2026-08-01'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('lets manual custom view list refresh bypass older in-flight reads', async () => {
|
||||
const staleRequest = deferred<ReturnType<typeof customViewsResponse>>()
|
||||
const refreshRequest = deferred<ReturnType<typeof customViewsResponse>>()
|
||||
|
||||
@@ -160,6 +160,26 @@ type CustomViewConnectionResponse = {
|
||||
| null
|
||||
}
|
||||
|
||||
type ProjectMutationResponse = {
|
||||
projectCreate?: {
|
||||
success?: boolean | null
|
||||
project?: LinearProjectNode | null
|
||||
} | null
|
||||
}
|
||||
|
||||
export type LinearProjectCreateInput = {
|
||||
name: string
|
||||
description?: string
|
||||
content?: string
|
||||
teamIds: string[]
|
||||
leadId?: string
|
||||
memberIds?: string[]
|
||||
labelIds?: string[]
|
||||
priority?: number
|
||||
startDate?: string
|
||||
targetDate?: string
|
||||
}
|
||||
|
||||
const ORCA_PROJECT_FIELDS = `
|
||||
id
|
||||
name
|
||||
@@ -316,6 +336,17 @@ const PROJECT_QUERY = `
|
||||
}
|
||||
`
|
||||
|
||||
const CREATE_PROJECT_MUTATION = `
|
||||
mutation OrcaLinearProjectCreate($input: ProjectCreateInput!) {
|
||||
projectCreate(input: $input) {
|
||||
success
|
||||
project {
|
||||
${ORCA_PROJECT_DETAIL_FIELDS}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const PROJECT_ISSUES_QUERY = `
|
||||
query OrcaLinearProjectIssues(
|
||||
$id: String!,
|
||||
@@ -865,6 +896,39 @@ export async function getProject(
|
||||
)
|
||||
}
|
||||
|
||||
export async function createProject(
|
||||
input: LinearProjectCreateInput,
|
||||
workspaceId?: string | null
|
||||
): Promise<{ ok: true; project: LinearProjectDetail } | { ok: false; error: string }> {
|
||||
const entry = getClients(workspaceId)[0]
|
||||
if (!entry) {
|
||||
return { ok: false, error: 'Not connected to Linear' }
|
||||
}
|
||||
|
||||
await acquire()
|
||||
try {
|
||||
const result = await entry.client.client.rawRequest<
|
||||
ProjectMutationResponse,
|
||||
LinearRawVariables
|
||||
>(CREATE_PROJECT_MUTATION, { input })
|
||||
const payload = result.data?.projectCreate
|
||||
const project = payload?.project
|
||||
if (!payload?.success || !project) {
|
||||
return { ok: false, error: 'Linear project create failed' }
|
||||
}
|
||||
return { ok: true, project: mapProjectDetailForWorkspace(entry, project) }
|
||||
} catch (error) {
|
||||
if (isAuthError(error)) {
|
||||
clearToken(entry.workspace.id)
|
||||
throw error
|
||||
}
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return { ok: false, error: message }
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
}
|
||||
|
||||
export async function listProjectIssues(
|
||||
projectId: string,
|
||||
limit = 20,
|
||||
|
||||
@@ -293,13 +293,15 @@ import {
|
||||
type LinearListFilter
|
||||
} from '../linear/issues'
|
||||
import {
|
||||
createProject as createLinearProject,
|
||||
getCustomView as getLinearCustomView,
|
||||
getProject as getLinearProject,
|
||||
listCustomViewIssues as listLinearCustomViewIssues,
|
||||
listCustomViewProjects as listLinearCustomViewProjects,
|
||||
listCustomViews as listLinearCustomViews,
|
||||
listProjectIssues as listLinearProjectIssues,
|
||||
listProjects as listLinearProjects
|
||||
listProjects as listLinearProjects,
|
||||
type LinearProjectCreateInput
|
||||
} from '../linear/projects'
|
||||
import {
|
||||
getTeamLabels as getLinearTeamLabels,
|
||||
@@ -13892,6 +13894,13 @@ export class OrcaRuntimeService {
|
||||
return listLinearProjects(query, Math.min(Math.max(1, limit), 50), workspaceId, force)
|
||||
}
|
||||
|
||||
linearCreateProject(
|
||||
input: LinearProjectCreateInput,
|
||||
workspaceId?: string
|
||||
): ReturnType<typeof createLinearProject> {
|
||||
return createLinearProject(input, workspaceId)
|
||||
}
|
||||
|
||||
linearGetProject(
|
||||
id: string,
|
||||
workspaceId: string,
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { z } from 'zod'
|
||||
import { defineMethod, type RpcMethod } from '../core'
|
||||
import { OptionalString, requiredString } from '../schemas'
|
||||
|
||||
const LinearPriority = z.number().int().min(0).max(4).optional()
|
||||
const LinearLabelIds = z.array(requiredString('Invalid label ID')).optional()
|
||||
|
||||
const CreateProject = z.object({
|
||||
name: requiredString('Project name is required'),
|
||||
description: OptionalString,
|
||||
content: OptionalString,
|
||||
workspaceId: OptionalString,
|
||||
teamIds: z.array(requiredString('Invalid team ID')).min(1, 'At least one team is required'),
|
||||
leadId: z.union([z.string(), z.null()]).optional(),
|
||||
memberIds: z.array(requiredString('Invalid member ID')).optional(),
|
||||
labelIds: LinearLabelIds,
|
||||
priority: LinearPriority,
|
||||
startDate: OptionalString,
|
||||
targetDate: OptionalString
|
||||
})
|
||||
|
||||
export const LINEAR_PROJECT_CREATE_METHOD: RpcMethod = defineMethod({
|
||||
name: 'linear.createProject',
|
||||
params: CreateProject,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.linearCreateProject(
|
||||
{
|
||||
name: params.name.trim(),
|
||||
description: params.description?.trim() || undefined,
|
||||
content: params.content?.trim() || undefined,
|
||||
teamIds: params.teamIds.map((id) => id.trim()),
|
||||
leadId: params.leadId ? params.leadId.trim() : undefined,
|
||||
memberIds: params.memberIds?.map((id) => id.trim()),
|
||||
labelIds: params.labelIds?.map((id) => id.trim()),
|
||||
priority: params.priority,
|
||||
startDate: params.startDate?.trim() || undefined,
|
||||
targetDate: params.targetDate?.trim() || undefined
|
||||
},
|
||||
params.workspaceId
|
||||
)
|
||||
})
|
||||
@@ -163,6 +163,7 @@ describe('linear RPC methods', () => {
|
||||
linearListProjectIssues: vi.fn().mockResolvedValue({ items: [{ id: 'issue-2' }] }),
|
||||
linearListTeams: vi.fn().mockResolvedValue([{ id: 'team-1' }]),
|
||||
linearListProjects: vi.fn().mockResolvedValue({ items: [{ id: 'project-1' }] }),
|
||||
linearCreateProject: vi.fn().mockResolvedValue({ ok: true, project: { id: 'project-3' } }),
|
||||
linearTeamStates: vi.fn().mockResolvedValue([{ id: 'state-1' }]),
|
||||
linearTeamLabels: vi.fn().mockResolvedValue([{ id: 'label-1' }]),
|
||||
linearTeamMembers: vi.fn().mockResolvedValue([{ id: 'member-1' }])
|
||||
@@ -173,6 +174,21 @@ describe('linear RPC methods', () => {
|
||||
await dispatcher.dispatch(
|
||||
makeRequest('linear.listProjects', { query: 'roadmap', limit: 5, force: true })
|
||||
)
|
||||
await dispatcher.dispatch(
|
||||
makeRequest('linear.createProject', {
|
||||
name: 'Roadmap',
|
||||
description: 'Summary',
|
||||
content: 'Brief',
|
||||
teamIds: ['team-1'],
|
||||
workspaceId: 'workspace-1',
|
||||
leadId: 'user-1',
|
||||
memberIds: ['user-1', 'user-2'],
|
||||
labelIds: ['label-1'],
|
||||
priority: 2,
|
||||
startDate: '2026-07-01',
|
||||
targetDate: '2026-08-01'
|
||||
})
|
||||
)
|
||||
await dispatcher.dispatch(
|
||||
makeRequest('linear.getProject', {
|
||||
id: 'project-1',
|
||||
@@ -232,6 +248,21 @@ describe('linear RPC methods', () => {
|
||||
|
||||
expect(runtime.linearListTeams).toHaveBeenCalledWith('all')
|
||||
expect(runtime.linearListProjects).toHaveBeenCalledWith('roadmap', 5, undefined, true)
|
||||
expect(runtime.linearCreateProject).toHaveBeenCalledWith(
|
||||
{
|
||||
name: 'Roadmap',
|
||||
description: 'Summary',
|
||||
content: 'Brief',
|
||||
teamIds: ['team-1'],
|
||||
leadId: 'user-1',
|
||||
memberIds: ['user-1', 'user-2'],
|
||||
labelIds: ['label-1'],
|
||||
priority: 2,
|
||||
startDate: '2026-07-01',
|
||||
targetDate: '2026-08-01'
|
||||
},
|
||||
'workspace-1'
|
||||
)
|
||||
expect(runtime.linearGetProject).toHaveBeenCalledWith('project-1', 'workspace-1', true)
|
||||
expect(runtime.linearListProjectIssues).toHaveBeenCalledWith(
|
||||
'project-1',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { z } from 'zod'
|
||||
import { defineMethod, type RpcMethod } from '../core'
|
||||
import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas'
|
||||
import { LINEAR_PROJECT_CREATE_METHOD } from './linear-project-create'
|
||||
|
||||
const VALID_FILTERS = ['assigned', 'created', 'all', 'completed'] as const
|
||||
const VALID_CUSTOM_VIEW_MODELS = ['issue', 'project'] as const
|
||||
@@ -222,6 +223,7 @@ export const LINEAR_METHODS: RpcMethod[] = [
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.linearListProjects(params?.query, params?.limit, params?.workspaceId, params?.force)
|
||||
}),
|
||||
LINEAR_PROJECT_CREATE_METHOD,
|
||||
defineMethod({
|
||||
name: 'linear.getProject',
|
||||
params: ProjectId,
|
||||
|
||||
@@ -227,6 +227,7 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([
|
||||
'linear.addIssueComment',
|
||||
'linear.connect',
|
||||
'linear.createIssue',
|
||||
'linear.createProject',
|
||||
'linear.issueComments',
|
||||
'linear.listCustomViewIssues',
|
||||
'linear.listCustomViewProjects',
|
||||
|
||||
@@ -1433,6 +1433,19 @@ export type PreloadApi = {
|
||||
workspaceId?: LinearWorkspaceSelection
|
||||
force?: boolean
|
||||
}) => Promise<LinearCollectionResult<LinearProjectSummary>>
|
||||
createProject: (args: {
|
||||
name: string
|
||||
description?: string
|
||||
content?: string
|
||||
teamIds: string[]
|
||||
workspaceId?: string
|
||||
leadId?: string | null
|
||||
memberIds?: string[]
|
||||
labelIds?: string[]
|
||||
priority?: number
|
||||
startDate?: string
|
||||
targetDate?: string
|
||||
}) => Promise<{ ok: true; project: LinearProjectDetail } | { ok: false; error: string }>
|
||||
getProject: (args: {
|
||||
id: string
|
||||
workspaceId: string
|
||||
|
||||
@@ -26,6 +26,7 @@ import type {
|
||||
GitUpstreamStatus,
|
||||
GhosttyImportPreview,
|
||||
ListWorkItemsResult,
|
||||
LinearProjectDetail,
|
||||
MemorySnapshot,
|
||||
NotificationDismissResult,
|
||||
NotificationDispatchResult,
|
||||
@@ -1282,6 +1283,21 @@ const api = {
|
||||
force?: boolean
|
||||
}): Promise<unknown> => ipcRenderer.invoke('linear:listProjects', args),
|
||||
|
||||
createProject: (args: {
|
||||
name: string
|
||||
description?: string
|
||||
content?: string
|
||||
teamIds: string[]
|
||||
workspaceId?: string
|
||||
leadId?: string | null
|
||||
memberIds?: string[]
|
||||
labelIds?: string[]
|
||||
priority?: number
|
||||
startDate?: string
|
||||
targetDate?: string
|
||||
}): Promise<{ ok: true; project: LinearProjectDetail } | { ok: false; error: string }> =>
|
||||
ipcRenderer.invoke('linear:createProject', args),
|
||||
|
||||
getProject: (args: { id: string; workspaceId: string; force?: boolean }): Promise<unknown> =>
|
||||
ipcRenderer.invoke('linear:getProject', args),
|
||||
|
||||
|
||||
@@ -203,6 +203,7 @@ import {
|
||||
useTeamLabels
|
||||
} from '@/hooks/useIssueMetadata'
|
||||
import {
|
||||
linearCreateProject,
|
||||
linearCreateIssue,
|
||||
linearGetIssue,
|
||||
linearTeamStates,
|
||||
@@ -498,10 +499,7 @@ const LINEAR_MODE_OPTIONS: { id: LinearMode; label: string }[] = [
|
||||
{ id: 'views', label: 'Views' }
|
||||
]
|
||||
|
||||
const LINEAR_CUSTOM_VIEW_MODEL_OPTIONS: { id: LinearCustomViewModel; label: string }[] = [
|
||||
{ id: 'issue', label: 'Issues' },
|
||||
{ id: 'project', label: 'Projects' }
|
||||
]
|
||||
const LINEAR_CUSTOM_VIEW_MODELS = ['issue', 'project'] satisfies readonly LinearCustomViewModel[]
|
||||
|
||||
const LINEAR_VIEW_OPTIONS: {
|
||||
id: LinearViewMode
|
||||
@@ -512,6 +510,17 @@ const LINEAR_VIEW_OPTIONS: {
|
||||
{ id: 'board', label: 'Board', Icon: LayoutGrid }
|
||||
]
|
||||
|
||||
function mergeLinearCollectionResults<T>(
|
||||
results: LinearCollectionResult<T>[]
|
||||
): LinearCollectionResult<T> {
|
||||
const errors = results.flatMap((result) => result.errors ?? [])
|
||||
return {
|
||||
items: results.flatMap((result) => result.items),
|
||||
...(errors.length > 0 ? { errors } : {}),
|
||||
...(results.some((result) => result.hasMore) ? { hasMore: true } : {})
|
||||
}
|
||||
}
|
||||
|
||||
const LINEAR_GROUP_OPTIONS: { id: LinearGroupBy; label: string }[] = [
|
||||
{ id: 'none', label: 'No grouping' },
|
||||
{ id: 'status', label: 'Status' },
|
||||
@@ -3200,7 +3209,6 @@ export default function TaskPage(): React.JSX.Element {
|
||||
>(null)
|
||||
const [linearProjectIssuesLoading, setLinearProjectIssuesLoading] = useState(false)
|
||||
const [linearProjectIssuesError, setLinearProjectIssuesError] = useState<string | null>(null)
|
||||
const [linearCustomViewModel, setLinearCustomViewModel] = useState<LinearCustomViewModel>('issue')
|
||||
const [linearCustomViewsResult, setLinearCustomViewsResult] = useState<
|
||||
LinearCollectionResult<LinearCustomViewSummary>
|
||||
>({ items: [] })
|
||||
@@ -3442,7 +3450,6 @@ export default function TaskPage(): React.JSX.Element {
|
||||
}
|
||||
|
||||
if (context.kind === 'view' && context.model) {
|
||||
setLinearCustomViewModel(context.model)
|
||||
setLinearMode('views')
|
||||
setLinearCustomViewsLoading(true)
|
||||
setLinearCustomViewsError(null)
|
||||
@@ -4246,6 +4253,41 @@ export default function TaskPage(): React.JSX.Element {
|
||||
[jiraIssues, jiraCacheSnapshot.issueCache, jiraCacheSnapshot.searchCache]
|
||||
)
|
||||
|
||||
// New Linear project dialog state
|
||||
const [newLinearProjectOpen, setNewLinearProjectOpen] = useState(false)
|
||||
const [newLinearProjectName, setNewLinearProjectName] = useState('')
|
||||
const [newLinearProjectDescription, setNewLinearProjectDescription] = useState('')
|
||||
const [newLinearProjectContent, setNewLinearProjectContent] = useState('')
|
||||
const [newLinearProjectTeamId, setNewLinearProjectTeamId] = useState<string | null>(null)
|
||||
const [newLinearProjectLeadId, setNewLinearProjectLeadId] = useState<string | null>(null)
|
||||
const [newLinearProjectMemberIds, setNewLinearProjectMemberIds] = useState<string[]>([])
|
||||
const [newLinearProjectLabelIds, setNewLinearProjectLabelIds] = useState<string[]>([])
|
||||
const [newLinearProjectPriority, setNewLinearProjectPriority] = useState<number>(0)
|
||||
const [newLinearProjectStartDate, setNewLinearProjectStartDate] = useState('')
|
||||
const [newLinearProjectTargetDate, setNewLinearProjectTargetDate] = useState('')
|
||||
const [newLinearProjectSubmitting, setNewLinearProjectSubmitting] = useState(false)
|
||||
|
||||
const newLinearProjectTargetTeam = useMemo(
|
||||
() => availableTeams.find((t) => t.id === newLinearProjectTeamId) ?? availableTeams[0] ?? null,
|
||||
[availableTeams, newLinearProjectTeamId]
|
||||
)
|
||||
const newLinearProjectMembers = useTeamMembers(
|
||||
newLinearProjectOpen ? (newLinearProjectTargetTeam?.id ?? null) : null,
|
||||
settings,
|
||||
newLinearProjectTargetTeam?.workspaceId
|
||||
)
|
||||
const newLinearProjectLabels = useTeamLabels(
|
||||
newLinearProjectOpen ? (newLinearProjectTargetTeam?.id ?? null) : null,
|
||||
settings,
|
||||
newLinearProjectTargetTeam?.workspaceId
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
setNewLinearProjectLeadId(null)
|
||||
setNewLinearProjectMemberIds([])
|
||||
setNewLinearProjectLabelIds([])
|
||||
}, [newLinearProjectTargetTeam?.id, newLinearProjectTargetTeam?.workspaceId])
|
||||
|
||||
// New Linear issue dialog state
|
||||
const [newLinearIssueOpen, setNewLinearIssueOpen] = useState(false)
|
||||
const [newLinearIssueTitle, setNewLinearIssueTitle] = useState('')
|
||||
@@ -4347,6 +4389,7 @@ export default function TaskPage(): React.JSX.Element {
|
||||
!gitlabDialogItem &&
|
||||
!selectedLinearIssue &&
|
||||
!newIssueOpen &&
|
||||
!newLinearProjectOpen &&
|
||||
!newLinearIssueOpen &&
|
||||
!linearConnectOpen &&
|
||||
activeModal === 'none',
|
||||
@@ -5112,6 +5155,7 @@ export default function TaskPage(): React.JSX.Element {
|
||||
githubMode !== 'items' ||
|
||||
dialogWorkItem ||
|
||||
newIssueOpen ||
|
||||
newLinearProjectOpen ||
|
||||
newLinearIssueOpen ||
|
||||
newJiraIssueOpen ||
|
||||
activeModal !== 'none'
|
||||
@@ -5154,6 +5198,7 @@ export default function TaskPage(): React.JSX.Element {
|
||||
dialogWorkItem,
|
||||
githubMode,
|
||||
newIssueOpen,
|
||||
newLinearProjectOpen,
|
||||
newLinearIssueOpen,
|
||||
newJiraIssueOpen,
|
||||
taskSource
|
||||
@@ -5350,6 +5395,81 @@ export default function TaskPage(): React.JSX.Element {
|
||||
setDialogWorkItem
|
||||
])
|
||||
|
||||
const handleCreateNewLinearProject = useCallback(async (): Promise<void> => {
|
||||
if (!newLinearProjectTargetTeam) {
|
||||
return
|
||||
}
|
||||
const name = newLinearProjectName.trim()
|
||||
if (!name || newLinearProjectSubmitting) {
|
||||
return
|
||||
}
|
||||
setNewLinearProjectSubmitting(true)
|
||||
try {
|
||||
const result = await linearCreateProject(settings, {
|
||||
name,
|
||||
description: newLinearProjectDescription.trim() || undefined,
|
||||
content: newLinearProjectContent.trim() || undefined,
|
||||
teamIds: [newLinearProjectTargetTeam.id],
|
||||
workspaceId: newLinearProjectTargetTeam.workspaceId,
|
||||
leadId: newLinearProjectLeadId || undefined,
|
||||
memberIds: newLinearProjectMemberIds.length > 0 ? newLinearProjectMemberIds : undefined,
|
||||
labelIds: newLinearProjectLabelIds.length > 0 ? newLinearProjectLabelIds : undefined,
|
||||
priority: newLinearProjectPriority,
|
||||
startDate: newLinearProjectStartDate || undefined,
|
||||
targetDate: newLinearProjectTargetDate || undefined
|
||||
})
|
||||
if (!result.ok) {
|
||||
toast.error(result.error || 'Failed to create project.')
|
||||
return
|
||||
}
|
||||
toast.success(`Created ${result.project.name}`, {
|
||||
action: result.project.url
|
||||
? {
|
||||
label: 'View',
|
||||
onClick: () => window.open(result.project.url, '_blank')
|
||||
}
|
||||
: undefined
|
||||
})
|
||||
setNewLinearProjectOpen(false)
|
||||
setNewLinearProjectName('')
|
||||
setNewLinearProjectDescription('')
|
||||
setNewLinearProjectContent('')
|
||||
setNewLinearProjectLeadId(null)
|
||||
setNewLinearProjectMemberIds([])
|
||||
setNewLinearProjectLabelIds([])
|
||||
setNewLinearProjectPriority(0)
|
||||
setNewLinearProjectStartDate('')
|
||||
setNewLinearProjectTargetDate('')
|
||||
setAppliedLinearProjectSearch('')
|
||||
setLinearProjectSearchInput('')
|
||||
setLinearProjectsResult((current) => ({
|
||||
...current,
|
||||
items: [result.project, ...current.items.filter((item) => item.id !== result.project.id)]
|
||||
}))
|
||||
setSelectedLinearProjectDetail(result.project)
|
||||
openLinearProjectContext(result.project)
|
||||
setLinearRefreshNonce((n) => n + 1)
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to create project.')
|
||||
} finally {
|
||||
setNewLinearProjectSubmitting(false)
|
||||
}
|
||||
}, [
|
||||
newLinearProjectContent,
|
||||
newLinearProjectDescription,
|
||||
newLinearProjectLabelIds,
|
||||
newLinearProjectLeadId,
|
||||
newLinearProjectMemberIds,
|
||||
newLinearProjectName,
|
||||
newLinearProjectPriority,
|
||||
newLinearProjectStartDate,
|
||||
newLinearProjectSubmitting,
|
||||
newLinearProjectTargetDate,
|
||||
newLinearProjectTargetTeam,
|
||||
openLinearProjectContext,
|
||||
settings
|
||||
])
|
||||
|
||||
const handleCreateNewLinearIssue = useCallback(async (): Promise<void> => {
|
||||
if (!newLinearIssueTargetTeam) {
|
||||
return
|
||||
@@ -5884,17 +6004,28 @@ export default function TaskPage(): React.JSX.Element {
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
const cached = getCachedLinearCustomViews(linearCustomViewModel, LINEAR_ITEM_LIMIT)
|
||||
if (cached) {
|
||||
setLinearCustomViewsResult(cached)
|
||||
const cachedResults = LINEAR_CUSTOM_VIEW_MODELS.map((model) =>
|
||||
getCachedLinearCustomViews(model, LINEAR_ITEM_LIMIT)
|
||||
)
|
||||
const allCached = cachedResults.every(
|
||||
(result): result is LinearCollectionResult<LinearCustomViewSummary> => result !== null
|
||||
)
|
||||
if (allCached) {
|
||||
setLinearCustomViewsResult(mergeLinearCollectionResults(cachedResults))
|
||||
}
|
||||
const force = linearRefreshNonce > 0
|
||||
setLinearCustomViewsLoading(force || cached === null)
|
||||
setLinearCustomViewsLoading(force || !allCached)
|
||||
setLinearCustomViewsError(null)
|
||||
void listLinearCustomViews(linearCustomViewModel, LINEAR_ITEM_LIMIT, undefined, { force })
|
||||
// Why: the Views tab already has a Model column, so listing both view
|
||||
// models avoids a second, redundant Issues/Projects switch.
|
||||
void Promise.all(
|
||||
LINEAR_CUSTOM_VIEW_MODELS.map((model) =>
|
||||
listLinearCustomViews(model, LINEAR_ITEM_LIMIT, undefined, { force })
|
||||
)
|
||||
)
|
||||
.then((result) => {
|
||||
if (!cancelled) {
|
||||
setLinearCustomViewsResult(result)
|
||||
setLinearCustomViewsResult(mergeLinearCollectionResults(result))
|
||||
setLinearCustomViewsLoading(false)
|
||||
}
|
||||
})
|
||||
@@ -5917,9 +6048,9 @@ export default function TaskPage(): React.JSX.Element {
|
||||
linearStatus.connected,
|
||||
selectedLinearWorkspaceId,
|
||||
selectedLinearCustomView,
|
||||
linearCustomViewModel,
|
||||
linearRefreshNonce,
|
||||
getCachedLinearCustomViews
|
||||
getCachedLinearCustomViews,
|
||||
listLinearCustomViews
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -6755,6 +6886,20 @@ export default function TaskPage(): React.JSX.Element {
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
if (linearMode === 'projects' && !selectedLinearProject) {
|
||||
setNewLinearProjectName('')
|
||||
setNewLinearProjectDescription('')
|
||||
setNewLinearProjectContent('')
|
||||
setNewLinearProjectTeamId(availableTeams[0]?.id ?? null)
|
||||
setNewLinearProjectLeadId(null)
|
||||
setNewLinearProjectMemberIds([])
|
||||
setNewLinearProjectLabelIds([])
|
||||
setNewLinearProjectPriority(0)
|
||||
setNewLinearProjectStartDate('')
|
||||
setNewLinearProjectTargetDate('')
|
||||
setNewLinearProjectOpen(true)
|
||||
return
|
||||
}
|
||||
setNewLinearIssueTitle('')
|
||||
setNewLinearIssueBody('')
|
||||
const projectTeamId =
|
||||
@@ -6770,14 +6915,20 @@ export default function TaskPage(): React.JSX.Element {
|
||||
setNewLinearIssueOpen(true)
|
||||
}}
|
||||
disabled={availableTeams.length === 0}
|
||||
aria-label="New Linear issue"
|
||||
aria-label={
|
||||
linearMode === 'projects' && !selectedLinearProject
|
||||
? 'New Linear project'
|
||||
: 'New Linear issue'
|
||||
}
|
||||
className="size-8 border-border/50 bg-transparent hover:bg-muted/50 backdrop-blur-md supports-[backdrop-filter]:bg-transparent"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
New Linear issue
|
||||
{linearMode === 'projects' && !selectedLinearProject
|
||||
? 'New Linear project'
|
||||
: 'New Linear issue'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
@@ -6890,38 +7041,6 @@ export default function TaskPage(): React.JSX.Element {
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : linearMode === 'views' && !selectedLinearCustomView ? (
|
||||
<div
|
||||
className="mt-3 flex min-w-0 flex-wrap items-center gap-2"
|
||||
role="group"
|
||||
aria-label="Linear view type"
|
||||
>
|
||||
{LINEAR_CUSTOM_VIEW_MODEL_OPTIONS.map((option) => {
|
||||
const active = option.id === linearCustomViewModel
|
||||
return (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
onClick={() => {
|
||||
setSelectedLinearCustomView(null)
|
||||
setLinearCustomViewModel(option.id)
|
||||
setLinearCustomViewsResult({ items: [] })
|
||||
setLinearCustomViewsError(null)
|
||||
setLinearRefreshNonce((n) => n + 1)
|
||||
}}
|
||||
className={cn(
|
||||
'rounded-md border px-2 py-1 text-xs transition',
|
||||
active
|
||||
? 'border-border/50 bg-foreground/90 text-background backdrop-blur-md'
|
||||
: 'border-border/50 bg-transparent text-foreground hover:bg-muted/50'
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : taskSource === 'jira' && jiraStatus.connected ? (
|
||||
@@ -8256,7 +8375,6 @@ export default function TaskPage(): React.JSX.Element {
|
||||
) : null}
|
||||
<LinearCustomViewTable
|
||||
views={linearCustomViewsResult.items}
|
||||
model={linearCustomViewModel}
|
||||
loading={linearCustomViewsLoading}
|
||||
hasError={!!linearCustomViewsResult.errors?.length}
|
||||
workspaceSelection={selectedLinearWorkspaceId}
|
||||
@@ -8272,7 +8390,7 @@ export default function TaskPage(): React.JSX.Element {
|
||||
errors={linearCustomViewsResult.errors}
|
||||
hasMore={linearCustomViewsResult.hasMore}
|
||||
count={linearCustomViewsResult.items.length}
|
||||
label={`${linearCustomViewModel} views`}
|
||||
label="views"
|
||||
/>
|
||||
</div>
|
||||
) : selectedLinearCustomView?.model === 'project' && !selectedLinearProject ? (
|
||||
@@ -9166,6 +9284,440 @@ export default function TaskPage(): React.JSX.Element {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={newLinearProjectOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!newLinearProjectSubmitting) {
|
||||
setNewLinearProjectOpen(open)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className="flex max-h-[88vh] flex-col gap-0 overflow-hidden rounded-xl border-border bg-background p-0 shadow-2xl sm:max-w-3xl"
|
||||
onKeyDown={(event) => {
|
||||
if (isScreenSubmitShortcut(event)) {
|
||||
event.preventDefault()
|
||||
void handleCreateNewLinearProject()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTitle className="sr-only">New Linear project</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
Create a Linear project for the selected team.
|
||||
</DialogDescription>
|
||||
<div className="flex items-center justify-between border-b border-border/60 bg-muted/10 px-5 py-3">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
New Project
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground/40">/</span>
|
||||
{availableTeams.length > 1 ? (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="h-7 max-w-56 gap-1 px-2 text-xs font-medium text-foreground hover:bg-muted"
|
||||
>
|
||||
<span className="truncate">
|
||||
{newLinearProjectTargetTeam
|
||||
? `${newLinearProjectTargetTeam.key} - ${newLinearProjectTargetTeam.name}`
|
||||
: 'Select team'}
|
||||
</span>
|
||||
<ChevronDown className="size-3 flex-none text-muted-foreground" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-72 p-1">
|
||||
<div className="px-2 py-1.5 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Team
|
||||
</div>
|
||||
<div className="max-h-64 overflow-y-auto scrollbar-sleek">
|
||||
{availableTeams.map((team) => (
|
||||
<button
|
||||
key={team.id}
|
||||
type="button"
|
||||
onClick={() => setNewLinearProjectTeamId(team.id)}
|
||||
className={cn(
|
||||
'flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-left text-xs transition-colors hover:bg-muted',
|
||||
newLinearProjectTargetTeam?.id === team.id
|
||||
? 'bg-muted font-medium text-foreground'
|
||||
: 'text-foreground/80'
|
||||
)}
|
||||
>
|
||||
<span className="truncate">
|
||||
{team.key} - {team.name}
|
||||
</span>
|
||||
{newLinearProjectTargetTeam?.id === team.id ? (
|
||||
<Check className="size-3 flex-none" />
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : (
|
||||
<span className="truncate text-xs font-medium text-foreground">
|
||||
{newLinearProjectTargetTeam
|
||||
? `${newLinearProjectTargetTeam.key} - ${newLinearProjectTargetTeam.name}`
|
||||
: ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setNewLinearProjectOpen(false)}
|
||||
className="rounded-md p-1 text-muted-foreground transition-colors hover:text-foreground"
|
||||
disabled={newLinearProjectSubmitting}
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-6 py-5 scrollbar-sleek">
|
||||
<input
|
||||
autoFocus
|
||||
value={newLinearProjectName}
|
||||
onChange={(event) => setNewLinearProjectName(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && !event.nativeEvent.isComposing) {
|
||||
event.preventDefault()
|
||||
void handleCreateNewLinearProject()
|
||||
}
|
||||
}}
|
||||
placeholder="Project name"
|
||||
disabled={newLinearProjectSubmitting}
|
||||
className="w-full border-none bg-transparent p-0 text-xl font-semibold text-foreground outline-none placeholder:text-muted-foreground/45 focus:outline-none focus:ring-0 focus-visible:ring-0"
|
||||
/>
|
||||
|
||||
<input
|
||||
value={newLinearProjectDescription}
|
||||
onChange={(event) => setNewLinearProjectDescription(event.target.value)}
|
||||
placeholder="Add a short summary..."
|
||||
disabled={newLinearProjectSubmitting}
|
||||
className="w-full border-none bg-transparent p-0 text-sm text-foreground outline-none placeholder:text-muted-foreground/45 focus:outline-none focus:ring-0 focus-visible:ring-0"
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
disabled={newLinearProjectSubmitting}
|
||||
className="flex items-center gap-1.5 rounded-md border border-border/80 bg-muted/15 px-2 py-1 text-xs text-foreground/80 transition-colors hover:bg-muted/50 active:bg-muted disabled:opacity-50"
|
||||
>
|
||||
<LinearPriorityIcon priority={newLinearProjectPriority} className="size-3.5" />
|
||||
<span>{getLinearPriorityLabel(newLinearProjectPriority)}</span>
|
||||
<ChevronDown className="size-3 text-muted-foreground/70" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-48 p-1">
|
||||
<div className="px-2 py-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Priority
|
||||
</div>
|
||||
{[0, 1, 2, 3, 4].map((priority) => (
|
||||
<button
|
||||
key={priority}
|
||||
type="button"
|
||||
onClick={() => setNewLinearProjectPriority(priority)}
|
||||
className={cn(
|
||||
'flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-left text-xs transition-colors hover:bg-muted',
|
||||
newLinearProjectPriority === priority
|
||||
? 'bg-muted font-medium text-foreground'
|
||||
: 'text-foreground/80'
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<LinearPriorityIcon priority={priority} className="size-3.5" />
|
||||
{getLinearPriorityLabel(priority)}
|
||||
</span>
|
||||
{newLinearProjectPriority === priority ? (
|
||||
<Check className="size-3 text-foreground" />
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
disabled={newLinearProjectSubmitting}
|
||||
className="flex items-center gap-1.5 rounded-md border border-border/80 bg-muted/15 px-2 py-1 text-xs text-foreground/80 transition-colors hover:bg-muted/50 active:bg-muted disabled:opacity-50"
|
||||
>
|
||||
<UserRound className="size-3.5 text-muted-foreground/70" />
|
||||
<span className="max-w-[120px] truncate">
|
||||
{newLinearProjectMembers.data.find(
|
||||
(member) => member.id === newLinearProjectLeadId
|
||||
)?.displayName ?? 'Lead'}
|
||||
</span>
|
||||
<ChevronDown className="size-3 text-muted-foreground/70" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-64 p-1">
|
||||
<div className="px-2 py-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Lead
|
||||
</div>
|
||||
{newLinearProjectMembers.loading ? (
|
||||
<div className="flex items-center justify-center p-4">
|
||||
<LoaderCircle className="size-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-h-64 overflow-y-auto scrollbar-sleek">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setNewLinearProjectLeadId(null)}
|
||||
className={cn(
|
||||
'flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-left text-xs transition-colors hover:bg-muted',
|
||||
newLinearProjectLeadId === null
|
||||
? 'bg-muted font-medium text-foreground'
|
||||
: 'text-foreground/80'
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<UserRound className="size-3.5 text-muted-foreground/50" />
|
||||
No lead
|
||||
</span>
|
||||
{newLinearProjectLeadId === null ? <Check className="size-3" /> : null}
|
||||
</button>
|
||||
{newLinearProjectMembers.data.map((member) => (
|
||||
<button
|
||||
key={member.id}
|
||||
type="button"
|
||||
onClick={() => setNewLinearProjectLeadId(member.id)}
|
||||
className={cn(
|
||||
'flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-left text-xs transition-colors hover:bg-muted',
|
||||
newLinearProjectLeadId === member.id
|
||||
? 'bg-muted font-medium text-foreground'
|
||||
: 'text-foreground/80'
|
||||
)}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
{member.avatarUrl ? (
|
||||
<img
|
||||
src={member.avatarUrl}
|
||||
alt={member.displayName}
|
||||
className="size-3.5 flex-none rounded-full"
|
||||
/>
|
||||
) : (
|
||||
<UserRound className="size-3.5 flex-none text-muted-foreground/70" />
|
||||
)}
|
||||
<span className="truncate">{member.displayName}</span>
|
||||
</span>
|
||||
{newLinearProjectLeadId === member.id ? (
|
||||
<Check className="size-3 flex-none" />
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
disabled={newLinearProjectSubmitting}
|
||||
className="flex items-center gap-1.5 rounded-md border border-border/80 bg-muted/15 px-2 py-1 text-xs text-foreground/80 transition-colors hover:bg-muted/50 active:bg-muted disabled:opacity-50"
|
||||
>
|
||||
<Users className="size-3.5 text-muted-foreground/70" />
|
||||
<span>
|
||||
{newLinearProjectMemberIds.length === 0
|
||||
? 'Members'
|
||||
: `${newLinearProjectMemberIds.length} member${newLinearProjectMemberIds.length > 1 ? 's' : ''}`}
|
||||
</span>
|
||||
<ChevronDown className="size-3 text-muted-foreground/70" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-64 p-1">
|
||||
<div className="px-2 py-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Members
|
||||
</div>
|
||||
{newLinearProjectMembers.loading ? (
|
||||
<div className="flex items-center justify-center p-4">
|
||||
<LoaderCircle className="size-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-h-64 overflow-y-auto scrollbar-sleek">
|
||||
{newLinearProjectMembers.data.map((member) => {
|
||||
const selected = newLinearProjectMemberIds.includes(member.id)
|
||||
return (
|
||||
<button
|
||||
key={member.id}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setNewLinearProjectMemberIds((current) =>
|
||||
selected
|
||||
? current.filter((id) => id !== member.id)
|
||||
: [...current, member.id]
|
||||
)
|
||||
}
|
||||
className={cn(
|
||||
'flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-left text-xs transition-colors hover:bg-muted',
|
||||
selected
|
||||
? 'bg-muted font-medium text-foreground'
|
||||
: 'text-foreground/80'
|
||||
)}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
{member.avatarUrl ? (
|
||||
<img
|
||||
src={member.avatarUrl}
|
||||
alt={member.displayName}
|
||||
className="size-3.5 flex-none rounded-full"
|
||||
/>
|
||||
) : (
|
||||
<UserRound className="size-3.5 flex-none text-muted-foreground/70" />
|
||||
)}
|
||||
<span className="truncate">{member.displayName}</span>
|
||||
</span>
|
||||
{selected ? <Check className="size-3 flex-none" /> : null}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
disabled={newLinearProjectSubmitting}
|
||||
className="flex items-center gap-1.5 rounded-md border border-border/80 bg-muted/15 px-2 py-1 text-xs text-foreground/80 transition-colors hover:bg-muted/50 active:bg-muted disabled:opacity-50"
|
||||
>
|
||||
<Tag className="size-3.5 text-muted-foreground/70" />
|
||||
<span>
|
||||
{newLinearProjectLabelIds.length === 0
|
||||
? 'Labels'
|
||||
: `${newLinearProjectLabelIds.length} label${newLinearProjectLabelIds.length > 1 ? 's' : ''}`}
|
||||
</span>
|
||||
<ChevronDown className="size-3 text-muted-foreground/70" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-64 p-1">
|
||||
<div className="px-2 py-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Labels
|
||||
</div>
|
||||
{newLinearProjectLabels.loading ? (
|
||||
<div className="flex items-center justify-center p-4">
|
||||
<LoaderCircle className="size-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-h-64 overflow-y-auto scrollbar-sleek">
|
||||
{newLinearProjectLabels.data.length === 0 ? (
|
||||
<div className="px-2 py-2 text-xs text-muted-foreground">No labels</div>
|
||||
) : (
|
||||
newLinearProjectLabels.data.map((label) => {
|
||||
const selected = newLinearProjectLabelIds.includes(label.id)
|
||||
return (
|
||||
<button
|
||||
key={label.id}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setNewLinearProjectLabelIds((current) =>
|
||||
selected
|
||||
? current.filter((id) => id !== label.id)
|
||||
: [...current, label.id]
|
||||
)
|
||||
}
|
||||
className={cn(
|
||||
'flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-left text-xs transition-colors hover:bg-muted',
|
||||
selected
|
||||
? 'bg-muted font-medium text-foreground'
|
||||
: 'text-foreground/80'
|
||||
)}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
className="size-2 flex-none rounded-full bg-muted-foreground/40"
|
||||
style={label.color ? { backgroundColor: label.color } : undefined}
|
||||
/>
|
||||
<span className="truncate">{label.name}</span>
|
||||
</span>
|
||||
{selected ? <Check className="size-3 flex-none" /> : null}
|
||||
</button>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<label className="flex cursor-pointer items-center gap-1.5 rounded-md border border-border bg-muted/30 px-2 py-1 text-xs text-foreground transition-colors hover:bg-muted/50 has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-50">
|
||||
<Clock3 className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="shrink-0 text-muted-foreground">Start</span>
|
||||
<input
|
||||
type="date"
|
||||
value={newLinearProjectStartDate}
|
||||
onChange={(event) => setNewLinearProjectStartDate(event.target.value)}
|
||||
disabled={newLinearProjectSubmitting}
|
||||
className="h-5 min-w-[6.75rem] cursor-pointer border-none bg-transparent p-0 text-xs text-foreground outline-none disabled:cursor-not-allowed"
|
||||
aria-label="Start date"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex cursor-pointer items-center gap-1.5 rounded-md border border-border bg-muted/30 px-2 py-1 text-xs text-foreground transition-colors hover:bg-muted/50 has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-50">
|
||||
<Clock3 className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="shrink-0 text-muted-foreground">Target</span>
|
||||
<input
|
||||
type="date"
|
||||
value={newLinearProjectTargetDate}
|
||||
onChange={(event) => setNewLinearProjectTargetDate(event.target.value)}
|
||||
disabled={newLinearProjectSubmitting}
|
||||
className="h-5 min-w-[6.75rem] cursor-pointer border-none bg-transparent p-0 text-xs text-foreground outline-none disabled:cursor-not-allowed"
|
||||
aria-label="Target date"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border/40 pt-4">
|
||||
<textarea
|
||||
value={newLinearProjectContent}
|
||||
onChange={(event) => setNewLinearProjectContent(event.target.value)}
|
||||
placeholder="Write a description, project brief, or collect ideas..."
|
||||
rows={8}
|
||||
disabled={newLinearProjectSubmitting}
|
||||
className="max-h-72 min-h-40 w-full min-w-0 resize-none overflow-y-auto border-none bg-transparent p-0 text-sm text-foreground outline-none placeholder:text-muted-foreground/45 scrollbar-sleek focus:outline-none focus:ring-0 focus-visible:ring-0"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground">{submitShortcutLabel} to submit.</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="border-t border-border/60 bg-muted/10 px-5 py-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setNewLinearProjectOpen(false)}
|
||||
disabled={newLinearProjectSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => void handleCreateNewLinearProject()}
|
||||
disabled={
|
||||
!newLinearProjectTargetTeam ||
|
||||
!newLinearProjectName.trim() ||
|
||||
newLinearProjectSubmitting
|
||||
}
|
||||
>
|
||||
{newLinearProjectSubmitting ? (
|
||||
<>
|
||||
<LoaderCircle className="size-4 animate-spin" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
'Create project'
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={newLinearIssueOpen}
|
||||
onOpenChange={(open) => {
|
||||
|
||||
@@ -20,7 +20,6 @@ import { Progress } from '@/components/ui/progress'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type {
|
||||
LinearCustomViewModel,
|
||||
LinearCustomViewSummary,
|
||||
LinearProjectDetail,
|
||||
LinearProjectSummary,
|
||||
@@ -56,7 +55,6 @@ type LinearProjectTableProps = {
|
||||
|
||||
type LinearCustomViewTableProps = {
|
||||
views: LinearCustomViewSummary[]
|
||||
model: LinearCustomViewModel
|
||||
loading: boolean
|
||||
hasError?: boolean
|
||||
selectedViewId?: string | null
|
||||
@@ -388,7 +386,6 @@ export function LinearProjectTable({
|
||||
|
||||
export function LinearCustomViewTable({
|
||||
views,
|
||||
model,
|
||||
loading,
|
||||
hasError,
|
||||
selectedViewId,
|
||||
@@ -420,7 +417,7 @@ export function LinearCustomViewTable({
|
||||
return (
|
||||
<div className="px-4 py-10 text-center">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{hasError ? `Unable to load ${model} views` : `No ${model} views found`}
|
||||
{hasError ? 'Unable to load views' : 'No views found'}
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{hasError
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
linearCreateIssue,
|
||||
linearCreateProject,
|
||||
linearCreateSubIssue,
|
||||
linearGetCustomView,
|
||||
linearGetProject,
|
||||
@@ -30,6 +31,7 @@ const linearStatusLocal = vi.fn()
|
||||
const linearSearchIssuesLocal = vi.fn()
|
||||
const linearListIssuesLocal = vi.fn()
|
||||
const linearCreateIssueLocal = vi.fn()
|
||||
const linearCreateProjectLocal = vi.fn()
|
||||
const linearUpdateIssueLocal = vi.fn()
|
||||
const linearListTeamsLocal = vi.fn()
|
||||
const linearListProjectsLocal = vi.fn()
|
||||
@@ -49,6 +51,7 @@ beforeEach(() => {
|
||||
linearSearchIssuesLocal.mockReset()
|
||||
linearListIssuesLocal.mockReset()
|
||||
linearCreateIssueLocal.mockReset()
|
||||
linearCreateProjectLocal.mockReset()
|
||||
linearUpdateIssueLocal.mockReset()
|
||||
linearListTeamsLocal.mockReset()
|
||||
linearListProjectsLocal.mockReset()
|
||||
@@ -70,6 +73,7 @@ beforeEach(() => {
|
||||
searchIssues: linearSearchIssuesLocal,
|
||||
listIssues: linearListIssuesLocal,
|
||||
createIssue: linearCreateIssueLocal,
|
||||
createProject: linearCreateProjectLocal,
|
||||
updateIssue: linearUpdateIssueLocal,
|
||||
listTeams: linearListTeamsLocal,
|
||||
listProjects: linearListProjectsLocal,
|
||||
@@ -97,6 +101,7 @@ describe('runtime linear client', () => {
|
||||
title: 'Child task',
|
||||
url: 'https://linear.app/ENG-2'
|
||||
})
|
||||
linearCreateProjectLocal.mockResolvedValue({ ok: true, project: { id: 'project-1' } })
|
||||
|
||||
await expect(linearStatus({ activeRuntimeEnvironmentId: null })).resolves.toEqual({
|
||||
connected: false,
|
||||
@@ -112,6 +117,10 @@ describe('runtime linear client', () => {
|
||||
{ activeRuntimeEnvironmentId: null },
|
||||
{ parentIssueId: 'issue-1', teamId: 'team-1', title: 'Child task' }
|
||||
)
|
||||
await linearCreateProject(
|
||||
{ activeRuntimeEnvironmentId: null },
|
||||
{ name: 'Roadmap', teamIds: ['team-1'], workspaceId: 'workspace-1' }
|
||||
)
|
||||
|
||||
expect(linearStatusLocal).toHaveBeenCalled()
|
||||
expect(linearSearchIssuesLocal).toHaveBeenCalledWith({
|
||||
@@ -129,6 +138,11 @@ describe('runtime linear client', () => {
|
||||
teamId: 'team-1',
|
||||
title: 'Child task'
|
||||
})
|
||||
expect(linearCreateProjectLocal).toHaveBeenCalledWith({
|
||||
name: 'Roadmap',
|
||||
teamIds: ['team-1'],
|
||||
workspaceId: 'workspace-1'
|
||||
})
|
||||
expect(runtimeEnvironmentCall).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -370,6 +384,12 @@ describe('runtime linear client', () => {
|
||||
result: { items: [{ id: 'project-1' }] },
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
id: 'rpc-create-project',
|
||||
ok: true,
|
||||
result: { ok: true, project: { id: 'project-2' } },
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
id: 'rpc-select',
|
||||
ok: true,
|
||||
@@ -399,6 +419,16 @@ describe('runtime linear client', () => {
|
||||
)
|
||||
await linearListTeams({ activeRuntimeEnvironmentId: 'env-1' }, 'all')
|
||||
await linearListProjects({ activeRuntimeEnvironmentId: 'env-1' }, 'roadmap', 10, 'workspace-1')
|
||||
await linearCreateProject(
|
||||
{ activeRuntimeEnvironmentId: 'env-1' },
|
||||
{
|
||||
name: 'Roadmap',
|
||||
description: 'Summary',
|
||||
teamIds: ['team-1'],
|
||||
workspaceId: 'workspace-1',
|
||||
priority: 2
|
||||
}
|
||||
)
|
||||
await linearSelectWorkspace({ activeRuntimeEnvironmentId: 'env-1' }, 'workspace-1')
|
||||
|
||||
expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(1, {
|
||||
@@ -442,6 +472,18 @@ describe('runtime linear client', () => {
|
||||
timeoutMs: 30_000
|
||||
})
|
||||
expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(6, {
|
||||
selector: 'env-1',
|
||||
method: 'linear.createProject',
|
||||
params: {
|
||||
name: 'Roadmap',
|
||||
description: 'Summary',
|
||||
teamIds: ['team-1'],
|
||||
workspaceId: 'workspace-1',
|
||||
priority: 2
|
||||
},
|
||||
timeoutMs: 30_000
|
||||
})
|
||||
expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(7, {
|
||||
selector: 'env-1',
|
||||
method: 'linear.selectWorkspace',
|
||||
params: { workspaceId: 'workspace-1' },
|
||||
|
||||
@@ -31,6 +31,9 @@ export type LinearConnectResult = { ok: true; viewer: LinearViewer } | { ok: fal
|
||||
export type LinearCreateIssueResult =
|
||||
| { ok: true; id: string; identifier: string; title: string; url: string }
|
||||
| { ok: false; error: string }
|
||||
export type LinearCreateProjectResult =
|
||||
| { ok: true; project: LinearProjectDetail }
|
||||
| { ok: false; error: string }
|
||||
export type LinearMutationResult = { ok: true } | { ok: false; error: string }
|
||||
export type LinearCommentResult = { ok: true; id: string } | { ok: false; error: string }
|
||||
export type LinearReadOptions = { force?: boolean }
|
||||
@@ -323,6 +326,30 @@ export async function linearListProjects(
|
||||
: { items: [] }
|
||||
}
|
||||
|
||||
export async function linearCreateProject(
|
||||
settings: RuntimeLinearSettings,
|
||||
args: {
|
||||
name: string
|
||||
description?: string
|
||||
content?: string
|
||||
teamIds: string[]
|
||||
workspaceId?: string
|
||||
leadId?: string | null
|
||||
memberIds?: string[]
|
||||
labelIds?: string[]
|
||||
priority?: number
|
||||
startDate?: string
|
||||
targetDate?: string
|
||||
}
|
||||
): Promise<LinearCreateProjectResult> {
|
||||
const target = getActiveRuntimeTarget(settings)
|
||||
return target.kind === 'environment'
|
||||
? callRuntimeRpc<LinearCreateProjectResult>(target, 'linear.createProject', args, {
|
||||
timeoutMs: 30_000
|
||||
})
|
||||
: window.api.linear.createProject(args)
|
||||
}
|
||||
|
||||
export async function linearGetProject(
|
||||
settings: RuntimeLinearSettings,
|
||||
id: string,
|
||||
|
||||
Reference in New Issue
Block a user