Skip to content
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
Original file line number Diff line number Diff line change
Expand Up @@ -25,19 +25,32 @@ health, and the server revalidates each submitted selection.
## Link hints and user path

Repeated `requiredServiceId` parameters on the canonical Bind or Edit URL remain
advisory hints for exact UserService IDs:
initial selection hints for exact UserService IDs:

```text
/scopes/:scopeId/channels/:registrationId/edit?requiredServiceId=:userServiceId
/scopes/:scopeId/channels/bind/:botId?skillId=:optionalSkillId&requiredServiceId=:userServiceId
```

The editor trims and deduplicates hints, accepts at most 20 nonempty values of at
most 128 characters, and ignores invalid values. Hints neither select services
nor add channel requirements. Active matching instances are marked Requested;
a same-slug instance never substitutes for the requested ID. Missing or inactive
hints get an availability notice with unresolved IDs behind a details disclosure.
There are no instructions to customize login consent.
most 128 characters, and ignores invalid values. On first entry to Bind or Edit,
the first successfully refreshed account inventory preselects active, available
exact matches alongside saved selections and built-in required services. Matches
retain their Requested label. A same-slug instance never substitutes for the
requested ID; unavailable, missing and account-denied entries are not selected.
Their existing availability notice remains, with unresolved IDs behind details.

Initialization runs once per entered channel form. Failed initial inventory loads
can retry before initialization. Later inventory refreshes, newly available
services, query-parameter changes and rerenders do not apply defaults again.
Users can deselect optional Requested services; manual changes made while cached
inventory is refreshing also take precedence over initialization. Opening another
bot or registration starts a new form with its own initial URL hints. Reopening
or fully reloading the page starts a new entry and applies the URL defaults again.

URL hints do not add mandatory requirements, authorize credentials or save a
channel. Bind bot or Save changes is still required. There are no instructions
to customize login consent.

Users search and select services, then explicitly click Bind bot or Save changes.
Skill service suggestions are withdrawn; selecting a Skill does not trigger
Expand All @@ -56,7 +69,8 @@ when the submitted configuration is observed, not when a command is accepted.

Keep the compact Channels form, search, selected count, typography and design
tokens. Route integration tests cover Bind and Edit with empty login grants,
exact-instance selection, explicit submission, Bind observation, inactive/deleted
initial exact-instance selection, one-time defaults, manual deselection, refresh
and route isolation, explicit submission, Bind observation, inactive/deleted
cleanup, failed-refresh preservation and reactivation. Adapter tests cover account
inventory, organization availability, authentication rejection and token refresh.
Full frontend typecheck, suite and production build are delegated to GitHub CI.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,12 @@ import * as React from 'react';
import { authFetch } from '@/shared/auth/fetch';
import { persistAuthSession } from '@/shared/auth/session';
import { createNyxIDServiceSession } from '../../../../tests/fixtures/nyxidServiceSession';
import { renderWithQueryClient } from '../../../../tests/reactQueryTestUtils';
import {
createTestQueryClient,
renderWithQueryClient,
} from '../../../../tests/reactQueryTestUtils';
import WorkflowActivityVNextPage from '../index';
import { channelKeys } from './queries';

jest.mock('@/shared/auth/fetch', () => ({ authFetch: jest.fn() }));
jest.mock('@/shared/auth/config', () => ({
Expand Down Expand Up @@ -133,12 +137,12 @@ beforeEach(() => {
});
});

it('binds with active services outside login consent only after explicit selection and confirms the observed result', async () => {
it('preselects requested active services outside login consent and binds only after confirmation', async () => {
grant([]);
const { queryClient } = mount();
const firecrawl = await screen.findByRole('checkbox', { name: /Firecrawl/ });
expect(firecrawl).toBeEnabled();
expect(firecrawl).not.toBeChecked();
expect(firecrawl).toBeChecked();
expect(screen.getByRole('checkbox', { name: /Lark Bot API/ })).toBeEnabled();
expect(
screen.queryByRole('button', { name: /Manage service access/ }),
Expand All @@ -147,7 +151,7 @@ it('binds with active services outside login consent only after explicit selecti
await screen.findByText('linked-default');
await chooseSupport();
fireEvent.click(screen.getByRole('checkbox', { name: /GitHub/ }));
fireEvent.click(firecrawl);
fireEvent.click(screen.getByRole('checkbox', { name: /Lark Bot API/ }));
expect(writes()).toHaveLength(0);
fireEvent.click(screen.getByRole('button', { name: 'Bind bot' }));
await screen.findByText('Confirming your changes...');
Expand Down Expand Up @@ -191,3 +195,80 @@ it('binds with active services outside login consent only after explicit selecti
'/scopes/scope-alpha/channels/reg-alpha',
);
});

it('preserves deselection across inventory refreshes and URL changes in the same form', async () => {
const { queryClient } = mount();
const firecrawl = await screen.findByRole('checkbox', { name: /Firecrawl/ });
expect(firecrawl).toBeChecked();
fireEvent.click(firecrawl);
await act(async () => {
// A new URL hint must not select an additional service after entry.
window.history.replaceState(
{},
'',
`${bindHref().split('#')[0]}&requiredServiceId=us-github`,
);
window.dispatchEvent(new PopStateEvent('popstate'));
await queryClient.invalidateQueries({
queryKey: channelKeys.services('scope-alpha'),
});
});
expect(screen.getByRole('checkbox', { name: /Firecrawl/ })).not.toBeChecked();
expect(screen.getByRole('checkbox', { name: /GitHub/ })).not.toBeChecked();
expect(screen.getByRole('checkbox', { name: /Lark Bot API/ })).toBeChecked();
expect(writes()).toHaveLength(0);
});

it('initializes a newly entered bot independently of the previous bot choices', async () => {
mount();
const firecrawl = await screen.findByRole('checkbox', { name: /Firecrawl/ });
fireEvent.click(firecrawl);
expect(firecrawl).not.toBeChecked();
await act(async () => {
window.history.replaceState({}, '', bindHref('bot-beta'));
window.dispatchEvent(new PopStateEvent('popstate'));
});
await waitFor(() =>
expect(screen.getByRole('checkbox', { name: /Firecrawl/ })).toBeChecked(),
);
expect(writes()).toHaveLength(0);
});

it('keeps manual choices made from cached inventory while the first fresh response is pending', async () => {
const normalFetch = fetchMock.getMockImplementation();
if (!normalFetch) throw new Error('Missing request fixture');
let complete!: (value: Response) => void;
const pending = new Promise<Response>((resolve) => {
complete = resolve;
});
fetchMock.mockImplementation((input, init) =>
String(input).endsWith('/user-services')
? pending
: normalFetch(input, init),
);
const queryClient = createTestQueryClient();
queryClient.setQueryData(
channelKeys.services('scope-alpha'),
inventory.map((item) => ({
id: item.id,
slug: item.slug,
label: item.label,
active: true,
allowed: true,
source: 'personal',
organizationName: null,
})),
);
window.history.replaceState({}, '', bindHref());
renderWithQueryClient(<WorkflowActivityVNextPage />, queryClient);
fireEvent.click(await screen.findByRole('checkbox', { name: /GitHub/ }));
await act(async () => {
complete(response({ services: inventory }));
});
expect(screen.getByRole('checkbox', { name: /GitHub/ })).toBeChecked();
expect(screen.getByRole('checkbox', { name: /Firecrawl/ })).not.toBeChecked();
expect(
screen.getByRole('checkbox', { name: /Lark Bot API/ }),
).not.toBeChecked();
expect(writes()).toHaveLength(0);
});
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,8 @@ function ConfigurationForm({
.filter((id) => id && id.length <= 128),
),
].slice(0, 20);
const [initialRequestedIds] = React.useState(requestedIds);
const servicesInitialized = React.useRef(false);
const [initialSkillId] = React.useState(editing ? undefined : defaultSkillId);
// Undefined means the link's default has not been resolved or overridden yet.
const [skillName, setSkillName] = React.useState<string | undefined>(() =>
Expand Down Expand Up @@ -260,18 +262,26 @@ function ConfigurationForm({
services.isFetchedAfterMount && services.isSuccess && !services.isFetching;
React.useEffect(() => {
if (!hasFreshServices) return;
// Only a successful inventory refresh removes unavailable choices. Failed
// requests preserve edits; reactivated services require a new selection.
// URL defaults apply only to the first fresh inventory for this form.
// Later refreshes remove unavailable choices without reapplying defaults.
const availableIds = new Set(
services.data
?.filter((service) => service.active && service.allowed)
.map((service) => service.id),
);
const requested = servicesInitialized.current
? []
: initialRequestedIds.filter((id) => availableIds.has(id));
servicesInitialized.current = true;
setServiceIds((selected) => {
const remaining = selected.filter((id) => availableIds.has(id));
return remaining.length === selected.length ? selected : remaining;
const next = [...new Set([...remaining, ...requested])];
return next.length === selected.length &&
next.every((id, index) => id === selected[index])
? selected
: next;
});
}, [hasFreshServices, services.data]);
}, [hasFreshServices, initialRequestedIds, services.data]);
const requiredServices = availableServices.filter((service) =>
requiredServiceSlugs.some((slug) => service.slug === slug),
);
Expand Down Expand Up @@ -593,7 +603,11 @@ function ConfigurationForm({
selectedIds={serviceIds}
requiredIds={requiredIds}
missingRequiredSlugs={missingRequiredSlugs}
onChange={setServiceIds}
onChange={(ids) => {
// An edit made from cached inventory also overrides URL defaults.
servicesInitialized.current = true;
setServiceIds(ids);
}}
loading={services.isPending}
failed={services.isError}
refreshing={services.isFetching}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { act, fireEvent, screen } from '@testing-library/react';
import { act, fireEvent, screen, waitFor } from '@testing-library/react';
import * as React from 'react';
import { authFetch } from '@/shared/auth/fetch';
import { persistAuthSession } from '@/shared/auth/session';
Expand Down Expand Up @@ -109,19 +109,19 @@ beforeEach(() => {
});
});

it('lets Edit select active services omitted at login and saves exact IDs without an access review', async () => {
it('preselects requested exact IDs on Edit without changing saved services until Save', async () => {
grant([]);
mount(
`${editHref}&requiredServiceId=us-firecrawl&requiredServiceId=unknown-service`,
);
const firecrawl = await screen.findByRole('checkbox', { name: /^Firecrawl/ });
expect(firecrawl).toBeEnabled();
expect(firecrawl).not.toBeChecked();
expect(firecrawl).toBeChecked();
expect(
screen.getByRole('checkbox', { name: /Other Firecrawl account/ }),
).not.toBeChecked();
expect(screen.getByRole('checkbox', { name: /GitHub/ })).toBeChecked();
expect(screen.getByText('3 selected')).toBeInTheDocument();
expect(screen.getByText('5 selected')).toBeInTheDocument();
expect(screen.getByText('Service not found')).toBeInTheDocument();
expect(
screen.queryByRole('button', { name: /Manage service access/ }),
Expand All @@ -130,7 +130,7 @@ it('lets Edit select active services omitted at login and saves exact IDs withou
screen.queryByText(/choose Customize under Service access/),
).not.toBeInTheDocument();
expect(writes()).toHaveLength(0);
fireEvent.click(firecrawl);
fireEvent.click(screen.getByRole('checkbox', { name: /Lark Bot API/ }));
fireEvent.click(screen.getByRole('button', { name: 'Save changes' }));
await screen.findByText('Confirming your changes...');
expect(writes()).toHaveLength(1);
Expand All @@ -144,7 +144,7 @@ it('lets Edit select active services omitted at login and saves exact IDs withou
});

it('preserves edits during failed inventory refresh, then removes inactive choices without reselecting them on reactivation', async () => {
const { queryClient } = mount();
const { queryClient } = mount(editHref.split('?')[0]);
fireEvent.change(await screen.findByLabelText('Label'), {
target: { value: 'Edited label' },
});
Expand Down Expand Up @@ -189,7 +189,9 @@ it('preserves edits during failed inventory refresh, then removes inactive choic
queryKey: channelKeys.services('scope-alpha'),
});
});
expect(screen.getByRole('checkbox', { name: /GitHub/ })).not.toBeChecked();
expect(
await screen.findByRole('checkbox', { name: /GitHub/ }),
).not.toBeChecked();
expect(screen.getByText('2 selected')).toBeInTheDocument();
const leaving = new Event('beforeunload', { cancelable: true });
window.dispatchEvent(leaving);
Expand Down Expand Up @@ -227,7 +229,7 @@ it('removes saved missing and inactive services while preserving active selectio
return Promise.resolve(response(saved));
return normalFetch(input, init);
});
mount();
mount(`${editHref.split('?')[0]}?requiredServiceId=us-firecrawl`);
await screen.findByText('Requested services unavailable');
expect(screen.getByText('Firecrawl', { exact: true })).toBeInTheDocument();
expect(
Expand All @@ -244,3 +246,71 @@ it('removes saved missing and inactive services while preserving active selectio
'us-ornn',
]);
});

it('waits for the first successful inventory and only preselects active account-available exact IDs', async () => {
const normalFetch = fetchMock.getMockImplementation();
if (!normalFetch) throw new Error('Missing request fixture');
let loaded = false;
fetchMock.mockImplementation((input, init) => {
if (String(input).endsWith('/user-services')) {
if (!loaded)
return Promise.resolve({ ok: false, status: 503 } as Response);
return Promise.resolve(
response({
services: inventory.map((item) =>
item.id === 'us-firecrawl'
? { ...item, is_active: false }
: item.id === 'us-lark'
? {
...item,
credential_source: {
type: 'org',
allowed: false,
org_name: 'Example',
},
}
: item,
),
}),
);
}
return normalFetch(input, init);
});
const { queryClient } = mount(
`${editHref}&requiredServiceId=us-firecrawl-other&requiredServiceId=unknown`,
);
await screen.findByText(
'Could not load your services. Try again before saving.',
);
expect(screen.getByText('3 selected')).toBeInTheDocument();
loaded = true;
fireEvent.click(screen.getByRole('button', { name: 'Try again' }));
const other = await screen.findByRole('checkbox', {
name: /Other Firecrawl account/,
});
await waitFor(() => expect(other).toBeChecked());
expect(
screen.queryByRole('checkbox', { name: /^Firecrawl/ }),
).not.toBeInTheDocument();
expect(
screen.queryByRole('checkbox', { name: /Lark Bot API/ }),
).not.toBeInTheDocument();
expect(screen.getByText('4 selected')).toBeInTheDocument();
expect(
screen.getByText('Requested services unavailable'),
).toBeInTheDocument();
fetchMock.mockImplementation(normalFetch);
await act(async () => {
await queryClient.invalidateQueries({
queryKey: channelKeys.services('scope-alpha'),
});
});
expect(
await screen.findByRole('checkbox', { name: /^Firecrawl/ }),
).not.toBeChecked();
expect(
screen.getByRole('checkbox', { name: /Lark Bot API/ }),
).not.toBeChecked();
expect(other).toBeChecked();
expect(writes()).toHaveLength(0);
});
Loading