-
Notifications
You must be signed in to change notification settings - Fork 150
feat(onboarding): pre-add teammates to the team invite list #5496
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
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| # Onboarding team-step harness | ||
|
|
||
| Renders `features/setup/flow/TeamStep` on its own — real component, real | ||
| Tailwind theme, no backend — so the screen can be driven and screenshotted | ||
| without a logged-in session. | ||
|
|
||
| The hooks the step needs (user email, contacts, onboarding state, team | ||
| queries/mutations, analytics) are swapped for fixtures in `mocks/` via Vite | ||
| aliases; everything else is the real code. | ||
|
|
||
| ```bash | ||
| bunx vite -c harness/vite.config.ts # http://localhost:5199 | ||
| bun harness/shoot.mjs ./shots # drive it + write screenshots | ||
| ``` | ||
|
|
||
| Query params: | ||
|
|
||
| - `?scenario=prefill` (default) — work-domain user with same-domain contacts, | ||
| so teammates arrive pre-added to the invite list | ||
| - `?scenario=plain` — personal-email user: no suggested domain, no prefill | ||
| - `?theme=dark` — Macro Dark instead of Macro Light | ||
|
|
||
| `shoot.mjs` also asserts behavior as it goes (which rows are pre-added, what | ||
| the remove buttons drop, what the mutation and analytics event receive) and | ||
| exits non-zero on a page error, so it doubles as a smoke test. | ||
|
|
||
| This directory sits outside `tsconfig.json`'s `include` and knip's `project` | ||
| globs on purpose — it is dev tooling, not shipped code. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| @import '../src/index.css'; | ||
|
|
||
| @source './main.tsx'; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| <!doctype html> | ||
| <html lang="en" data-theme-light="true"> | ||
| <head> | ||
| <meta charset="utf-8" /> | ||
| <meta name="viewport" content="width=device-width, initial-scale=1" /> | ||
| <title>Team step harness</title> | ||
| <style> | ||
| html, | ||
| body, | ||
| #root { | ||
| height: 100%; | ||
| margin: 0; | ||
| } | ||
| </style> | ||
| </head> | ||
| <body> | ||
| <div id="root"></div> | ||
| <script type="module" src="./main.tsx"></script> | ||
| </body> | ||
| </html> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,63 @@ | ||
| import { render } from 'solid-js/web'; | ||
| import { NoiseBackground } from '../src/features/setup/flow/shared'; | ||
| import { TeamStep } from '../src/features/setup/flow/TeamStep'; | ||
| import { DEFAULT_THEMES } from '../src/features/theme/constants'; | ||
| import './harness.css'; | ||
|
|
||
| /** Paints one of the app's real theme presets onto :root. */ | ||
| function applyTheme(id: string) { | ||
| const theme = DEFAULT_THEMES.find((t) => t.id === id); | ||
| if (!theme) throw new Error(`no theme ${id}`); | ||
| const root = document.documentElement; | ||
| for (const [key, v] of Object.entries( | ||
| theme.tokens as Record<string, { l: number; c: number; h: number }> | ||
| )) { | ||
| root.style.setProperty(`--${key}l`, `${v.l}`); | ||
| root.style.setProperty(`--${key}c`, `${v.c}`); | ||
| root.style.setProperty(`--${key}h`, `${v.h}deg`); | ||
| } | ||
| const tokens = theme.tokens as Record<string, { l: number }>; | ||
| root.dataset.themeLight = tokens.b0.l > tokens.c0.l ? 'true' : 'false'; | ||
| } | ||
|
|
||
| /** | ||
| * Renders the onboarding team step on its own, with the flow's card chrome | ||
| * copied from OnboardingFlow, so it can be screenshotted without a backend. | ||
| * `?scenario=plain` swaps in a personal-email user; `?theme=dark` flips it. | ||
| */ | ||
| function Harness() { | ||
| const params = new URLSearchParams(window.location.search); | ||
| applyTheme(params.get('theme') === 'dark' ? 'Macro Dark' : 'Macro Light'); | ||
|
|
||
| return ( | ||
| <div class="relative size-full overflow-hidden bg-surface font-sans text-ink"> | ||
| <NoiseBackground /> | ||
| <div class="relative z-10 size-full overflow-y-auto overscroll-contain"> | ||
| <div class="flex min-h-full items-center justify-center px-6 py-12"> | ||
| <div class="w-full sm:max-w-lg"> | ||
| <div class="flex flex-col gap-8"> | ||
| <div class="flex flex-col gap-1.5"> | ||
| <h1 class="text-2xl font-semibold tracking-tight text-ink"> | ||
| Macro is meant for teams | ||
| </h1> | ||
| <p class="max-w-md text-sm leading-relaxed text-ink-muted"> | ||
| Macro is built to be used with others. Invite your team to | ||
| share docs, channels, and context from day one. | ||
| </p> | ||
| </div> | ||
| <div class="flex flex-col gap-8"> | ||
| <TeamStep | ||
| onContinue={() => console.log('[continue]')} | ||
| onSkip={() => console.log('[skip]')} | ||
| /> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| const root = document.getElementById('root'); | ||
| if (root) render(() => <Harness />, root); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| export const useAnalytics = () => ({ | ||
| track: (event: string, props?: unknown) => | ||
| console.log('[analytics]', event, props), | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| import { hasWorkDomain } from '../scenario'; | ||
|
|
||
| const WORK_CONTACTS = [ | ||
| 'nikhil@macro.com', | ||
| 'priya@macro.com', | ||
| 'tom@macro.com', | ||
| 'sarah@macro.com', | ||
| 'diego@macro.com', | ||
| 'ade@macro.com', | ||
| 'ellen@macro.com', | ||
| ]; | ||
|
|
||
| // Personal-email users still have contacts — just none on a team domain. | ||
| const PERSONAL_CONTACTS = ['mom@aol.com', 'nikhil@macro.com']; | ||
|
|
||
| export const useContacts = () => () => | ||
| (hasWorkDomain() ? WORK_CONTACTS : PERSONAL_CONTACTS).map((email) => ({ | ||
| id: email, | ||
| email, | ||
| name: email.split('@')[0], | ||
| })); | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,6 @@ | ||||||||||||||||||||||||||||
| export const useUserInvitesQuery = () => ({ data: { invites: [] } }); | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| export const useJoinTeamMutation = () => ({ | ||||||||||||||||||||||||||||
| isPending: false, | ||||||||||||||||||||||||||||
| mutate: (v: unknown) => console.log('[join-team]', v), | ||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||
|
Comment on lines
+3
to
+6
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win Preserve the The consumer in Accept the options and invoke Proposed mock correction-export const useJoinTeamMutation = () => ({
+export const useJoinTeamMutation = (
+ options?: { onSuccess?: () => void }
+) => ({
isPending: false,
- mutate: (v: unknown) => console.log('[join-team]', v),
+ mutate: (v: unknown) => {
+ console.log('[join-team]', v);
+ options?.onSuccess?.();
+ },
});📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| import { hasWorkDomain } from '../scenario'; | ||
|
|
||
| export const useOnboardingQuery = () => ({ | ||
| data: { | ||
| suggested_team_domain: hasWorkDomain() ? 'macro.com' : null, | ||
| }, | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| export const useUserTeamsQuery = () => ({ data: [] as { name: string }[] }); | ||
|
|
||
| export const useCreateTeamWithInvitesMutation = () => ({ | ||
| isPending: false, | ||
| mutateAsync: async (body: unknown) => { | ||
| console.log('[create-team]', JSON.stringify(body)); | ||
| }, | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| import { hasWorkDomain } from '../scenario'; | ||
|
|
||
| /** Mirrors the real hook: a hook returning an Accessor<string | undefined>. */ | ||
| export const useEmail = () => () => | ||
| hasWorkDomain() ? 'jacob@macro.com' : 'jacob@gmail.com'; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| /** Which fixture the page renders, read from `?scenario=`. */ | ||
| export const scenario = () => | ||
| new URLSearchParams(window.location.search).get('scenario') ?? 'prefill'; | ||
|
|
||
| /** A work-domain user with same-domain contacts vs. a personal-email user. */ | ||
| export const hasWorkDomain = () => scenario() !== 'plain'; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,108 @@ | ||
| import { mkdirSync } from 'node:fs'; | ||
| import { chromium } from 'playwright'; | ||
|
|
||
| const OUT = process.argv[2] ?? '/tmp/shots'; | ||
| const BASE = 'http://127.0.0.1:5199'; | ||
| mkdirSync(OUT, { recursive: true }); | ||
|
|
||
| const browser = await chromium.launch({ | ||
| executablePath: '/opt/pw-browsers/chromium', | ||
| }); | ||
| const page = await browser.newPage({ | ||
| viewport: { width: 900, height: 1000 }, | ||
| deviceScaleFactor: 2, | ||
| }); | ||
| const errors = []; | ||
| page.on('pageerror', (e) => errors.push(String(e))); | ||
| page.on('console', (m) => { | ||
| if (m.type() === 'error') errors.push(m.text()); | ||
| else console.log(` console: ${m.text()}`); | ||
| }); | ||
|
|
||
| const shot = (name) => page.screenshot({ path: `${OUT}/${name}.png` }); | ||
|
|
||
| const open = async (query) => { | ||
| await page.goto(`${BASE}/${query}`, { waitUntil: 'networkidle' }); | ||
| await page.waitForSelector('#team-name'); | ||
| await page.waitForTimeout(400); | ||
| }; | ||
|
|
||
| const inviteValues = () => | ||
| page.$$eval('input[id^="invite-"]', (els) => els.map((e) => e.value)); | ||
|
|
||
| // 1. The new default: same-domain teammates already in the list. | ||
| await open('?scenario=prefill'); | ||
| console.log('prefilled rows:', await inviteValues()); | ||
| console.log('cta:', await page.textContent('button:has-text("Create team")')); | ||
| await shot('01-prefilled-light'); | ||
| console.log( | ||
| 'remove buttons:', | ||
| await page.$$eval('button[aria-label^="Don\'t invite"]', (e) => e.length), | ||
| 'of', | ||
| (await inviteValues()).length, | ||
| 'rows' | ||
| ); | ||
| console.log('page scrollable:', await page.evaluate(() => document.body.scrollHeight > window.innerHeight)); | ||
|
|
||
| // 2. Hover state on a remove button. | ||
| await page.hover('button[aria-label="Don\'t invite tom@macro.com"]'); | ||
| await page.waitForTimeout(200); | ||
| await shot('02-remove-hover'); | ||
|
|
||
| // 3. Two teammates removed — the X actually drops the right rows. | ||
| await page.click('button[aria-label="Don\'t invite tom@macro.com"]'); | ||
| await page.click('button[aria-label="Don\'t invite ade@macro.com"]'); | ||
| await page.waitForTimeout(200); | ||
| console.log('after removing 2:', await inviteValues()); | ||
| console.log('cta:', await page.textContent('button:has-text("Create team")')); | ||
| await shot('03-after-removing-two'); | ||
|
|
||
| // 4. Typing into the trailing empty row still works, and the row grows an X. | ||
| const slots = await page.$$('input[id^="invite-"]'); | ||
| await slots[slots.length - 1].fill('newhire@macro.com'); | ||
| await page.waitForTimeout(200); | ||
| console.log('after typing:', await inviteValues()); | ||
| await shot('04-typed-into-empty-row'); | ||
|
|
||
| // 5. What actually gets submitted (logged by the mocked mutation). | ||
| const submitted = []; | ||
| page.on('console', (m) => { | ||
| if (m.text().startsWith('[create-team]')) submitted.push(m.text()); | ||
| if (m.text().startsWith('[analytics]')) submitted.push(m.text()); | ||
| }); | ||
| await page.click('button:has-text("Create team")'); | ||
| await page.waitForTimeout(500); | ||
| console.log('submitted:', submitted.join('\n ')); | ||
|
Comment on lines
+30
to
+75
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Assert the expected invite and submission values. The script only logs 🤖 Prompt for AI Agents |
||
|
|
||
| // 6. Dark theme. | ||
| await open('?scenario=prefill&theme=dark'); | ||
| await shot('05-prefilled-dark'); | ||
|
|
||
| // 7. Personal-email user: no domain, no prefill, plain two-slot form. | ||
| await open('?scenario=plain'); | ||
| console.log('plain rows:', await inviteValues()); | ||
| console.log( | ||
| 'remove buttons on empty form:', | ||
| await page.$$eval('button[aria-label^="Remove"]', (e) => e.length), | ||
| '/ dont-invite buttons:', | ||
| await page.$$eval('button[aria-label^="Don\'t invite"]', (e) => e.length) | ||
| ); | ||
| await shot('06-personal-email-plain'); | ||
|
|
||
| // 8. Plain form, one address typed by hand — X appears only on that row. | ||
| await page.fill('#invite-0', 'someone@elsewhere.com'); | ||
| await page.waitForTimeout(200); | ||
| console.log( | ||
| 'after typing one:', | ||
| await page.$$eval('button[aria-label^="Don\'t invite"]', (e) => | ||
| e.map((b) => b.getAttribute('aria-label')) | ||
| ) | ||
| ); | ||
| await shot('07-plain-typed-one'); | ||
|
|
||
| await browser.close(); | ||
| if (errors.length) { | ||
| console.log('\nPAGE ERRORS:\n' + errors.join('\n')); | ||
| process.exit(1); | ||
| } | ||
| console.log('\nno page errors'); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| import { fileURLToPath } from 'node:url'; | ||
| import tailwind from '@tailwindcss/vite'; | ||
| import { defineConfig } from 'vite'; | ||
| import solid from 'vite-plugin-solid'; | ||
| import solidSvg from 'vite-plugin-solid-svg'; | ||
| import tsconfigPaths from 'vite-tsconfig-paths'; | ||
|
|
||
| const r = (p: string) => fileURLToPath(new URL(p, import.meta.url)); | ||
|
|
||
| const mock = (find: RegExp, file: string) => ({ | ||
| find, | ||
| replacement: r(`./mocks/${file}`), | ||
| }); | ||
|
|
||
| export default defineConfig({ | ||
| root: r('.'), | ||
| plugins: [ | ||
| tailwind(), | ||
| tsconfigPaths({ root: r('..') }), | ||
| solid(), | ||
| solidSvg({ defaultAsComponent: true }), | ||
| ], | ||
| resolve: { | ||
| dedupe: ['solid-js'], | ||
| alias: [ | ||
| mock(/^@app\/lib\/analytics\/analytics-context$/, 'analytics.ts'), | ||
| mock(/^@core\/context\/user$/, 'user.ts'), | ||
| mock(/^@queries\/contacts\/contacts$/, 'contacts.ts'), | ||
| mock(/^@queries\/onboarding$/, 'onboarding.ts'), | ||
| mock(/^@queries\/team\/invitations$/, 'invitations.ts'), | ||
| mock(/^@queries\/team\/teams$/, 'teams.ts'), | ||
| ], | ||
| }, | ||
| server: { | ||
| port: 5199, | ||
| fs: { allow: [r('../../..')] }, | ||
| }, | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Keep personal fixtures off the team domain.
PERSONAL_CONTACTScontainsnikhil@macro.com, although the comment says that personal-email users have no team-domain contacts. Replace this address with a non-macro.comfixture, or update the scenario contract if the cross-domain contact is intentional.Proposed fixture correction
📝 Committable suggestion
🤖 Prompt for AI Agents