Skip to content
Merged
7 changes: 7 additions & 0 deletions .changeset/olive-donkeys-repeat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@knocklabs/react": patch
---

Stop `sortByDisplayName` from reordering the array it is given

The MS Teams comboboxes sorted their teams and channels in place. `useMsTeamsChannels` returns a reference straight into SWR's cache, so rendering `MsTeamsChannelInTeamCombobox` reordered the cached array for every other reader of that key, including apps calling the hook themselves. The helper now copies before sorting, matching its Slack counterpart.
9 changes: 9 additions & 0 deletions .changeset/olive-eels-smile.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@knocklabs/react": patch
---

Pin every `@telegraph/*` dependency to a caret range at its latest release

These were open `>=` ranges, so an install resolved to whatever was newest on npm rather than a version this package had been built against. In practice that already meant `@telegraph/combobox` 0.6.0, a rewrite onto Base UI, so the three comboboxes are now tested and pinned against it rather than receiving it by accident.

`@telegraph/select` was a second route to the same problem: it sat on 0.1.0, which pins `@telegraph/combobox` at 0.5.0, so consumers received two copies of the combobox. Moving it to 0.2.0 leaves one.
7 changes: 7 additions & 0 deletions .changeset/quiet-hounds-wave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@knocklabs/react": patch
---

Name the Slack channel combobox trigger without changing what it renders

The trigger announced the raw Slack channel id, because an option's accessible name is derived from its children and these render an icon beside the name. The component now passes its own `aria-label`, which the trigger spreads over the one it derives, so the channel icon and the layout stay exactly as they were.
18 changes: 9 additions & 9 deletions examples/nextjs-app-dir-example/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,15 @@
"@faker-js/faker": "^9.9.0",
"@knocklabs/node": "^1.30.0",
"@knocklabs/react": "workspace:*",
"@telegraph/button": ">=0.8.0",
"@telegraph/combobox": ">=0.5.0",
"@telegraph/icon": ">=0.6.0",
"@telegraph/input": ">=0.5.0",
"@telegraph/layout": ">=0.6.1",
"@telegraph/select": ">=0.1.0",
"@telegraph/textarea": ">=0.4.0",
"@telegraph/tokens": ">=0.2.2",
"@telegraph/typography": ">=0.5.0",
"@telegraph/button": "^0.9.1",
"@telegraph/combobox": "^0.6.0",
"@telegraph/icon": "^0.6.2",
"@telegraph/input": "^0.5.2",
"@telegraph/layout": "^0.6.3",
"@telegraph/select": "^0.2.0",
"@telegraph/textarea": "^0.4.1",
"@telegraph/tokens": "^0.2.2",
"@telegraph/typography": "^0.5.2",
"framer-motion": "^12.38.0",
"next": "npm:next@15.3.6",
"next-seo": "^6.8.0",
Expand Down
18 changes: 9 additions & 9 deletions examples/nextjs-example/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,15 @@
"@faker-js/faker": "^9.9.0",
"@knocklabs/node": "^1.30.0",
"@knocklabs/react": "workspace:^",
"@telegraph/button": ">=0.8.0",
"@telegraph/combobox": ">=0.5.0",
"@telegraph/icon": ">=0.6.0",
"@telegraph/input": ">=0.5.0",
"@telegraph/layout": ">=0.6.1",
"@telegraph/select": ">=0.1.0",
"@telegraph/textarea": ">=0.4.0",
"@telegraph/tokens": ">=0.2.2",
"@telegraph/typography": ">=0.5.0",
"@telegraph/button": "^0.9.1",
"@telegraph/combobox": "^0.6.0",
"@telegraph/icon": "^0.6.2",
"@telegraph/input": "^0.5.2",
"@telegraph/layout": "^0.6.3",
"@telegraph/select": "^0.2.0",
"@telegraph/textarea": "^0.4.1",
"@telegraph/tokens": "^0.2.2",
"@telegraph/typography": "^0.5.2",
"framer-motion": "^12.38.0",
"next": "15.3.6",
"next-seo": "^6.8.0",
Expand Down
24 changes: 12 additions & 12 deletions packages/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -78,23 +78,23 @@
"@popperjs/core": "^2.11.8",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-hover-card": "^1.1.15",
"@telegraph/button": ">=0.8.0",
"@telegraph/combobox": ">=0.5.0",
"@telegraph/icon": ">=0.6.0",
"@telegraph/layout": ">=0.6.1",
"@telegraph/segmented-control": ">=0.4.0",
"@telegraph/select": ">=0.1.0",
"@telegraph/tag": ">=0.3.0",
"@telegraph/toggle": ">=0.3.0",
"@telegraph/tokens": ">=0.2.2",
"@telegraph/tooltip": ">=0.6.0",
"@telegraph/typography": ">=0.5.0",
"@telegraph/button": "^0.9.1",
"@telegraph/combobox": "^0.6.0",
"@telegraph/icon": "^0.6.2",
"@telegraph/layout": "^0.6.3",
"@telegraph/segmented-control": "^0.4.3",
"@telegraph/select": "^0.2.0",
"@telegraph/tag": "^0.3.3",
"@telegraph/toggle": "^0.3.2",
"@telegraph/tokens": "^0.2.2",
"@telegraph/tooltip": "^0.6.3",
"@telegraph/typography": "^0.5.2",
"lucide-react": "^0.544.0"
},
"devDependencies": {
"@codecov/vite-plugin": "^2.0.1",
"@tanstack/react-router": "1.168.21",
"@telegraph/style-engine": "^0.4.0",
"@telegraph/style-engine": "^0.4.2",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.2",
"@types/eslint-plugin-jsx-a11y": "^6",
Expand Down
14 changes: 14 additions & 0 deletions packages/react/setupTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,17 @@ import { afterEach } from "vitest";
afterEach(() => {
cleanup();
});

// jsdom implements neither of these, and Base UI (via @telegraph/combobox)
// calls both while positioning its popup.
if (!("ResizeObserver" in globalThis)) {
globalThis.ResizeObserver = class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
};
}

if (!Element.prototype.scrollIntoView) {
Element.prototype.scrollIntoView = () => {};
}
Original file line number Diff line number Diff line change
Expand Up @@ -64,12 +64,11 @@ export const MsTeamsChannelInTeamCombobox: FunctionComponent<
const comboboxValue = useMemo(
() =>
currentConnections
?.filter(
(connection) =>
connection.ms_teams_channel_id &&
isChannelInThisTeam(connection.ms_teams_channel_id),
)
.map((connection) => connection.ms_teams_channel_id),
?.map((connection) => connection.ms_teams_channel_id)
.filter(
(channelId): channelId is string =>
!!channelId && isChannelInThisTeam(channelId),
),
[currentConnections, isChannelInThisTeam],
);

Expand Down
4 changes: 2 additions & 2 deletions packages/react/src/modules/ms-teams/utils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
export const sortByDisplayName = <T extends { displayName: string }>(
items: T[],
items: readonly T[],
) =>
items.sort((a, b) =>
[...items].sort((a, b) =>
a.displayName.toLowerCase().localeCompare(b.displayName.toLowerCase()),
);
Original file line number Diff line number Diff line change
Expand Up @@ -62,21 +62,22 @@ export const SlackChannelCombobox: FunctionComponent<
updating: connectedChannelsUpdating,
} = useConnectedSlackChannels({ slackChannelsRecipientObject });

const currentConnectedChannels = useMemo<SlackChannelConnection[]>(() => {
// Used to make sure we're only showing currently available channels to select from.
// There are cases where a channel is "connected" in Knock, but it wouldn't be
// posting to it if the channel is private and the Slackbot doesn't belong to it,
// so the channel won't show up here and it won't be posted to.
const slackChannelsMap = new Map(
slackChannels.map((channel) => [channel.id, channel]),
);
const slackChannelsById = useMemo(
() => new Map(slackChannels.map((channel) => [channel.id, channel])),
[slackChannels],
);

return (
connectedChannels?.filter((connectedChannel) => {
return slackChannelsMap.has(connectedChannel.channel_id || "");
}) || []
);
}, [connectedChannels, slackChannels]);
const currentConnectedChannels = useMemo<SlackChannelConnection[]>(
() =>
// Used to make sure we're only showing currently available channels to select from.
// There are cases where a channel is "connected" in Knock, but it wouldn't be
// posting to it if the channel is private and the Slackbot doesn't belong to it,
// so the channel won't show up here and it won't be posted to.
connectedChannels?.filter((connectedChannel) =>
slackChannelsById.has(connectedChannel.channel_id || ""),
) || [],
[connectedChannels, slackChannelsById],
);

const inErrorState = useMemo(
() =>
Expand Down Expand Up @@ -142,10 +143,29 @@ export const SlackChannelCombobox: FunctionComponent<
]);

const comboboxValue = useMemo(
() => currentConnectedChannels.map((connection) => connection.channel_id),
() =>
currentConnectedChannels
.map((connection) => connection.channel_id)
.filter((channelId): channelId is string => !!channelId),
[currentConnectedChannels],
);

// An option's accessible name is derived from its children, and ours are
// elements (an icon beside the name), so the combobox falls back to the raw
// channel id. Name the trigger ourselves rather than flattening the option
// to a bare string, which would cost the icon.
const triggerLabel = useMemo(() => {
const connectedNames = comboboxValue
.map((channelId) => slackChannelsById.get(channelId)?.name)
.filter((name): name is string => !!name);

// Falling back to the placeholder matches what the combobox would have
// named the trigger on its own. It has to stay a string: the trigger
// spreads our props over its own `aria-label`, so undefined would leave
// the trigger unnamed rather than deferring.
return connectedNames.join(", ") || searchPlaceholder || "";
}, [slackChannelsById, comboboxValue, searchPlaceholder]);

if (slackChannels.length > MAX_ALLOWED_CHANNELS) {
return (
<SlackAddChannelInput
Expand Down Expand Up @@ -190,7 +210,7 @@ export const SlackChannelCombobox: FunctionComponent<
false
}
>
<Combobox.Trigger />
<Combobox.Trigger aria-label={triggerLabel} />
<Combobox.Content>
<Combobox.Search
label={t("slackSearchChannels")}
Expand Down
133 changes: 133 additions & 0 deletions packages/react/test/ms-teams/MsTeamsChannelCombobox.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, test, vi } from "vitest";

import { MsTeamsChannelInTeamCombobox } from "../../src/modules/ms-teams/components/MsTeamsChannelCombobox/MsTeamsChannelInTeamCombobox";
import { MsTeamsTeamCombobox } from "../../src/modules/ms-teams/components/MsTeamsChannelCombobox/MsTeamsTeamCombobox";

const teams = [
{ id: "team_1", displayName: "Engineering" },
{ id: "team_2", displayName: "Design" },
];

const channels = [
{ id: "channel_1", displayName: "General" },
{ id: "channel_2", displayName: "Releases" },
];

let connectedChannels: Array<{
ms_teams_team_id?: string;
ms_teams_channel_id?: string;
}> = [];

vi.mock("@knocklabs/react-core", async () => {
const actual = await vi.importActual("@knocklabs/react-core");
return {
...actual,
useKnockMsTeamsClient: () => ({ connectionStatus: "connected" }),
useMsTeamsTeams: () => ({ data: teams, isLoading: false }),
useMsTeamsChannels: () => ({ data: channels, isLoading: false }),
useConnectedMsTeamsChannels: () => ({
data: connectedChannels,
updateConnectedChannels: vi.fn().mockResolvedValue(undefined),
error: null,
}),
};
});

const recipientObject = { objectId: "object_123", collection: "projects" };

beforeEach(() => {
connectedChannels = [];
});

describe("MsTeamsTeamCombobox", () => {
// These options pass string children, so @telegraph/combobox resolves the
// trigger's accessible name from them without an explicit `label` prop.
test("trigger announces the selected team by display name", () => {
render(
<MsTeamsTeamCombobox
team={teams[0]!}
onTeamChange={vi.fn()}
getChannelCount={() => 0}
/>,
);

expect(screen.getByRole("combobox")).toHaveAccessibleName("Engineering");
});

test("trigger includes the connected channel count", () => {
render(
<MsTeamsTeamCombobox
team={teams[0]!}
onTeamChange={vi.fn()}
getChannelCount={() => 2}
/>,
);

expect(screen.getByRole("combobox")).toHaveAccessibleName(
"Engineering (2)",
);
});

test("shows the placeholder when no team is selected", () => {
render(
<MsTeamsTeamCombobox
team={null}
onTeamChange={vi.fn()}
getChannelCount={() => 0}
/>,
);

expect(screen.getByRole("combobox")).toHaveAccessibleName("Select team");
});
});

describe("MsTeamsChannelInTeamCombobox", () => {
test("trigger announces connected channels by display name", () => {
connectedChannels = [
{ ms_teams_team_id: "team_1", ms_teams_channel_id: "channel_1" },
];

render(
<MsTeamsChannelInTeamCombobox
teamId="team_1"
msTeamsChannelsRecipientObject={recipientObject}
/>,
);

expect(screen.getByRole("combobox")).toHaveAccessibleName("General");
});

test("ignores connections that belong to another team", () => {
connectedChannels = [
{ ms_teams_team_id: "team_1", ms_teams_channel_id: "channel_1" },
{ ms_teams_team_id: "team_2", ms_teams_channel_id: "channel_from_team_2" },
];

render(
<MsTeamsChannelInTeamCombobox
teamId="team_1"
msTeamsChannelsRecipientObject={recipientObject}
/>,
);

// The accessible name alone cannot catch this: an id with no matching
// option resolves to undefined and drops out of the name. It does render a
// tag though, so assert on that.
expect(screen.getByRole("combobox")).toHaveAccessibleName("General");
expect(screen.queryByText("channel_from_team_2")).not.toBeInTheDocument();
});

test("shows the placeholder when no channels are connected", () => {
render(
<MsTeamsChannelInTeamCombobox
teamId="team_1"
msTeamsChannelsRecipientObject={recipientObject}
/>,
);

expect(screen.getByRole("combobox")).toHaveAccessibleName(
"Select channels",
);
});
});
19 changes: 19 additions & 0 deletions packages/react/test/ms-teams/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,23 @@ describe("sortByDisplayName", () => {
"Charlie",
]);
});

test("leaves the array it was given alone", () => {
// Callers hand this the array returned by useMsTeamsTeams and
// useMsTeamsChannels, and the latter points straight into SWR's cache.
const items = [
{ id: 1, displayName: "Charlie" },
{ id: 2, displayName: "alpha" },
{ id: 3, displayName: "Bravo" },
];

const sorted = sortByDisplayName(items);

expect(items.map((i) => i.displayName)).toEqual([
"Charlie",
"alpha",
"Bravo",
]);
expect(sorted).not.toBe(items);
});
});
Loading
Loading