🏡 index : ~doyle/chartered.git

author Jordan Doyle <jordan@doyle.la> 2021-10-09 14:57:28.0 +01:00:00
committer Jordan Doyle <jordan@doyle.la> 2021-10-09 14:57:28.0 +01:00:00
commit
d13bc75c1e9cbf02bec42751580431bdee57fbc7 [patch]
tree
1f0490caca78ef93b637ff4aa6092a1df3b4610e
parent
9c905ceab9debebbbdbd3f83bc5c195497d08bcc
download
d13bc75c1e9cbf02bec42751580431bdee57fbc7.tar.gz

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(-)

diff --git a/chartered-db/src/crates.rs b/chartered-db/src/crates.rs
index 5840386..ce553b6 100644
--- a/chartered-db/src/crates.rs
+++ a/chartered-db/src/crates.rs
@@ -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,
diff --git a/chartered-frontend/src/index.tsx b/chartered-frontend/src/index.tsx
index 4635203..4309469 100644
--- a/chartered-frontend/src/index.tsx
+++ a/chartered-frontend/src/index.tsx
@@ -94,11 +94,7 @@
            path="/organisations/create"
            component={() => <CreateOrganisation />}
          />
          <PrivateRoute
            exact
            path="/search"
            component={() => <Search />}
          />
          <PrivateRoute exact path="/search" component={() => <Search />} />
        </Switch>
      </Router>
    </ProvideAuth>
diff --git a/chartered-frontend/src/pages/Search.tsx b/chartered-frontend/src/pages/Search.tsx
index f7048eb..b1106b9 100644
--- a/chartered-frontend/src/pages/Search.tsx
+++ a/chartered-frontend/src/pages/Search.tsx
@@ -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>
  );
}
diff --git a/chartered-frontend/src/sections/Nav.tsx b/chartered-frontend/src/sections/Nav.tsx
index b795532..772f065 100644
--- a/chartered-frontend/src/sections/Nav.tsx
+++ a/chartered-frontend/src/sections/Nav.tsx
@@ -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)}`);
    }
  };

diff --git a/chartered-frontend/src/pages/crate/Members.tsx b/chartered-frontend/src/pages/crate/Members.tsx
index 4e2463e..b44eb75 100644
--- a/chartered-frontend/src/pages/crate/Members.tsx
+++ a/chartered-frontend/src/pages/crate/Members.tsx
@@ -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();
  };

diff --git a/chartered-web/src/endpoints/web_api/crates/mod.rs b/chartered-web/src/endpoints/web_api/crates/mod.rs
index 5f6b636..28f6bd4 100644
--- a/chartered-web/src/endpoints/web_api/crates/mod.rs
+++ a/chartered-web/src/endpoints/web_api/crates/mod.rs
@@ -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)))
}
diff --git a/chartered-web/src/endpoints/web_api/crates/search.rs b/chartered-web/src/endpoints/web_api/crates/search.rs
new file mode 100644
index 0000000..f8a2dd4 100644
--- /dev/null
+++ a/chartered-web/src/endpoints/web_api/crates/search.rs
@@ -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(), // todo
                    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);