Skip to content

Commit

Permalink
Merge pull request #505 from MuckRock/sveltekit-manager-addons
Browse files Browse the repository at this point in the history
[SvelteKit] Add-On Pages
  • Loading branch information
allanlasser authored Apr 22, 2024
2 parents a59e49d + 9153405 commit 8f181a2
Show file tree
Hide file tree
Showing 29 changed files with 761 additions and 283 deletions.
7 changes: 6 additions & 1 deletion src/addons/AddOnPin.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -52,4 +52,9 @@
}
</script>

<Pin active={addon.active} on:click={toggle} {size} />
<Pin
active={addon.active}
on:click={toggle}
{size}
--fill={addon.active ? "var(--orange)" : "var(--gray-3)"}
/>
8 changes: 4 additions & 4 deletions src/addons/browser/AddOnList.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@
import ListItem from "./AddOnListItem.svelte";
import type { AddOnListItem } from "../types";
export let items: AddOnListItem[];
export let loading: boolean;
export let error: string | undefined;
export let reload: () => void | undefined;
export let items: AddOnListItem[] = [];
export let loading: boolean = false;
export let error: string | undefined = undefined;
export let reload: () => void | undefined = undefined;
$: empty = !(items && items.length > 0);
</script>
Expand Down
11 changes: 11 additions & 0 deletions src/api/types/addons.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import type { PageParams } from "./common";

export interface AddOnParams extends PageParams {
query?: string;
active?: boolean;
default?: boolean;
featured?: boolean;
premium?: boolean;
category?: string;
repository?: string;
}
5 changes: 5 additions & 0 deletions src/api/types/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,8 @@ export interface Page<T> {
results: T[];
escaped?: boolean;
}

export interface PageParams {
cursor?: string;
per_page?: number;
}
1 change: 1 addition & 0 deletions src/common/icons/Pin.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,6 @@
svg {
display: block;
transform: rotate(-45deg);
fill: var(--fill, var(--orange, #ec7b6b));
}
</style>
9 changes: 2 additions & 7 deletions src/common/icons/Premium.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,9 @@
const REM = 16;
export let size = 1;
export let title = "Premium";
export let color = "#24CC99";
</script>

<svg
width={`${size * REM}px`}
height={`${size * REM}px`}
viewBox="0 0 16 16"
style="--premium-color: {color}"
>
<svg width={`${size * REM}px`} height={`${size * REM}px`} viewBox="0 0 16 16">
<title>{title}</title>
<path
fill-rule="evenodd"
Expand All @@ -27,5 +21,6 @@
<style>
svg {
display: block;
fill: var(--fill, var(--green, #27c6a2));
}
</style>
95 changes: 95 additions & 0 deletions src/lib/api/addons.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { vi, test, describe, it, expect, beforeEach, afterEach } from "vitest";
import * as addons from "./addons";
import { BASE_API_URL } from "@/config/config";
import { addonsList } from "@/test/fixtures/addons";
import { emptyList } from "@/test/fixtures/common";

afterEach(() => {
vi.restoreAllMocks();
});

describe("getAddons", () => {
let mockFetch;
beforeEach(() => {
mockFetch = vi
.fn()
.mockResolvedValue({ ok: true, json: async () => addonsList });
});
it("calls the addons API endpoint", async () => {
await addons.getAddons({}, mockFetch);
const expectedEndpoint = new URL(`addons`, BASE_API_URL);
expect(mockFetch).toHaveBeenCalledWith(
expectedEndpoint,
expect.any(Object),
);
});
it("passes parameters through as query arguments", async () => {
await addons.getAddons(
{ active: true, featured: true, query: "foobar" },
mockFetch,
);
const expectedEndpoint = new URL(`addons`, BASE_API_URL);
expectedEndpoint.searchParams.set("active", "true");
expectedEndpoint.searchParams.set("featured", "true");
expectedEndpoint.searchParams.set("query", "foobar");
expect(mockFetch).toHaveBeenCalledWith(
expectedEndpoint,
expect.any(Object),
);
});
it("returns the full list", async () => {
const response = await addons.getAddons({}, mockFetch);
expect(response).toBe(addonsList);
});
it("calls SvelteKit's error fn given a response error", async () => {
mockFetch = vi.fn().mockResolvedValue({
ok: false,
status: 500,
statusText: "Server Error!",
});
await expect(addons.getAddons({}, mockFetch)).rejects.toThrowError();
});
});

test("getPinnedAddons", async () => {
const mockFetch = vi
.fn()
.mockResolvedValue({ ok: true, json: async () => {} });
await addons.getPinnedAddons(mockFetch);
expect(mockFetch).toHaveBeenCalledWith(
new URL(`addons?active=true`, BASE_API_URL),
expect.any(Object),
);
});

describe("getAddon", async () => {
let mockFetch;
beforeEach(() => {
mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => addonsList,
});
});
afterEach(() => {
vi.restoreAllMocks();
});
it("calls the addons list endpoint with a respository query argument", async () => {
await addons.getAddon("MuckRock", "addon-repo", mockFetch);
expect(mockFetch).toHaveBeenCalledWith(
new URL(`addons?repository=MuckRock%2Faddon-repo`, BASE_API_URL),
expect.any(Object),
);
});
it("returns the first result in the addon list", async () => {
const response = await addons.getAddon("MuckRock", "addon-repo", mockFetch);
expect(response).toEqual(addonsList.results[0]);
});
it("returns null if the returned list is empty", async () => {
mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => emptyList,
});
const response = await addons.getAddon("MuckRock", "addon-repo", mockFetch);
expect(response).toEqual(null);
});
});
49 changes: 34 additions & 15 deletions src/lib/api/addons.ts
Original file line number Diff line number Diff line change
@@ -1,32 +1,51 @@
import type { Page } from "@/api/types/common";
import type { AddOnParams } from "@/api/types/addons";
import type { AddOnListItem } from "@/addons/types";

import { BASE_API_URL } from "@/config/config";
import { isErrorCode } from "../utils/api";
import { error } from "@sveltejs/kit";

import { BASE_API_URL } from "@/config/config.js";
import { isErrorCode } from "../utils";
export const CATEGORIES = [
["ai", "AI"],
["statistical", "Analyze"],
["bulk", "Bulk"],
["export", "Export"],
["extraction", "Extract"],
["file", "File"],
["monitor", "Monitor"],
];

export async function getPinnedAddons(
export async function getAddons(
params: AddOnParams = {},
fetch = globalThis.fetch,
): Promise<Page<AddOnListItem>> {
const endpoint = new URL(
"/api/addons/?active=true&per_page=100",
BASE_API_URL,
);
const endpoint = new URL("addons", BASE_API_URL);
Object.entries(params).forEach(([key, value]) => {
endpoint.searchParams.set(key, String(value));
});
const resp = await fetch(endpoint, { credentials: "include" });
if (isErrorCode(resp.status)) {
error(resp.status, resp.statusText);
}
return resp.json() as Promise<Page<AddOnListItem>>;
return resp.json();
}

export async function getAddons(
export async function getPinnedAddons(
fetch = globalThis.fetch,
): Promise<Page<AddOnListItem>> {
const endpoint = new URL("/api/addons/?per_page=100", BASE_API_URL);
const resp = await fetch(endpoint, { credentials: "include" });
if (isErrorCode(resp.status)) {
error(resp.status, resp.statusText);
return getAddons({ active: true }, fetch);
}

export async function getAddon(
owner: string,
repo: string,
fetch = globalThis.fetch,
): Promise<AddOnListItem | null> {
const repository = [owner, repo].join("/");
const addons = await getAddons({ repository }, fetch);
// there should only be one result, if the addon exists
if (addons.results.length < 1) {
return null;
}
return resp.json() as Promise<Page<AddOnListItem>>;
return addons.results[0];
}
4 changes: 3 additions & 1 deletion src/lib/components/ContentLayout.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -30,20 +30,22 @@
width: 100%;
position: sticky;
top: 0;
z-index: 2;
padding: 0.625rem;
}
main {
flex: 1 0 0;
width: 100%;
display: flex;
flex-direction: column;
justify-content: center;
justify-content: flex-start;
}
footer {
flex: 0 0 0;
width: 100%;
position: sticky;
bottom: 0;
z-index: 2;
padding: 0.625rem;
}
</style>
125 changes: 125 additions & 0 deletions src/lib/components/addons/AddOnListItem.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
<script lang="ts">
import type { AddOnListItem } from "@/addons/types";
import { _ } from "svelte-i18n";
import AddOnPin from "@/addons/AddOnPin.svelte";
import AddOnPopularity from "@/addons/Popularity.svelte";
import PremiumBadge from "@/premium-credits/PremiumBadge.svelte";
export let addon: AddOnListItem;
$: url = `/app/add-ons/${addon?.repository}`;
$: description = addon?.parameters?.description;
$: author = { name: addon?.repository?.split("/")[0] };
$: isPremium = addon?.parameters?.categories?.includes("premium") ?? false;
</script>

<a class="addon-link" href={url}>
<div class="container" id={addon.repository}>
<div class="row">
<div class="center-self">
<AddOnPin {addon} />
</div>
<div class="stretch">
<h3 class="addon-name">{addon.name}</h3>
</div>
<div class="metadata">
{#if author?.name}
<p class="author">
<a
href="http://github.com/{addon.repository}"
target="_blank"
rel="noopener noreferrer"
title={$_("addonBrowserDialog.viewsource")}>{author.name}</a
>
</p>
{/if}
{#if addon.usage}
<AddOnPopularity useCount={addon.usage} />
{/if}
{#if isPremium}
<span class="badge"><PremiumBadge /></span>
{/if}
</div>
</div>
{#if description}
<div class="description">{@html description}</div>
{/if}
</div>
</a>

<style>
a {
color: inherit;
text-decoration: none;
}
.container {
display: block;
min-width: 12rem;
padding: 0.5rem 0.5rem 0.75rem;
text-align: left;
}
.addon-link:hover .container {
background-color: var(--blue-1);
}
.row {
display: flex;
align-items: flex-end;
gap: 0.5rem;
margin: 0.5rem;
}
.badge {
margin-bottom: -0.25em;
font-size: 0.8em;
}
.metadata {
display: flex;
align-items: flex-end;
gap: 1rem;
color: var(--gray-4);
}
.description {
margin: 0 0.5em;
opacity: 0.6;
font-size: 0.875em;
line-height: 1.4;
color: var(--gray-4);
overflow: hidden;
-webkit-line-clamp: 4;
display: -webkit-box;
-webkit-box-orient: vertical;
& > * {
margin-top: 0;
font-size: 0.875rem;
}
}
.addon-name {
margin: 0;
font-weight: 600;
font-size: var(--font-l);
}
.center-self {
align-self: center;
}
.stretch {
flex: 1 1 auto;
}
.author a:hover {
opacity: 0.7;
}
p {
margin: 0;
}
</style>
Loading

0 comments on commit 8f181a2

Please sign in to comment.