Implement search results for crates
Diff
chartered-db/src/crates.rs | 44 ++++++++++++++++++++++++++++++++++++++++++++
chartered-frontend/src/index.tsx | 6 +-----
chartered-frontend/src/pages/Search.tsx | 227 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
chartered-frontend/src/sections/Nav.tsx | 4 ++--
chartered-frontend/src/pages/crate/Members.tsx | 6 +++++-
chartered-web/src/endpoints/web_api/crates/mod.rs | 4 +++-
chartered-web/src/endpoints/web_api/crates/search.rs | 74 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
7 files changed, 311 insertions(+), 54 deletions(-)
@@ -92,6 +92,50 @@
}
impl Crate {
pub async fn search(
conn: ConnectionPool,
requesting_user_id: i32,
terms: String,
limit: i64,
) -> Result<HashMap<Organisation, Vec<CrateWithPermissions>>> {
tokio::task::spawn_blocking(move || {
let conn = conn.get()?;
let crates = crate_with_permissions!(requesting_user_id)
.inner_join(organisations::table)
.filter(
select_permissions!()
.bitwise_and(UserPermission::VISIBLE.bits())
.eq(UserPermission::VISIBLE.bits()),
)
.filter(
(organisations::name.concat("/").concat(crates::name))
.like(&format!("%{}%", terms)),
)
.select((
organisations::all_columns,
crates::all_columns,
select_permissions!(),
))
.limit(limit)
.load(&conn)?
.into_iter()
.map(|(organisation, crate_, permissions)| {
(
organisation,
CrateWithPermissions {
crate_,
permissions,
},
)
})
.into_group_map();
Ok(crates)
})
.await?
}
pub async fn list_with_versions(
conn: ConnectionPool,
requesting_user_id: i32,
@@ -94,11 +94,7 @@
path="/organisations/create"
component={() => <CreateOrganisation />}
/>
<PrivateRoute
exact
path="/search"
component={() => <Search />}
/>
<PrivateRoute exact path="/search" component={() => <Search />} />
</Switch>
</Router>
</ProvideAuth>
@@ -1,31 +1,27 @@
import React = require("react");
import { useState, useEffect } from "react";
import { Link, useHistory, useLocation } from "react-router-dom";
import Nav from "../sections/Nav";
import { useAuth } from "../useAuth";
import { authenticatedEndpoint, ProfilePicture, useAuthenticatedRequest } from "../util";
import {
authenticatedEndpoint,
ProfilePicture,
useAuthenticatedRequest,
} from "../util";
import { Plus } from "react-bootstrap-icons";
import { BoxSeam, Plus } from "react-bootstrap-icons";
import { LoadingSpinner } from "./Loading";
import { result } from "lodash";
interface UsersSearchResponse {
users: UserSearchResponseUser[];
}
interface UserSearchResponseUser {
user_uuid: string;
display_name: string;
picture_url: string;
}
export default function Search() {
const auth = useAuth();
const location = useLocation();
const query = location.pathname === '/search'
? new URLSearchParams(location.search).get("q") || ""
: "";
const query =
location.pathname === "/search"
? new URLSearchParams(location.search).get("q") || ""
: "";
return (
<div className="text-white">
@@ -35,41 +31,182 @@
<h1>Search Results {query ? <>for '{query}'</> : <></>}</h1>
<UsersResults query={query} />
<CrateResults query={query} className="mt-2" />
</div>
</div>
);
}
interface UsersSearchResponse {
users: UserSearchResponseUser[];
}
interface UserSearchResponseUser {
user_uuid: string;
display_name: string;
picture_url: string;
}
function UsersResults({ query }: { query: string }) {
const auth = useAuth();
const { response: results, error } =
useAuthenticatedRequest<UsersSearchResponse>({
auth,
endpoint: "users/search?q=" + encodeURIComponent(query),
}, [query]);
if (!results) {
return <div className="card border-0 shadow-sm text-black p-2">
<div className="card-body">
{[0, 1, 2].map((i) => (
<ProfilePicture key={i} height="5rem" width="5rem" className="me-2" src={undefined} />
))}
</div>
const auth = useAuth();
const { response: results, error } =
useAuthenticatedRequest<UsersSearchResponse>(
{
auth,
endpoint: "users/search?q=" + encodeURIComponent(query),
},
[query]
);
if (!results) {
return (
<div className="card border-0 shadow-sm text-black p-2">
<div className="card-body">
{[0, 1, 2].map((i) => (
<ProfilePicture
key={i}
height="5rem"
width="5rem"
className="me-2"
src={undefined}
/>
))}
</div>
}
if (results?.users.length === 0) {
return <></>;
}
return <div className="card border-0 shadow-sm text-black p-2">
<div className="card-body">
{results.users.map((user, i) => (
<Link to={`users/${user.user_uuid}`}>
<ProfilePicture key={i} height="5rem" width="5rem" className="me-2" src={user.picture_url} />
</Link>
))}
</div>
);
}
if (results?.users.length === 0) {
return <></>;
}
return (
<div className="card border-0 shadow-sm text-black p-2">
<div className="card-body">
{results.users.map((user, i) => (
<Link to={`users/${user.user_uuid}`}>
<ProfilePicture
key={i}
height="5rem"
width="5rem"
className="me-2"
src={user.picture_url}
/>
</Link>
))}
</div>
</div>
);
}
interface CrateSearchResponse {
crates: CrateSearchResponseCrate[];
}
interface CrateSearchResponseCrate {
organisation: string;
name: string;
description: string;
homepage?: string;
repository?: string;
}
function CrateResults({
query,
className,
}: {
query: string;
className?: string;
}) {
const auth = useAuth();
const { response: results, error } =
useAuthenticatedRequest<CrateSearchResponse>(
{
auth,
endpoint: "crates/search?q=" + encodeURIComponent(query),
},
[query]
);
if (!results) {
return (
<div className={`card border-0 shadow-sm text-black p-2 ${className}`}>
<div className="card-body">
<LoadingSpinner />
</div>
</div>;
}
</div>
);
}
if (results?.crates.length === 0) {
return <></>;
}
return (
<div className="card border-0 shadow-sm text-black">
<div className="table-responsive">
<table className="table table-striped">
<tbody>
{results?.crates.map((crate, i) => (
<tr key={i}>
<td className="p-3">
<div className="d-flex flex-row align-items-center">
<div
className="text-white circle bg-primary bg-gradient d-inline rounded-circle d-inline-flex justify-content-center align-items-center"
style={{ width: "2rem", height: "2rem" }}
>
<BoxSeam />
</div>
<Link
to={`/crates/${crate.organisation}/${crate.name}`}
className="text-decoration-none"
>
<h4 className="text-primary d-inline px-2 m-0">
<span className="text-secondary">
{crate.organisation}
</span>
/{crate.name}
</h4>
</Link>
<h6 className="text-secondary m-0 mt-1">0.1.2</h6>
</div>
<p className="m-0">{crate.description}</p>
{crate.homepage || crate.repository ? (
<div className="mt-2 small">
{crate.homepage ? (
<a
href={crate.homepage}
className="text-decoration-none me-2"
>
Homepage
</a>
) : (
<></>
)}
{crate.repository ? (
<a
href={crate.repository}
className="text-decoration-none me-2"
>
Repository
</a>
) : (
<></>
)}
</div>
) : (
<></>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
@@ -16,7 +16,7 @@
};
const [search, setSearch] = React.useState(
location.pathname === '/search'
location.pathname === "/search"
? new URLSearchParams(location.search).get("q") || ""
: ""
);
@@ -24,7 +24,7 @@
e.preventDefault();
if (search != "") {
history.push(`/search?q=${encodeURIComponent(search)}`)
history.push(`/search?q=${encodeURIComponent(search)}`);
}
};
@@ -292,7 +292,11 @@
};
const handleChange = (selected) => {
onInsert(selected[0].display_name, selected[0].picture_url, selected[0].user_uuid);
onInsert(
selected[0].display_name,
selected[0].picture_url,
selected[0].user_uuid
);
searchRef.current.clear();
};
@@ -1,6 +1,7 @@
mod info;
mod members;
mod recently_updated;
mod search;
use axum::{
body::{Body, BoxBody},
@@ -26,5 +27,6 @@
.route("/:org/:crate/members", patch(members::handle_patch))
.route("/:org/:crate/members", put(members::handle_put))
.route("/:org/:crate/members", delete(members::handle_delete))
.route("/recently-updated", get(recently_updated::handle)))
.route("/recently-updated", get(recently_updated::handle))
.route("/search", get(search::handle)))
}
@@ -1,0 +1,74 @@
use axum::{extract, Json};
use chartered_db::{crates::Crate, permissions::UserPermission, users::User, ConnectionPool};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use thiserror::Error;
#[derive(Deserialize)]
pub struct RequestParams {
q: String,
}
#[derive(Serialize)]
pub struct Response {
crates: Vec<ResponseCrate>,
}
#[derive(Serialize)]
pub struct ResponseCrate {
organisation: String,
name: String,
description: Option<String>,
version: String,
homepage: Option<String>,
repository: Option<String>,
updated: DateTime<Utc>,
permissions: UserPermission,
}
pub async fn handle(
extract::Extension(db): extract::Extension<ConnectionPool>,
extract::Extension(user): extract::Extension<Arc<User>>,
extract::Query(req): extract::Query<RequestParams>,
) -> Result<Json<Response>, Error> {
let crates = Crate::search(db, user.id, req.q, 5)
.await?
.into_iter()
.map(|(org, crates_with_permissions)| {
crates_with_permissions
.into_iter()
.map(move |v| ResponseCrate {
organisation: org.name.to_string(),
name: v.crate_.name,
description: v.crate_.description,
version: "test".to_string(),
homepage: v.crate_.homepage,
repository: v.crate_.repository,
updated: Utc::now(),
permissions: v.permissions,
})
})
.flatten()
.collect();
Ok(Json(Response { crates }))
}
#[derive(Error, Debug)]
pub enum Error {
#[error("{0}")]
Database(#[from] chartered_db::Error),
}
impl Error {
pub fn status_code(&self) -> axum::http::StatusCode {
use axum::http::StatusCode;
match self {
Self::Database(_) => StatusCode::INTERNAL_SERVER_ERROR,
}
}
}
define_error_response!(Error);