diff --git a/docs/context/interface/dashboard.md b/docs/context/interface/dashboard.md index 056c60a6..4a70b38c 100644 --- a/docs/context/interface/dashboard.md +++ b/docs/context/interface/dashboard.md @@ -102,6 +102,11 @@ this page keeps what no single file shows. The look follows the root `design.md` name keeps filtering and gets its answer too), and while it reads, or when it is `none` or empty, it is one line. Enter (or the suggestion row) asks for the full answer (`findFull`): shelves unfiltered and lit where it landed. +- A find result opens its job (`findOpen`): the job's comparison on its platform when that shelf + compares it, else the tool in the drawer; from the Catalog page the shelf loads first and the + comparison replaces its history entry. Back returns to the Catalog page with the box, the answer + and the filtered shelves as they were: the first entry of a page opened at `/catalog` (or + `/catalog/`) has no history state, so popstate resolves it from the path. - A shelf's own box answers from that shelf only. An empty answer there is one line, "Nothing in for …", and "Search all tools" (`findEverywhere`) moves to the Catalog page with the box prefilled and the same words asked unscoped. "Request it" appears only on an unscoped gap (and on diff --git a/frontend/e2e/catalog-find.spec.ts b/frontend/e2e/catalog-find.spec.ts index 95e50400..aac40e10 100644 --- a/frontend/e2e/catalog-find.spec.ts +++ b/frontend/e2e/catalog-find.spec.ts @@ -99,3 +99,18 @@ test('an empty answer on a shelf offers the whole catalog, and asks it with the await expect(page.locator('.fa .ui-tbody .ui-tr')).toHaveCount(1) expect(asked.at(-1)).toEqual({ q: 'verify an email before sending', platform: null }) }) + +test('a result opened from the Catalog page leads to its job, and Back returns to the Catalog page', async ({ page }) => { + const asked = await answer(page, { verdict: 'strong', rows: [row('hunter.people.email.find', 0.9)] }) + await box(page).fill('find a work email') + await expect.poll(() => asked).toEqual(['find a work email']) + await page.locator('.fa .ui-tbody .ui-tr').first().click() + // the job's own comparison on its platform, every provider of it + await expect(page).toHaveURL(/\/catalog\/people\/[^/]+$/) + await expect(page.locator('h1')).toContainText('work email') + await page.goBack() + await expect(page).toHaveURL(/\/catalog$/) + await expect(page.locator('.pl.cat h1')).toContainText('tools for agents') // the Catalog page, not "Your own tools" + await expect(box(page)).toHaveValue('find a work email') + await expect(page.locator('.fa .ui-tbody .ui-tr')).toHaveCount(1) // the answer is still there +}) diff --git a/frontend/src/state/boot.js b/frontend/src/state/boot.js index 12f5d812..42f129f3 100644 --- a/frontend/src/state/boot.js +++ b/frontend/src/state/boot.js @@ -30,6 +30,10 @@ export default async function boot(){ if(d){ this.openDetail(d.kind, d.name, true); return; } const rid=(e.state&&e.state.run)||this.runFromPath(location.pathname); if(rid){ this.openRun(rid, true); return; } + // The first entry of a page opened at a public catalog URL (/catalog, /catalog/) carries no + // state and no hash: resolve it from the path, as the first load did, not as the tools view. + const cr=!(e.state&&e.state.view) && !location.hash && this.catalogFromPath(location.pathname); + if(cr){ this.openCatalogRoute(cr); return; } let v=(e.state&&e.state.view)||(location.hash||'').replace('#','')||'tools'; if(v==='billing'){ this.orgTab='billing'; v='orgs'; } if(['tools','orgs','activity','usage','admin','help','secrets','start','resources','catalog','connections','referrals','hub'].includes(v)) this.go(v, true); diff --git a/frontend/src/state/catalog.js b/frontend/src/state/catalog.js index 389916fe..155ae454 100644 --- a/frontend/src/state/catalog.js +++ b/frontend/src/state/catalog.js @@ -86,7 +86,7 @@ openPlatform(slug, fromPop, cap){ this.resetConfirms(); // Connected/not-connected is a member fact and the endpoint needs a session; a public // visitor has none, so skip it rather than fire a guaranteed 401 on every shelf view. if(!this.publicCatalog && !this.providers.length) this.loadConnections(); - this.loadPlatform(); }, + return this.loadPlatform(); }, // the shelf's data, for a caller that acts on it once loaded // Where a provider name leads: the public provider page signed out, the provider's page in the app // signed in. Both list every tool the provider serves. provUrl(service){ return this.publicCatalog ? '/tools/'+encodeURIComponent(service) : '/app/marketplace/'+encodeURIComponent(service); }, diff --git a/frontend/src/state/find.js b/frontend/src/state/find.js index 68b5e59c..200b05b7 100644 --- a/frontend/src/state/find.js +++ b/frontend/src/state/find.js @@ -214,20 +214,23 @@ export default { signed_in:!!this.authed, ...extra}); }, - // Open the platform shelf the row lives on, its search box filtered to this job. - // On a shelf's own search the answer is already on that shelf: a job several providers do opens its - // comparison, anything else opens in the tool drawer. - findOpen(group, rank){ + // Open the job a row is: its comparison on its platform when that shelf compares it (several + // providers), else the tool itself in the drawer. From another page the shelf is opened first and + // the job chosen once its data is in, the comparison replacing the shelf's history entry so Back + // returns to where the answer was. The shelf's own box is never filled with the row's name. + async findOpen(group, rank){ this.findTrackClick('job', group.platform, {provider:group.rows[0]?.provider, rank}); - if(this.view==='platform' && group.platform===this.platSlug){ - const cap=group.rows[0]?.capability; - const job=cap && this.platComparisons.find(j=>j.key===cap); - if(job) this.openComparison(job.slug); - else this.openTool(group.rows[0].id); - return; + const slug=group.platform, cap=group.rows[0]?.capability, id=group.rows[0]?.id; + const here=this.view==='platform' && slug===this.platSlug; + if(!here){ + await this.openPlatform(slug); + if(this.view!=='platform' || this.platSlug!==slug) return; // the person moved on meanwhile } - this.openPlatform(group.platform); - this.platQ=group.rows[0]?.name || group.label; + const job=cap && this.platComparisons.find(j=>j.key===cap); + if(!job){ this.openTool(id); return; } + if(here){ this.openComparison(job.slug); return; } + this.openPlatform(slug, true, job.slug); + history.replaceState({platform:slug}, '', this.platUrl(slug, job.slug)); }, // From /search into the dashboard: the platform's page. Signed diff --git a/frontend/tests/find.test.ts b/frontend/tests/find.test.ts index 5e9dc541..3ab17860 100644 --- a/frontend/tests/find.test.ts +++ b/frontend/tests/find.test.ts @@ -74,3 +74,30 @@ test('a shelf\'s answer asks the whole catalog with the same words', () => { find.findEverywhere.call(vm) expect(calls).toEqual([['exit'], ['go', 'catalog', 'verify emails'], ['run', 'verify emails', { scope: '' }]]) }) + +test('a result opens its job: the comparison when its shelf has one, else the tool; never the shelf\'s box', async () => { + const group = { platform: 'people', rows: [{ id: 'hunter.people.email.find', capability: 'people.email.find', provider: 'hunter' }] } + const calls: any[] = [] + const replaced: any[] = [] + const history0 = globalThis.history + ;(globalThis as any).history = { replaceState: (...a: any[]) => replaced.push(a) } + const vm: any = { view: 'catalog', platSlug: '', platQ: '', platComparisons: [] as any[], + findTrackClick() {}, platUrl: (slug: string, key: string) => '/catalog/' + slug + '/' + key, + async openPlatform(slug: string, fromPop?: boolean, key?: string) { + calls.push(['platform', slug, fromPop ?? false, key ?? null]); this.view = 'platform'; this.platSlug = slug }, + openComparison(key: string) { calls.push(['comparison', key]) }, openTool(id: string) { calls.push(['tool', id]) } } + try { + vm.platComparisons = [{ key: 'people.email.find', slug: 'email.find' }] + await find.findOpen.call(vm, group, 1) // from the Catalog page: shelf, then its job + expect(calls).toEqual([['platform', 'people', false, null], ['platform', 'people', true, 'email.find']]) + expect(replaced).toEqual([[{ platform: 'people' }, '', '/catalog/people/email.find']]) + calls.length = 0 + await find.findOpen.call(vm, group, 1) // already on that shelf: the comparison directly + expect(calls).toEqual([['comparison', 'email.find']]) + calls.length = 0 + vm.platComparisons = [] + await find.findOpen.call(vm, group, 1) // a job with no comparison: the tool + expect(calls).toEqual([['tool', 'hunter.people.email.find']]) + expect(vm.platQ).toBe('') + } finally { (globalThis as any).history = history0 } +})