Skip to content

Commit e363785

Browse files
Merge master into feature/q-dev-ux
2 parents 4b65664 + c6f0d5f commit e363785

File tree

8 files changed

+301
-38
lines changed

8 files changed

+301
-38
lines changed

packages/core/src/notifications/controller.ts

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ export class NotificationsController {
5555
startUp: {},
5656
emergency: {},
5757
dismissed: [],
58+
newlyReceived: [],
5859
})
5960
}
6061

@@ -97,6 +98,17 @@ export class NotificationsController {
9798
await this.writeState()
9899
void this.notificationsNode.focusPanel()
99100
}
101+
102+
// Process on-receive behavior for newly received notifications that passes rule engine
103+
const newlyReceivedToDisplay = [...startUp, ...emergency].filter((n) => this.state.newlyReceived.includes(n.id))
104+
if (newlyReceivedToDisplay.length > 0) {
105+
await this.notificationsNode.onReceiveNotifications(newlyReceivedToDisplay)
106+
// remove displayed notifications from newlyReceived
107+
this.state.newlyReceived = this.state.newlyReceived.filter(
108+
(id) => !newlyReceivedToDisplay.some((n) => n.id === id)
109+
)
110+
await this.writeState()
111+
}
100112
}
101113

102114
/**
@@ -121,10 +133,27 @@ export class NotificationsController {
121133
getLogger('notifications').verbose('No new notifications for category: %s', category)
122134
return
123135
}
136+
// Parse the notifications
137+
const newPayload = JSON.parse(response.content)
138+
const newNotifications = newPayload.notifications ?? []
139+
140+
// Get the current notifications
141+
const currentNotifications = this.state[category].payload?.notifications ?? []
142+
const currentNotificationIds = new Set(currentNotifications.map((n: any) => n.id))
143+
144+
// Compare and find if there's any notifications newly added
145+
const addedNotifications = newNotifications.filter((n: any) => !currentNotificationIds.has(n.id))
146+
147+
if (addedNotifications.length > 0) {
148+
getLogger('notifications').verbose(
149+
'New notifications received for category %s, ids: %s',
150+
category,
151+
addedNotifications.map((n: any) => n.id).join(', ')
152+
)
153+
this.state.newlyReceived.push(...addedNotifications.map((n: any) => n.id))
154+
}
124155

125-
getLogger('notifications').verbose('ETAG has changed for notifications category: %s', category)
126-
127-
this.state[category].payload = JSON.parse(response.content)
156+
this.state[category].payload = newPayload
128157
this.state[category].eTag = response.eTag
129158
await this.writeState()
130159

packages/core/src/notifications/panelNode.ts

Lines changed: 121 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ import { contextKey, setContext } from '../shared/vscode/setContext'
1111
import { NotificationType, ToolkitNotification } from './types'
1212
import { ToolkitError } from '../shared/errors'
1313
import { isAmazonQ } from '../shared/extensionUtilities'
14+
import { getLogger } from '../shared/logger/logger'
15+
import { tempDirPath } from '../shared/filesystemUtilities'
16+
import path from 'path'
17+
import fs from '../shared/fs/fs'
1418

1519
/**
1620
* Controls the "Notifications" side panel/tree in each extension. It takes purely UX actions
@@ -126,15 +130,125 @@ export class NotificationsNode implements TreeNode {
126130
/**
127131
* Fired when a notification is clicked on in the panel. It will run any rendering
128132
* instructions included in the notification. See {@link ToolkitNotification.uiRenderInstructions}.
129-
*
130-
* TODO: implement more rendering possibilites.
131133
*/
132-
private async openNotification(notification: ToolkitNotification) {
133-
await vscode.window.showTextDocument(
134-
await vscode.workspace.openTextDocument({
135-
content: notification.uiRenderInstructions.content['en-US'].description,
136-
})
134+
public async openNotification(notification: ToolkitNotification) {
135+
switch (notification.uiRenderInstructions.onClick.type) {
136+
case 'modal':
137+
// Render blocking modal
138+
getLogger('notifications').verbose(`rendering modal for notificaiton: ${notification.id} ...`)
139+
await this.showInformationWindow(notification, 'modal')
140+
break
141+
case 'openUrl':
142+
if (!notification.uiRenderInstructions.onClick.url) {
143+
throw new ToolkitError('No url provided for onclick open url')
144+
}
145+
// Show open url option
146+
getLogger('notifications').verbose(`opening url for notification: ${notification.id} ...`)
147+
await vscode.env.openExternal(vscode.Uri.parse(notification.uiRenderInstructions.onClick.url))
148+
break
149+
case 'openTextDocument':
150+
// Display read-only txt document
151+
getLogger('notifications').verbose(`showing txt document for notification: ${notification.id} ...`)
152+
await this.showReadonlyTextDocument(notification.uiRenderInstructions.content['en-US'].description)
153+
break
154+
}
155+
}
156+
157+
/**
158+
* Shows a read only txt file for the contect of notification on a side column
159+
* It's read-only so that the "save" option doesn't appear when user closes the notification
160+
*/
161+
private async showReadonlyTextDocument(content: string): Promise<void> {
162+
try {
163+
const tempFilePath = path.join(tempDirPath, 'AWSToolkitNotifications.txt')
164+
165+
if (await fs.existsFile(tempFilePath)) {
166+
// If file exist, make sure it has write permission (0o644)
167+
await fs.chmod(tempFilePath, 0o644)
168+
}
169+
170+
await fs.writeFile(tempFilePath, content)
171+
172+
// Set the file permissions to read-only (0o444)
173+
await fs.chmod(tempFilePath, 0o444)
174+
175+
// Now, open the document
176+
const document = await vscode.workspace.openTextDocument(tempFilePath)
177+
178+
const options: vscode.TextDocumentShowOptions = {
179+
viewColumn: vscode.ViewColumn.Beside,
180+
preserveFocus: true,
181+
preview: true,
182+
}
183+
184+
await vscode.window.showTextDocument(document, options)
185+
} catch (error) {
186+
throw new ToolkitError(`Error showing text document: ${error}`)
187+
}
188+
}
189+
190+
/**
191+
* Renders information window with the notification's content and buttons.
192+
* Can be either a blocking modal or a bottom-right corner toast
193+
* Handles the button click actions based on the button type.
194+
*/
195+
public async showInformationWindow(notification: ToolkitNotification, type: string = 'toast') {
196+
const isModal = type === 'modal'
197+
198+
// modal has to have defined actions(buttons)
199+
const buttons = notification.uiRenderInstructions.actions ?? []
200+
const buttonLabels = buttons.map((actions) => actions.displayText['en-US'])
201+
const detail = notification.uiRenderInstructions.content['en-US'].description
202+
203+
// we use toastPreview to display as titlefor toast, since detail won't be shown
204+
const title = isModal
205+
? notification.uiRenderInstructions.content['en-US'].title
206+
: (notification.uiRenderInstructions.content['en-US'].toastPreview ??
207+
notification.uiRenderInstructions.content['en-US'].title)
208+
209+
const selectedText = await vscode.window.showInformationMessage(
210+
title,
211+
{ modal: isModal, detail },
212+
...buttonLabels
137213
)
214+
215+
if (selectedText) {
216+
const selectedButton = buttons.find((actions) => actions.displayText['en-US'] === selectedText)
217+
// Different button options
218+
if (selectedButton) {
219+
switch (selectedButton.type) {
220+
case 'openTxt':
221+
await this.showReadonlyTextDocument(
222+
notification.uiRenderInstructions.content['en-US'].description
223+
)
224+
break
225+
case 'updateAndReload':
226+
await this.updateAndReload(notification.displayIf.extensionId)
227+
break
228+
case 'openUrl':
229+
if (selectedButton.url) {
230+
await vscode.env.openExternal(vscode.Uri.parse(selectedButton.url))
231+
} else {
232+
throw new ToolkitError('url not provided')
233+
}
234+
break
235+
default:
236+
throw new ToolkitError('button action not defined')
237+
}
238+
}
239+
}
240+
}
241+
242+
public async onReceiveNotifications(notifications: ToolkitNotification[]) {
243+
for (const notification of notifications) {
244+
void this.showInformationWindow(notification, notification.uiRenderInstructions.onRecieve)
245+
}
246+
}
247+
248+
private async updateAndReload(id: string) {
249+
getLogger('notifications').verbose('Updating and reloading the extension...')
250+
await vscode.commands.executeCommand('workbench.extensions.installExtension', id)
251+
await vscode.commands.executeCommand('workbench.action.reloadWindow')
138252
}
139253

140254
/**

packages/core/src/notifications/types.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,21 @@ export interface UIRenderInstructions {
5151
[`en-US`]: {
5252
title: string
5353
description: string
54+
toastPreview?: string // optional property for toast
5455
}
5556
}
56-
// TODO actions
57+
onRecieve: string
58+
onClick: {
59+
type: string
60+
url?: string // optional property for 'openUrl'
61+
}
62+
actions?: Array<{
63+
type: string
64+
displayText: {
65+
[`en-US`]: string
66+
}
67+
url?: string // optional property for 'openUrl'
68+
}>
5769
}
5870

5971
/** Condition/criteria section of a notification. */
@@ -87,14 +99,16 @@ export type NotificationsState = {
8799

88100
// Util
89101
dismissed: string[]
102+
newlyReceived: string[]
90103
}
91104

92105
export const NotificationsStateConstructor: TypeConstructor<NotificationsState> = (v: unknown): NotificationsState => {
93106
const isNotificationsState = (v: Partial<NotificationsState>): v is NotificationsState => {
94-
const requiredKeys: (keyof NotificationsState)[] = ['startUp', 'emergency', 'dismissed']
107+
const requiredKeys: (keyof NotificationsState)[] = ['startUp', 'emergency', 'dismissed', 'newlyReceived']
95108
return (
96109
requiredKeys.every((key) => key in v) &&
97110
Array.isArray(v.dismissed) &&
111+
Array.isArray(v.newlyReceived) &&
98112
typeof v.startUp === 'object' &&
99113
typeof v.emergency === 'object'
100114
)
@@ -106,7 +120,7 @@ export const NotificationsStateConstructor: TypeConstructor<NotificationsState>
106120
throw new Error('Cannot cast to NotificationsState.')
107121
}
108122

109-
export type NotificationType = keyof Omit<NotificationsState, 'dismissed'>
123+
export type NotificationType = keyof Omit<NotificationsState, 'dismissed' | 'newlyReceived'>
110124

111125
export interface RuleContext {
112126
readonly ideVersion: typeof vscode.version

packages/core/src/test/notifications/controller.test.ts

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,11 @@ import { installFakeClock } from '../testUtil'
1515
import { NotificationFetcher, RemoteFetcher, ResourceResponse } from '../../notifications/controller'
1616
import { HttpResourceFetcher } from '../../shared/resourcefetcher/httpResourceFetcher'
1717

18+
// one test node to use across different tests
19+
// re-declaration would cause a command conflict
20+
export const panelNode: NotificationsNode = new NotificationsNode()
21+
1822
describe('Notifications Controller', function () {
19-
const panelNode: NotificationsNode = new NotificationsNode()
2023
const ruleEngine: RuleEngine = new RuleEngine({
2124
ideVersion: '1.83.0',
2225
extensionVersion: '1.20.0',
@@ -116,6 +119,7 @@ describe('Notifications Controller', function () {
116119
},
117120
emergency: {},
118121
dismissed: [],
122+
newlyReceived: ['id:startup2'],
119123
})
120124
assert.equal(panelNode.startUpNotifications.length, 1)
121125
assert.equal(panelNode.emergencyNotifications.length, 0)
@@ -146,6 +150,7 @@ describe('Notifications Controller', function () {
146150
eTag,
147151
},
148152
dismissed: [content.notifications[0].id],
153+
newlyReceived: ['id:emergency2'],
149154
})
150155
assert.equal(panelNode.startUpNotifications.length, 0)
151156
assert.equal(panelNode.emergencyNotifications.length, 1)
@@ -202,6 +207,7 @@ describe('Notifications Controller', function () {
202207
eTag: eTag2,
203208
},
204209
dismissed: [emergencyContent.notifications[0].id],
210+
newlyReceived: ['id:startup2', 'id:emergency2'],
205211
})
206212
assert.equal(panelNode.startUpNotifications.length, 1)
207213
assert.equal(panelNode.emergencyNotifications.length, 1)
@@ -234,6 +240,7 @@ describe('Notifications Controller', function () {
234240
},
235241
emergency: {},
236242
dismissed: [],
243+
newlyReceived: [],
237244
})
238245

239246
await dismissNotification(content.notifications[1])
@@ -246,6 +253,7 @@ describe('Notifications Controller', function () {
246253
},
247254
emergency: {},
248255
dismissed: [content.notifications[1].id],
256+
newlyReceived: [],
249257
})
250258

251259
assert.equal(panelNode.getChildren().length, 1)
@@ -284,6 +292,7 @@ describe('Notifications Controller', function () {
284292
},
285293
emergency: {},
286294
dismissed: [content.notifications[0].id],
295+
newlyReceived: [],
287296
})
288297

289298
assert.equal(panelNode.getChildren().length, 1)
@@ -335,6 +344,7 @@ describe('Notifications Controller', function () {
335344
},
336345
emergency: {},
337346
dismissed: [],
347+
newlyReceived: ['id:startup2'],
338348
})
339349
assert.equal(panelNode.getChildren().length, 1)
340350

@@ -351,6 +361,7 @@ describe('Notifications Controller', function () {
351361
},
352362
emergency: {},
353363
dismissed: [],
364+
newlyReceived: ['id:startup2'],
354365
})
355366
assert.equal(panelNode.getChildren().length, 1)
356367
})
@@ -388,6 +399,7 @@ describe('Notifications Controller', function () {
388399
eTag: '1',
389400
},
390401
dismissed: [emergencyContent.notifications[0].id, startUpContent.notifications[0].id],
402+
newlyReceived: [],
391403
})
392404

393405
const emptyContent = {
@@ -410,6 +422,7 @@ describe('Notifications Controller', function () {
410422
eTag: '1',
411423
},
412424
dismissed: [emergencyContent.notifications[0].id],
425+
newlyReceived: [],
413426
})
414427
assert.equal(panelNode.getChildren().length, 1)
415428

@@ -429,6 +442,7 @@ describe('Notifications Controller', function () {
429442
eTag: '1',
430443
},
431444
dismissed: [],
445+
newlyReceived: [],
432446
})
433447

434448
assert.equal(panelNode.getChildren().length, 0)
@@ -445,6 +459,27 @@ describe('Notifications Controller', function () {
445459
assert.doesNotThrow(() => new NotificationsController(panelNode, fetcher).pollForStartUp(ruleEngine))
446460
assert.ok(wasCalled)
447461
})
462+
463+
it('calls onReceiveNotifications when a new valid notification is added', async function () {
464+
const eTag = randomUUID()
465+
const content = {
466+
schemaVersion: '1.x',
467+
notifications: [getValidTestNotification('id:newValidNotification')],
468+
}
469+
fetcher.setStartUpContent({
470+
eTag,
471+
content: JSON.stringify(content),
472+
})
473+
474+
const onReceiveSpy = sinon.spy(panelNode, 'onReceiveNotifications')
475+
476+
await controller.pollForStartUp(ruleEngine)
477+
478+
assert.equal(onReceiveSpy.callCount, 1)
479+
assert.deepStrictEqual(onReceiveSpy.args[0][0], [content.notifications[0]])
480+
481+
onReceiveSpy.restore()
482+
})
448483
})
449484

450485
describe('RemoteFetcher', function () {
@@ -502,6 +537,11 @@ function getValidTestNotification(id: string) {
502537
description: 'test',
503538
},
504539
},
540+
onRecieve: 'toast',
541+
onClick: {
542+
type: 'openUrl',
543+
url: 'https://aws.amazon.com/visualstudiocode/',
544+
},
505545
},
506546
}
507547
}

0 commit comments

Comments
 (0)