From c2b462741b24219817a4468851f38641a55f5dff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20H=C3=B8jberg?= Date: Mon, 3 Jan 2022 13:02:38 -0500 Subject: [PATCH] Catalog: add search support Add support for fuzzy searching the catalog --- elm.json | 1 + src/Project.elm | 5 ++ src/UnisonShare/Catalog.elm | 20 +++++ src/UnisonShare/Page/Catalog.elm | 80 ++++++++++++++++---- src/css/unison-share/page/catalog.css | 101 ++++++++++++++++++++++---- tests/UnisonShare/CatalogTests.elm | 33 +++++++++ 6 files changed, 211 insertions(+), 29 deletions(-) diff --git a/elm.json b/elm.json index 2f4bf47..d330c2c 100644 --- a/elm.json +++ b/elm.json @@ -6,6 +6,7 @@ "elm-version": "0.19.1", "dependencies": { "direct": { + "NoRedInk/elm-simple-fuzzy": "1.0.3", "elm/browser": "1.0.2", "elm/core": "1.0.5", "elm/html": "1.0.0", diff --git a/src/Project.elm b/src/Project.elm index 69368aa..79c7db6 100644 --- a/src/Project.elm +++ b/src/Project.elm @@ -22,6 +22,11 @@ slug project = FQN.cons (ownerToString project.owner) project.name +slugString : Project a -> String +slugString project = + project |> slug |> FQN.toString + + ownerToString : Owner -> String ownerToString (Owner o) = o diff --git a/src/UnisonShare/Catalog.elm b/src/UnisonShare/Catalog.elm index 7c3daf8..d2de85e 100644 --- a/src/UnisonShare/Catalog.elm +++ b/src/UnisonShare/Catalog.elm @@ -5,6 +5,7 @@ import FullyQualifiedName as FQN import Json.Decode as Decode import OrderedDict exposing (OrderedDict) import Project exposing (ProjectListing) +import Simple.Fuzzy as Fuzzy import UnisonShare.Catalog.CatalogMask as CatalogMask exposing (CatalogMask) @@ -86,6 +87,25 @@ fromList items = -- HELPERS +{-| Fuzzy search through a flattened catalog by project name and category +-} +search : Catalog -> String -> List ( ProjectListing, String ) +search catalog_ query = + let + flat ( category, projects ) acc = + acc ++ List.map (\p -> ( p, category )) projects + + normalize ( p, c ) = + p + |> Project.slugString + |> (++) c + in + catalog_ + |> toList + |> List.foldl flat [] + |> Fuzzy.filter normalize query + + isEmpty : Catalog -> Bool isEmpty (Catalog dict) = OrderedDict.isEmpty dict diff --git a/src/UnisonShare/Page/Catalog.elm b/src/UnisonShare/Page/Catalog.elm index b163e59..bf77240 100644 --- a/src/UnisonShare/Page/Catalog.elm +++ b/src/UnisonShare/Page/Catalog.elm @@ -3,9 +3,9 @@ module UnisonShare.Page.Catalog exposing (..) import Api import Env exposing (Env) import FullyQualifiedName as FQN -import Html exposing (Html, a, div, h1, input, strong, text) +import Html exposing (Html, a, div, h1, input, span, strong, text) import Html.Attributes exposing (autofocus, class, href, placeholder) -import Html.Events exposing (onInput) +import Html.Events exposing (onBlur, onFocus, onInput) import Http import Perspective import Project exposing (ProjectListing) @@ -25,6 +25,7 @@ import UnisonShare.Route as Route type alias LoadedModel = { query : String + , hasFocus : Bool , catalog : Catalog } @@ -65,6 +66,7 @@ fetchCatalog env = type Msg = UpdateQuery String + | UpdateFocus Bool | ClearQuery | NoOp | FetchCatalogFinished (Result Http.Error Catalog) @@ -79,7 +81,10 @@ update msg model = ( Failure e, Cmd.none ) Ok catalog -> - ( Success { query = "", catalog = catalog }, Cmd.none ) + ( Success { query = "", hasFocus = True, catalog = catalog }, Cmd.none ) + + ( UpdateFocus hasFocus, Success m ) -> + ( Success { m | hasFocus = hasFocus }, Cmd.none ) ( UpdateQuery query, Success m ) -> ( Success { m | query = query }, Cmd.none ) @@ -95,18 +100,18 @@ update msg model = -- VIEW +projectUrl : ProjectListing -> String +projectUrl = + Route.forProject >> Route.toUrlString + + viewProjectListing : ProjectListing -> Html msg viewProjectListing project = let slug = - FQN.cons (Project.ownerToString project.owner) project.name - - url = - project - |> Route.forProject - |> Route.toUrlString + Project.slug project in - a [ class "project-listing", href url ] [ FQN.view slug ] + a [ class "project-listing", href (projectUrl project) ] [ FQN.view slug ] viewCategory : ( String, List ProjectListing ) -> Html msg @@ -120,6 +125,37 @@ viewCategory ( category, projects ) = |> Card.view +viewSearchResult : ( ProjectListing, String ) -> Html msg +viewSearchResult ( project, category ) = + a + [ class "search-result", href (projectUrl project) ] + [ project |> Project.slug |> FQN.view + , span [ class "category" ] [ text category ] + ] + + +viewSearchResults : LoadedModel -> Html msg +viewSearchResults model = + if String.length model.query > 3 then + let + results = + model.query + |> Catalog.search model.catalog + |> List.map viewSearchResult + + resultsPane = + if List.isEmpty results then + [ div [ class "empty-state" ] [ text ("No matching projects found for \"" ++ model.query ++ "\"") ] ] + + else + results + in + div [ class "search-results" ] resultsPane + + else + UI.nothing + + viewLoaded : LoadedModel -> PageLayout Msg viewLoaded model = let @@ -127,6 +163,13 @@ viewLoaded model = model.catalog |> Catalog.toList |> List.map viewCategory + + searchResults = + if model.hasFocus then + viewSearchResults model + + else + UI.nothing in PageLayout.HeroLayout { hero = @@ -144,13 +187,18 @@ viewLoaded model = , div [] [ text "Projects, libraries, documention, terms, and types" ] ] , div [ class "catalog-search" ] - [ Icon.view Icon.search - , input - [ placeholder "Search for projects" - , onInput UpdateQuery - , autofocus True + [ div [ class "search-field" ] + [ Icon.view Icon.search + , input + [ placeholder "Search for projects" + , onInput UpdateQuery + , autofocus True + , onBlur (UpdateFocus False) + , onFocus (UpdateFocus True) + ] + [] ] - [] + , searchResults ] ] ) diff --git a/src/css/unison-share/page/catalog.css b/src/css/unison-share/page/catalog.css index 5d7c84a..26db3a5 100644 --- a/src/css/unison-share/page/catalog.css +++ b/src/css/unison-share/page/catalog.css @@ -56,41 +56,100 @@ .catalog-hero .catalog-search { width: 50rem; - height: 3.5rem; background: var(--color-main-bg); color: var(--color-main-fg); border-radius: var(--border-radius-base); position: absolute; - bottom: -1.75rem; - border: 2px solid var(--color-catalog-hero-bg); + top: calc(var(--page-hero-height) - 1.75rem); + border: 2px solid transparent; box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.1); display: flex; - flex-direction: row; - align-items: center; - padding: 1rem 0 1rem 1rem; + flex-direction: column; left: 50%; transform: translateX(-50%); z-index: var(--layer-base); transition: all 0.2s; + background: var(--color-gray-lighten-55); +} + +.catalog-hero .catalog-search:focus-within { + border-color: var(--color-main-focus-fg); + box-shadow: 0 0 0 2px var(--color-main-focus-outline); +} + +.catalog-hero .catalog-search .search-field { + display: flex; + flex-direction: row; + align-items: center; + height: 3.5rem; + padding: 1rem 0 1rem 1rem; + border-radius: var(--border-radius-base); +} + +.catalog-hero .catalog-search .search-field:focus-within { + background: var(--color-gray-lighten-60); +} + +.catalog-hero .catalog-search .search-field .icon { + font-size: 1.5rem; + color: var(--color-main-subtle-fg); } -.catalog-hero .catalog-search input { + +.catalog-hero .catalog-search .search-field input { width: 100%; height: calc(3.5rem - 4px); margin-left: 0.75rem; font-size: 1.125rem; border-radius: var(--border-radius-base); + font-weight: bold; + background: transparent; } -.catalog-hero .catalog-search input:focus { + +.catalog-hero .catalog-search .search-field input::placeholder { + font-weight: normal; +} + +.catalog-hero .catalog-search .search-field input:focus { outline: none; } -.catalog-hero .catalog-search:focus-within { - border-color: var(--color-main-focus-fg); - box-shadow: 0 0 0 2px var(--color-main-focus-outline); + +.catalog-hero .catalog-search .search-results { + background: var(--color-main-bg); + border-top: 1px solid var(--color-gray-lighten-50); + border-radius: 0 0 var(--border-radius-base) var(--border-radius-base); + display: flex; + flex-direction: column; + gap: 0.75rem; + padding: 0.75rem; } -.catalog-hero .catalog-search .icon { - font-size: 1.5rem; +.catalog-hero .catalog-search .search-results .search-result { + display: flex; + flex-direction: row; + padding: 0.5rem 1rem; + align-items: center; + height: 3rem; + border-radius: var(--border-radius-base); + font-size: 1rem; +} + +.catalog-hero .catalog-search .search-results .search-result:hover { + background: var(--color-gray-lighten-55); + text-decoration: none; +} + +.catalog-hero .catalog-search .search-results .search-result .category { color: var(--color-main-subtle-fg); + font-size: var(--font-size-medium); + margin-left: auto; + justify-self: flex-end; +} + +.catalog-hero .catalog-search .search-results .empty-state { + font-size: var(--font-size-base); + color: var(--color-main-subtle-fg); + text-align: center; + padding: 1rem 0; } .categories { @@ -103,4 +162,20 @@ .categories .card { width: 18.75rem; + gap: 0.5rem; +} + +.categories .card .project-listing { + display: flex; + flex-direction: row; + align-items: center; + height: 2rem; + padding: 0 0.25rem; + margin-left: -0.25rem; + border-radius: var(--border-radius-base); +} + +.categories .card .project-listing:hover { + text-decoration: none; + background: var(--color-gray-lighten-55); } diff --git a/tests/UnisonShare/CatalogTests.elm b/tests/UnisonShare/CatalogTests.elm index e048900..4e03b3d 100644 --- a/tests/UnisonShare/CatalogTests.elm +++ b/tests/UnisonShare/CatalogTests.elm @@ -83,6 +83,39 @@ toList = ] +search : Test +search = + describe "Catalog.search" + [ test "Fuzzy finds projects in the catalog by project name " <| + \_ -> + let + projectListings_ = + [ baseListing, distributedListing, textExtraListing, nanoidListing ] + + catalog_ = + Catalog.catalog catalogMask projectListings_ + in + Expect.equal + [ ( baseListing, "Featured" ) + , ( distributedListing, "Featured" ) + ] + (Catalog.search catalog_ "unison") + , test "Fuzzy finds projects in the catalog by category" <| + \_ -> + let + projectListings_ = + [ baseListing, distributedListing, textExtraListing, nanoidListing ] + + catalog_ = + Catalog.catalog catalogMask projectListings_ + in + Expect.equal + [ ( textExtraListing, "Parsers & Text Manipulation" ) + ] + (Catalog.search catalog_ "parsers") + ] + + -- helpers