diff --git a/e2e/specs/profile-settings.spec.ts b/e2e/specs/profile-settings.spec.ts index 6da8cb3a3..26790b73a 100644 --- a/e2e/specs/profile-settings.spec.ts +++ b/e2e/specs/profile-settings.spec.ts @@ -128,14 +128,15 @@ const focusGrip = (index: number) => async (ctx: Ctx) => { await gripOf(index).locate(ctx.page, ctx).focus(); }; -const dragAbove = (index: number, over: number) => async (ctx: Ctx) => { +// the cards sit in a grid, so the drop goes to the card nearest the pointer +const dragOnto = (index: number, over: number) => async (ctx: Ctx) => { const grip = await gripOf(index).locate(ctx.page, ctx).boundingBox(); - const above = await card(over).locate(ctx.page, ctx).boundingBox(); - if (!grip || !above) throw new Error("the grip or the card to drag over is not on the page"); + const onto = await card(over).locate(ctx.page, ctx).boundingBox(); + if (!grip || !onto) throw new Error("the grip or the card to drag onto is not on the page"); const { mouse } = ctx.page; await mouse.move(grip.x + grip.width / 2, grip.y + grip.height / 2); await mouse.down(); - await mouse.move(grip.x + grip.width / 2, above.y + 4, { steps: 12 }); + await mouse.move(onto.x + onto.width / 2, onto.y + onto.height / 2, { steps: 12 }); await mouse.up(); }; @@ -172,7 +173,7 @@ flow("repositories are pinned from the picker, reordered with the keyboard and a .press("ArrowUp") .step("the third repo moved up one", listsInOrder([0, 2, 1])) - .step("drag the second repo's grip above the first card", dragAbove(1, 0)) + .step("drag the second repo's grip onto the first card", dragOnto(1, 0)) .step("the second repo is first", listsInOrder([1, 0, 2])) .click(within(card(0), testid("unpin"))) @@ -202,23 +203,77 @@ flow("repositories are pinned from the picker, reordered with the keyboard and a await expect.poll(() => shownOnProfile(ctx)).toEqual([1, 2]); }); +const startEditing = within(component("UserProfileChrome"), testid("startEditing")); +const saveProfile = within(component("UserProfileChrome"), testid("submit")); + +flow("repositories are pinned, reordered and unpinned in place on the profile page, and saved") + .actor("owner") + .repo() + .step("remember the first repo", keep) + .repo() + .step("remember the second repo", keep) + .repo() + .step("remember the third repo", keep) + + .open(page.profile) + .sees(component("PinnedRepositories")) + .click(startEditing) + .sees(within(component("PinnedRepositories"), picker)) + .use(pin(0)) + .use(pin(1)) + .use(pin(2)) + .step("they are listed in the order they were picked", listsInOrder([0, 1, 2])) + + .step("focus the third repo's grip", focusGrip(2)) + .press("ArrowLeft") + .step("the third repo moved back one", listsInOrder([0, 2, 1])) + + .step("drag the second repo's grip onto the first card", dragOnto(1, 0)) + .step("the second repo is first", listsInOrder([1, 0, 2])) + + .click(within(card(0), testid("unpin"))) + .sees(not(card(0))) + .step("the first repo is no longer listed", listsInOrder([1, 2])) + + .click(saveProfile) + .sends( + profileId, + pds, + (ctx) => ({ record: { pinnedRepositories: [nth(ctx, 1).did, nth(ctx, 2).did] } }), + "put" + ) + .trace(profileId, through(pds, hydrant, bobbin)) + .step("the pds keeps the repo dids in the saved order", async (ctx) => { + expect((await profileOnPds(ctx)).pinnedRepositories).toEqual([ + nth(ctx, 1).did, + nth(ctx, 2).did + ]); + }) + .sees(not(picker)) + .step("the profile shows the saved pins in order without a reload", async (ctx) => { + await expect.poll(() => shownOnProfile(ctx)).toEqual([1, 2]); + }) + .reload() + .step("and still does after one", async (ctx) => { + await expect.poll(() => shownOnProfile(ctx)).toEqual([1, 2]); + }); + // valid dids with no repo behind them, so filling the picker needs no repos const strangers = [..."abcdef"].map((letter) => `did:plc:${letter.repeat(24)}` as const); -flow("the picker is disabled once six repositories are pinned") +flow("the picker goes away once six repositories are pinned") .actor("owner") .step("a profile with six pins", writeProfile({ pinnedRepositories: strangers })) .open(profileSettings) - .sees(picker) - .step("the picker can't open", async (ctx) => { - await expect(pickerTrigger.locate(ctx.page, ctx)).toBeDisabled(); - }) + .sees(testid("pinnedRepo", { key: strangers[5] })) .step("six cards are listed", async (ctx) => { await expect(testid("pinnedRepo").locate(ctx.page, ctx)).toHaveCount(6); }) + .sees(not(picker)) .click(within(testid("pinnedRepo", { key: strangers[0] }), testid("unpin"))) - .step("unpinning one opens the picker again", async (ctx) => { + .sees(picker) + .step("unpinning one brings the picker back", async (ctx) => { await expect(pickerTrigger.locate(ctx.page, ctx)).toBeEnabled(); }); diff --git a/e2e/specs/profile.spec.ts b/e2e/specs/profile.spec.ts index 8197d8527..ee5a6d9b7 100644 --- a/e2e/specs/profile.spec.ts +++ b/e2e/specs/profile.spec.ts @@ -41,7 +41,6 @@ const richProfile = (ctx: Ctx) => pinnedRepositories: [ctx.repo.did] }) satisfies Profile; -// the ui can't pin a repo, so the pin has to be written from node const writeRichProfile = async (ctx: Ctx) => { const written = await pdsCall(ctx.actor, "com.atproto.repo.putRecord", { collection: profileId.nsid, @@ -72,9 +71,9 @@ flow("editing the bio on the profile page keeps the pinned repo and the other fi .open(page.profile) .sees(pinnedRepo) - .click(within(component("UserProfileChrome"), testid("setEditing"))) + .click(within(component("UserProfileChrome"), testid("startEditing"))) .fill(within(component("ProfileEditForm"), testid("description")), "after") - .click(within(component("ProfileEditForm"), testid("submit"))) + .click(within(component("UserProfileChrome"), testid("submit"))) .sends(profileId, pds, bioAfter, "put") .trace(profileId, through(pds, hydrant, bobbin)) .step("the pds keeps the pin, the pronouns, the location and the link", keepsTheRest) @@ -140,9 +139,9 @@ flow("a javascript: link in a profile is not shown as a link") .actor("owner") .open(page.profile) - .click(within(component("UserProfileChrome"), testid("setEditing"))) + .click(within(component("UserProfileChrome"), testid("startEditing"))) .fill(testid("socialLink", { index: 0 }), scripted) - .click(within(component("ProfileEditForm"), testid("submit"))) + .click(within(component("UserProfileChrome"), testid("submit"))) .sends(profileId, pds, { record: { links: [scripted] } }, "put") .trace(profileId, through(pds, hydrant, bobbin)) .sees(not(javascriptLink)) diff --git a/web/src/lib/components/settings/PinnedReposEditor.svelte b/web/src/lib/components/profile/PinnedReposEditor.svelte similarity index 55% rename from web/src/lib/components/settings/PinnedReposEditor.svelte rename to web/src/lib/components/profile/PinnedReposEditor.svelte index f7f3b1894..c48427353 100644 --- a/web/src/lib/components/settings/PinnedReposEditor.svelte +++ b/web/src/lib/components/profile/PinnedReposEditor.svelte @@ -15,9 +15,9 @@ } from "#lib/api/pinning.ts"; import { getAuth } from "#lib/auth.svelte.ts"; import { fetchPinnedByKey, fetchReposPage } from "#lib/components/profile/pages.ts"; + import { dropIndex, shiftFor, type Slot } from "#lib/components/profile/pinnedReorder.ts"; import type { RepoCardData } from "#lib/components/profile/types.ts"; import RepoCard from "#lib/components/repo/RepoCard.svelte"; - import { dropIndex, shiftFor, type Slot } from "#lib/components/settings/pinnedReorder.ts"; import Button from "#lib/components/ui/Button.svelte"; import Card from "#lib/components/ui/Card.svelte"; import Combobox from "#lib/components/ui/Combobox.svelte"; @@ -30,14 +30,21 @@ keys: PinnedKey[]; did: string; handle: string; + cards?: Record; + showOwner?: boolean; } - let { keys = $bindable(), did, handle }: Props = $props(); + let { + keys = $bindable(), + did, + handle, + cards = $bindable({}), + showOwner = false + }: Props = $props(); const auth = getAuth(); const ctx = $derived(createBobbinClient({ serviceUrl: auth.bobbinUrl })); - let cards = $state>({}); let loadError = $state(); // not state: it only stops a key being fetched twice // eslint-disable-next-line svelte/prefer-svelte-reactivity @@ -117,7 +124,7 @@ let items: HTMLElement[] = []; let grips: Record = {}; let status = $state(""); - let drag = $state<{ from: number; to: number; offset: number; slots: Slot[] }>(); + let drag = $state<{ from: number; to: number; x: number; y: number; slots: Slot[] }>(); const labelOf = (key: string) => cards[key]?.name ?? key; @@ -130,10 +137,18 @@ grips[key]?.focus(); }; + const steps: Record = { + ArrowUp: -1, + ArrowLeft: -1, + ArrowDown: 1, + ArrowRight: 1 + }; + const onGripKeydown = (event: KeyboardEvent, index: number) => { - if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return; + const step = steps[event.key]; + if (step === undefined) return; event.preventDefault(); - void reorder(index, index + (event.key === "ArrowUp" ? -1 : 1)); + void reorder(index, index + step); }; const onGripPointerdown = (event: PointerEvent, from: number) => { @@ -142,15 +157,20 @@ (event.currentTarget as HTMLElement).focus(); stopDrag?.(); const slots = items.slice(0, keys.length).map((item) => { - const { top, height } = item.getBoundingClientRect(); - return { top, height }; + const { left, top, width, height } = item.getBoundingClientRect(); + return { left, top, width, height }; }); - const centers = slots.map((slot) => slot.top + slot.height / 2); + const startX = event.clientX; const startY = event.clientY; const move = (moveEvent: PointerEvent) => { - const offset = moveEvent.clientY - startY; - drag = { from, to: dropIndex(centers, centers[from] + offset), offset, slots }; + drag = { + from, + to: dropIndex(slots, moveEvent.clientX, moveEvent.clientY), + x: moveEvent.clientX - startX, + y: moveEvent.clientY - startY, + slots + }; }; const drop = () => end(true); const abort = () => end(false); @@ -171,99 +191,108 @@ const transformFor = (index: number) => { if (!drag) return undefined; - const offset = - index === drag.from ? drag.offset : shiftFor(index, drag.from, drag.to, drag.slots); - return offset === 0 ? undefined : `translateY(${offset}px)`; + const { x, y } = + index === drag.from ? drag : shiftFor(index, drag.from, drag.to, drag.slots); + return x === 0 && y === 0 ? undefined : `translate(${x}px, ${y}px)`; }; -
- - +{#snippet tile({ props }: { props: Record })} + +{/snippet} - {#if keys.length > 0} -
    - {#each keys as key, index (key)} - {@const card = cards[key]} -
  • - {#snippet actions()} -
    - +
    + {/snippet} + {#if card} + + {:else} + +
    + {key} -
    - {/snippet} - {#if card} - - {:else} - -
    - {key} - {@render actions()} -
    -
    - {/if} -
  • - {/each} -
-

- Drag the handle to reorder, or focus it and use the arrow keys. {keys.length} of {PINNED_LIMIT} + + {/if} + + {/each} + {#if !full} +

  • + +
  • + {/if} + +

    + {#if keys.length > 0} + Drag a handle to reorder, or focus it and use the arrow keys. {keys.length} of {PINNED_LIMIT} pinned. -

    - {:else} -

    - Nothing pinned. Pinned repositories show on your profile overview. -

    - {/if} + {:else} + Nothing pinned yet. Pin up to {PINNED_LIMIT} repositories to show them on your profile. + {/if} +

    {status}
    diff --git a/web/src/lib/components/profile/PinnedRepositories.svelte b/web/src/lib/components/profile/PinnedRepositories.svelte index b6a788cff..72e3e7da2 100644 --- a/web/src/lib/components/profile/PinnedRepositories.svelte +++ b/web/src/lib/components/profile/PinnedRepositories.svelte @@ -1,6 +1,10 @@ -
    +

    Pinned repositories

    - {#if repositories.length > 0} -
    - {#each repositories.slice(0, 4) as repo (repoKey(repo))} + {#if edit?.editing} + + {:else if shown.length > 0} +
    + {#each shown.slice(0, PINNED_LIMIT) as repo (repoKey(repo))} {/each}
    diff --git a/web/src/lib/components/profile/ProfileChrome.stories.svelte b/web/src/lib/components/profile/ProfileChrome.stories.svelte index ae9a84e37..c41b3fe93 100644 --- a/web/src/lib/components/profile/ProfileChrome.stories.svelte +++ b/web/src/lib/components/profile/ProfileChrome.stories.svelte @@ -1,6 +1,6 @@ @@ -147,6 +237,10 @@ {/snippet} +{#snippet pinnedContent()} + +{/snippet} + {#snippet organizationContent()} {/snippet} @@ -163,6 +257,35 @@ /> + + + + + + + + import { untrack } from "svelte"; - import Check from "$icon/check"; import Link from "$icon/link"; import MapPin from "$icon/map-pin"; - import X from "$icon/x"; - import Button from "#lib/components/ui/Button.svelte"; import Field from "#lib/components/ui/Field.svelte"; import Input from "#lib/components/ui/Input.svelte"; import Textarea from "#lib/components/ui/Textarea.svelte"; import { getAuth } from "#lib/auth.svelte.ts"; import { PROFILE_LIMITS, putProfile, uploadProfileAvatar } from "#lib/api/profile.ts"; import { awaitIndexedOrSaved } from "#lib/api/awaitRecord.ts"; + import type { PinnedKey } from "#lib/api/pinning.ts"; import { LINK_SLOTS, normalizeProfileLink } from "#lib/api/profileSettings.ts"; import { bumpAvatar } from "#lib/avatar.ts"; import { createBobbinClient } from "#lib/api/client.ts"; import type { ProfileRecord } from "#lib/api/records.ts"; - import { createAction } from "#lib/action.svelte.ts"; interface Props { + id: string; profile: ProfileRecord | null; + pinned: PinnedKey[]; avatarFile?: File | null; avatarError?: string; - onSaved: (record: ProfileRecord) => void; - onCancel: () => void; + error?: string; + onsubmit: () => void; } - let { profile, avatarFile, avatarError, onSaved, onCancel }: Props = $props(); + let { id, profile, pinned, avatarFile, avatarError, error, onsubmit }: Props = $props(); const auth = getAuth(); @@ -54,12 +53,11 @@ } }); - const save = createAction(async (event: SubmitEvent) => { - event.preventDefault(); + export const save = async (): Promise => { const agent = auth.agent; - if (!agent) return; + if (!agent) return undefined; const checked = checkLinks(); - if (links.some((link) => link.error)) return; + if (links.some((link) => link.error)) return undefined; const cleanLinks = checked.filter((link) => link !== undefined); const avatar = avatarFile ? await uploadProfileAvatar(agent, avatarFile) : profile?.avatar; const record: ProfileRecord = { @@ -69,17 +67,25 @@ description: description.trim() || undefined, pronouns: pronouns.trim() || undefined, location: location.trim() || undefined, - links: cleanLinks.length > 0 ? (cleanLinks as ProfileRecord["links"]) : undefined + links: cleanLinks.length > 0 ? (cleanLinks as ProfileRecord["links"]) : undefined, + pinnedRepositories: pinned.length > 0 ? pinned : undefined }; const written = await putProfile(agent, record); const ctx = createBobbinClient({ serviceUrl: auth.bobbinUrl }); await awaitIndexedOrSaved(ctx, written); if (avatarFile) bumpAvatar(agent.sub); - onSaved(record); - }); + return record; + }; -
    + { + event.preventDefault(); + onsubmit(); + }} +> {#if avatarError}

    {avatarError}

    {:else if avatarFile} @@ -87,88 +93,73 @@ {avatarFile.name} will be uploaded when you save.

    {/if} - - {#snippet children({ id })} -