Skip to content

Commit

Permalink
- Simplifies Socials: at.hn not managed separately anymore.
Browse files Browse the repository at this point in the history
- Introduces Playwright
- Autofilling problem fixed
- Introduction of SafeUrl for arbitrary website links
- Updates CopyToClipboardBtn using shadcn tooltip
- Fixes some cache pb where getUser would never be revalidated
- Adds new social options
- ...more!
  • Loading branch information
borisghidaglia committed Oct 9, 2024
1 parent 2827aac commit ed9a7f4
Show file tree
Hide file tree
Showing 24 changed files with 1,284 additions and 451 deletions.
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,9 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts

# playwright
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
13 changes: 9 additions & 4 deletions app/(not-root)/city/[...id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,17 +51,20 @@ async function UserTable({ city }: { city: City }) {
const users: DbUser[] = await getUsers(city.id);

return (
<div className="grid grid-cols-[max-content,max-content,1fr] gap-x-12 gap-y-1">
<div className="grid grid-cols-[max-content,1fr] gap-x-12 gap-y-1">
{users.map((user) => {
const clientUser = getClientUser(user);
const isClientUserRegisterToAtHn =
clientUser.socials?.find((s) => s.name === "at.hn") !== undefined;

return (
<Fragment key={clientUser.username}>
<p>
<ExternalLink
href={
clientUser.atHnUrl ??
`https://news.ycombinator.com/user?id=${clientUser.username}`
isClientUserRegisterToAtHn
? `https://${clientUser.username}.at.hn`
: `https://news.ycombinator.com/user?id=${clientUser.username}`
}
className="font-medium"
>
Expand All @@ -70,7 +73,9 @@ async function UserTable({ city }: { city: City }) {
</p>
<div className="mt-0.5">
{clientUser.socials ? (
<Socials socials={clientUser.socials} />
<Socials
socials={clientUser.socials.filter((s) => s.name !== "at.hn")}
/>
) : null}
</div>
{clientUser.tags ? (
Expand Down
3 changes: 2 additions & 1 deletion app/_actions/addUser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export const addUser = async (

const about = await getHnUserAboutSection(username);

if (!about)
if (about === undefined)
return {
success: false,
message: (
Expand Down Expand Up @@ -144,6 +144,7 @@ async function saveUserAndCity(
// City hackers attribute changes, cache update required
revalidateTag("cities"); // just for the hacker count...
revalidateTag(encodeURIComponent(city.id));
revalidateTag(encodeURIComponent(user.username));
revalidateTag(`${encodeURIComponent(city.id)}-users`);
return;
} else {
Expand Down
2 changes: 1 addition & 1 deletion app/_actions/deleteUser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export const deleteUser = async (
return {
success: false,
message:
"Can't remove your account if you don't give me your username 🤷",
"Can't remove your account if you don't give me your username 😢",
};

const about = await getHnUserAboutSection(username);
Expand Down
13 changes: 9 additions & 4 deletions app/_db/User.client.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
import { parseAtHnUrl, parseSocials } from "@/components/Socials";
import { parseSocials } from "@/components/Socials";
import { parseTags } from "@/components/Tags";
import { decode } from "he";
import { ClientUser, DbUser } from "./schema";

export const getClientUser = (user: DbUser): ClientUser => {
const decodedAbout = decode(user.about);
const sortedSocials = parseSocials(decodedAbout)?.sort(
({ name: nameA }, { name: nameB }) =>
nameA.toLowerCase() > nameB.toLowerCase() ? 1 : -1,
);
const sortedTags = parseTags(decodedAbout)?.sort();

return {
...user,
about: decodedAbout,
tags: parseTags(decodedAbout),
socials: parseSocials(decodedAbout),
atHnUrl: parseAtHnUrl(decodedAbout, user.username),
tags: sortedTags,
socials: sortedSocials,
};
};
6 changes: 4 additions & 2 deletions app/_db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@ export type DbUser = {
updatedAt: number;
};

export type SafeUrl = URL & { [SafeUrl]: true };
export const SafeUrl = Symbol("safeUrl");

export type ClientUser = DbUser & {
socials?: Social[];
socials?: (Social & { url?: SafeUrl; value: string })[];
tags?: string[];
atHnUrl?: string;
};

export type UserSocials = {
Expand Down
72 changes: 29 additions & 43 deletions app/_tests/hnAboutParsing.test.ts
Original file line number Diff line number Diff line change
@@ -1,78 +1,64 @@
import { describe, expect, it } from "vitest";

import {
parseAtHnUrl,
parseSocials,
supportedSocials,
} from "@/components/Socials";
import { parseSocials, Social } from "@/components/Socials";

describe("parseHnAboutSection", () => {
it("parses all social profile urls from HN user about section", () => {
const about = `
meet.hn/city/fr-Toulouse
Socials:
- sirobg.at.hn
- bsky.app/profile/boris.fyi
- cal.com/peer
- calendly.com/daedaliumx/better-call-ouss
- discord:sirobg#1499
- [email protected]
- github.com/borisghidaglia
- gitlab.com/bghidaglia_joko
- calendar.app.google/GSadqubSJfRERVLv8
- www.instagram.com/sirob.g/
- www.linkedin.com/in/boris-ghidaglia/
- www.reddit.com/user/sirobg/
- scholar.google.com/citations?user=WLN3QrAAAAAJ
- www.instagram.com/sirob.g
- www.linkedin.com/in/boris-ghidaglia
- mastodon.social/@borisfyi
- www.reddit.com/user/sirobg
- soundcloud.com/boris-ghidaglia
- open.spotify.com/user/21fck52nwq4xr65gtemvmslxq
- t.me/borisfyi
- 0m.studio
- x.com/borisfyi
- youtube.com/@borisghidaglia
- music.youtube.com/channel/UC_kEi4T_421er6ovq64GdsQ
---
`;
const validParsedUrls: {
[k in (typeof supportedSocials)[number]["name"]]: string;
const validParsedValues: {
[key in Social["name"]]: string;
} = {
"at.hn": "https://sirobg.at.hn/",
Bluesky: "https://bsky.app/profile/boris.fyi",
"Cal.com": "https://cal.com/peer",
Calendly: "https://calendly.com/daedaliumx/better-call-ouss",
Discord: "discord:sirobg#1499",
Email: "[email protected]",
Github: "https://github.com/borisghidaglia",
Gitlab: "https://gitlab.com/bghidaglia_joko",
"Google Calendar": "https://calendar.app.google/GSadqubSJfRERVLv8",
Instagram: "https://www.instagram.com/sirob.g/",
LinkedIn: "https://www.linkedin.com/in/boris-ghidaglia/",
Reddit: "https://www.reddit.com/user/sirobg/",
"Google Scholar":
"https://scholar.google.com/citations?user=WLN3QrAAAAAJ",
Instagram: "https://www.instagram.com/sirob.g",
LinkedIn: "https://www.linkedin.com/in/boris-ghidaglia",
Mastodon: "https://mastodon.social/@borisfyi",
Reddit: "https://www.reddit.com/user/sirobg",
SoundCloud: "https://soundcloud.com/boris-ghidaglia",
Spotify: "https://open.spotify.com/user/21fck52nwq4xr65gtemvmslxq",
Telegram: "https://t.me/borisfyi",
Website: "https://0m.studio/",
"X/Twitter": "https://x.com/borisfyi",
YouTube: "https://youtube.com/@borisghidaglia",
"YouTube Music":
"https://music.youtube.com/channel/UC_kEi4T_421er6ovq64GdsQ",
};
expect(parseSocials(about)).toStrictEqual(
supportedSocials.map((social) => ({
...social,
url: validParsedUrls[social.name as keyof typeof validParsedUrls],
})),
);
});

it("parses at.hn url from HN user about section", () => {
const about = `
meet.hn/city/fr-Toulouse
Socials:
- sirobg.at.hn
- bsky.app/profile/boris.fyi
- cal.com/peer
- calendly.com/daedaliumx/better-call-ouss
- calendar.app.google/GSadqubSJfRERVLv8
- www.instagram.com/sirob.g/
- www.linkedin.com/in/boris-ghidaglia/
- www.reddit.com/user/sirobg/
- soundcloud.com/boris-ghidaglia
- open.spotify.com/user/21fck52nwq4xr65gtemvmslxq
- t.me/borisfyi
- x.com/borisfyi
- music.youtube.com/channel/UC_kEi4T_421er6ovq64GdsQ
---
`;
expect(parseAtHnUrl(about, "sirobg")).toBe("https://sirobg.at.hn");
expect(
parseSocials(about)?.map((e) => e.url?.href ?? e.value),
).toStrictEqual(Object.values(validParsedValues));
});
});
149 changes: 149 additions & 0 deletions app/_tests/playwright/SignUpForm.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { test, expect } from "@playwright/test";

const socialNamesToValues = {
"Google Calendar": "GSadqubSJfRERVLv8",
LinkedIn: "boris-ghidaglia",
"X/Twitter": "borisfyi",
Telegram: "borisfyi",
Github: "borisghidaglia",
"at.hn": "sirobg.at.hn",
Bluesky: "boris.fyi",
"Cal.com": "peer",
Calendly: "daedaliumx/better-call-ouss",
Email: "[email protected]",
Gitlab: "bghidaglia_joko",
"Google Scholar": "WLN3QrAAAAAJ",
Instagram: "sirob.g",
Reddit: "sirobg",
Spotify: "21fck52nwq4xr65gtemvmslxq",
SoundCloud: "boris-ghidaglia",
YouTube: "@borisghidaglia",
"YouTube Music": "UC_kEi4T_421er6ovq64GdsQ",
Discord: "sirobg#1499",
Mastodon: "mastodon.soc/@borisfyi",
Website: "0m.studio",
} as const;

test("Full signup", async ({ page }) => {
await page.goto("http://localhost:3000/");
await page.getByPlaceholder("HN username").click();
await page.getByPlaceholder("HN username").fill("sirobg");

await page.getByPlaceholder("Search location").click();
await page.getByPlaceholder("Search location").fill("Toulouse");
await page.getByTestId("city-selector-res-0").click();

await page.getByText("Add Socials...").click();
for (const name of Object.keys(socialNamesToValues)) {
await page.getByTestId(name).click();
}

await page.getByText("Add Tags...").click();
await page.getByRole("option", { name: "AI/ML" }).click();
await page.getByRole("option", { name: "AI/ML" }).click();
await page.getByPlaceholder("Search tags...").click();
await page.getByPlaceholder("Search tags...").fill("fitne");
await page.getByPlaceholder("Search tags...").press("Enter");
await page.getByPlaceholder("Search tags...").fill("");
await page.getByPlaceholder("Search tags...").fill("free");
await page.getByPlaceholder("Search tags...").press("Enter");
await page.getByPlaceholder("Search tags...").fill("");
await page.getByPlaceholder("Search tags...").fill("Start");
await page.getByPlaceholder("Search tags...").press("Enter");
await page.getByPlaceholder("Search tags...").fill("");
await page.getByPlaceholder("Search tags...").fill("Web D");
await page.getByPlaceholder("Search tags...").press("Enter");

for (const [name, value] of Object.entries(socialNamesToValues)) {
if (name === "at.hn") continue;
await page.locator(`input[name="${name}"]`).click();
await page.locator(`input[name="${name}"]`).fill(value);
}

const page1Promise = page.waitForEvent("popup");
await page.getByRole("link", { name: "Open my HN account" }).click();
const page1 = await page1Promise;
expect(page1.url()).toBe("https://news.ycombinator.com/user?id=sirobg");
console.log(await page.getByTestId("generated-text").textContent());
expect(await page.getByTestId("generated-text").textContent()).toBe(
`meet.hn/city/43.6044622,1.4442469/ToulouseSocials:- sirobg.at.hn- bsky.app/profile/boris.fyi- cal.com/peer- calendly.com/daedaliumx/better-call-ouss- discord:sirobg#1499- [email protected] github.com/borisghidaglia- gitlab.com/bghidaglia_joko- calendar.app.google/GSadqubSJfRERVLv8- scholar.google.com/citations?user=WLN3QrAAAAAJ- instagram.com/sirob.g- linkedin.com/in/boris-ghidaglia- mastodon:mastodon.soc/@borisfyi- reddit.com/user/sirobg- soundcloud.com/boris-ghidaglia- open.spotify.com/user/21fck52nwq4xr65gtemvmslxq- t.me/borisfyi- 0m.studio- x.com/borisfyi- youtube.com/@borisghidaglia- music.youtube.com/channel/UC_kEi4T_421er6ovq64GdsQInterests:Fitness, Freelancing, Startups, Web Development---\n`,
);
});

test("Full signup using autofill", async ({ page }) => {
await page.goto("http://localhost:3000/");
await page.getByPlaceholder("HN username").click();
await page.getByPlaceholder("HN username").fill("sirobg");
await page.getByRole("button", { name: "👋" }).click();
await page.waitForTimeout(300);

expect(await page.getByTestId("generated-text").textContent()).toBe(
`meet.hn/city/43.6044622,1.4442469/ToulouseSocials:- sirobg.at.hn- bsky.app/profile/boris.fyi- cal.com/peer- calendly.com/daedaliumx/better-call-ouss- discord:sirobg#1499- [email protected] github.com/borisghidaglia- gitlab.com/bghidaglia_joko- calendar.app.google/GSadqubSJfRERVLv8- scholar.google.com/citations?user=WLN3QrAAAAAJ- instagram.com/sirob.g- linkedin.com/in/boris-ghidaglia- mastodon:mastodon.soc/@borisfyi- reddit.com/user/sirobg- soundcloud.com/boris-ghidaglia- open.spotify.com/user/21fck52nwq4xr65gtemvmslxq- t.me/borisfyi- 0m.studio- x.com/borisfyi- youtube.com/@borisghidaglia- music.youtube.com/channel/UC_kEi4T_421er6ovq64GdsQInterests:Fitness, Freelancing, Startups, Web Development---\n`,
);
});

test("Empty social doesn't lead to empty list element in generated text", async ({
page,
}) => {
await page.goto("http://localhost:3000/");
await page.getByPlaceholder("HN username").click();
await page.getByPlaceholder("HN username").fill("sirobg");
await page.getByPlaceholder("Search location").click();
await page
.getByPlaceholder("Search location")
.fill("São Paulo, Região Imediata de São Paulo");
await page.getByTestId("city-selector-res-0").click();

await page.getByText("Add Socials...").click();
await page.getByRole("option", { name: "Google Calendar" }).click();

expect(await page.getByTestId("generated-text").textContent()).toBe(
`meet.hn/city/-23.5506507,-46.6333824/São-Paulo`,
);
});

test("Autofilling doesn't keep old social input values", async ({ page }) => {
await page.goto("http://localhost:3000/");
await page.getByPlaceholder("HN username").click();
await page.getByPlaceholder("HN username").fill("sirobg");
await page.getByRole("button", { name: "👋" }).click();
await page.waitForTimeout(300);
expect(await page.getByPlaceholder("Search location").inputValue()).toBe(
"Toulouse",
);
await page.getByPlaceholder("HN username").click();
await page.getByPlaceholder("HN username").fill("uryga");
await page.waitForTimeout(500);
await page.getByRole("button", { name: "👋" }).click();
await page.waitForTimeout(300);
expect(await page.getByPlaceholder("Search location").inputValue()).toBe(
"Berlin",
);
expect(await page.locator('input[name="X\\/Twitter"]').inputValue()).toBe(
"lubieowoce",
);
expect(await page.locator('input[name="Github"]').inputValue()).toBe(
"lubieowoce",
);
});

test("Focus not lost when typing in social input", async ({ page }) => {
await page.goto("http://localhost:3000/");
await page.getByPlaceholder("HN username").click();
await page.getByPlaceholder("HN username").fill("sirobg");
await page.getByPlaceholder("Search location").click();
await page.getByPlaceholder("Search location").fill("NYC");
await page.getByTestId("city-selector-res-0").click();

await page.getByText("Add Socials...").click();
await page.getByRole("option", { name: "Google Calendar" }).click();

// We do this one sequentially to make sure SocialSelector.Input doesn't lose focus when the user types
await page
.locator('input[name="Google Calendar"]')
.pressSequentially("GSadqubSJfRERVLv8");

expect(await page.getByTestId("generated-text").textContent()).toBe(
`meet.hn/city/40.7127281,-74.0060152/New-YorkSocials:- calendar.app.google/GSadqubSJfRERVLv8---\n`,
);
});
3 changes: 2 additions & 1 deletion components/CitySelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -116,9 +116,10 @@ export function CitySelector({
</ExternalLink>
</CommandEmpty>
<CommandGroup>
{cities.map((city) => (
{cities.map((city, idx) => (
<CommandItem
key={city.id}
data-testid={`city-selector-res-${idx}`}
onSelect={() => {
// We don't want to propagate addresstype further
const { addresstype, ...bareCity } = city;
Expand Down
Loading

0 comments on commit ed9a7f4

Please sign in to comment.