diff --git a/src/UI/Icon.elm b/src/UI/Icon.elm index e381d63..ac20cb9 100644 --- a/src/UI/Icon.elm +++ b/src/UI/Icon.elm @@ -379,3 +379,12 @@ clipboard = [ path [ fill "currentColor", fillRule "evenodd", d "M8 2.25C8 2.11193 7.88807 2 7.75 2H6.25C6.11193 2 6 2.11193 6 2.25V2.75C6 2.88807 6.11193 3 6.25 3H7.75C7.88807 3 8 2.88807 8 2.75V2.25ZM6 1C5.44772 1 5 1.44772 5 2V3C5 3.55228 5.44772 4 6 4H8C8.55228 4 9 3.55228 9 3V2C9 1.44772 8.55228 1 8 1H6Z" ] [] , path [ fill "currentColor", fillRule "evenodd", d "M3 2.5C3 2.22386 3.22386 2 3.5 2C3.77614 2 4 2.22386 4 2.5V10.5C4 10.7761 4.22386 11 4.5 11H9.5C9.77614 11 10 10.7761 10 10.5V2.5C10 2.22386 10.2239 2 10.5 2C10.7761 2 11 2.22386 11 2.5V11C11 11.5523 10.5523 12 10 12H4C3.44772 12 3 11.5523 3 11V2.5Z" ] [] ] + + +user : Icon msg +user = + Icon "user" + [] + [ path [ fill "currentColor", d "M7 7C3.97669 7 1.47565 9.60877 1.06051 13.0021C0.99344 13.5503 1.44772 14 2 14H12C12.5523 14 13.0066 13.5503 12.9395 13.0021C12.5243 9.60877 10.0233 7 7 7Z" ] [] + , circle [ fill "currentColor", cx "7", cy "3", r "3" ] [] + ] diff --git a/src/UnisonShare/Catalog.elm b/src/UnisonShare/Catalog.elm index d2de85e..7c3daf8 100644 --- a/src/UnisonShare/Catalog.elm +++ b/src/UnisonShare/Catalog.elm @@ -5,7 +5,6 @@ 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) @@ -87,25 +86,6 @@ 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 22ed5e7..8152df7 100644 --- a/src/UnisonShare/Page/Catalog.elm +++ b/src/UnisonShare/Page/Catalog.elm @@ -4,15 +4,18 @@ import Api import Env exposing (Env) import Html exposing (Html, div, h1, input, strong, table, tbody, td, text, tr) import Html.Attributes exposing (autofocus, class, classList, placeholder) -import Html.Events exposing (onBlur, onClick, onFocus, onInput) +import Html.Events exposing (onBlur, onFocus, onInput, onMouseDown) import Http import KeyboardShortcut exposing (KeyboardShortcut(..)) import KeyboardShortcut.Key as Key exposing (Key(..)) import KeyboardShortcut.KeyboardEvent as KeyboardEvent exposing (KeyboardEvent) +import List.Extra as ListE +import Maybe.Extra as MaybeE import Perspective import Project exposing (ProjectListing) import RemoteData exposing (RemoteData(..), WebData) import SearchResults exposing (SearchResults(..)) +import Simple.Fuzzy as Fuzzy import Task import UI import UI.Card as Card @@ -20,19 +23,26 @@ import UI.Click as Click import UI.Icon as Icon import UI.PageLayout as PageLayout exposing (PageLayout) import UnisonShare.Catalog as Catalog exposing (Catalog) +import UnisonShare.Catalog.CatalogMask exposing (CatalogMask) import UnisonShare.Route as Route +import UnisonShare.User as User exposing (Username) -- MODEL -type alias SearchResult = - ( ProjectListing, String ) +type Match + = UserMatch Username + | ProjectMatch ProjectListing String type alias CatalogSearchResults = - SearchResults SearchResult + SearchResults Match + + + +-- TODO: Rename type alias CatalogSearch = @@ -43,6 +53,7 @@ type alias LoadedModel = { search : CatalogSearch , hasFocus : Bool , catalog : Catalog + , usernames : List Username , keyboardShortcut : KeyboardShortcut.Model } @@ -53,14 +64,14 @@ type alias Model = init : Env -> ( Model, Cmd Msg ) init env = - ( Loading, fetchCatalog env ) + ( Loading, fetch env ) {-| Fetch the Catalog in sequence by first fetching the doc, then the projectListings and finally merging them into a Catalog -} -fetchCatalog : Env -> Cmd Msg -fetchCatalog env = +fetch : Env -> Cmd Msg +fetch env = let perspective = Perspective.toCodebasePerspective env.perspective @@ -73,8 +84,7 @@ fetchCatalog env = |> Api.toTask env.apiBasePath Project.decodeListings |> Task.map (\projects -> ( catalog, projects )) ) - |> Task.map (\( cm, ps ) -> Catalog.catalog cm ps) - |> Task.attempt FetchCatalogFinished + |> Task.attempt FetchFinished @@ -86,7 +96,8 @@ type Msg | UpdateFocus Bool | ClearQuery | SelectProject ProjectListing - | FetchCatalogFinished (Result Http.Error Catalog) + | SelectUser Username + | FetchFinished (Result Http.Error ( CatalogMask, List ProjectListing )) | Keydown KeyboardEvent | KeyboardShortcutMsg KeyboardShortcut.Msg @@ -94,17 +105,25 @@ type Msg update : Env -> Msg -> Model -> ( Model, Cmd Msg ) update env msg model = case ( msg, model ) of - ( FetchCatalogFinished catalogResult, _ ) -> - case catalogResult of + ( FetchFinished result, _ ) -> + case result of Err e -> ( Failure e, Cmd.none ) - Ok catalog -> + Ok ( mask, listings ) -> let + usernames = + listings + |> List.map (.owner >> Project.ownerToString) + |> ListE.unique + |> List.map User.usernameFromString + |> MaybeE.values + initModel = { search = { query = "", results = SearchResults.empty } , hasFocus = True - , catalog = catalog + , catalog = Catalog.catalog mask listings + , usernames = usernames , keyboardShortcut = KeyboardShortcut.init env.operatingSystem } in @@ -121,7 +140,7 @@ update env msg model = else query - |> Catalog.search m.catalog + |> search m.catalog m.usernames |> SearchResults.fromList in ( Success { m | search = { query = query, results = searchResults } }, Cmd.none ) @@ -132,6 +151,9 @@ update env msg model = ( SelectProject project, Success m ) -> ( Success m, Route.navigateToProject env.navKey project ) + ( SelectUser username, Success m ) -> + ( Success m, Route.navigateToUsername env.navKey username ) + ( Keydown event, Success m ) -> let ( keyboardShortcut, kCmd ) = @@ -174,8 +196,7 @@ update env msg model = navigate = matches |> SearchResults.focus - |> Tuple.first - |> Route.navigateToProject env.navKey + |> matchToNavigate env in ( Success newModel, Cmd.batch [ cmd, navigate ] ) @@ -185,8 +206,7 @@ update env msg model = let navigate = SearchResults.getAt (n - 1) m.search.results - |> Maybe.map Tuple.first - |> Maybe.map (Route.navigateToProject env.navKey) + |> Maybe.map (matchToNavigate env) |> Maybe.withDefault Cmd.none in ( Success newModel, Cmd.batch [ cmd, navigate ] ) @@ -209,8 +229,49 @@ update env msg model = mapSearch : (CatalogSearchResults -> CatalogSearchResults) -> CatalogSearch -> CatalogSearch -mapSearch f search = - { search | results = f search.results } +mapSearch f search_ = + { search_ | results = f search_.results } + + +matchToNavigate : Env -> Match -> Cmd Msg +matchToNavigate env match = + case match of + UserMatch username -> + Route.navigateToUsername env.navKey username + + ProjectMatch project _ -> + Route.navigateToProject env.navKey project + + +toMatches : Catalog -> List Username -> List Match +toMatches catalog users = + let + flat ( category, projects ) acc = + acc ++ List.map (\p -> ProjectMatch p category) projects + + projectMatches = + catalog + |> Catalog.toList + |> List.foldl flat [] + + userMatches = + List.map UserMatch users + in + userMatches ++ projectMatches + + +search : Catalog -> List Username -> String -> List Match +search catalog users query = + let + normalize m = + case m of + UserMatch u -> + User.usernameToString u + + ProjectMatch p _ -> + Project.slugString p + in + Fuzzy.filter normalize query (toMatches catalog users) @@ -238,8 +299,11 @@ viewCategory ( category, projects ) = |> Card.view -viewMatch : KeyboardShortcut.Model -> SearchResult -> Bool -> Maybe Key -> Html Msg -viewMatch keyboardShortcut ( project, category ) isFocused shortcut = +{-| View a match in the dropdown list. Use `onMouseDown` instead of `onClick` +to avoid competing with `onBlur` on the input +-} +viewMatch : KeyboardShortcut.Model -> Match -> Bool -> Maybe Key -> Html Msg +viewMatch keyboardShortcut match isFocused shortcut = let shortcutIndicator = if isFocused then @@ -253,14 +317,31 @@ viewMatch keyboardShortcut ( project, category ) isFocused shortcut = Just key -> KeyboardShortcut.view keyboardShortcut (Sequence (Just Key.Semicolon) key) in - tr - [ classList [ ( "search-result", True ), ( "focused", isFocused ) ] - , onClick (SelectProject project) - ] - [ td [ class "project-name" ] [ Project.viewProjectListing Click.Disabled project ] - , td [ class "category" ] [ text category ] - , td [] [ div [ class "shortcut" ] [ shortcutIndicator ] ] - ] + case match of + UserMatch username -> + tr + [ classList [ ( "search-result", True ), ( "focused", isFocused ) ] + , onMouseDown (SelectUser username) + ] + [ td [ class "match-name" ] + [ div [ class "user-listing" ] + [ div [ class "avatar" ] [ Icon.view Icon.user ] + , text (User.usernameToString username) + ] + ] + , td [ class "category" ] [ text "User" ] + , td [] [ div [ class "shortcut" ] [ shortcutIndicator ] ] + ] + + ProjectMatch project category -> + tr + [ classList [ ( "search-result", True ), ( "focused", isFocused ) ] + , onMouseDown (SelectProject project) + ] + [ td [ class "match-name" ] [ Project.viewProjectListing Click.Disabled project ] + , td [ class "category" ] [ text category ] + , td [] [ div [ class "shortcut" ] [ shortcutIndicator ] ] + ] indexToShortcut : Int -> Maybe Key @@ -276,7 +357,7 @@ indexToShortcut index = n |> String.fromInt |> Key.fromString |> Just -viewMatches : KeyboardShortcut.Model -> SearchResults.Matches SearchResult -> Html Msg +viewMatches : KeyboardShortcut.Model -> SearchResults.Matches Match -> Html Msg viewMatches keyboardShortcut matches = let matchItems = @@ -295,7 +376,7 @@ viewSearchResults keyboardShortcut { query, results } = resultsPane = case results of Empty -> - div [ class "empty-state" ] [ text ("No matching projects found for \"" ++ query ++ "\"") ] + div [ class "empty-state" ] [ text ("No matches found for \"" ++ query ++ "\"") ] SearchResults matches -> viewMatches keyboardShortcut matches @@ -347,7 +428,7 @@ viewLoaded model = [ div [ class "search-field" ] [ Icon.view Icon.search , input - [ placeholder "Search for projects" + [ placeholder "Search for projects and users" , onInput UpdateQuery , autofocus True , onBlur (UpdateFocus False) diff --git a/src/UnisonShare/Route.elm b/src/UnisonShare/Route.elm index b61e3cb..e6a9e30 100644 --- a/src/UnisonShare/Route.elm +++ b/src/UnisonShare/Route.elm @@ -10,6 +10,8 @@ module UnisonShare.Route exposing , navigateToLatestCodebase , navigateToPerspective , navigateToProject + , navigateToUser + , navigateToUsername , perspectiveParams , replacePerspective , toDefinition @@ -328,6 +330,16 @@ navigateToProject navKey project = navigate navKey (forProject project) +navigateToUser : Nav.Key -> User.User a -> Cmd msg +navigateToUser navKey user_ = + navigate navKey (forUser user_) + + +navigateToUsername : Nav.Key -> User.Username -> Cmd msg +navigateToUsername navKey username_ = + navigate navKey (User username_) + + -- TODO: this should go away in UnisonShare diff --git a/src/css/unison-share/page/catalog.css b/src/css/unison-share/page/catalog.css index 45a5b40..2c4aa7b 100644 --- a/src/css/unison-share/page/catalog.css +++ b/src/css/unison-share/page/catalog.css @@ -127,6 +127,7 @@ padding: 0.5rem 0.75rem; height: 3rem; font-size: 1rem; + cursor: pointer; } .catalog-hero .catalog-search .search-results td:first-child { @@ -137,7 +138,7 @@ border-radius: 0 var(--border-radius-base) var(--border-radius-base) 0; } -.catalog-hero .catalog-search .search-results .search-result td.project-name { +.catalog-hero .catalog-search .search-results .search-result td.match-name { width: 20em; text-overflow: ellipsis; overflow: hidden; @@ -179,6 +180,51 @@ text-decoration: none; } +.catalog-hero + .catalog-search + .search-results + .search-result + .project-listing:hover { + background: none; +} + +.catalog-hero .catalog-search .search-results .search-result .user-listing { + display: flex; + flex-direction: row; + align-items: center; + font-weight: bold; + padding-left: 0.25rem; +} + +.catalog-hero + .catalog-search + .search-results + .search-result + .user-listing + .avatar { + width: 1.5rem; + height: 1.5rem; + margin-right: 0.5rem; + background: var(--color-main-subtle-bg); + border: 1px solid var(--color-main-border); + border-radius: 0.75rem; + display: flex; + flex-direction: row; + align-items: flex-end; + justify-content: center; + overflow: hidden; +} +.catalog-hero + .catalog-search + .search-results + .search-result + .user-listing + .avatar + .icon { + font-size: 1rem; + color: var(--color-main-border); +} + .catalog-hero .catalog-search .search-results .empty-state { font-size: var(--font-size-base); color: var(--color-main-subtle-fg); diff --git a/tests/UnisonShare/CatalogTests.elm b/tests/UnisonShare/CatalogTests.elm index 4e03b3d..e048900 100644 --- a/tests/UnisonShare/CatalogTests.elm +++ b/tests/UnisonShare/CatalogTests.elm @@ -83,39 +83,6 @@ 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