diff --git a/packages/twenty-apps/internal/twenty-linear/.yarn/install-state.gz b/packages/twenty-apps/internal/twenty-linear/.yarn/install-state.gz new file mode 100644 index 00000000000..3f53467b6de Binary files /dev/null and b/packages/twenty-apps/internal/twenty-linear/.yarn/install-state.gz differ diff --git a/packages/twenty-apps/internal/twenty-linear/package.json b/packages/twenty-apps/internal/twenty-linear/package.json index 93c83dade04..7cd91c2bc30 100644 --- a/packages/twenty-apps/internal/twenty-linear/package.json +++ b/packages/twenty-apps/internal/twenty-linear/package.json @@ -1,6 +1,6 @@ { "name": "twenty-linear", - "version": "0.1.5", + "version": "0.1.6", "description": "Linear integration for Twenty. Connect a user's Linear account and create issues from logic functions.", "license": "MIT", "engines": { @@ -20,7 +20,7 @@ "test:watch": "vitest --config vitest.unit.config.ts" }, "dependencies": { - "twenty-sdk": "2.3.0" + "twenty-sdk": "2.3.1" }, "devDependencies": { "@types/node": "^24.7.2", diff --git a/packages/twenty-apps/internal/twenty-linear/public/gallery/command-menu-item-1.png b/packages/twenty-apps/internal/twenty-linear/public/gallery/command-menu-item-1.png new file mode 100644 index 00000000000..f19b12e367c Binary files /dev/null and b/packages/twenty-apps/internal/twenty-linear/public/gallery/command-menu-item-1.png differ diff --git a/packages/twenty-apps/internal/twenty-linear/public/gallery/command-menu-item-2.png b/packages/twenty-apps/internal/twenty-linear/public/gallery/command-menu-item-2.png new file mode 100644 index 00000000000..f466b85b9e2 Binary files /dev/null and b/packages/twenty-apps/internal/twenty-linear/public/gallery/command-menu-item-2.png differ diff --git a/packages/twenty-apps/internal/twenty-linear/public/gallery/command-menu-item-3.png b/packages/twenty-apps/internal/twenty-linear/public/gallery/command-menu-item-3.png new file mode 100644 index 00000000000..2453b0e6c1e Binary files /dev/null and b/packages/twenty-apps/internal/twenty-linear/public/gallery/command-menu-item-3.png differ diff --git a/packages/twenty-apps/internal/twenty-linear/public/gallery/command-menu-item-4.png b/packages/twenty-apps/internal/twenty-linear/public/gallery/command-menu-item-4.png new file mode 100644 index 00000000000..8b04dfa1c18 Binary files /dev/null and b/packages/twenty-apps/internal/twenty-linear/public/gallery/command-menu-item-4.png differ diff --git a/packages/twenty-apps/internal/twenty-linear/src/application.config.ts b/packages/twenty-apps/internal/twenty-linear/src/application.config.ts index 8e72d387f23..7873a88535c 100644 --- a/packages/twenty-apps/internal/twenty-linear/src/application.config.ts +++ b/packages/twenty-apps/internal/twenty-linear/src/application.config.ts @@ -1,9 +1,6 @@ import { defineApplication } from 'twenty-sdk/define'; - -import { - APPLICATION_UNIVERSAL_IDENTIFIER, - DEFAULT_ROLE_UNIVERSAL_IDENTIFIER, -} from 'src/constants/universal-identifiers'; +import { ABOUT_DESCRIPTION } from './constants/ABOUT_DESCRIPTION.md'; +import { APPLICATION_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers'; export default defineApplication({ universalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER, @@ -11,10 +8,19 @@ export default defineApplication({ description: 'Connect Linear to Twenty. Each workspace member connects their own Linear account; logic functions can then create issues and read team data on their behalf.', logoUrl: 'public/linear-logomark.svg', - defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER, - // OAuth client_id/secret live at the registration level (one OAuth app per - // Twenty server, configured by the server admin) — not per-workspace — - // so they're declared as serverVariables, not applicationVariables. + aboutDescription: ABOUT_DESCRIPTION, + applicationVariables: undefined, + author: 'Twenty', + category: 'Product management', + emailSupport: 'contact@twenty.com', + screenshots: [ + 'public/gallery/command-menu-item-1.png', + 'public/gallery/command-menu-item-2.png', + 'public/gallery/command-menu-item-3.png', + 'public/gallery/command-menu-item-4.png', + ], + termsUrl: 'https://github.com/twentyhq/twenty?tab=License-1-ov-file#readme', + websiteUrl: 'https://www.twenty.com', serverVariables: { LINEAR_CLIENT_ID: { description: diff --git a/packages/twenty-apps/internal/twenty-linear/src/command-menu-items/create-linear-issue.command-menu-item.ts b/packages/twenty-apps/internal/twenty-linear/src/command-menu-items/create-linear-issue.command-menu-item.ts new file mode 100644 index 00000000000..1ca3f75e540 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-linear/src/command-menu-items/create-linear-issue.command-menu-item.ts @@ -0,0 +1,17 @@ +import { defineCommandMenuItem } from 'twenty-sdk/define'; + +import { + CREATE_ISSUE_COMMAND_UNIVERSAL_IDENTIFIER, + CREATE_ISSUE_FORM_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER, +} from 'src/constants/universal-identifiers'; + +export default defineCommandMenuItem({ + universalIdentifier: CREATE_ISSUE_COMMAND_UNIVERSAL_IDENTIFIER, + label: 'Create Linear issue', + shortLabel: 'Linear issue', + icon: 'IconPlaylistAdd', + isPinned: false, + availabilityType: 'GLOBAL', + frontComponentUniversalIdentifier: + CREATE_ISSUE_FORM_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER, +}); diff --git a/packages/twenty-apps/internal/twenty-linear/src/components/create-issue-form.front-component.tsx b/packages/twenty-apps/internal/twenty-linear/src/components/create-issue-form.front-component.tsx new file mode 100644 index 00000000000..84fbdaa387c --- /dev/null +++ b/packages/twenty-apps/internal/twenty-linear/src/components/create-issue-form.front-component.tsx @@ -0,0 +1,1015 @@ +import { useCallback, useEffect, useState } from 'react'; +import { defineFrontComponent } from 'twenty-sdk/define'; +import { + closeSidePanel, + enqueueSnackbar, + unmountFrontComponent, +} from 'twenty-sdk/front-component'; + +import { CREATE_ISSUE_FORM_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers'; + +type LinearTeam = { + id: string; + name: string; + key: string; +}; + +type LinearWorkflowState = { + id: string; + name: string; + type: string; + position: number; +}; + +type LinearMember = { + id: string; + name: string; + displayName: string; +}; + +type LinearProject = { + id: string; + name: string; +}; + +type LinearLabel = { + id: string; + name: string; + color: string; +}; + +type LinearCycle = { + id: string; + name: string | null; + number: number; + startsAt: string; + endsAt: string; +}; + +type IssueOptions = { + states: LinearWorkflowState[]; + members: LinearMember[]; + projects: LinearProject[]; + labels: LinearLabel[]; + cycles: LinearCycle[]; + estimationType: string; + estimationAllowZero: boolean; +}; + +type CreatedIssue = { + id: string; + identifier: string; + title: string; + url: string; +}; + +const PRIORITY_OPTIONS = [ + { value: 0, label: 'No priority' }, + { value: 1, label: 'Urgent' }, + { value: 2, label: 'High' }, + { value: 3, label: 'Medium' }, + { value: 4, label: 'Low' }, +]; + +const ESTIMATE_SCALES: Record = { + exponential: [ + { value: 1, label: '1' }, + { value: 2, label: '2' }, + { value: 4, label: '4' }, + { value: 8, label: '8' }, + { value: 16, label: '16' }, + { value: 32, label: '32' }, + ], + fibonacci: [ + { value: 1, label: '1' }, + { value: 2, label: '2' }, + { value: 3, label: '3' }, + { value: 5, label: '5' }, + { value: 8, label: '8' }, + { value: 13, label: '13' }, + { value: 21, label: '21' }, + ], + linear: [ + { value: 1, label: '1' }, + { value: 2, label: '2' }, + { value: 3, label: '3' }, + { value: 4, label: '4' }, + { value: 5, label: '5' }, + { value: 6, label: '6' }, + { value: 7, label: '7' }, + { value: 8, label: '8' }, + { value: 9, label: '9' }, + { value: 10, label: '10' }, + ], + tShirt: [ + { value: 1, label: 'XS' }, + { value: 2, label: 'S' }, + { value: 3, label: 'M' }, + { value: 5, label: 'L' }, + { value: 8, label: 'XL' }, + { value: 13, label: 'XXL' }, + ], +}; + +const STATE_TYPE_ICONS: Record = { + backlog: '○', + unstarted: '○', + started: '◐', + completed: '●', + cancelled: '✕', +}; + +// The front component sandbox dispatches events with a non-standard shape. +// Values may live on e.detail.value, e.value, or e.target.value. +const readSerializedValue = ( + e: React.SyntheticEvent, +): string | undefined => { + const obj = e as { + detail?: { value?: string }; + value?: string; + target?: { value?: string }; + }; + + if (typeof obj.detail?.value === 'string') return obj.detail.value; + if (typeof obj.value === 'string') return obj.value; + if (typeof obj.target?.value === 'string') return obj.target.value; + + return undefined; +}; + +const onValueChange = + (fn: (value: string) => void) => (e: React.SyntheticEvent) => { + const v = readSerializedValue(e); + + if (typeof v === 'string') fn(v); + }; + +const callAppRoute = async ( + path: string, + method: 'GET' | 'POST', + body?: Record, +) => { + const apiBaseUrl = process.env.TWENTY_API_URL; + const token = + process.env.TWENTY_APP_ACCESS_TOKEN ?? process.env.TWENTY_API_KEY; + + if (!apiBaseUrl || !token) { + throw new Error('API configuration missing'); + } + + const response = await fetch(`${apiBaseUrl}/s${path}`, { + method, + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + ...(body ? { body: JSON.stringify(body) } : {}), + }); + + if (!response.ok) { + const text = await response.text().catch(() => ''); + + throw new Error( + `Request failed (${response.status}): ${text.slice(0, 200)}`, + ); + } + + return response.json(); +}; + +const COLOR = { + bg: '#f1f1f1', + card: '#ffffff', + surface: '#fcfcfc', + border: '#ebebeb', + borderStrong: '#d6d6d6', + text: '#333333', + textSecondary: '#666666', + textTertiary: '#999999', + placeholder: '#cccccc', + accent: '#606acc', + error: '#e05252', +}; + +const INLINE_SELECT: React.CSSProperties = { + appearance: 'none', + WebkitAppearance: 'none' as const, + border: 'none', + background: 'transparent', + color: 'inherit', + font: 'inherit', + cursor: 'pointer', + outline: 'none', + padding: 0, +}; + +const STYLES = { + outer: { + fontFamily: 'Inter, system-ui, -apple-system, sans-serif', + fontSize: '13px', + backgroundColor: COLOR.bg, + padding: '12px', + height: '100%', + boxSizing: 'border-box' as const, + }, + container: { + backgroundColor: COLOR.card, + borderRadius: '8px', + border: `1px solid ${COLOR.border}`, + display: 'flex', + flexDirection: 'column' as const, + height: '100%', + boxSizing: 'border-box' as const, + color: COLOR.text, + }, + topBar: { + display: 'flex', + alignItems: 'center', + padding: '8px 12px', + borderBottom: `1px solid ${COLOR.border}`, + flexShrink: 0, + }, + teamSelect: { + appearance: 'none' as const, + WebkitAppearance: 'none' as const, + background: COLOR.surface, + border: `1px solid ${COLOR.border}`, + borderRadius: '6px', + padding: '3px 8px', + color: COLOR.text, + fontSize: '12px', + fontFamily: 'inherit', + cursor: 'pointer', + outline: 'none', + }, + body: { + flex: 1, + display: 'flex', + flexDirection: 'column' as const, + padding: '24px', + overflow: 'auto', + minHeight: 0, + gap: '10px', + }, + titleInput: { + background: 'transparent', + border: 'none', + outline: 'none', + color: COLOR.text, + fontSize: '20px', + fontWeight: 500, + fontFamily: 'inherit', + width: '100%', + padding: 0, + marginBottom: '4px', + }, + descriptionInput: { + background: 'transparent', + border: 'none', + outline: 'none', + color: COLOR.text, + fontSize: '14px', + fontFamily: 'inherit', + width: '100%', + padding: 0, + resize: 'none' as const, + flex: 1, + }, + labelPicker: { + flexShrink: 0, + padding: '12px', + display: 'flex', + flexDirection: 'column' as const, + gap: '6px', + borderBottom: `1px solid ${COLOR.border}`, + }, + optionsLoadingIndicator: { + marginLeft: 'auto', + display: 'flex', + alignItems: 'center', + }, + spinner: { + width: '14px', + height: '14px', + border: `2px solid ${COLOR.border}`, + borderTopColor: COLOR.textSecondary, + borderRadius: '50%', + animation: 'spin 0.6s linear infinite', + }, + chipsRow: { + display: 'flex', + flexWrap: 'wrap' as const, + gap: '5px', + alignItems: 'center', + }, + chip: { + appearance: 'none' as const, + WebkitAppearance: 'none' as const, + background: 'transparent', + border: `1px solid ${COLOR.border}`, + borderRadius: '20px', + padding: '3px 9px', + color: COLOR.textSecondary, + fontSize: '12px', + fontFamily: 'inherit', + cursor: 'pointer', + outline: 'none', + display: 'inline-flex', + alignItems: 'center', + gap: '4px', + whiteSpace: 'nowrap' as const, + lineHeight: '16px', + }, + chipIcon: { + fontSize: '10px', + lineHeight: 1, + flexShrink: 0, + }, + labelsRow: { + display: 'flex', + flexWrap: 'wrap' as const, + gap: '4px', + alignItems: 'center', + }, + labelToggle: { + display: 'inline-flex', + alignItems: 'center', + gap: '4px', + padding: '2px 7px', + borderRadius: '20px', + border: `1px solid ${COLOR.border}`, + fontSize: '11px', + fontFamily: 'inherit', + cursor: 'pointer', + background: 'transparent', + color: COLOR.textSecondary, + }, + labelToggleSelected: { + display: 'inline-flex', + alignItems: 'center', + gap: '4px', + padding: '2px 7px', + borderRadius: '20px', + border: `1px solid ${COLOR.accent}`, + fontSize: '11px', + fontFamily: 'inherit', + cursor: 'pointer', + backgroundColor: '#eef0fb', + color: COLOR.accent, + }, + labelDot: { + width: '6px', + height: '6px', + borderRadius: '50%', + flexShrink: 0, + }, + dateChip: { + appearance: 'none' as const, + WebkitAppearance: 'none' as const, + background: 'transparent', + border: `1px solid ${COLOR.border}`, + borderRadius: '20px', + padding: '3px 9px', + color: COLOR.textSecondary, + fontSize: '12px', + fontFamily: 'inherit', + cursor: 'pointer', + outline: 'none', + lineHeight: '16px', + }, + actionBar: { + display: 'flex', + alignItems: 'center', + justifyContent: 'flex-end', + gap: '6px', + padding: '6px 12px', + borderTop: `1px solid ${COLOR.border}`, + flexShrink: 0, + }, + cancelButton: { + background: 'transparent', + border: 'none', + color: COLOR.textSecondary, + fontSize: '12px', + fontFamily: 'inherit', + cursor: 'pointer', + padding: '5px 10px', + borderRadius: '6px', + }, + createButton: { + background: COLOR.accent, + border: 'none', + color: '#ffffff', + fontSize: '12px', + fontWeight: 500, + fontFamily: 'inherit', + cursor: 'pointer', + padding: '5px 14px', + borderRadius: '6px', + }, + createButtonDisabled: { + background: COLOR.borderStrong, + color: COLOR.textTertiary, + cursor: 'not-allowed', + }, + successOuter: { + fontFamily: 'Inter, system-ui, -apple-system, sans-serif', + backgroundColor: COLOR.bg, + padding: '12px', + height: '100%', + boxSizing: 'border-box' as const, + }, + successContainer: { + display: 'flex', + alignItems: 'center', + gap: '10px', + padding: '10px 14px', + boxSizing: 'border-box' as const, + backgroundColor: COLOR.card, + borderRadius: '8px', + border: `1px solid ${COLOR.border}`, + color: COLOR.text, + }, + successCheck: { + width: '20px', + height: '20px', + borderRadius: '50%', + backgroundColor: '#2ea043', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + fontSize: '11px', + color: '#fff', + flexShrink: 0, + }, + issueLink: { + color: COLOR.accent, + fontWeight: 500, + textDecoration: 'underline', + fontSize: '13px', + textUnderlineOffset: '2px', + flex: 1, + overflow: 'hidden' as const, + textOverflow: 'ellipsis' as const, + whiteSpace: 'nowrap' as const, + }, + closeButton: { + background: 'transparent', + border: `1px solid ${COLOR.border}`, + color: COLOR.textSecondary, + fontSize: '12px', + fontFamily: 'inherit', + cursor: 'pointer', + padding: '5px 14px', + borderRadius: '6px', + flexShrink: 0, + }, + loadingOuter: { + fontFamily: 'Inter, system-ui, -apple-system, sans-serif', + backgroundColor: COLOR.bg, + padding: '12px', + height: '100%', + boxSizing: 'border-box' as const, + }, + loadingContainer: { + color: COLOR.textSecondary, + fontSize: '13px', + textAlign: 'center' as const, + backgroundColor: COLOR.card, + borderRadius: '8px', + border: `1px solid ${COLOR.border}`, + padding: '24px', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }, + errorOuter: { + fontFamily: 'Inter, system-ui, -apple-system, sans-serif', + backgroundColor: COLOR.bg, + padding: '12px', + height: '100%', + boxSizing: 'border-box' as const, + }, + errorContainer: { + backgroundColor: COLOR.card, + borderRadius: '8px', + border: `1px solid ${COLOR.border}`, + padding: '24px', + display: 'flex', + flexDirection: 'column' as const, + alignItems: 'center', + justifyContent: 'center', + gap: '10px', + }, + errorText: { + color: COLOR.error, + fontSize: '12px', + }, + retryButton: { + background: COLOR.surface, + border: `1px solid ${COLOR.border}`, + color: COLOR.text, + fontSize: '12px', + fontFamily: 'inherit', + cursor: 'pointer', + padding: '5px 12px', + borderRadius: '6px', + }, +}; + +const EMPTY_OPTIONS: IssueOptions = { + states: [], + members: [], + projects: [], + labels: [], + cycles: [], + estimationType: 'notUsed', + estimationAllowZero: false, +}; + +const CreateIssueForm = () => { + const [teams, setTeams] = useState([]); + const [teamsLoading, setTeamsLoading] = useState(true); + const [teamsError, setTeamsError] = useState(null); + const [submitting, setSubmitting] = useState(false); + const [createdIssue, setCreatedIssue] = useState(null); + const [closing, setClosing] = useState(false); + const [optionsLoading, setOptionsLoading] = useState(false); + const [options, setOptions] = useState(EMPTY_OPTIONS); + const [showLabels, setShowLabels] = useState(false); + + const [teamId, setTeamId] = useState(''); + const [title, setTitle] = useState(''); + const [description, setDescription] = useState(''); + const [priority, setPriority] = useState('0'); + const [stateId, setStateId] = useState(''); + const [assigneeId, setAssigneeId] = useState(''); + const [projectId, setProjectId] = useState(''); + const [estimate, setEstimate] = useState(''); + const [selectedLabelIds, setSelectedLabelIds] = useState([]); + const [cycleId, setCycleId] = useState(''); + const [dueDate, setDueDate] = useState(''); + + const fetchIssueOptions = useCallback(async (selectedTeamId: string) => { + if (!selectedTeamId) { + setOptions(EMPTY_OPTIONS); + setStateId(''); + setAssigneeId(''); + setProjectId(''); + setEstimate(''); + setSelectedLabelIds([]); + setCycleId(''); + + return; + } + + setOptionsLoading(true); + + try { + const result = await callAppRoute( + `/linear/issue-options?teamId=${encodeURIComponent(selectedTeamId)}`, + 'GET', + ); + + if (!result.success) { + setOptions(EMPTY_OPTIONS); + + return; + } + + const issueOptions: IssueOptions = result.options; + + setOptions(issueOptions); + + const todoState = issueOptions.states.find((s) => s.type === 'unstarted'); + + setStateId(todoState?.id ?? issueOptions.states[0]?.id ?? ''); + setAssigneeId(''); + setProjectId(''); + setEstimate(''); + setSelectedLabelIds([]); + setCycleId(''); + } catch { + setOptions(EMPTY_OPTIONS); + } finally { + setOptionsLoading(false); + } + }, []); + + const handleTeamChange = useCallback( + (newTeamId: string) => { + setTeamId(newTeamId); + fetchIssueOptions(newTeamId); + }, + [fetchIssueOptions], + ); + + const fetchTeams = useCallback(async () => { + try { + setTeamsError(null); + const result = await callAppRoute('/linear/teams', 'GET'); + + if (!result.success) { + setTeamsError(result.error ?? 'Failed to load teams'); + + return; + } + + setTeams(result.teams ?? []); + + if (result.teams?.length === 1) { + setTeamId(result.teams[0].id); + fetchIssueOptions(result.teams[0].id); + } + } catch (error) { + setTeamsError( + error instanceof Error ? error.message : 'Failed to load teams', + ); + } finally { + setTeamsLoading(false); + } + }, [fetchIssueOptions]); + + useEffect(() => { + fetchTeams(); + }, [fetchTeams]); + + const toggleLabel = useCallback((labelId: string) => { + setSelectedLabelIds((prev) => + prev.includes(labelId) + ? prev.filter((id) => id !== labelId) + : [...prev, labelId], + ); + }, []); + + const handleSubmit = async () => { + const trimmedTitle = (title ?? '').trim(); + + if (!teamId || !trimmedTitle) { + return; + } + + setSubmitting(true); + + try { + const result = await callAppRoute('/linear/issues', 'POST', { + teamId, + title: trimmedTitle, + description: (description ?? '').trim() || undefined, + priority: Number(priority) || undefined, + stateId: stateId || undefined, + assigneeId: assigneeId || undefined, + projectId: projectId || undefined, + estimate: estimate ? Number(estimate) : undefined, + labelIds: selectedLabelIds.length > 0 ? selectedLabelIds : undefined, + cycleId: cycleId || undefined, + dueDate: dueDate || undefined, + }); + + if (!result.success) { + await enqueueSnackbar({ + message: result.error ?? 'Failed to create issue', + variant: 'error', + }); + setSubmitting(false); + + return; + } + + setCreatedIssue(result.issue); + await enqueueSnackbar({ + message: `Created ${result.issue.identifier}: ${result.issue.title}`, + variant: 'success', + }); + } catch (error) { + await enqueueSnackbar({ + message: + error instanceof Error ? error.message : 'Failed to create issue', + variant: 'error', + }); + setSubmitting(false); + } + }; + + const handleCancel = () => { + setClosing(true); + unmountFrontComponent(); + closeSidePanel(); + }; + + if (closing) { + return null; + } + + if (createdIssue) { + return ( + + ); + } + + if (teamsLoading) { + return ( +
+
Loading...
+
+ ); + } + + if (teamsError) { + return ( +
+
+

{teamsError}

+ +
+
+ ); + } + + const canSubmit = teamId && (title ?? '').trim() && !submitting; + const hasEstimates = options.estimationType !== 'notUsed'; + const baseEstimateOptions = ESTIMATE_SCALES[options.estimationType] ?? []; + const estimateOptions = + options.estimationAllowZero && + baseEstimateOptions.length > 0 && + !baseEstimateOptions.some((o) => o.value === 0) + ? [{ value: 0, label: 'None' }, ...baseEstimateOptions] + : baseEstimateOptions; + + const selectedState = options.states.find((s) => s.id === stateId); + const stateIcon = + STATE_TYPE_ICONS[selectedState?.type ?? ''] ?? STATE_TYPE_ICONS.unstarted; + const labelsCount = selectedLabelIds.length; + + const hasStatuses = options.states.length > 0; + + return ( +
+ +
+ {/* Top bar with team selector */} +
+ + {optionsLoading && ( +
+
+
+ )} +
+ + {/* Property chips & labels */} +
+
+ {/* Status */} + {hasStatuses && !optionsLoading && ( + + )} + + {/* Priority */} + + + {/* Assignee */} + + + {/* Estimate */} + {hasEstimates && ( + + )} + + {/* Labels toggle */} + {options.labels.length > 0 && ( + + )} + + {/* Cycle */} + {options.cycles.length > 0 && ( + + )} + + {/* Due date */} + + + {/* Project */} + {options.projects.length > 0 && ( + + )} +
+ + {showLabels && options.labels.length > 0 && ( +
+ {options.labels.map((label) => { + const isSelected = selectedLabelIds.includes(label.id); + + return ( + + ); + })} +
+ )} +
+ +
+ +