@@ -23,10 +23,17 @@ vi.mock("~/components/primitives/Dialog", () => ({
2323 DialogContent : ( { children } : any ) => createElement ( "div" , null , children ) ,
2424 DialogHeader : ( { children } : any ) => createElement ( "h2" , null , children ) ,
2525} ) ) ;
26- vi . mock ( "~/hooks/useOrganizations" , ( ) => ( { useOrganization : ( ) => ( { slug : "org" } ) } ) ) ;
27- vi . mock ( "~/hooks/useProject" , ( ) => ( { useProject : ( ) => ( { slug : "proj" , id : "p1" } ) } ) ) ;
26+ vi . mock ( "~/hooks/useOrganizations" , ( ) => ( {
27+ useOrganization : ( ) => ( { slug : "org" } ) ,
28+ useOptionalOrganization : ( ) => ( { slug : "org" } ) ,
29+ } ) ) ;
30+ vi . mock ( "~/hooks/useProject" , ( ) => ( {
31+ useProject : ( ) => ( { slug : "proj" , id : "p1" } ) ,
32+ useOptionalProject : ( ) => ( { slug : "proj" , id : "p1" } ) ,
33+ } ) ) ;
2834vi . mock ( "~/hooks/useEnvironment" , ( ) => ( {
2935 useEnvironment : ( ) => ( { slug : "prod" , type : "PRODUCTION" } ) ,
36+ useOptionalEnvironment : ( ) => ( { slug : "prod" , type : "PRODUCTION" } ) ,
3037} ) ) ;
3138vi . mock ( "~/hooks/useShowSelfServe" , ( ) => ( { useShowSelfServe : ( ) => true } ) ) ;
3239vi . mock ( "~/components/Feedback" , ( ) => ( { Feedback : ( ) => null } ) ) ;
@@ -41,17 +48,34 @@ if (typeof (globalThis as any).ResizeObserver === "undefined") {
4148
4249const NEW_CHANNEL = { id : "ch_new" , name : "Email to ops@example.com" , type : "EMAIL" } ;
4350const posted : FormData [ ] = [ ] ;
44- const channelsLoader = vi . fn ( async ( ) => ( {
45- alertChannels : [ ] ,
46- limits : { used : 0 , limit : 10 } ,
47- canWrite : true ,
48- form : { slack : { status : "NOT_CONFIGURED" } , emailAlertsEnabled : true } ,
49- } ) ) ;
51+ let slack : Record < string , unknown > = { status : "NOT_CONFIGURED" } ;
52+ let existing : unknown [ ] = [ ] ;
53+ const EXISTING_CHANNEL = {
54+ id : "ch_1" ,
55+ name : "Ops webhook" ,
56+ type : "WEBHOOK" ,
57+ enabled : true ,
58+ alertTypes : [ "TASK_RUN" ] ,
59+ environmentTypes : [ "PRODUCTION" ] ,
60+ properties : { type : "WEBHOOK" , url : "https://example.com/hook" } ,
61+ } ;
62+ let hold : Promise < void > | undefined ;
63+ let startManual = false ;
64+ const channelsLoader = vi . fn ( async ( ) => {
65+ await hold ;
66+ return {
67+ alertChannels : existing ,
68+ limits : { used : existing . length , limit : 10 } ,
69+ canWrite : true ,
70+ form : { slack, emailAlertsEnabled : true } ,
71+ } ;
72+ } ) ;
5073const onCreated = vi . fn ( ) ;
5174
5275let container : HTMLElement ;
5376let root : Root ;
5477let latest : CreationFlowState | undefined ;
78+ let router : ReturnType < typeof createMemoryRouter > ;
5579
5680function Harness ( { access } : { access : AddChannelAccess } ) {
5781 const flow = useCreationFlowState ( undefined , {
@@ -63,40 +87,54 @@ function Harness({ access }: { access: AddChannelAccess }) {
6387 // eslint-disable-next-line -- test harness captures the hook return
6488 latest = flow ;
6589 const [ channels ] = useState ( [ { id : "ch_1" , name : "Ops webhook" , type : "WEBHOOK" } ] ) ;
66- return createElement ( CreationTranscript , { flow, channels, addChannel : access } ) ;
90+ const [ resumed , setResumed ] = useState ( access . resumed ?? false ) ;
91+ const [ manual , setManual ] = useState ( startManual ) ;
92+ return createElement ( CreationTranscript , {
93+ flow,
94+ channels,
95+ manual,
96+ onManualChange : setManual ,
97+ onFreeText : ( ) => { } ,
98+ addChannel : { ...access , resumed, onResumeConsumed : ( ) => setResumed ( false ) } ,
99+ } ) ;
67100}
68101
69- function mount ( access : Partial < AddChannelAccess > = { } ) {
102+ function mount ( access : Partial < AddChannelAccess > = { } , entry = "/" ) {
70103 const full : AddChannelAccess = {
71104 projectId : "p1" ,
105+ environmentId : "e1" ,
72106 canWrite : true ,
73107 atLimit : false ,
74108 onCreated,
75109 ...access ,
76110 } ;
77- const router = createMemoryRouter ( [
78- {
79- path : "/" ,
80- element : createElement (
81- OperatingSystemContextProvider ,
82- { platform : "mac" } as ComponentProps < typeof OperatingSystemContextProvider > ,
83- createElement ( ShortcutsProvider , null , createElement ( Harness , { access : full } ) )
84- ) ,
85- } ,
86- {
87- path : "/orgs/org/projects/proj/env/prod/alerts/channels" ,
88- loader : ( ) => channelsLoader ( ) ,
89- } ,
90- {
91- path : "/resources/alerts/channels" ,
92- action : async ( { request } ) => {
93- posted . push ( await request . formData ( ) ) ;
94- return { channel : NEW_CHANNEL } ;
111+ router = createMemoryRouter (
112+ [
113+ {
114+ path : "/" ,
115+ element : createElement (
116+ OperatingSystemContextProvider ,
117+ { platform : "mac" } as ComponentProps < typeof OperatingSystemContextProvider > ,
118+ createElement ( ShortcutsProvider , null , createElement ( Harness , { access : full } ) )
119+ ) ,
95120 } ,
96- } ,
97- ] ) ;
121+ {
122+ path : "/orgs/org/projects/proj/env/prod/alerts/channels" ,
123+ loader : ( ) => channelsLoader ( ) ,
124+ } ,
125+ {
126+ path : "/resources/alerts/channels" ,
127+ action : async ( { request } ) => {
128+ posted . push ( await request . formData ( ) ) ;
129+ return { channel : NEW_CHANNEL } ;
130+ } ,
131+ } ,
132+ { path : "/orgs/org/projects/proj/env/prod/alerts/new/connect-to-slack" , element : null } ,
133+ ] ,
134+ { initialEntries : [ entry ] }
135+ ) ;
98136 act ( ( ) => root . render ( createElement ( RouterProvider , { router } ) ) ) ;
99- pickCard ( "No expiry" ) ;
137+ if ( ! access . resumed ) pickCard ( "No expiry" ) ;
100138}
101139
102140function pickCard ( label : string ) {
@@ -124,6 +162,11 @@ describe("add a channel from the notifiers step", () => {
124162 posted . length = 0 ;
125163 channelsLoader . mockClear ( ) ;
126164 onCreated . mockClear ( ) ;
165+ sessionStorage . clear ( ) ;
166+ slack = { status : "NOT_CONFIGURED" } ;
167+ hold = undefined ;
168+ startManual = false ;
169+ existing = [ ] ;
127170 } ) ;
128171
129172 afterEach ( ( ) => {
@@ -192,4 +235,130 @@ describe("add a channel from the notifiers step", () => {
192235 expect ( latest ! . answers . notifiers ) . toContain ( "ch_new" ) ;
193236 expect ( latest ! . currentStep ) . toBe ( "notifiers" ) ;
194237 } ) ;
238+
239+ it ( "saves the answers and returns to the creation card when connecting Slack" , async ( ) => {
240+ const returnTo = "/orgs/org/projects/proj/env/prod/alerts?create=1&add=slack" ;
241+ mount ( { connectReturnTo : returnTo } ) ;
242+ act ( ( ) => latest ! . toggleNotifier ( "ch_1" ) ) ;
243+ act ( ( ) => addRow ( ) ! . click ( ) ) ;
244+ await flush ( ) ;
245+ const slackTab = Array . from ( document . querySelectorAll < HTMLElement > ( '[role="radio"]' ) ) . find (
246+ ( b ) => b . textContent === "Slack"
247+ ) ! ;
248+ act ( ( ) => slackTab . click ( ) ) ;
249+
250+ const connect = Array . from ( document . querySelectorAll ( "a" ) ) . find ( ( a ) =>
251+ a . textContent ?. includes ( "Connect to Slack" )
252+ ) ! ;
253+ expect ( new URL ( connect . href ) . searchParams . get ( "returnTo" ) ) . toBe ( returnTo ) ;
254+ act ( ( ) => connect . click ( ) ) ;
255+ expect ( JSON . parse ( sessionStorage . getItem ( "alerts.slackConnectResume.p1.e1" ) ! ) ) . toMatchObject ( {
256+ what : "queue.depth" ,
257+ condition : "100" ,
258+ expiry : "none" ,
259+ notifiers : [ "ch_1" ] ,
260+ } ) ;
261+ } ) ;
262+
263+ it ( "reopens the channels modal on Slack and selects the created channel" , async ( ) => {
264+ slack = {
265+ status : "READY" ,
266+ integrationId : "int_1" ,
267+ channels : [ { id : "C123" , name : "alerts" , is_private : false } ] ,
268+ } ;
269+ existing = [ EXISTING_CHANNEL ] ;
270+ mount ( { resumed : true } , "/?add=slack" ) ;
271+ await flush ( ) ;
272+ expect ( latest ! . currentStep ) . toBe ( "notifiers" ) ;
273+ expect ( document . querySelector ( '[data-testid="dialog"]' ) ) . not . toBeNull ( ) ;
274+ expect ( document . querySelector ( 'input[name="integrationId"]' ) ) . not . toBeNull ( ) ;
275+
276+ const form = document . querySelector < HTMLFormElement > ( "#create-alert" ) ! ;
277+ const value = document . createElement ( "input" ) ;
278+ value . type = "hidden" ;
279+ value . name = "channelValue" ;
280+ value . value = "C123/alerts" ;
281+ form . appendChild ( value ) ;
282+ act ( ( ) => form . requestSubmit ( ) ) ;
283+ await flush ( ) ;
284+ await flush ( ) ;
285+
286+ expect ( posted [ 0 ] ! . get ( "type" ) ) . toBe ( "SLACK" ) ;
287+ const close = Array . from ( document . querySelectorAll ( "button" ) ) . find (
288+ ( b ) => b . textContent === "Close dialog"
289+ ) ! ;
290+ act ( ( ) => close . click ( ) ) ;
291+ expect ( latest ! . answers . notifiers ) . toContain ( "ch_new" ) ;
292+ } ) ;
293+
294+ it ( "reopens on the Slack form even when the URL marker is cleared before channels load" , async ( ) => {
295+ slack = {
296+ status : "READY" ,
297+ integrationId : "int_1" ,
298+ channels : [ { id : "C123" , name : "alerts" , is_private : false } ] ,
299+ } ;
300+ existing = [ EXISTING_CHANNEL ] ;
301+ let release ! : ( ) => void ;
302+ hold = new Promise ( ( resolve ) => ( release = resolve ) ) ;
303+ mount ( { resumed : true } , "/?add=slack" ) ;
304+ await act ( async ( ) => router . navigate ( "/" , { replace : true } ) ) ;
305+ await act ( async ( ) => release ( ) ) ;
306+ await flush ( ) ;
307+ await flush ( ) ;
308+
309+ expect ( document . querySelector ( "#create-alert" ) ) . not . toBeNull ( ) ;
310+ expect ( document . querySelector ( 'input[name="integrationId"]' ) ) . not . toBeNull ( ) ;
311+ } ) ;
312+
313+ it ( "opens a later Add channel on the list, not the Slack form" , async ( ) => {
314+ slack = {
315+ status : "READY" ,
316+ integrationId : "int_1" ,
317+ channels : [ { id : "C123" , name : "alerts" , is_private : false } ] ,
318+ } ;
319+ existing = [ EXISTING_CHANNEL ] ;
320+ mount ( { resumed : true } , "/?add=slack" ) ;
321+ await flush ( ) ;
322+ expect ( document . querySelector ( 'input[name="integrationId"]' ) ) . not . toBeNull ( ) ;
323+
324+ const close = Array . from ( document . querySelectorAll ( "button" ) ) . find (
325+ ( b ) => b . textContent === "Close dialog"
326+ ) ! ;
327+ act ( ( ) => close . click ( ) ) ;
328+ pickCard ( "No expiry" ) ;
329+ act ( ( ) => addRow ( ) ! . click ( ) ) ;
330+ await flush ( ) ;
331+
332+ expect ( document . querySelector ( '[data-testid="dialog"]' ) ) . not . toBeNull ( ) ;
333+ expect ( document . querySelector ( "#create-alert" ) ) . toBeNull ( ) ;
334+ } ) ;
335+
336+ describe . each ( [
337+ [ "Manual" , true ] ,
338+ [ "Agent" , false ] ,
339+ ] ) ( "resumed in %s" , ( _label , manualFirst ) => {
340+ it ( "does not reopen the Slack dialog in the other mode after it is closed" , async ( ) => {
341+ slack = {
342+ status : "READY" ,
343+ integrationId : "int_1" ,
344+ channels : [ { id : "C123" , name : "alerts" , is_private : false } ] ,
345+ } ;
346+ existing = [ EXISTING_CHANNEL ] ;
347+ startManual = manualFirst ;
348+ mount ( { resumed : true } , "/?add=slack" ) ;
349+ await flush ( ) ;
350+ expect ( document . querySelector ( 'input[name="integrationId"]' ) ) . not . toBeNull ( ) ;
351+
352+ const close = Array . from ( document . querySelectorAll ( "button" ) ) . find (
353+ ( b ) => b . textContent === "Close dialog"
354+ ) ! ;
355+ act ( ( ) => close . click ( ) ) ;
356+ const other = document . querySelector < HTMLElement > ( '[role="radio"][aria-checked="false"]' ) ! ;
357+ act ( ( ) => other . click ( ) ) ;
358+ await flush ( ) ;
359+
360+ expect ( document . querySelector ( '[data-testid="dialog"]' ) ) . toBeNull ( ) ;
361+ expect ( document . querySelector ( "#create-alert" ) ) . toBeNull ( ) ;
362+ } ) ;
363+ } ) ;
195364} ) ;
0 commit comments