@sentry/junior-dashboard 0.234.0 → 0.235.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app.js +91 -16
- package/dist/client.js +43 -43
- package/dist/index.js +91 -16
- package/dist/tailwind.css +1 -1
- package/package.json +3 -3
- package/src/assets/junior-avatar.png +0 -0
- package/src/assets/junior-favicon.png +0 -0
- package/src/assets.ts +1 -0
- package/src/client/App.tsx +13 -0
- package/src/client/api.ts +43 -0
- package/src/client/components/README.md +8 -4
- package/src/client/components/SegmentedNav.tsx +167 -0
- package/src/client/components/SegmentedTabs.tsx +24 -5
- package/src/client/components/layout/SecondaryNavigation.tsx +40 -83
- package/src/client/pages/automations/AutomationDetailsDrawer.tsx +14 -21
- package/src/client/pages/automations/AutomationEditPage.tsx +3 -4
- package/src/client/pages/automations/AutomationEditor.tsx +1 -1
- package/src/client/pages/automations/AutomationFilters.tsx +22 -14
- package/src/client/pages/automations/AutomationRow.tsx +172 -187
- package/src/client/pages/automations/AutomationsPage.tsx +40 -42
- package/src/client/pages/automations/AutomationsPageLayout.tsx +6 -5
- package/src/client/pages/automations/automationFormat.ts +43 -0
- package/src/client/pages/code/CodeChangeRow.tsx +41 -0
- package/src/client/pages/code/CodePage.tsx +18 -93
- package/src/client/pages/code/CodeSummaryCards.tsx +53 -0
- package/src/client/pages/code/RepositoryPage.tsx +227 -0
- package/src/client/pages/code/RepositorySections.tsx +108 -0
- package/src/client/pages/code/RepositoryTabs.tsx +146 -0
- package/src/client/pages/code/codeRepositoryRoutes.ts +18 -0
- package/src/client/pages/dev/ComponentsPage.tsx +5 -22
- package/src/client/pages/dev/SegmentedFixtures.tsx +58 -0
- package/src/client/pages/memory/MemoryPageLayout.tsx +12 -13
- package/src/client/pages/system/SystemNavigation.tsx +6 -2
- package/src/client/pages/system/SystemPageLayout.tsx +4 -6
- package/src/mock-reporting/code-repository.ts +77 -0
- package/src/mock-reporting/routes.ts +25 -8
- package/src/shell.ts +21 -5
package/src/client/api.ts
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
conversationStatsReportSchema,
|
|
7
7
|
codeOverviewReportSchema,
|
|
8
8
|
codePersonReportSchema,
|
|
9
|
+
codeRepositoryReportSchema,
|
|
9
10
|
statsReportSchema,
|
|
10
11
|
} from "@sentry/junior/api/schema";
|
|
11
12
|
import {
|
|
@@ -115,6 +116,48 @@ export function useCodeOverviewData() {
|
|
|
115
116
|
});
|
|
116
117
|
}
|
|
117
118
|
|
|
119
|
+
/** Fetch code activity, recent changes, and Workspaces for one repository. */
|
|
120
|
+
export function useCodeRepositoryData(repositoryId: string | undefined) {
|
|
121
|
+
return useQuery({
|
|
122
|
+
enabled: Boolean(repositoryId),
|
|
123
|
+
queryKey: ["dashboard", "code", "repositories", repositoryId],
|
|
124
|
+
queryFn: ({ signal }) =>
|
|
125
|
+
fetchDashboardJson(
|
|
126
|
+
codeRepositoryReportSchema,
|
|
127
|
+
`/api/code/repositories/${encodeURIComponent(repositoryId!)}`,
|
|
128
|
+
signal,
|
|
129
|
+
),
|
|
130
|
+
retry: false,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Fetch conversations linked to one repository through its code changes. */
|
|
135
|
+
export function useCodeRepositoryConversationsData(
|
|
136
|
+
repositoryId: string | undefined,
|
|
137
|
+
search = "",
|
|
138
|
+
) {
|
|
139
|
+
return useQuery({
|
|
140
|
+
enabled: Boolean(repositoryId),
|
|
141
|
+
queryKey: [
|
|
142
|
+
"dashboard",
|
|
143
|
+
"conversations",
|
|
144
|
+
"code-repository",
|
|
145
|
+
{ repositoryId, search },
|
|
146
|
+
],
|
|
147
|
+
queryFn: ({ signal }) => {
|
|
148
|
+
const params = new URLSearchParams({ codeRepositoryId: repositoryId! });
|
|
149
|
+
if (search) params.set("q", search);
|
|
150
|
+
return fetchDashboardJson(
|
|
151
|
+
conversationFeedSchema,
|
|
152
|
+
`/api/conversations?${params.toString()}`,
|
|
153
|
+
signal,
|
|
154
|
+
);
|
|
155
|
+
},
|
|
156
|
+
placeholderData: keepPreviousData,
|
|
157
|
+
retry: false,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
118
161
|
/** Fetch the signed-in viewer's scheduled and event automations. */
|
|
119
162
|
export function useAutomationsData(enabled: boolean, search: string) {
|
|
120
163
|
return useQuery({
|
|
@@ -7,10 +7,14 @@ instead of copying Tailwind blocks.
|
|
|
7
7
|
|
|
8
8
|
1. **Primitives** — `Button` / `ToggleButton`, `Field`, `TextInput`,
|
|
9
9
|
`StatusChip`, `StatusDot`, `Notice`, `FormNotice`, `Select`, `Drawer`, `Tooltip`, `Skeleton`,
|
|
10
|
-
`PageContentSkeleton`, `SegmentedTabs`, and other small reusable controls.
|
|
11
|
-
Use
|
|
12
|
-
|
|
13
|
-
and
|
|
10
|
+
`PageContentSkeleton`, `SegmentedTabs`, `SegmentedNav`, and other small reusable controls.
|
|
11
|
+
Use the segmented pill track to pick one of a few siblings. Use
|
|
12
|
+
`SegmentedTabs` to switch panels in place. Pass the selected value, items,
|
|
13
|
+
change handler, and selected panel content. It owns tab/panel labels and
|
|
14
|
+
keyboard focus. Use `SegmentedNav` for links between sibling pages. With
|
|
15
|
+
`fill`, links share the row width equally while their labels fit. Otherwise
|
|
16
|
+
each link keeps its label width. Rows that do not fit scroll sideways. Keep
|
|
17
|
+
collection filters in `FilterTabList`. Prefer
|
|
14
18
|
`ToggleButton` variants (`pill`, `segment`, `text`) over one-off pressed
|
|
15
19
|
styles. Prefer `Field` sizes (`default`, `compact`) over handwritten labels.
|
|
16
20
|
Prefer `StatusDot` for dense table or list status markers and `StatusChip`
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { useLayoutEffect, useRef, useState, type CSSProperties } from "react";
|
|
2
|
+
import { Link, NavLink, useLocation } from "react-router";
|
|
3
|
+
|
|
4
|
+
import { pathWithSearch } from "../searchParams";
|
|
5
|
+
import {
|
|
6
|
+
segmentedTabClass,
|
|
7
|
+
segmentedTabsFrameClass,
|
|
8
|
+
segmentedTabsInsetClass,
|
|
9
|
+
} from "./SegmentedTabs";
|
|
10
|
+
import { cn } from "../styles";
|
|
11
|
+
|
|
12
|
+
export type SegmentedNavItem = {
|
|
13
|
+
end?: boolean;
|
|
14
|
+
/** Override default NavLink matching when a path family should stay active. */
|
|
15
|
+
isActive?: (pathname: string) => boolean;
|
|
16
|
+
label: string;
|
|
17
|
+
to: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** Link to a sibling page and keep page filters (range, scope, q). */
|
|
21
|
+
export function SiblingPageLink(props: {
|
|
22
|
+
className: (state: { isActive: boolean }) => string;
|
|
23
|
+
item: SegmentedNavItem;
|
|
24
|
+
}) {
|
|
25
|
+
const { className, item } = props;
|
|
26
|
+
const location = useLocation();
|
|
27
|
+
const to = pathWithSearch(item.to, location.search);
|
|
28
|
+
if (item.isActive) {
|
|
29
|
+
const isActive = item.isActive(location.pathname);
|
|
30
|
+
return (
|
|
31
|
+
<Link
|
|
32
|
+
aria-current={isActive ? "page" : undefined}
|
|
33
|
+
className={className({ isActive })}
|
|
34
|
+
to={to}
|
|
35
|
+
>
|
|
36
|
+
{item.label}
|
|
37
|
+
</Link>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<NavLink className={className} end={item.end} to={to}>
|
|
43
|
+
{item.label}
|
|
44
|
+
</NavLink>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Width of the fade on an edge that hides more links. */
|
|
49
|
+
const EDGE_FADE_PX = 24;
|
|
50
|
+
|
|
51
|
+
type HiddenEdges = { end: boolean; start: boolean };
|
|
52
|
+
|
|
53
|
+
function hiddenEdges(row: HTMLElement): HiddenEdges {
|
|
54
|
+
const maxScroll = row.scrollWidth - row.clientWidth;
|
|
55
|
+
return {
|
|
56
|
+
end: row.scrollLeft < maxScroll - 1,
|
|
57
|
+
start: row.scrollLeft > 1,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Scroll the current page link into view when the row or a fade covers it. */
|
|
62
|
+
function revealCurrentLink(row: HTMLElement) {
|
|
63
|
+
const link = row.querySelector<HTMLElement>('[aria-current="page"]');
|
|
64
|
+
if (!link || row.clientWidth === 0) return;
|
|
65
|
+
const edges = hiddenEdges(row);
|
|
66
|
+
const visibleStart = row.scrollLeft + (edges.start ? EDGE_FADE_PX : 0);
|
|
67
|
+
const visibleEnd =
|
|
68
|
+
row.scrollLeft + row.clientWidth - (edges.end ? EDGE_FADE_PX : 0);
|
|
69
|
+
const start = link.offsetLeft;
|
|
70
|
+
const end = start + link.offsetWidth;
|
|
71
|
+
if (start >= visibleStart && end <= visibleEnd) return;
|
|
72
|
+
// scrollIntoView can also scroll the page, so set scrollLeft on the row.
|
|
73
|
+
row.scrollLeft = start - (row.clientWidth - link.offsetWidth) / 2;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Fade each edge of the row that hides more links. */
|
|
77
|
+
function edgeFade(edges: HiddenEdges): CSSProperties | undefined {
|
|
78
|
+
if (!edges.start && !edges.end) return undefined;
|
|
79
|
+
const fade = `${EDGE_FADE_PX}px`;
|
|
80
|
+
const start = edges.start ? `transparent, black ${fade}` : "black";
|
|
81
|
+
const end = edges.end ? `black calc(100% - ${fade}), transparent` : "black";
|
|
82
|
+
const mask = `linear-gradient(to right, ${start}, ${end})`;
|
|
83
|
+
return { maskImage: mask, WebkitMaskImage: mask };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Show links between sibling pages in the segmented pill track.
|
|
88
|
+
*
|
|
89
|
+
* With `fill`, links share the row width equally while their labels fit.
|
|
90
|
+
* Otherwise each link keeps its label width. When the links do not fit, the
|
|
91
|
+
* row scrolls sideways and fades the edge that hides more links.
|
|
92
|
+
*/
|
|
93
|
+
export function SegmentedNav(props: {
|
|
94
|
+
ariaLabel: string;
|
|
95
|
+
fill?: boolean;
|
|
96
|
+
items: readonly SegmentedNavItem[];
|
|
97
|
+
}) {
|
|
98
|
+
const { pathname } = useLocation();
|
|
99
|
+
const rowRef = useRef<HTMLDivElement>(null);
|
|
100
|
+
const [edges, setEdges] = useState<HiddenEdges>({
|
|
101
|
+
end: false,
|
|
102
|
+
start: false,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
useLayoutEffect(() => {
|
|
106
|
+
const row = rowRef.current;
|
|
107
|
+
if (!row) return;
|
|
108
|
+
const updateEdges = () => {
|
|
109
|
+
const { end, start } = hiddenEdges(row);
|
|
110
|
+
setEdges((previous) =>
|
|
111
|
+
previous.start === start && previous.end === end
|
|
112
|
+
? previous
|
|
113
|
+
: { end, start },
|
|
114
|
+
);
|
|
115
|
+
};
|
|
116
|
+
// A row hidden at this breakpoint has no width. Reveal the current link
|
|
117
|
+
// when it gets one.
|
|
118
|
+
const observer = new ResizeObserver(() => {
|
|
119
|
+
revealCurrentLink(row);
|
|
120
|
+
updateEdges();
|
|
121
|
+
});
|
|
122
|
+
updateEdges();
|
|
123
|
+
row.addEventListener("scroll", updateEdges, { passive: true });
|
|
124
|
+
observer.observe(row);
|
|
125
|
+
return () => {
|
|
126
|
+
row.removeEventListener("scroll", updateEdges);
|
|
127
|
+
observer.disconnect();
|
|
128
|
+
};
|
|
129
|
+
}, []);
|
|
130
|
+
|
|
131
|
+
useLayoutEffect(() => {
|
|
132
|
+
if (rowRef.current) revealCurrentLink(rowRef.current);
|
|
133
|
+
}, [pathname]);
|
|
134
|
+
|
|
135
|
+
const linkClass = ({ isActive }: { isActive: boolean }) =>
|
|
136
|
+
cn(
|
|
137
|
+
segmentedTabClass(isActive),
|
|
138
|
+
"flex items-center justify-center whitespace-nowrap no-underline",
|
|
139
|
+
props.fill ? "min-w-max flex-[1_0_0]" : "shrink-0",
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
return (
|
|
143
|
+
<nav
|
|
144
|
+
aria-label={props.ariaLabel}
|
|
145
|
+
className={cn(
|
|
146
|
+
segmentedTabsFrameClass,
|
|
147
|
+
"flex min-w-0 max-w-full",
|
|
148
|
+
!props.fill && "w-fit",
|
|
149
|
+
)}
|
|
150
|
+
>
|
|
151
|
+
{/* Pad the scrolling row, not the frame, so the last link keeps its
|
|
152
|
+
padding when the row is scrolled to the end. */}
|
|
153
|
+
<div
|
|
154
|
+
className={cn(
|
|
155
|
+
segmentedTabsInsetClass,
|
|
156
|
+
"relative flex min-w-0 flex-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
|
|
157
|
+
)}
|
|
158
|
+
ref={rowRef}
|
|
159
|
+
style={edgeFade(edges)}
|
|
160
|
+
>
|
|
161
|
+
{props.items.map((item) => (
|
|
162
|
+
<SiblingPageLink className={linkClass} item={item} key={item.to} />
|
|
163
|
+
))}
|
|
164
|
+
</div>
|
|
165
|
+
</nav>
|
|
166
|
+
);
|
|
167
|
+
}
|
|
@@ -2,6 +2,23 @@ import { useId, type ReactNode } from "react";
|
|
|
2
2
|
|
|
3
3
|
import { cn } from "../styles";
|
|
4
4
|
|
|
5
|
+
/** Style the border and fill of the segmented pill track. */
|
|
6
|
+
export const segmentedTabsFrameClass =
|
|
7
|
+
"rounded-lg border border-dashboard-border bg-dashboard-surface-panel";
|
|
8
|
+
|
|
9
|
+
/** Style the spacing inside the segmented pill track. */
|
|
10
|
+
export const segmentedTabsInsetClass = "gap-1 p-1";
|
|
11
|
+
|
|
12
|
+
/** Style one tab or link in the segmented pill track. */
|
|
13
|
+
export function segmentedTabClass(selected: boolean): string {
|
|
14
|
+
return cn(
|
|
15
|
+
"min-h-9 cursor-pointer rounded-md border-0 px-3 py-2 font-sans text-sm font-medium transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-dashboard-focus",
|
|
16
|
+
selected
|
|
17
|
+
? "bg-dashboard-fill-strong text-dashboard-text"
|
|
18
|
+
: "bg-transparent text-dashboard-text-muted hover:bg-dashboard-fill-faint hover:text-dashboard-text",
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
5
22
|
/** Switch between related panels with equal-width tabs and keyboard navigation. */
|
|
6
23
|
export function SegmentedTabs<const Value extends string>(props: {
|
|
7
24
|
children: ReactNode;
|
|
@@ -16,7 +33,11 @@ export function SegmentedTabs<const Value extends string>(props: {
|
|
|
16
33
|
<div className="grid min-w-0 gap-4">
|
|
17
34
|
<div
|
|
18
35
|
aria-label={props.label}
|
|
19
|
-
className=
|
|
36
|
+
className={cn(
|
|
37
|
+
"grid auto-cols-fr grid-flow-col",
|
|
38
|
+
segmentedTabsFrameClass,
|
|
39
|
+
segmentedTabsInsetClass,
|
|
40
|
+
)}
|
|
20
41
|
role="tablist"
|
|
21
42
|
>
|
|
22
43
|
{props.items.map((item, index) => (
|
|
@@ -24,10 +45,8 @@ export function SegmentedTabs<const Value extends string>(props: {
|
|
|
24
45
|
aria-controls={`${id}-panel`}
|
|
25
46
|
aria-selected={props.value === item.value}
|
|
26
47
|
className={cn(
|
|
27
|
-
"min-
|
|
28
|
-
props.value === item.value
|
|
29
|
-
? "bg-dashboard-fill-strong text-dashboard-text"
|
|
30
|
-
: "bg-transparent text-dashboard-text-muted hover:bg-dashboard-fill-faint hover:text-dashboard-text",
|
|
48
|
+
"min-w-0",
|
|
49
|
+
segmentedTabClass(props.value === item.value),
|
|
31
50
|
)}
|
|
32
51
|
id={`${id}-${item.value}`}
|
|
33
52
|
key={item.value}
|
|
@@ -1,6 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
SegmentedNav,
|
|
5
|
+
SiblingPageLink,
|
|
6
|
+
type SegmentedNavItem,
|
|
7
|
+
} from "../SegmentedNav";
|
|
4
8
|
import {
|
|
5
9
|
cn,
|
|
6
10
|
dashboardContainerClass,
|
|
@@ -8,62 +12,20 @@ import {
|
|
|
8
12
|
} from "../../styles";
|
|
9
13
|
import { SecondaryNavigationPortal } from "./DashboardChrome";
|
|
10
14
|
|
|
11
|
-
export type SecondaryNavigationItem =
|
|
12
|
-
end?: boolean;
|
|
13
|
-
/** Override default NavLink matching when a path family should stay active. */
|
|
14
|
-
isActive?: (pathname: string) => boolean;
|
|
15
|
-
label: string;
|
|
16
|
-
to: string;
|
|
17
|
-
};
|
|
18
|
-
|
|
19
|
-
type LinkClassState = { isActive: boolean };
|
|
20
|
-
|
|
21
|
-
function SecondaryNavItem(props: {
|
|
22
|
-
className: (state: LinkClassState) => string;
|
|
23
|
-
item: SecondaryNavigationItem;
|
|
24
|
-
pathname: string;
|
|
25
|
-
search: string;
|
|
26
|
-
}) {
|
|
27
|
-
const { className, item, pathname, search } = props;
|
|
28
|
-
// Keep page filters (range, scope, q) when moving across secondary sections.
|
|
29
|
-
const to = pathWithSearch(item.to, search);
|
|
30
|
-
if (item.isActive) {
|
|
31
|
-
const isActive = item.isActive(pathname);
|
|
32
|
-
return (
|
|
33
|
-
<Link
|
|
34
|
-
aria-current={isActive ? "page" : undefined}
|
|
35
|
-
className={className({ isActive })}
|
|
36
|
-
to={to}
|
|
37
|
-
>
|
|
38
|
-
{item.label}
|
|
39
|
-
</Link>
|
|
40
|
-
);
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
return (
|
|
44
|
-
<NavLink className={className} end={item.end} to={to}>
|
|
45
|
-
{item.label}
|
|
46
|
-
</NavLink>
|
|
47
|
-
);
|
|
48
|
-
}
|
|
15
|
+
export type SecondaryNavigationItem = SegmentedNavItem;
|
|
49
16
|
|
|
50
|
-
/**
|
|
17
|
+
/**
|
|
18
|
+
* Render page navigation above the page, in the desktop chrome, and in the
|
|
19
|
+
* mobile menu. On mobile the links sit at the top of the page and scroll with
|
|
20
|
+
* it. Pass the page as children so the links and the page share one layout
|
|
21
|
+
* row.
|
|
22
|
+
*/
|
|
51
23
|
export function SecondaryNavigation(props: {
|
|
52
24
|
ariaLabel: string;
|
|
25
|
+
children: ReactNode;
|
|
53
26
|
items: SecondaryNavigationItem[];
|
|
54
27
|
}) {
|
|
55
|
-
const
|
|
56
|
-
const desktopLinkClass = ({ isActive }: LinkClassState) =>
|
|
57
|
-
cn(
|
|
58
|
-
"relative flex h-12 shrink-0 items-center px-3 font-display text-xs font-medium no-underline transition-colors after:absolute after:inset-x-3 after:bottom-0 after:h-0.5 after:transition-colors sm:text-sm",
|
|
59
|
-
isActive
|
|
60
|
-
? "text-dashboard-text after:bg-cyan-300"
|
|
61
|
-
: cn(
|
|
62
|
-
"after:bg-transparent hover:bg-white/[0.025]",
|
|
63
|
-
dashboardInteractiveTextClass,
|
|
64
|
-
),
|
|
65
|
-
);
|
|
66
|
-
const mobileLinkClass = ({ isActive }: LinkClassState) =>
|
|
28
|
+
const menuLinkClass = ({ isActive }: { isActive: boolean }) =>
|
|
67
29
|
cn(
|
|
68
30
|
"rounded-lg px-3 py-3 pl-6 font-mono text-sm font-medium no-underline transition-colors",
|
|
69
31
|
isActive
|
|
@@ -72,44 +34,39 @@ export function SecondaryNavigation(props: {
|
|
|
72
34
|
);
|
|
73
35
|
|
|
74
36
|
return (
|
|
75
|
-
<
|
|
76
|
-
|
|
77
|
-
|
|
37
|
+
<div className="min-w-0">
|
|
38
|
+
<SecondaryNavigationPortal
|
|
39
|
+
desktop={
|
|
40
|
+
<div className="border-b border-white/[0.06] bg-white/[0.018]">
|
|
41
|
+
<div
|
|
42
|
+
className={cn(
|
|
43
|
+
dashboardContainerClass,
|
|
44
|
+
"min-w-0 px-4 py-2 md:px-8",
|
|
45
|
+
)}
|
|
46
|
+
>
|
|
47
|
+
<SegmentedNav ariaLabel={props.ariaLabel} items={props.items} />
|
|
48
|
+
</div>
|
|
49
|
+
</div>
|
|
50
|
+
}
|
|
51
|
+
mobile={
|
|
78
52
|
<nav
|
|
79
53
|
aria-label={props.ariaLabel}
|
|
80
|
-
className=
|
|
81
|
-
dashboardContainerClass,
|
|
82
|
-
"flex min-w-0 gap-1 overflow-x-auto px-4 [scrollbar-width:none] md:px-8 [&::-webkit-scrollbar]:hidden",
|
|
83
|
-
)}
|
|
54
|
+
className="mt-3 grid gap-1 border-t border-white/[0.07] pt-3"
|
|
84
55
|
>
|
|
85
56
|
{props.items.map((item) => (
|
|
86
|
-
<
|
|
87
|
-
className={
|
|
57
|
+
<SiblingPageLink
|
|
58
|
+
className={menuLinkClass}
|
|
88
59
|
item={item}
|
|
89
60
|
key={item.to}
|
|
90
|
-
pathname={location.pathname}
|
|
91
|
-
search={location.search}
|
|
92
61
|
/>
|
|
93
62
|
))}
|
|
94
63
|
</nav>
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
<
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
{props.items.map((item) => (
|
|
103
|
-
<SecondaryNavItem
|
|
104
|
-
className={mobileLinkClass}
|
|
105
|
-
item={item}
|
|
106
|
-
key={item.to}
|
|
107
|
-
pathname={location.pathname}
|
|
108
|
-
search={location.search}
|
|
109
|
-
/>
|
|
110
|
-
))}
|
|
111
|
-
</nav>
|
|
112
|
-
}
|
|
113
|
-
/>
|
|
64
|
+
}
|
|
65
|
+
/>
|
|
66
|
+
<div className="px-4 pt-4 sm:px-8 md:hidden">
|
|
67
|
+
<SegmentedNav ariaLabel={props.ariaLabel} fill items={props.items} />
|
|
68
|
+
</div>
|
|
69
|
+
{props.children}
|
|
70
|
+
</div>
|
|
114
71
|
);
|
|
115
72
|
}
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { AutomationLifecycle } from "./AutomationLifecycle";
|
|
2
2
|
import { automationOutcomeLabel } from "./automationOutcomes";
|
|
3
|
+
import {
|
|
4
|
+
automationEventConditions,
|
|
5
|
+
automationEventNames,
|
|
6
|
+
automationScheduleLabel,
|
|
7
|
+
formatAutomationRunTime,
|
|
8
|
+
} from "./automationFormat";
|
|
3
9
|
import type { AutomationSummary } from "@sentry/junior/api/schema";
|
|
4
10
|
import { Link, useLocation } from "react-router";
|
|
5
11
|
import { Detail, DetailList } from "../../components/DetailList";
|
|
@@ -34,26 +40,24 @@ export function AutomationDetailsDrawer(props: {
|
|
|
34
40
|
) : (
|
|
35
41
|
automation.createdBy
|
|
36
42
|
);
|
|
43
|
+
const conditions =
|
|
44
|
+
automation.kind === "event" ? automationEventConditions(automation) : "";
|
|
37
45
|
const details =
|
|
38
46
|
automation.kind === "scheduled"
|
|
39
47
|
? [
|
|
40
|
-
{ label: "Schedule", value: automation
|
|
48
|
+
{ label: "Schedule", value: automationScheduleLabel(automation) },
|
|
41
49
|
{
|
|
42
50
|
label: "Next run",
|
|
43
51
|
value:
|
|
44
52
|
automation.status === "active" && automation.nextRunAt
|
|
45
|
-
?
|
|
53
|
+
? formatAutomationRunTime(automation.nextRunAt)
|
|
46
54
|
: "None",
|
|
47
55
|
},
|
|
48
56
|
]
|
|
49
57
|
: [
|
|
50
58
|
{ label: "Watching", value: automation.resource },
|
|
51
|
-
{
|
|
52
|
-
|
|
53
|
-
value: automation.events
|
|
54
|
-
.map((event) => event.replaceAll(/[._]/g, " "))
|
|
55
|
-
.join(", "),
|
|
56
|
-
},
|
|
59
|
+
{ label: "Events", value: automationEventNames(automation) },
|
|
60
|
+
...(conditions ? [{ label: "Conditions", value: conditions }] : []),
|
|
57
61
|
];
|
|
58
62
|
const titleId = "automation-details-drawer-title";
|
|
59
63
|
|
|
@@ -192,10 +196,10 @@ function AutomationExecutionSummary(props: {
|
|
|
192
196
|
className="text-dashboard-text underline decoration-white/20 underline-offset-2 hover:decoration-white/60"
|
|
193
197
|
to={conversationPath(automation.lastConversationId)}
|
|
194
198
|
>
|
|
195
|
-
{
|
|
199
|
+
{formatAutomationRunTime(automation.lastRunAt)}
|
|
196
200
|
</Link>
|
|
197
201
|
) : automation.lastRunAt ? (
|
|
198
|
-
|
|
202
|
+
formatAutomationRunTime(automation.lastRunAt)
|
|
199
203
|
) : null}
|
|
200
204
|
</span>
|
|
201
205
|
</div>
|
|
@@ -208,14 +212,3 @@ function formatDate(value: string): string {
|
|
|
208
212
|
timeStyle: "short",
|
|
209
213
|
});
|
|
210
214
|
}
|
|
211
|
-
|
|
212
|
-
function formatRunDate(value: string): string {
|
|
213
|
-
return formatTime(value, {
|
|
214
|
-
day: "numeric",
|
|
215
|
-
hour: "numeric",
|
|
216
|
-
minute: "2-digit",
|
|
217
|
-
month: "short",
|
|
218
|
-
timeZoneName: "short",
|
|
219
|
-
year: "numeric",
|
|
220
|
-
});
|
|
221
|
-
}
|
|
@@ -52,9 +52,8 @@ export function AutomationEditPage(props: { enabled: boolean }) {
|
|
|
52
52
|
? "warning"
|
|
53
53
|
: "neutral";
|
|
54
54
|
return (
|
|
55
|
-
|
|
56
|
-
<
|
|
57
|
-
<div className="mx-auto w-full min-w-0 max-w-5xl px-5 pt-7 sm:px-8 sm:pt-9">
|
|
55
|
+
<AutomationsNavigation>
|
|
56
|
+
<div className="mx-auto w-full min-w-0 max-w-5xl px-4 pt-7 sm:px-8 sm:pt-9">
|
|
58
57
|
<Link
|
|
59
58
|
to={returnPath}
|
|
60
59
|
className="inline-flex min-h-8 items-center gap-2 text-sm text-dashboard-text-muted no-underline hover:text-dashboard-text"
|
|
@@ -107,7 +106,7 @@ export function AutomationEditPage(props: { enabled: boolean }) {
|
|
|
107
106
|
<AutomationReadOnly automation={value} />
|
|
108
107
|
)}
|
|
109
108
|
</div>
|
|
110
|
-
|
|
109
|
+
</AutomationsNavigation>
|
|
111
110
|
);
|
|
112
111
|
}
|
|
113
112
|
|
|
@@ -355,7 +355,7 @@ export function AutomationEditor(props: {
|
|
|
355
355
|
<p className="mb-5 text-xs leading-relaxed text-dashboard-text-muted sm:hidden">
|
|
356
356
|
Changes apply to future work. Saving does not run this automation.
|
|
357
357
|
</p>
|
|
358
|
-
<div className="sticky bottom-0 z-20 -mx-
|
|
358
|
+
<div className="sticky bottom-0 z-20 -mx-4 border-t border-dashboard-border-emphasis bg-dashboard-bg px-4 py-4 sm:-mx-8 sm:px-8">
|
|
359
359
|
<div className="flex items-center justify-between gap-3">
|
|
360
360
|
<div className="min-w-0 flex-1" role="status">
|
|
361
361
|
<p className="m-0 text-xs font-medium sm:text-sm">
|
|
@@ -6,6 +6,7 @@ import type {
|
|
|
6
6
|
AutomationListQuery,
|
|
7
7
|
} from "@sentry/junior/api/schema";
|
|
8
8
|
import { Button, ToggleButton } from "../../components/Button";
|
|
9
|
+
import { DirectorySortSelect } from "../../components/controls/DirectorySortSelect";
|
|
9
10
|
import { FilterGroup } from "../../components/FilterBar";
|
|
10
11
|
import { SearchInput } from "../../components/SearchInput";
|
|
11
12
|
|
|
@@ -25,7 +26,6 @@ export function AutomationFilters(props: {
|
|
|
25
26
|
Boolean(filters.creator),
|
|
26
27
|
Boolean(filters.destination),
|
|
27
28
|
filters.state !== "all",
|
|
28
|
-
filters.sort !== "newest",
|
|
29
29
|
].filter(Boolean).length;
|
|
30
30
|
return (
|
|
31
31
|
<div className="grid gap-3">
|
|
@@ -76,12 +76,12 @@ export function AutomationFilters(props: {
|
|
|
76
76
|
<div
|
|
77
77
|
id={controlsId}
|
|
78
78
|
className={cn(
|
|
79
|
-
"grid-cols-2 items-end gap-3 lg:grid-cols-
|
|
79
|
+
"grid-cols-2 items-end gap-3 lg:grid-cols-3 xl:grid-cols-[auto_repeat(3,minmax(0,1fr))]",
|
|
80
80
|
expanded ? "grid" : "hidden",
|
|
81
81
|
)}
|
|
82
82
|
>
|
|
83
83
|
<FilterGroup
|
|
84
|
-
className="col-span-2 lg:col-span-
|
|
84
|
+
className="col-span-2 lg:col-span-3 xl:col-span-1"
|
|
85
85
|
label="Type"
|
|
86
86
|
>
|
|
87
87
|
{(["all", "scheduled", "event"] as const).map((value) => (
|
|
@@ -123,22 +123,30 @@ export function AutomationFilters(props: {
|
|
|
123
123
|
]}
|
|
124
124
|
onChange={(value) => onChange("state", value)}
|
|
125
125
|
/>
|
|
126
|
-
<AutomationFilterSelect
|
|
127
|
-
label="Sort"
|
|
128
|
-
ariaLabel="Sort automations"
|
|
129
|
-
value={filters.sort}
|
|
130
|
-
onChange={(value) => onChange("sort", value)}
|
|
131
|
-
options={[
|
|
132
|
-
{ value: "newest", label: "Newest first" },
|
|
133
|
-
{ value: "oldest", label: "Oldest first" },
|
|
134
|
-
{ value: "title", label: "Title" },
|
|
135
|
-
]}
|
|
136
|
-
/>
|
|
137
126
|
</div>
|
|
138
127
|
</div>
|
|
139
128
|
);
|
|
140
129
|
}
|
|
141
130
|
|
|
131
|
+
/** Keep list order visible beside the result count instead of inside Filters. */
|
|
132
|
+
export function AutomationSort(props: {
|
|
133
|
+
value: AutomationListQuery["sort"];
|
|
134
|
+
onChange(value: string): void;
|
|
135
|
+
}) {
|
|
136
|
+
return (
|
|
137
|
+
<DirectorySortSelect
|
|
138
|
+
ariaLabel="Sort automations"
|
|
139
|
+
onChange={props.onChange}
|
|
140
|
+
options={[
|
|
141
|
+
{ value: "newest", label: "Recently created" },
|
|
142
|
+
{ value: "oldest", label: "Oldest created" },
|
|
143
|
+
{ value: "title", label: "Title A–Z" },
|
|
144
|
+
]}
|
|
145
|
+
value={props.value}
|
|
146
|
+
/>
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
142
150
|
function AutomationFilterSelect(props: {
|
|
143
151
|
label: string;
|
|
144
152
|
ariaLabel?: string;
|