diff --git a/.storybook/preview.ts b/.storybook/preview.ts index 6802bc389..90518e863 100644 --- a/.storybook/preview.ts +++ b/.storybook/preview.ts @@ -5,7 +5,7 @@ import UserContextDecorator from "./decorators/UserContextDecorator.svelte"; import OrgContextDecorator from "./decorators/OrgContextDecorator.svelte"; import "@/style/kit.css"; -import "../src/lib/i18n/index.js"; +import "@/lib/i18n/index.js"; // Initialize MSW initialize({ @@ -29,7 +29,9 @@ const preview: Preview = { stores: { page: { url: "/", - data: {}, + data: { + breadcrumbs: [], + }, }, }, }, diff --git a/package.json b/package.json index e8bbce250..6b2d50771 100644 --- a/package.json +++ b/package.json @@ -87,6 +87,7 @@ "test:unit": "vitest run", "test:watch": "vitest watch", "test:coverage": "vitest run --coverage", + "test:dev": "vitest watch --coverage", "format": "prettier --write .", "format:check": "prettier --check src", "build-storybook": "storybook build", diff --git a/src/api/document.js b/src/api/document.js index 5539f52a9..5c43f2992 100644 --- a/src/api/document.js +++ b/src/api/document.js @@ -2,7 +2,7 @@ * Methods related to the DocumentCloud document API */ -import session from "./session.js"; +import session from "./session"; import { apiUrl } from "./base.js"; import { timeout } from "@/util/timeout.js"; import { queryBuilder } from "@/util/url.js"; diff --git a/src/lib/components/MainLayout.svelte b/src/lib/components/MainLayout.svelte index 1d2716f27..6226ac849 100644 --- a/src/lib/components/MainLayout.svelte +++ b/src/lib/components/MainLayout.svelte @@ -1,21 +1,23 @@ + + + + + + diff --git a/src/lib/components/navigation/HelpMenu.svelte b/src/lib/components/navigation/HelpMenu.svelte new file mode 100644 index 000000000..323eee0e7 --- /dev/null +++ b/src/lib/components/navigation/HelpMenu.svelte @@ -0,0 +1,58 @@ + + + + + + + + + + + + {$_("authSection.help.faq")} + + + + {$_("authSection.help.searchDocs")} + + + + {$_("authSection.help.apiDocs")} + + + + {$_("authSection.help.addOns")} + + + + {$_("authSection.help.premium")} + + + + {$_("authSection.help.emailUs")} + + + + + diff --git a/src/lib/components/navigation/LanguageMenu.svelte b/src/lib/components/navigation/LanguageMenu.svelte new file mode 100644 index 000000000..f549ff20c --- /dev/null +++ b/src/lib/components/navigation/LanguageMenu.svelte @@ -0,0 +1,60 @@ + + +{#if langs.length > 1} + + + + {currentLang[2]} + + + + + {#each langs as [name, code, flag]} + { + updateLanguage(code); + closeDropdown("language"); + }} + hover + active={code === $locale} + > + {flag} + {name} + {#if code === $locale}{/if} + + {/each} + + +{/if} + + diff --git a/src/lib/components/accounts/OrgMenu.svelte b/src/lib/components/navigation/OrgMenu.svelte similarity index 100% rename from src/lib/components/accounts/OrgMenu.svelte rename to src/lib/components/navigation/OrgMenu.svelte diff --git a/src/lib/components/accounts/UserMenu.svelte b/src/lib/components/navigation/UserMenu.svelte similarity index 76% rename from src/lib/components/accounts/UserMenu.svelte rename to src/lib/components/navigation/UserMenu.svelte index 0b78089cb..f5249c497 100644 --- a/src/lib/components/accounts/UserMenu.svelte +++ b/src/lib/components/navigation/UserMenu.svelte @@ -10,6 +10,7 @@ ChevronDown16, Gear16, Paperclip16, + Person16, SignOut16, } from "svelte-octicons"; import Menu from "@/common/Menu.svelte"; @@ -26,8 +27,14 @@ - Avatar - {user.name} +
+ {#if user.avatar_url} + Avatar + {:else} + + {/if} +
+ {user.name ?? user.username}
@@ -52,6 +59,16 @@ height: 1.5rem; width: 1.5rem; border-radius: 0.75rem; + background: var(--gray-2); + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + } + + .avatar > img { + height: 100%; + width: 100%; } .dropdownArrow { diff --git a/src/lib/components/navigation/stories/Breadcrumbs.stories.svelte b/src/lib/components/navigation/stories/Breadcrumbs.stories.svelte new file mode 100644 index 000000000..285cbb7ca --- /dev/null +++ b/src/lib/components/navigation/stories/Breadcrumbs.stories.svelte @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + +
+ +
+
+ + + +

DocumentCloud

+
+
diff --git a/src/lib/components/navigation/stories/LanguageMenu.stories.svelte b/src/lib/components/navigation/stories/LanguageMenu.stories.svelte new file mode 100644 index 000000000..9893db827 --- /dev/null +++ b/src/lib/components/navigation/stories/LanguageMenu.stories.svelte @@ -0,0 +1,17 @@ + + + + + diff --git a/src/lib/components/accounts/stories/OrgMenu.stories.svelte b/src/lib/components/navigation/stories/OrgMenu.stories.svelte similarity index 100% rename from src/lib/components/accounts/stories/OrgMenu.stories.svelte rename to src/lib/components/navigation/stories/OrgMenu.stories.svelte diff --git a/src/lib/components/navigation/stories/UserMenu.stories.svelte b/src/lib/components/navigation/stories/UserMenu.stories.svelte new file mode 100644 index 000000000..178e7b9e3 --- /dev/null +++ b/src/lib/components/navigation/stories/UserMenu.stories.svelte @@ -0,0 +1,94 @@ + + + + + + + diff --git a/src/lib/components/navigation/tests/Breadcrumbs.test.ts b/src/lib/components/navigation/tests/Breadcrumbs.test.ts new file mode 100644 index 000000000..ec72ec8bd --- /dev/null +++ b/src/lib/components/navigation/tests/Breadcrumbs.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect } from "vitest"; +import { render } from "@testing-library/svelte"; +import Breadcrumbs from "../Breadcrumbs.svelte"; +import Logo from "../../common/Logo.svelte"; + +describe("Breadcrumbs", () => { + it("renders the DocumentCloud logo at the root breadcrumb by default", () => { + const result = render(Breadcrumbs); + const links = result.getAllByRole("link"); + expect(links[0]).toHaveAttribute("href", "/app"); + expect(links[0].getElementsByTagName("svg")[0]).toEqual( + render(Logo).container.getElementsByTagName("svg")[0], + ); + }); + it("renders more breadcrumbs provided in the trail", () => { + const trail = [ + { href: "/documents", title: "Documents" }, + { href: "/documents/[id]", title: "Some Document" }, + ]; + const result = render(Breadcrumbs, { trail }); + const links = result.getAllByRole("link"); + links.forEach((link, index) => { + if (index > 0) { + expect(link).toHaveAttribute("href", trail[index - 1].href); + expect(link).toHaveAttribute("title", trail[index - 1].title); + } + }); + }); +}); diff --git a/src/lib/utils/index.ts b/src/lib/utils/index.ts index af9443a45..9e936e597 100644 --- a/src/lib/utils/index.ts +++ b/src/lib/utils/index.ts @@ -1 +1,2 @@ export { isErrorCode, isRedirectCode } from "./api"; +export { breadcrumbTrail } from "./navigation"; diff --git a/src/lib/utils/navigation.ts b/src/lib/utils/navigation.ts new file mode 100644 index 000000000..87c37ad8c --- /dev/null +++ b/src/lib/utils/navigation.ts @@ -0,0 +1,13 @@ +type Breadcrumb = { href?: string; title: string }; +type Parent = () => Promise<{ breadcrumbs?: Array }>; + +/** Returns a trail of breadcrumbs, built upon the parent's trail. + * Primarily for use in `+layout.ts` and `+page.ts` files. + */ +export async function breadcrumbTrail( + parent: Parent, + crumbs: Array = [], +) { + const { breadcrumbs: trail = [] } = await parent(); + return trail.concat(crumbs); +} diff --git a/src/lib/utils/api.test.ts b/src/lib/utils/tests/api.test.ts similarity index 92% rename from src/lib/utils/api.test.ts rename to src/lib/utils/tests/api.test.ts index 581e52d3f..304e026d8 100644 --- a/src/lib/utils/api.test.ts +++ b/src/lib/utils/tests/api.test.ts @@ -1,5 +1,5 @@ import { test, expect } from "vitest"; -import { isErrorCode, isRedirectCode } from "./api"; +import { isErrorCode, isRedirectCode } from "../api"; test("isErrorCode", () => { expect(isErrorCode(200)).toBe(false); diff --git a/src/lib/utils/files.test.ts b/src/lib/utils/tests/files.test.ts similarity index 99% rename from src/lib/utils/files.test.ts rename to src/lib/utils/tests/files.test.ts index 1ebedb890..8d085b5f9 100644 --- a/src/lib/utils/files.test.ts +++ b/src/lib/utils/tests/files.test.ts @@ -1,5 +1,5 @@ import { describe, test, it, expect } from "vitest"; -import * as files from "./files"; +import * as files from "../files"; describe("files.getFileExtensionFromType", () => { it("returns the second half of a Mimetype", () => { diff --git a/src/lib/utils/tests/navigation.test.ts b/src/lib/utils/tests/navigation.test.ts new file mode 100644 index 000000000..5bf0d6a29 --- /dev/null +++ b/src/lib/utils/tests/navigation.test.ts @@ -0,0 +1,23 @@ +import { vi, describe, it, expect, beforeEach, afterEach } from "vitest"; +import { breadcrumbTrail } from "../navigation"; + +describe("breadcrumbTrail", () => { + it("returns an empty array as a base case", async () => { + const parent = vi.fn().mockResolvedValue({}); + expect(await breadcrumbTrail(parent)).toEqual([]); + }); + it("returns the parent's breadcrumb trail, if it exists", async () => { + const parentTrail = [{ href: "/first", title: "First Level" }]; + const parent = vi.fn().mockResolvedValue({ breadcrumbs: parentTrail }); + expect(await breadcrumbTrail(parent)).toEqual(parentTrail); + }); + it("concats the provided trail onto the parent's trail", async () => { + const parentTrail = [{ href: "/first", title: "First Level" }]; + const childTrail = [{ href: "/second", title: "Second Level" }]; + const parent = vi.fn().mockResolvedValue({ breadcrumbs: parentTrail }); + expect(await breadcrumbTrail(parent, childTrail)).toEqual([ + ...parentTrail, + ...childTrail, + ]); + }); +}); diff --git a/src/routes/(embed)/stories/note-embed.stories.svelte b/src/routes/(embed)/stories/note-embed.stories.svelte index 018bde7bc..0ba117589 100644 --- a/src/routes/(embed)/stories/note-embed.stories.svelte +++ b/src/routes/(embed)/stories/note-embed.stories.svelte @@ -11,7 +11,6 @@ import document from "$lib/api/fixtures/documents/document-expanded.json"; import note from "$lib/api/fixtures/notes/note-expanded.json"; import notes from "$lib/api/fixtures/notes/notes-expanded.json"; - import type { SvelteComponent } from "svelte"; export const meta = { title: "Embed / Note", @@ -28,6 +27,7 @@ embed: true, me: null, org: null, + breadcrumbs: [], }; diff --git a/src/routes/(embed)/stories/page-embed.stories.svelte b/src/routes/(embed)/stories/page-embed.stories.svelte index 8d7104c88..6bc134422 100644 --- a/src/routes/(embed)/stories/page-embed.stories.svelte +++ b/src/routes/(embed)/stories/page-embed.stories.svelte @@ -26,6 +26,7 @@ embed: false, me: null, org: null, + breadcrumbs: [], }; diff --git a/src/routes/(embed)/stories/project-embed.stories.svelte b/src/routes/(embed)/stories/project-embed.stories.svelte index 8dfc82365..0d6388ed5 100644 --- a/src/routes/(embed)/stories/project-embed.stories.svelte +++ b/src/routes/(embed)/stories/project-embed.stories.svelte @@ -25,6 +25,7 @@ embed: true, me: null, org: null, + breadcrumbs: [], }; diff --git a/src/routes/+layout.ts b/src/routes/+layout.ts index 271a6b670..4f52bfffc 100644 --- a/src/routes/+layout.ts +++ b/src/routes/+layout.ts @@ -24,5 +24,5 @@ export async function load({ fetch, url }) { org = me?.organization; } - return { me, org, embed }; + return { me, org, embed, breadcrumbs: [] }; } diff --git a/src/routes/app/+layout.svelte b/src/routes/app/+layout.svelte index cee03b703..6241ba987 100644 --- a/src/routes/app/+layout.svelte +++ b/src/routes/app/+layout.svelte @@ -2,8 +2,6 @@ import "@/style/kit.css"; import { PlusCircle16 } from "svelte-octicons"; - import type { AddOnListItem } from "@/lib/api/types"; - import type { Page } from "@/api/types"; import MainLayout from "@/lib/components/MainLayout.svelte"; import Button from "@/lib/components/common/Button.svelte"; @@ -14,9 +12,7 @@ import Documents from "./sidebar/Documents.svelte"; import Projects from "./sidebar/Projects.svelte"; - export let data: { - pinnedAddons: Promise>; - }; + export let data; diff --git a/src/routes/app/+layout.ts b/src/routes/app/+layout.ts index 9ff57d834..1faa34a41 100644 --- a/src/routes/app/+layout.ts +++ b/src/routes/app/+layout.ts @@ -1,14 +1,18 @@ -import { getPinnedAddons } from "@/lib/api/addons"; import * as projects from "$lib/api/projects"; +import { getPinnedAddons } from "$lib/api/addons"; +import { breadcrumbTrail } from "$lib/utils/navigation"; -export async function load({ url, fetch }) { +export async function load({ url, fetch, parent }) { const pinnedAddons = getPinnedAddons(fetch); const pinnedProjects = projects .list({ pinned: true }, fetch) .then((r) => r.results); - + const breadcrumbs = await breadcrumbTrail(parent, [ + { href: "/app", title: "Documents" }, // TODO: move document manager to `/documents` route + ]); return { pinnedAddons, pinnedProjects, + breadcrumbs, }; } diff --git a/src/routes/app/sidebar/AddOns.svelte b/src/routes/app/sidebar/AddOns.svelte index 138ec47d5..778d6fabd 100644 --- a/src/routes/app/sidebar/AddOns.svelte +++ b/src/routes/app/sidebar/AddOns.svelte @@ -3,7 +3,6 @@ import type { AddOnListItem } from "@/addons/types"; import { Book16, Hourglass24, Pin24, Plug16 } from "svelte-octicons"; - import Action from "$lib/components/common/Action.svelte"; import Empty from "$lib/components/common/Empty.svelte"; import Flex from "$lib/components/common/Flex.svelte"; diff --git a/src/routes/app/sidebar/Documents.svelte b/src/routes/app/sidebar/Documents.svelte index 8ed19360a..82c5de8e5 100644 --- a/src/routes/app/sidebar/Documents.svelte +++ b/src/routes/app/sidebar/Documents.svelte @@ -20,8 +20,8 @@ $: query = $page.url.searchParams.get("q") || ""; - $: minePublic = userDocs($me, "public"); - $: minePrivate = userDocs($me, "private"); + $: minePublic = $me ? userDocs($me, "public") : ""; + $: minePrivate = $me ? userDocs($me, "private") : ""; // +organization:muckrock-125 $: orgDocs = $org ? `+organization:${slugify($org.name)}-${$org.id}` : ""; diff --git a/src/routes/documents/[id]-[slug]/+layout.ts b/src/routes/documents/[id]-[slug]/+layout.ts index f404e07fb..ab90aede8 100644 --- a/src/routes/documents/[id]-[slug]/+layout.ts +++ b/src/routes/documents/[id]-[slug]/+layout.ts @@ -6,10 +6,16 @@ import { redirect } from "@sveltejs/kit"; import * as documents from "@/lib/api/documents"; -import { getPinnedAddons } from "$lib/api/addons.js"; +import type { Document } from "@/lib/api/types"; +import { getPinnedAddons } from "$lib/api/addons"; +import { breadcrumbTrail } from "$lib/utils/navigation"; + +function documentPath(document: Document) { + return `/documents/${document.id}-${document.slug}`; +} /** @type {import('./$types').PageLoad} */ -export async function load({ fetch, params }) { +export async function load({ fetch, params, parent }) { const document = await documents.get(+params.id, fetch); if (document.slug !== params.slug) { @@ -17,8 +23,17 @@ export async function load({ fetch, params }) { redirect(302, canonical.pathname); } + const breadcrumbs = await breadcrumbTrail(parent, [ + { href: "/app", title: "Documents" }, // TODO: move document manager to `/documents` route + { href: documentPath(document), title: document.title }, + ]); + // stream this const pinnedAddons = getPinnedAddons(fetch); - return { document, pinnedAddons }; + return { + document, + pinnedAddons, + breadcrumbs, + }; } diff --git a/src/util/paginate.js b/src/util/paginate.js index 7892020fa..7b1e661ca 100644 --- a/src/util/paginate.js +++ b/src/util/paginate.js @@ -1,4 +1,4 @@ -import session from "@/api/session.js"; +import session from "@/api/session"; import { queryBuilder } from "./url.js"; import { MAX_PER_PAGE } from "../config/config.js";