Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Proposal] Feature: Keyword search capability for systems #190

Merged
merged 4 commits into from
Nov 25, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/tame-beds-hunt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@envyjs/webui': minor
---

Added new System capability to allow custom search keywords for traces
80 changes: 54 additions & 26 deletions docs/customizing.md
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 Most of these changes it seems were caused by auto formatting. Only real change is the addition of the getSearchKeywords documentation

Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
# Customizing Envy


## Contents

- [Introduction](#introduction)
Expand All @@ -9,7 +8,6 @@
- [Basic example walkthrough](#basic-example-walkthrough)
- [System implementation](#system-implementation)


## Introduction

Whilst you can run envy as a standalone viewer using the command `npx @envyjs/webui`, it is also possible to self-host the Envy viewer in order to unlock a number of customization capabilities.
Expand All @@ -18,7 +16,7 @@ This guide will walk you through how to self-host and customize your Envy viewer

## Self hosting

The `@envyjs/webui` package has a default export which is the Envy viewer root component; therefore, to self-host Envy, all you need to do is to mount this `EnvyViewer` component somewhere. It could be in a new route in your application or as a separate standalone application.
The `@envyjs/webui` package has a default export which is the Envy viewer root component; therefore, to self-host Envy, all you need to do is to mount this `EnvyViewer` component somewhere. It could be in a new route in your application or as a separate standalone application.

For example, we might choose to create a new entry point for a standalone application which can be run alongside your current appliation:

Expand All @@ -36,13 +34,13 @@ root.render(<EnvyViewer />);

### Running the Envy collector with a self-hosted viewer

Any self-hosted Envy viewer will need to connect to the Envy collector which is automatically started by the standalone Envy viewer. In order to start the collector without starting the standalone viewer, you can use the following command:
Any self-hosted Envy viewer will need to connect to the Envy collector which is automatically started by the standalone Envy viewer. In order to start the collector without starting the standalone viewer, you can use the following command:

`npx @envyjs/webui --no-ui`

You can then start your custom viewer and it will connect to this collector via web sockets on port `9999`.

The standalone Envy viewer is fully functional, and so the question should be asked "why would I self-host?". To answer that question, we should look at the ways in which Envy can be customized:
The standalone Envy viewer is fully functional, and so the question should be asked "why would I self-host?". To answer that question, we should look at the ways in which Envy can be customized:

- You can create new systems to filter traces and control presentation

Expand Down Expand Up @@ -86,7 +84,7 @@ export default class CatFactsSystem implements System<null> {
}
```

Once you have that system, we need to register it with your self-hosted Envy viewer. To do this, you can pass your custom systems in as a prop to the `EnvyViewer` component:
Once you have that system, we need to register it with your self-hosted Envy viewer. To do this, you can pass your custom systems in as a prop to the `EnvyViewer` component:

```tsx
// ./src/MyEnvyViewer.tsx
Expand All @@ -99,12 +97,7 @@ import CatFactsSystem from './systems/CatFactsSystem';
const container = document.getElementById('root');
const root = createRoot(container);

root.render(
<EnvyViewer systems={[
new CatFactsSystem()
]}
/>
);
root.render(<EnvyViewer systems={[new CatFactsSystem()]} />);
```

Once you have done this, you can start up your self-hosted Envy viewer and you will see that this system has been registered and will control how traces belonging to that system are displayed:
Expand All @@ -123,6 +116,7 @@ interface System<T = null> {
isMatch(trace: Trace): boolean;
getData?(trace: Trace): T;
getIconUri?(): string | null;
getSearchKeywords?(context: TraceContext<T>): string[];
getTraceRowData?(context: TraceContext<T>): TraceRowData | null;
getRequestDetailComponent?(context: TraceContext<T>): React.ReactNode;
getRequestBody?(context: TraceContext<T>): any;
Expand All @@ -134,23 +128,27 @@ interface System<T = null> {
---

### `name` - required

The name of the system as it would appear in the system dropdown in the header bar.

**Returns:** `string`

**Example:**

```tsx
name: 'Salesforce'
name: 'Salesforce';
```

---

### `isMatch` - required
Used to determine whether the supplied trace belongs to this system. Typically this would be determined based on host or path based details, but any details in the trace can be used to determine whether it is a match.

Used to determine whether the supplied trace belongs to this system. Typically this would be determined based on host or path based details, but any details in the trace can be used to determine whether it is a match.

**Returns:** `boolean`

**Example:**

```tsx
isMatch(trace: Trace) {
return (trace.http?.host ?? '').endsWith('.commercecloud.salesforce.com');
Expand All @@ -160,31 +158,39 @@ isMatch(trace: Trace) {
---

### `getData` - optional
Used to extract pertinent data from the trace into an object for use in other functions of the system. This must conform to the type variable used for the system class (i.e., the `T` of `System<T>`). This data will be included in the `TraceContext` data supplied to other functions of the system implementation.

Used to extract pertinent data from the trace into an object for use in other functions of the system. This must conform to the type variable used for the system class (i.e., the `T` of `System<T>`). This data will be included in the `TraceContext` data supplied to other functions of the system implementation.

**Returns:** `T`

**Example:**

```tsx
getData(trace: Trace) {
const [path, qs] = (trace.http?.path ?? '').split('?');
const query = new URLSearchParams(qs);
const productIds = path.endsWith('/product') ? query.get('ids') : [];
// parse the response and get all of the product names to display
const data = JSON.parse(trace.http?.responseBody ?? '{}');
const productNames = data.results?.map(x => x.name) ?? [];

return {
productIds
productIds,
productNames
}
}
```

---

### `getIconUri` - optional
Used to define the URI for the icon to be used for the system, as displayed in the trace list, trace detail and system dropdown. This will be used as the `src` of an HTML `<img>` element, so you can use any valid value for that.

Used to define the URI for the icon to be used for the system, as displayed in the trace list, trace detail and system dropdown. This will be used as the `src` of an HTML `<img>` element, so you can use any valid value for that.

**Returns:** `string`

**Example:**

```tsx
getIconUri() {
return '<base_64_data>'; // real base64 image data too long to use as an example
Expand All @@ -197,12 +203,30 @@ getIconUri() {

---

### `getSearchKeywords` - optional

Used to provide additional keywords related to the trace which can be used to help find the trace via the main search. Given that the main search will only look for the search term in the URL by default, this allows a system to define other data in the request or response which can be searched for.

**Returns:** `string`

**Example:**

```tsx
getSearchKeywords({ data }: TraceContext<{ productIds: string[] }>) {
return [...data.productNames, data.productCategory];
}
```

---

### `getTraceRowData` - optional
Used to specify details to appear for the trace in the trace list. Currently this defines the "data" to appear below the host and path part of the trace. This has access to both the `trace` itself and the `data` from the `getData` function.

Used to specify details to appear for the trace in the trace list. Currently this defines the "data" to appear below the host and path part of the trace. This has access to both the `trace` itself and the `data` from the `getData` function.

**Returns:** `{ data: string }`

**Example:**

```tsx
getTraceRowData({ data }: TraceContext<{ productIds: string[] }>) {
return {
Expand All @@ -218,11 +242,13 @@ getTraceRowData({ data }: TraceContext<{ productIds: string[] }>) {
---

### getRequestDetailComponent - optional

Used to render a custom component after the main request details. This has access to both the `trace` itself and the `data` from the `getData` function.

**Returns:** `React.ReactNode`

**Example:**

```tsx
getRequestDetailComponent({ data }: TraceContext<{ productIds: string[] }>) {
// note: `@envyjs/webui` exports some useful components for you to retain visual
Expand All @@ -241,13 +267,14 @@ getRequestDetailComponent({ data }: TraceContext<{ productIds: string[] }>) {

---


### `getRequestBody` - optional
Used to determine what the body of the request is. This will automatically be the contents of `trace.http.requestBody` and so typically you would not need to override this function, but if you wanted to have some control over what data is presented in the request body part of the trace details, then you can do that here.

Used to determine what the body of the request is. This will automatically be the contents of `trace.http.requestBody` and so typically you would not need to override this function, but if you wanted to have some control over what data is presented in the request body part of the trace details, then you can do that here.

**Returns:** `string | undefined`

**Example:**

```tsx
getRequestBody({ trace }: TraceContext<{ productIds: string[] }>) {
const body = JSON.parse(trace.http?.requestBody ?? '{}');
Expand All @@ -259,21 +286,20 @@ getRequestBody({ trace }: TraceContext<{ productIds: string[] }>) {
---

### getResponseDetailComponent - optional

Used to render a custom component after the main response details. This has access to both the `trace` itself and the `data` from the `getData` function.

**Returns:** `React.ReactNode`

**Example:**

```tsx
getResponseDetailComponent({ trace }: TraceContext<{ productIds: string[] }>) {
// parse the response and get all of the product names to display
const data = JSON.parse(trace.http?.responseBody ?? '{}');
const productNames = data.results?.map(x => x.name) ?? [];
getResponseDetailComponent({ data }: TraceContext<{ productIds: string[] }>) {
return (
<Fields>
<Field label="Product Names">
<ul>
{productNames.map(x => (<li key={x}>{x}</li>))}
{data.productNames.map(x => (<li key={x}>{x}</li>))}
</ul>
</Field>
</Fields>
Expand All @@ -288,11 +314,13 @@ getResponseDetailComponent({ trace }: TraceContext<{ productIds: string[] }>) {
---

### `getResponseBody` - optional
Used to determine what the body of the response is. This will automatically be the contents of `trace.http.responseBody` and so typically you would not need to override this function, but if you wanted to have some control over what data is presented in the response body part of the trace details, then you can do that here.

Used to determine what the body of the response is. This will automatically be the contents of `trace.http.responseBody` and so typically you would not need to override this function, but if you wanted to have some control over what data is presented in the response body part of the trace details, then you can do that here.

**Returns:** `string | undefined`

**Example:**

```tsx
getResponseBody({ trace }: TraceContext<{ productIds: string[] }>) {
const body = JSON.parse(trace.http?.responseBody ?? '{}');
Expand Down
4 changes: 4 additions & 0 deletions examples/apollo-client/src/viewer/systems/CocktailDb.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ export default class CocktailDbSystem implements System<CocktailDbData> {
return 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHNoYXBlLXJlbmRlcmluZz0iZ2VvbWV0cmljUHJlY2lzaW9uIiB0ZXh0LXJlbmRlcmluZz0iZ2VvbWV0cmljUHJlY2lzaW9uIiBpbWFnZS1yZW5kZXJpbmc9Im9wdGltaXplUXVhbGl0eSIgZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIHZpZXdCb3g9IjAgMCA1MDUgNTEyLjQ0Ij48cGF0aCBmaWxsPSIjRkRDNDU0IiBkPSJNNDU5LjIzNSAxNDUuNDQ1YzIwLjU3NCA4MS45NDggMTcuMDU0IDE1OS43MjItNTAuMTI0IDE4MS41ODctMzcuNTcyIDEyLjE0OS02Ny45NzkgNy43MDItOTEuNjI5LTEyLjM4OS0zMi44NzQtMjcuOTI5LTQ2LjYzOC03Ny41ODUtNTIuNTk2LTEzMC4xMDUgOTMuNzg4LTQyLjAzOSAxOTYuMDc2LTkyLjU3IDE5NC4zNDktMzkuMDkzeiIvPjxwYXRoIGZpbGw9IiM2ODM4MDAiIGZpbGwtcnVsZT0ibm9uemVybyIgZD0iTTM1Mi45NTEgNTA0LjE4M2MtNy41NTMgMS40NDMtMTQuODUyLTMuNTEyLTE2LjI5Ni0xMS4wNjQtMS40NDMtNy41NTMgMy41MTEtMTQuODUzIDExLjA2NC0xNi4yOTZsNTguNjc0LTExLjM0NS0yNS43MTUtMTI1Ljc2OWMtMS41MzMtNy41MjQgMy4zMjQtMTQuODczIDEwLjg0OS0xNi40MDYgNy41MjUtMS41MzQgMTQuODczIDMuMzIzIDE2LjQwNiAxMC44NDlsMjUuNzc1IDEyNi4wNDIgNTQuNzQ2LTEwLjU4NWM3LjU1My0xLjQ0MyAxNC44NTMgMy41MTEgMTYuMjk2IDExLjA2NCAxLjQ0MyA3LjU1My0zLjUxMSAxNC44NTItMTEuMDY0IDE2LjI5NmwtMTQwLjczNSAyNy4yMTR6Ii8+PHBhdGggZmlsbD0iI0ZEQzQ1NCIgZD0iTTQ5Ljc2OCAxNDUuNDQ1Yy0yMC41NzQgODEuOTQ4LTE3LjA1NCAxNTkuNzIyIDUwLjEyNCAxODEuNTg3IDM3LjU3MiAxMi4xNDkgNjcuOTc5IDcuNzAyIDkxLjYyOS0xMi4zODkgMzIuODc0LTI3LjkyOSA0Ni42MzgtNzcuNTg1IDUyLjU5Ni0xMzAuMTA1LTQ2Ljc2OS01NS40MTUtMTI5LjU2Ni0yNi4wNjItMTk0LjM0OS0zOS4wOTN6Ii8+PHBhdGggZmlsbD0iIzY4MzgwMCIgZmlsbC1ydWxlPSJub256ZXJvIiBkPSJNMjM5Ljk2NyA1NS44MzhjLS42NDktMy44NjItMS45NDYtNi45ODQtMy45MzQtOS4yOTgtMS45NzEtMi4yOTctNC44NjQtNC4wMTEtOC43MTktNS4wOTdsLTc2Ljk3NS0xNS4xNzhjLTQuODc0LS45NjEtOS42OTctMS45OTktMTMuOTQ3LTIuOTExLTIzLjEwMi00Ljk2NS0yMy4yMDctNC45ODUtMzUuNjQxIDI1Ljk0NC0yNy41NjggNjguNTc2LTUwLjE0NiAxMzAuMDAxLTUzLjQ3MyAxNzcuNTg3LTMuMDk1IDQ0LjIzOCAxMS4xNzEgNzYuNDA2IDU1LjYyOCA5MC44NzRsLjQwNi4xNDJjMTcuMTg3IDUuNTAzIDMyLjYwNSA3LjI1NCA0Ni4yODEgNS4zN2wuMjc4LS4wMzRjMTMuMjM5LTEuODY3IDI1LjAxNC03LjI0OCAzNS4zNTMtMTYuMDM2IDUxLjk2Ny00NC4xNTEgNTMuMTMtMTM5LjU1MiA1NC4wNDItMjE0LjYyMi4xNi0xMy4wMjguMzEyLTI1LjQ3OS43MDEtMzYuNzQxek0xMjcuNjY2IDM0Mi45NDNMMTAwLjg4IDQ3My45MjJsNTYuNDAxIDEwLjkwOGM3LjU1MyAxLjQ0NCAxMi41MDcgOC43NDMgMTEuMDY0IDE2LjI5Ni0xLjQ0MyA3LjU1My04Ljc0MyAxMi41MDctMTYuMjk2IDExLjA2NEwxMS4zMTQgNDg0Ljk3NkMzLjc2MSA0ODMuNTMzLTEuMTkzIDQ3Ni4yMzMuMjUgNDY4LjY4YzEuNDQ0LTcuNTUzIDguNzQzLTEyLjUwNyAxNi4yOTYtMTEuMDYzbDU3LjAyMiAxMS4wMjUgMjYuODQtMTMxLjI0N2MtMS4wMS0uMzAyLTIuMDE5LS42MTctMy4wMzYtLjk0M2wtLjQ5Ni0uMTQ2Yy01NS4wOS0xNy45MjYtNzIuODEyLTU3LjA3MS02OS4wNjItMTEwLjcxOCAzLjUxOC01MC4yOTYgMjYuNjM1LTExMy4zNjYgNTQuODQ4LTE4My41NDFDMTAxLjIzMy00LjE1NSAxMDEuNC00LjExNiAxNDAuNDM4IDQuMjcyYzMuODE5LjgxOSA4LjE0MiAxLjc1MyAxMy42NDEgMi44MzVsNzcuNzY5IDE1LjM2OWM4LjAyNSAyLjEyIDE0LjMyMiA1Ljk2NCAxOC45OTIgMTEuMzk0IDQuNjA4IDUuMzU2IDcuNDE0IDEyLjAzOSA4LjU0OCAxOS45MzUuMDY3LjU2Mi4wOTggMS4xMzQuMDc3IDEuNzE3LS40MjMgMTEuNzY1LS41NzYgMjQuMjI3LS43MzUgMzcuMjg1LS45NTggNzguOTk4LTIuMTgzIDE3OS4zODUtNjAuOTEyIDIyOS4yNzgtMTMuMTQzIDExLjE2OC0yOC4yMDMgMTguMDMxLTQ1LjIxMyAyMC40NDJsLS4zNC4wNTVjLTcuODIgMS4wNzktMTYuMDE5IDEuMjA0LTI0LjU5OS4zNjF6Ii8+PHBhdGggZmlsbD0iI2ZmZiIgZmlsbC1ydWxlPSJub256ZXJvIiBkPSJNMjAyLjk5NyAyMjMuMTI4YTUuNTcyIDUuNTcyIDAgMDExMS4wMzMgMS41NjhjLTMuMTE2IDIxLjQ0NC0xMC41MyAzOC41NjItMjEuNzA4IDUxLjg2NC0xMS4yMTMgMTMuMzQzLTI2LjA5MyAyMi43MjEtNDQuMTI0IDI4LjY2NGE1LjU4IDUuNTggMCAwMS03LjA0Mi0zLjU1MyA1LjU4IDUuNTggMCAwMTMuNTUyLTcuMDQzYzE2LjExNi01LjMxMSAyOS4zMTMtMTMuNTY5IDM5LjEwNy0yNS4yMjIgOS44MjUtMTEuNjkyIDE2LjM3Ni0yNi45NjEgMTkuMTgyLTQ2LjI3OHpNMzA5LjAxMSAyNDguMjY0YTUuNTczIDUuNTczIDAgMDExMC4zOC00LjA2YzcuMTQ3IDE4LjE0MiAxNy4wMjUgMzEuNTAyIDI5LjI4OSA0MC42NDggMTIuMjA5IDkuMTA3IDI2LjkzNiAxNC4xMjQgNDMuODE4IDE1LjYwMmE1LjU4MiA1LjU4MiAwIDAxLS45NTcgMTEuMTIyYy0xOC45MzktMS42NTUtMzUuNTgyLTcuMzc2LTQ5LjUzNi0xNy43ODctMTMuOTEyLTEwLjM3Ny0yNS4wNDItMjUuMzQ0LTMyLjk5NC00NS41MjV6Ii8+PHBhdGggZmlsbD0iIzY4MzgwMCIgZmlsbC1ydWxlPSJub256ZXJvIiBkPSJNMjQ5LjYyMiA1My44MDVjMS4xMjQtNy44OTYgMy45MzUtMTQuNTc1IDguNTM5LTE5LjkzNSA0LjY2OS01LjQzIDEwLjk2Ni05LjI3NCAxOC45OTQtMTEuMzk0bDc3Ljc3LTE1LjM2OWM1LjUwNi0xLjA4NiA5LjgxOC0yLjAxNiAxMy42NDEtMi44MzUgMzkuMDM1LTguMzg4IDM5LjIwNC04LjQyMyA1Ny43NzYgMzcuNzc1IDI4LjIxMyA3MC4xNzUgNTEuMzI5IDEzMy4yNDUgNTQuODQ3IDE4My41NDEgMy43NTEgNTMuNjQ3LTEzLjk3NCA5Mi43OTItNjkuMDY1IDExMC43MThsLS40OTYuMTQ2Yy0yMC4wMjEgNi40MTQtMzguMzA1IDguNDE2LTU0Ljg4OCA2LjEzbC0uMzQxLS4wNTVjLTE3LjAxLTIuNDExLTMyLjA3MS05LjI3NC00NS4yMTItMjAuNDQyLTU4LjczNy00OS44OTctNTkuOTU4LTE1MC4yOTQtNjAuOTE2LTIyOS4yOTUtLjE1Ni0xMy4wNTUtLjMwOS0yNS41MTctLjczMi0zNy4yNjgtLjAyMS0uNTgzLjAxLTEuMTU1LjA4My0xLjcxN3ptMjMuMzQ2LTcuMjY1Yy0xLjk4NSAyLjMxLTMuMjg2IDUuNDMzLTMuOTMxIDkuMjk0LjM4OCAxMS4yNjIuNTQxIDIzLjcwNi42OTcgMzYuNzI3LjkxMyA3NS4wNzggMi4wNzUgMTcwLjQ4OSA1NC4wNDYgMjE0LjY0IDEwLjMzOSA4Ljc4OCAyMi4xMTQgMTQuMTY5IDM1LjM1MyAxNi4wMzZsLjI3OC4wMzRjMTMuNjggMS44ODggMjkuMDk0LjEzMyA0Ni4yNzgtNS4zN2wuNDA2LS4xNDJjNDQuNDU2LTE0LjQ2OCA1OC43MjYtNDYuNjM2IDU1LjYzMS05MC44NzQtMy4zMjctNDcuNTg2LTI1LjkwNi0xMDkuMDExLTUzLjQ3My0xNzcuNTg3LTEyLjQzMS0zMC45MjYtMTIuNTM4LTMwLjkwOS0zNS42NDEtMjUuOTQ0LTQuMjU0LjkxMi05LjA4MyAxLjk1My0xMy45NDcgMi45MTFMMjgxLjY5IDQxLjQ0N2MtMy44NTggMS4wODItNi43NDggMi43OTktOC43MjIgNS4wOTN6Ii8+PC9zdmc+';
}

getSearchKeywords(context: TraceContext<CocktailDbData>): string[] {
return [context.data.name];
}

getTraceRowData({ data }: TraceContext<CocktailDbData>) {
return {
data: data.name,
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
"lint": "turbo run lint",
"test": "turbo run test",
"example:apollo": "concurrently \"cd examples/apollo && yarn start\" \"cd examples/apollo-client && yarn start\"",
"example:apollo:custom-viewer": "concurrently \"cd examples/apollo && yarn start\" \"cd examples/apollo-client && yarn start\"",
"example:apollo:custom-viewer": "concurrently \"cd examples/apollo && yarn start\" \"cd examples/apollo-client && yarn start:custom-viewer\"",
"example:express": "concurrently \"cd examples/express && yarn start\" \"cd examples/express-client && yarn dev\"",
"example:next": "cd examples/next && yarn && yarn dev",
"changeset": "changeset"
Expand Down
3 changes: 3 additions & 0 deletions packages/webui/pkg/demoResolver.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ const isDemo = process.env.DEMO === 'true';
const productionCode = `
const mockTraces = [];
export default mockTraces;
export function generateLotsOfMockTraces() {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 Not sure what this is all about, but it wouldn't build without it

return [];
}
export function mockTraceCollection() {
return new Map();
}
Expand Down
24 changes: 24 additions & 0 deletions packages/webui/src/components/Menu.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,30 @@ describe('Menu', () => {
expect(fn3).not.toHaveBeenCalled();
});

it('should include event object in callback when clicked', async () => {
const { getByRole, getAllByTestId } = render(<Menu label="Menu" items={itemsWithCallback} />);
const menu = getByRole('menu');

await act(async () => {
await userEvent.click(menu);
});

await act(async () => {
const user = userEvent.setup();

const listItems = getAllByTestId('menu-items-item');
const firstItem = listItems.at(0)!;

await user.keyboard('{Shift>}');
await user.keyboard('{Meta>}');
await user.click(firstItem);
await user.keyboard('{/Shift}');
await user.keyboard('{/Meta}');
Comment on lines +218 to +222
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 In the userEvent library, {Key>} means holding the key down, and {/Key} means releasing it 🤷. Basically these five lines perform a click whilst holding (and then releasing) "Shift" and "Win/Cmd" keys.

});

expect(fn1).toHaveBeenCalledWith(expect.objectContaining({ shiftKey: true, metaKey: true }));
});

it('should hide items after clicked', async () => {
const { getByRole, getAllByTestId, queryByTestId } = render(<Menu label="Menu" items={items} />);
const menu = getByRole('menu');
Expand Down
8 changes: 4 additions & 4 deletions packages/webui/src/components/Menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import Button from './Button';
export type MenuItem = {
label: string;
description?: string;
callback: () => void;
callback: (e: React.MouseEvent) => void;
};

type MenuProps = React.HTMLAttributes<HTMLDivElement> & {
Expand Down Expand Up @@ -45,8 +45,8 @@ function Menu({ Icon, label, items, className, focusKey, ...props }: MenuProps,

useClickAway(finalRef, () => setIsOpen(false));

function handleSelection(item: MenuItem) {
item.callback();
function handleSelection(e: React.MouseEvent, item: MenuItem) {
item.callback(e);
setIsOpen(false);
}

Expand All @@ -69,7 +69,7 @@ function Menu({ Icon, label, items, className, focusKey, ...props }: MenuProps,
key={x.label}
data-test-id="menu-items-item"
className="cursor-pointer py-2 px-4 hover:bg-apple-200"
onClick={() => handleSelection(x)}
onClick={e => handleSelection(e, x)}
>
<div className="flex flex-col">
<div data-test-id="label" className="block">
Expand Down
Loading