Skip to content
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

feat: Integrate OpenLaws API #54

Closed
wants to merge 4 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/tasty-lizards-lay.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"namesake": minor
---

Integrate with OpenLaws API to display recent laws
2 changes: 2 additions & 0 deletions convex/_generated/api.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import type * as constants from "../constants.js";
import type * as formFields from "../formFields.js";
import type * as forms from "../forms.js";
import type * as http from "../http.js";
import type * as openlaws from "../openlaws.js";
import type * as quests from "../quests.js";
import type * as users from "../users.js";
import type * as usersQuests from "../usersQuests.js";
Expand All @@ -36,6 +37,7 @@ declare const fullApi: ApiFromModules<{
formFields: typeof formFields;
forms: typeof forms;
http: typeof http;
openlaws: typeof openlaws;
quests: typeof quests;
users: typeof users;
usersQuests: typeof usersQuests;
Expand Down
80 changes: 80 additions & 0 deletions convex/openlaws.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { v } from "convex/values";
import { action } from "./_generated/server";

/** https://openlaws.apidocumentation.com/api-reference#/tag/divisions/GET/api/v1/jurisdictions/{jurisdiction_id}/divisions/keyword_search */

export type LawData = {
/** OpenLaws identifier for the Law. */
law_key: string;

/** Human-readable identifier for the Division; guaranteed to be unique among its sibling Divisions */
label: string;

/** The concatenation of all of the Division's ancestors's labels resulting in a filepath-like string */
path: string;

/** OpenLaws standardization of the Division document type */
division_type: string;

/** The source document's identifier */
identifier: string;

/** If the Division is part a range, this contains the ending value for the identifier range, e.g., §§ 100 to 200 -> 200 is the range_end_identifier. */
range_end_identifier?: string;

/** The name of the document from the source document. */
name?: string;

/** If the source documment and annotations contain an effective date, this field will be contain the start and end date as a range. Positive and negative infinity indicate the effective date is unbounded. */
effective_date: string;

/** OpenLaws' internal notes for the Division. */
description?: string;

/** Contains short or common names for the Division */
aliases?: string;

/** Plaintext content without rich text formatting and markup to define pincite locations. Suitable for use with LLMs. */
plaintext_content: string;

/** Rich text content in Commonmark with CSS annotations. Meant to be rendered into human-readable HTML along with the OpenLaws Markdown CSS. */
markdown_content: string;

/** Annotations and interpretations if available from the official source. */
annotations?: string;

/** Authoritative Source URL for law. Deep-links to the source where supported. **(Law Firm and Enterprise plans only.)** */
source_url: string;

/** (Experimental) Whether the Division is repealed, contingent repealed, or no longer effective. */
is_repealed?: boolean;

/** (Experimental) Whether the Division identifier and range_end_identifier are reserved by the Jurisdiction */
is_reserved?: boolean;

/** (Experimental) Contains the new identifier and location where the Division was renumbered to. */
renumbered?: string;
};

export const getLaws = action({
args: {
// Two letter Jurisdiction ID, or 'FED' for federal
jurisdiction: v.string(),
// Keyword to search for
query: v.string(),
},
handler: async (_ctx, args) => {
const encodedQuery = encodeURIComponent(args.query);
const url = `https://beta.openlaws.us/api/v1/jurisdictions/${args.jurisdiction}/divisions/keyword_search?query=${encodedQuery}`;

const response = await fetch(url, {
headers: {
Authorization: `Bearer ${process.env.OPENLAWS_API_KEY}`,
"Content-Type": "application/json",
},
});

const data: Promise<LawData[]> = await response.json();
return data;
},
});
1 change: 1 addition & 0 deletions src/components/shared/AppHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export const AppHeader = () => {
return (
<div className="flex gap-4 items-center w-screen py-3 px-4 border-b border-gray-dim">
<Link href={{ to: "/" }}>Namesake</Link>
<Link href={{ to: "/laws" }}>Laws</Link>
<Authenticated>
{/* TODO: Gate this by role */}
<Link href={{ to: "/admin" }}>Admin</Link>
Expand Down
2 changes: 1 addition & 1 deletion src/components/shared/SearchField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export function SearchField({
{...props}
className={composeTailwindRenderProps(
props.className,
"group flex flex-col gap-1 min-w-[40px]",
"group flex flex-col gap-2 min-w-[40px]",
)}
>
{label && <Label>{label}</Label>}
Expand Down
25 changes: 25 additions & 0 deletions src/routeTree.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { Route as SigninImport } from './routes/signin'
import { Route as AdminRouteImport } from './routes/admin/route'
import { Route as IndexImport } from './routes/index'
import { Route as SettingsIndexImport } from './routes/settings/index'
import { Route as LawsIndexImport } from './routes/laws/index'
import { Route as AdminIndexImport } from './routes/admin/index'
import { Route as AdminQuestsIndexImport } from './routes/admin/quests/index'
import { Route as AdminFormsIndexImport } from './routes/admin/forms/index'
Expand Down Expand Up @@ -43,6 +44,11 @@ const SettingsIndexRoute = SettingsIndexImport.update({
getParentRoute: () => rootRoute,
} as any)

const LawsIndexRoute = LawsIndexImport.update({
path: '/laws/',
getParentRoute: () => rootRoute,
} as any)

const AdminIndexRoute = AdminIndexImport.update({
path: '/',
getParentRoute: () => AdminRouteRoute,
Expand Down Expand Up @@ -100,6 +106,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AdminIndexImport
parentRoute: typeof AdminRouteImport
}
'/laws/': {
id: '/laws/'
path: '/laws'
fullPath: '/laws'
preLoaderRoute: typeof LawsIndexImport
parentRoute: typeof rootRoute
}
'/settings/': {
id: '/settings/'
path: '/settings'
Expand Down Expand Up @@ -165,6 +178,7 @@ export interface FileRoutesByFullPath {
'/admin': typeof AdminRouteRouteWithChildren
'/signin': typeof SigninRoute
'/admin/': typeof AdminIndexRoute
'/laws': typeof LawsIndexRoute
'/settings': typeof SettingsIndexRoute
'/admin/forms/$formId': typeof AdminFormsFormIdRoute
'/admin/quests/$questId': typeof AdminQuestsQuestIdRoute
Expand All @@ -176,6 +190,7 @@ export interface FileRoutesByTo {
'/': typeof IndexRoute
'/signin': typeof SigninRoute
'/admin': typeof AdminIndexRoute
'/laws': typeof LawsIndexRoute
'/settings': typeof SettingsIndexRoute
'/admin/forms/$formId': typeof AdminFormsFormIdRoute
'/admin/quests/$questId': typeof AdminQuestsQuestIdRoute
Expand All @@ -189,6 +204,7 @@ export interface FileRoutesById {
'/admin': typeof AdminRouteRouteWithChildren
'/signin': typeof SigninRoute
'/admin/': typeof AdminIndexRoute
'/laws/': typeof LawsIndexRoute
'/settings/': typeof SettingsIndexRoute
'/admin/forms/$formId': typeof AdminFormsFormIdRoute
'/admin/quests/$questId': typeof AdminQuestsQuestIdRoute
Expand All @@ -203,6 +219,7 @@ export interface FileRouteTypes {
| '/admin'
| '/signin'
| '/admin/'
| '/laws'
| '/settings'
| '/admin/forms/$formId'
| '/admin/quests/$questId'
Expand All @@ -213,6 +230,7 @@ export interface FileRouteTypes {
| '/'
| '/signin'
| '/admin'
| '/laws'
| '/settings'
| '/admin/forms/$formId'
| '/admin/quests/$questId'
Expand All @@ -224,6 +242,7 @@ export interface FileRouteTypes {
| '/admin'
| '/signin'
| '/admin/'
| '/laws/'
| '/settings/'
| '/admin/forms/$formId'
| '/admin/quests/$questId'
Expand All @@ -236,13 +255,15 @@ export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
AdminRouteRoute: typeof AdminRouteRouteWithChildren
SigninRoute: typeof SigninRoute
LawsIndexRoute: typeof LawsIndexRoute
SettingsIndexRoute: typeof SettingsIndexRoute
}

const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
AdminRouteRoute: AdminRouteRouteWithChildren,
SigninRoute: SigninRoute,
LawsIndexRoute: LawsIndexRoute,
SettingsIndexRoute: SettingsIndexRoute,
}

Expand All @@ -261,6 +282,7 @@ export const routeTree = rootRoute
"/",
"/admin",
"/signin",
"/laws/",
"/settings/"
]
},
Expand All @@ -284,6 +306,9 @@ export const routeTree = rootRoute
"filePath": "admin/index.tsx",
"parent": "/admin"
},
"/laws/": {
"filePath": "laws/index.tsx"
},
"/settings/": {
"filePath": "settings/index.tsx"
},
Expand Down
106 changes: 106 additions & 0 deletions src/routes/laws/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { createFileRoute } from "@tanstack/react-router";
import { useAction } from "convex/react";
import { useEffect, useState } from "react";
import type { Key } from "react-aria";
import Markdown from "react-markdown";
import { useDebouncedCallback } from "use-debounce";
import { api } from "../../../convex/_generated/api";
import { JURISDICTIONS } from "../../../convex/constants";
import type { LawData } from "../../../convex/openlaws";
import {
Container,
PageHeader,
SearchField,
Select,
SelectItem,
} from "../../components";

export const Route = createFileRoute("/laws/")({
component: LawsRoute,
});

function LawsRoute() {
const getLaws = useAction(api.openlaws.getLaws);

const [laws, setLaws] = useState<LawData[] | null | undefined>(null);
const [jurisdiction, setJurisdiction] = useState<JURISDICTIONS | null>(null);
const [query, setQuery] = useState("");
const [debouncedQuery, setDebouncedQuery] = useState("");

const searchLaws = () => {
if (jurisdiction === null || query === null) return;

getLaws({
jurisdiction: jurisdiction,
query: query,
}).then((res) => {
setLaws(res);
});
};

// biome-ignore lint/correctness/useExhaustiveDependencies: This is correct
useEffect(() => {
searchLaws();
}, [debouncedQuery, jurisdiction]);

const debouncedSearch = useDebouncedCallback(() => {
setDebouncedQuery(query);
}, 1000);

const handleUpdateQuery = (value: string) => {
setQuery(value);
debouncedSearch();
};

const handleUpdateJurisdiction = (value: Key) => {
setJurisdiction(value as JURISDICTIONS);
};

const Laws = () => {
if (laws === undefined) return "Loading";
if (laws === null || laws.length === 0) return "No laws found";

return (
<>
{laws.map((law) => (
<div key={law.identifier}>
<h2 className="text-xl mt-4">{law.name}</h2>
{law.label}
<a href={law.source_url}>{law.source_url}</a>
<Markdown className="prose dark:prose-invert">
{law.markdown_content}
</Markdown>
</div>
))}
</>
);
};

return (
<Container>
<PageHeader title="Laws" />
<div className="flex gap-4">
<Select
label="Jurisdiction"
placeholder="Select a jurisdiction"
selectedKey={jurisdiction}
onSelectionChange={handleUpdateJurisdiction}
>
{Object.entries(JURISDICTIONS).map(([value, label]) => (
<SelectItem key={value} id={value}>
{label}
</SelectItem>
))}
</Select>
<SearchField
value={query}
onChange={handleUpdateQuery}
label="Keyword"
/>
</div>
<div className="pt-4">
<Laws />
</div>
</Container>
);
}