diff --git a/scripts/import-channels-into-elasticsearch b/scripts/import-channels-into-elasticsearch index b6afa8c..89b1b28 100755 --- a/scripts/import-channels-into-elasticsearch +++ b/scripts/import-channels-into-elasticsearch @@ -54,7 +54,7 @@ def get_packages(evaluation): check=True, ) packages = json.loads(result.stdout).items() - packages = list(packages)[:10] + packages = list(packages) def gen(): for attr_name, data in packages: @@ -123,7 +123,7 @@ def get_options(evaluation): if os.path.exists(options_file): with open(options_file) as f: options = json.load(f).items() - options = list(options)[:10] + options = list(options) def gen(): for name, option in options: diff --git a/src/ElasticSearch.elm b/src/ElasticSearch.elm new file mode 100644 index 0000000..0022de0 --- /dev/null +++ b/src/ElasticSearch.elm @@ -0,0 +1,315 @@ +module ElasticSearch exposing + ( Model + , Msg(..) + , Options + , Result + , ResultItem + , decodeResult + , init + , makeRequest + , showLoadingOnQuery + , update + , view + ) + +import Base64 +import Browser.Navigation +import Html + exposing + ( Html + , button + , div + , form + , h1 + , input + , text + ) +import Html.Attributes + exposing + ( class + , type_ + , value + ) +import Html.Events + exposing + ( onInput + , onSubmit + ) +import Http +import Json.Decode +import Json.Encode +import RemoteData +import Url.Builder + + +type alias Model a = + { query : Maybe String + , result : RemoteData.WebData (Result a) + , showDetailsFor : Maybe String + } + + +type alias Result a = + { hits : ResultHits a + } + + +type alias ResultHits a = + { total : ResultHitsTotal + , max_score : Maybe Float + , hits : List (ResultItem a) + } + + +type alias ResultHitsTotal = + { value : Int + , relation : String -- TODO: this should probably be Enum + } + + +type alias ResultItem a = + { index : String + , id : String + , score : Float + , source : a + } + + +init : + Maybe String + -> Maybe String + -> ( Model a, Cmd msg ) +init query showDetailsFor = + ( { query = query + , result = RemoteData.NotAsked + , showDetailsFor = showDetailsFor + } + , Cmd.none + ) + + + +-- --------------------------- +-- UPDATE +-- --------------------------- + + +type Msg a + = QueryInput String + | QuerySubmit + | QueryResponse (RemoteData.WebData (Result a)) + | ShowDetails String + + +update : + String + -> Browser.Navigation.Key + -> Msg a + -> Model a + -> ( Model a, Cmd (Msg a) ) +update path navKey msg model = + case msg of + QueryInput query -> + ( { model | query = Just query } + , Cmd.none + ) + + QuerySubmit -> + ( model + , createUrl path model.query model.showDetailsFor + |> Browser.Navigation.pushUrl navKey + ) + + QueryResponse result -> + ( { model | result = result } + , Cmd.none + ) + + ShowDetails selected -> + ( { model | showDetailsFor = Just selected } + , Cmd.none + ) + + +showLoadingOnQuery : Model a -> Model a +showLoadingOnQuery model = + -- TODO: use this + { model + | result = + case model.query of + Just query -> + RemoteData.Loading + + Nothing -> + RemoteData.NotAsked + } + + +createUrl : String -> Maybe String -> Maybe String -> String +createUrl path query showDetailsFor = + [] + |> List.append + (query + |> Maybe.map + (\x -> + [ Url.Builder.string "query" x ] + ) + |> Maybe.withDefault [] + ) + |> List.append + (showDetailsFor + |> Maybe.map + (\x -> + [ Url.Builder.string "showDetailsFor" x + ] + ) + |> Maybe.withDefault [] + ) + |> Url.Builder.absolute [ path ] + + + +-- VIEW + + +view : + { title : String } + -> Model a + -> (Maybe String -> Result a -> Html b) + -> (Msg a -> b) + -> Html b +view options model viewSuccess outMsg = + div [ class "search-page" ] + [ h1 [ class "page-header" ] [ text options.title ] + , div [ class "search-input" ] + [ form [ onSubmit (outMsg QuerySubmit) ] + [ div [ class "input-append" ] + [ input + [ type_ "text" + , onInput (\x -> outMsg (QueryInput x)) + , value <| Maybe.withDefault "" model.query + ] + [] + , div [ class "btn-group" ] + [ button [ class "btn" ] [ text "Search" ] + + --TODO: and option to select the right channel+version+evaluation + --, button [ class "btn" ] [ text "Loading ..." ] + --, div [ class "popover bottom" ] + -- [ div [ class "arrow" ] [] + -- , div [ class "popover-title" ] [ text "Select options" ] + -- , div [ class "popover-content" ] + -- [ p [] [ text "Sed posuere consectetur est at lobortis. Aenean eu leo quam. Pellentesque ornare sem lacinia quam venenatis vestibulum." ] ] + -- ] + ] + ] + ] + ] + , case model.result of + RemoteData.NotAsked -> + div [] [ text "NotAsked" ] + + RemoteData.Loading -> + div [] [ text "Loading" ] + + RemoteData.Success result -> + viewSuccess model.showDetailsFor result + + RemoteData.Failure error -> + div [] + [ text "Error!" + + --, pre [] [ text (Debug.toString error) ] + ] + ] + + + +-- API + + +type alias Options = + { url : String + , username : String + , password : String + } + + +makeRequestBody : String -> String -> Http.Body +makeRequestBody field query = + let + stringIn name value = + [ ( name, Json.Encode.string value ) ] + + objectIn name object = + [ ( name, Json.Encode.object object ) ] + in + -- I'm not sure we need fuziness + --, ( "fuzziness", Json.Encode.int 1 ) + query + |> stringIn "query" + |> objectIn field + |> objectIn "match" + |> objectIn "query" + |> Json.Encode.object + |> Http.jsonBody + + +makeRequest : + String + -> String + -> Json.Decode.Decoder a + -> Options + -> String + -> Cmd (Msg a) +makeRequest field index decodeResultItemSource options query = + Http.riskyRequest + { method = "POST" + , headers = + [ Http.header "Authorization" ("Basic " ++ Base64.encode (options.username ++ ":" ++ options.password)) + ] + , url = options.url ++ "/" ++ index ++ "/_search" + , body = makeRequestBody field query + , expect = + Http.expectJson + (RemoteData.fromResult >> QueryResponse) + (decodeResult decodeResultItemSource) + , timeout = Nothing + , tracker = Nothing + } + + + +-- JSON + + +decodeResult : + Json.Decode.Decoder a + -> Json.Decode.Decoder (Result a) +decodeResult decodeResultItemSource = + Json.Decode.map Result + (Json.Decode.field "hits" (decodeResultHits decodeResultItemSource)) + + +decodeResultHits : Json.Decode.Decoder a -> Json.Decode.Decoder (ResultHits a) +decodeResultHits decodeResultItemSource = + Json.Decode.map3 ResultHits + (Json.Decode.field "total" decodeResultHitsTotal) + (Json.Decode.field "max_score" (Json.Decode.nullable Json.Decode.float)) + (Json.Decode.field "hits" (Json.Decode.list (decodeResultItem decodeResultItemSource))) + + +decodeResultHitsTotal : Json.Decode.Decoder ResultHitsTotal +decodeResultHitsTotal = + Json.Decode.map2 ResultHitsTotal + (Json.Decode.field "value" Json.Decode.int) + (Json.Decode.field "relation" Json.Decode.string) + + +decodeResultItem : Json.Decode.Decoder a -> Json.Decode.Decoder (ResultItem a) +decodeResultItem decodeResultItemSource = + Json.Decode.map4 ResultItem + (Json.Decode.field "_index" Json.Decode.string) + (Json.Decode.field "_id" Json.Decode.string) + (Json.Decode.field "_score" Json.Decode.float) + (Json.Decode.field "_source" decodeResultItemSource) diff --git a/src/Main.elm b/src/Main.elm index 27bc86c..30e5c42 100644 --- a/src/Main.elm +++ b/src/Main.elm @@ -1,66 +1,39 @@ module Main exposing (main) -import Base64 -import Browser exposing (UrlRequest(..)) -import Browser.Navigation as Nav exposing (Key) +--exposing (UrlRequest(..)) + +import Browser +import Browser.Navigation +import ElasticSearch import Html exposing ( Html , a - , button , div , footer - , form - , h1 , header , img - , input , li - , p - , pre - , table - , tbody - , td , text - , th - , thead - , tr , ul ) import Html.Attributes exposing ( class - , colspan + , classList , href , src - , type_ - , value ) -import Html.Events - exposing - ( onClick - , onInput - , onSubmit - ) -import Http -import Json.Decode as D -import Json.Decode.Pipeline as DP -import Json.Encode as E -import RemoteData as R -import Url exposing (Url) -import Url.Builder as UrlBuilder -import Url.Parser as UrlParser - exposing - ( () - , Parser - ) -import Url.Parser.Query as UrlParserQuery +import Page.Home +import Page.Options +import Page.Packages +import RemoteData +import Route +import Url --- --------------------------- -- MODEL --- --------------------------- type alias Flags = @@ -71,403 +44,174 @@ type alias Flags = type alias Model = - { key : Key - , elasticsearchUrl : String - , elasticsearchUsername : String - , elasticsearchPassword : String + { navKey : Browser.Navigation.Key + , url : Url.Url + , elasticsearch : ElasticSearch.Options , page : Page } -type alias SearchModel = - { query : Maybe String - , result : R.WebData SearchResult - , showDetailsFor : Maybe String - } - - type Page - = SearchPage SearchModel + = NotFound + | Home Page.Home.Model + | Packages Page.Packages.Model + | Options Page.Options.Model - ---| PackagePage SearchModel ---| MaintainerPage SearchModel - - -type alias SearchResult = - { hits : SearchResultHits - } - - -type alias SearchResultHits = - { total : SearchResultHitsTotal - , max_score : Maybe Float - , hits : List SearchResultItem - } - - -type alias SearchResultHitsTotal = - { value : Int - , relation : String -- TODO: this should probably be Enum - } - - -type alias SearchResultItem = - { index : String - , id : String - , score : Float - , source : SearchResultItemSource - } - - -type SearchResultItemSource - = Package SearchResultPackage - | Option SearchResultOption - - -type alias SearchResultPackage = - { attr_name : String - , name : String - , version : String - , description : Maybe String - , longDescription : Maybe String - , licenses : List SearchResultPackageLicense - , maintainers : List SearchResultPackageMaintainer - , platforms : List String - , position : Maybe String - , homepage : Maybe String - } - - -type alias SearchResultOption = - { option_name : String - , description : String - , type_ : String - , default : String - , example : String - , source : String - } - - -type alias SearchResultPackageLicense = - { fullName : Maybe String - , url : Maybe String - } - - -type alias SearchResultPackageMaintainer = - { name : String - , email : String - , github : String - } - - -emptySearch : Page -emptySearch = - SearchPage - { query = Nothing - , result = R.NotAsked - , showDetailsFor = Nothing - } - - -init : Flags -> Url -> Key -> ( Model, Cmd Msg ) -init flags url key = +init : + Flags + -> Url.Url + -> Browser.Navigation.Key + -> ( Model, Cmd Msg ) +init flags url navKey = let model = - { key = key - , elasticsearchUrl = flags.elasticsearchUrl - , elasticsearchUsername = flags.elasticsearchUsername - , elasticsearchPassword = flags.elasticsearchPassword - , page = UrlParser.parse urlParser url |> Maybe.withDefault emptySearch + { navKey = navKey + , url = url + , elasticsearch = + ElasticSearch.Options + flags.elasticsearchUrl + flags.elasticsearchUsername + flags.elasticsearchPassword + , page = NotFound } in - ( model - , initPageCmd model model - ) - - -initPageCmd : Model -> Model -> Cmd Msg -initPageCmd oldModel model = - let - makeRequest query = - Http.riskyRequest - { method = "POST" - , headers = - [ Http.header "Authorization" ("Basic " ++ Base64.encode (model.elasticsearchUsername ++ ":" ++ model.elasticsearchPassword)) - ] - , url = model.elasticsearchUrl ++ "/nixos-unstable-packages/_search" - , body = - Http.jsonBody <| - E.object - [ ( "query" - , E.object - [ ( "match" - , E.object - [ ( "attr_name" - , E.object - [ ( "query", E.string query ) - - -- I'm not sure we need fuziness - --, ( "fuzziness", E.int 1 ) - ] - ) - ] - ) - ] - ) - ] - , expect = Http.expectJson (R.fromResult >> SearchQueryResponse) decodeResult - , timeout = Nothing - , tracker = Nothing - } - in - case oldModel.page of - SearchPage oldSearchModel -> - case model.page of - SearchPage searchModel -> - if (oldSearchModel.query == searchModel.query) && R.isSuccess oldSearchModel.result then - Cmd.none - - else - searchModel.query - |> Maybe.map makeRequest - |> Maybe.withDefault Cmd.none + changeRouteTo model url --- --------------------------- --- URL Parsing and Routing --- --------------------------- - - -handleUrlRequest : Key -> UrlRequest -> Cmd msg -handleUrlRequest key urlRequest = - case urlRequest of - Internal url -> - Nav.pushUrl key (Url.toString url) - - External url -> - Nav.load url - - -urlParser : Parser (Page -> msg) msg -urlParser = - UrlParser.oneOf - [ UrlParser.map - (\query showDetailsFor -> - SearchPage - { query = query - , result = R.NotAsked - , showDetailsFor = showDetailsFor - } - ) - (UrlParser.s "search" UrlParserQuery.string "query" UrlParserQuery.string "showDetailsFor") - ] - - - --- --------------------------- -- UPDATE --- --------------------------- type Msg - = OnUrlRequest UrlRequest - | OnUrlChange Url - | SearchPageInput String - | SearchQuerySubmit - | SearchQueryResponse (R.WebData SearchResult) - | SearchShowPackageDetails String + = ChangedUrl Url.Url + | ClickedLink Browser.UrlRequest + | HomeMsg Page.Home.Msg + | PackagesMsg Page.Packages.Msg + | OptionsMsg Page.Options.Msg -decodeResult : D.Decoder SearchResult -decodeResult = - D.map SearchResult - (D.field "hits" decodeResultHits) +updateWith : + (subModel -> Page) + -> (subMsg -> Msg) + -> Model + -> ( subModel, Cmd subMsg ) + -> ( Model, Cmd Msg ) +updateWith toPage toMsg model ( subModel, subCmd ) = + ( { model | page = toPage subModel } + , Cmd.map toMsg subCmd + ) -decodeResultHits : D.Decoder SearchResultHits -decodeResultHits = - D.map3 SearchResultHits - (D.field "total" decodeResultHitsTotal) - (D.field "max_score" (D.nullable D.float)) - (D.field "hits" (D.list decodeResultItem)) +submitQuery : + Model + -> ( Model, Cmd Msg ) + -> ( Model, Cmd Msg ) +submitQuery old ( new, cmd ) = + let + triggerSearch oldModel newModel msg makeRequest = + if (oldModel.query == newModel.query) && RemoteData.isSuccess oldModel.result then + ( new, cmd ) + + else + ( new + , Cmd.batch + [ cmd + , Page.Packages.makeRequest + new.elasticsearch + (Maybe.withDefault "" newModel.query) + |> Cmd.map PackagesMsg + ] + ) + in + case ( old.page, new.page ) of + ( Packages oldModel, Packages newModel ) -> + triggerSearch oldModel newModel PackagesMsg Page.Packages.makeRequest + + ( Options oldModel, Options newModel ) -> + triggerSearch oldModel newModel OptionsMsg Page.Options.makeRequest + + ( _, _ ) -> + ( new, cmd ) -decodeResultHitsTotal : D.Decoder SearchResultHitsTotal -decodeResultHitsTotal = - D.map2 SearchResultHitsTotal - (D.field "value" D.int) - (D.field "relation" D.string) +changeRouteTo : Model -> Url.Url -> ( Model, Cmd Msg ) +changeRouteTo model url = + let + newModel = + { model | url = url } - -decodeResultItem : D.Decoder SearchResultItem -decodeResultItem = - D.map4 SearchResultItem - (D.field "_index" D.string) - (D.field "_id" D.string) - (D.field "_score" D.float) - (D.field "_source" decodeResultItemSource) - - -decodeResultItemSource : D.Decoder SearchResultItemSource -decodeResultItemSource = - D.oneOf - [ D.map Package decodeResultPackage - - --, D.map Option decodeResultOption - ] - - -decodeResultPackage : D.Decoder SearchResultPackage -decodeResultPackage = - D.succeed SearchResultPackage - |> DP.required "attr_name" D.string - |> DP.required "name" D.string - |> DP.required "version" D.string - |> DP.required "description" (D.nullable D.string) - |> DP.required "longDescription" (D.nullable D.string) - |> DP.required "license" (D.list decodeResultPackageLicense) - |> DP.required "maintainers" (D.list decodeResultPackageMaintainer) - |> DP.required "platforms" (D.list D.string) - |> DP.required "position" (D.nullable D.string) - |> DP.required "homepage" (D.nullable D.string) - - -decodeResultPackageLicense : D.Decoder SearchResultPackageLicense -decodeResultPackageLicense = - D.map2 SearchResultPackageLicense - (D.field "fullName" (D.nullable D.string)) - (D.field "url" (D.nullable D.string)) - - -decodeResultPackageMaintainer : D.Decoder SearchResultPackageMaintainer -decodeResultPackageMaintainer = - D.map3 SearchResultPackageMaintainer - (D.field "name" D.string) - (D.field "email" D.string) - (D.field "github" D.string) - - -decodeResultOption : D.Decoder SearchResultOption -decodeResultOption = - D.map6 SearchResultOption - (D.field "option_name" D.string) - (D.field "description" D.string) - (D.field "type" D.string) - (D.field "default" D.string) - (D.field "example" D.string) - (D.field "source" D.string) - - -update : Msg -> Model -> ( Model, Cmd Msg ) -update message model = - case message of - OnUrlRequest urlRequest -> - ( model, handleUrlRequest model.key urlRequest ) - - OnUrlChange url -> - let - newModel = - { model | page = UrlParser.parse urlParser url |> Maybe.withDefault model.page } - - newPage = - case newModel.page of - SearchPage searchModel -> - SearchPage - { searchModel - | result = - case searchModel.query of - Just query -> - R.Loading - - Nothing -> - R.NotAsked - } - - newNewModel = - { newModel | page = newPage } - in - ( newNewModel - , initPageCmd newModel newNewModel - ) - - SearchPageInput query -> - ( { model - | page = - case model.page of - SearchPage searchModel -> - SearchPage { searchModel | query = Just query } + maybeRoute = + Route.fromUrl url + in + case maybeRoute of + Nothing -> + ( { newModel + | page = NotFound } , Cmd.none ) - SearchQuerySubmit -> - case model.page of - SearchPage searchModel -> - ( model - , Nav.pushUrl model.key <| createSearchUrl searchModel - ) - - SearchQueryResponse result -> - case model.page of - SearchPage searchModel -> - let - newPage = - SearchPage { searchModel | result = result } - in - ( { model | page = newPage } - , Cmd.none - ) - - SearchShowPackageDetails showDetailsFor -> - case model.page of - SearchPage searchModel -> - let - newSearchModel = - { searchModel - | showDetailsFor = - if searchModel.showDetailsFor == Just showDetailsFor then - Nothing - - else - Just showDetailsFor - } - in - ( model - , Nav.pushUrl model.key <| createSearchUrl newSearchModel - ) - - -createSearchUrl : SearchModel -> String -createSearchUrl model = - [] - |> List.append - (model.query - |> Maybe.map - (\query -> - [ UrlBuilder.string "query" query ] - ) - |> Maybe.withDefault [] + Just Route.NotFound -> + ( { newModel + | page = NotFound + } + , Cmd.none ) - |> List.append - (model.showDetailsFor - |> Maybe.map - (\x -> - [ UrlBuilder.string "showDetailsFor" x - ] + + Just Route.Home -> + -- Always redirect to /packages until we have something to show + -- on the home page + ( newModel, Browser.Navigation.pushUrl newModel.navKey "/packages" ) + + Just (Route.Packages query showDetailsFor) -> + Page.Packages.init query showDetailsFor + |> updateWith Packages PackagesMsg newModel + |> submitQuery newModel + + Just (Route.Options query showDetailsFor) -> + Page.Options.init query showDetailsFor + |> updateWith Options OptionsMsg newModel + |> submitQuery newModel + + +update : Msg -> Model -> ( Model, Cmd Msg ) +update msg model = + case ( msg, model.page ) of + ( ClickedLink urlRequest, _ ) -> + case urlRequest of + Browser.Internal url -> + ( model + , Browser.Navigation.pushUrl model.navKey <| Url.toString url ) - |> Maybe.withDefault [] - ) - |> UrlBuilder.absolute [ "search" ] + + Browser.External href -> + ( model + , Browser.Navigation.load href + ) + + ( ChangedUrl url, _ ) -> + changeRouteTo model url + + ( HomeMsg subMsg, Home subModel ) -> + Page.Home.update subMsg subModel + |> updateWith Home HomeMsg model + + ( PackagesMsg subMsg, Packages subModel ) -> + Page.Packages.update model.navKey subMsg subModel + |> updateWith Packages PackagesMsg model + + ( OptionsMsg subMsg, Options subModel ) -> + Page.Options.update model.navKey subMsg subModel + |> updateWith Options OptionsMsg model + + ( _, _ ) -> + -- Disregard messages that arrived for the wrong page. + ( model, Cmd.none ) --- --------------------------- -- VIEW --- --------------------------- view : Model -> Html Msg @@ -480,134 +224,79 @@ view model = [ a [ class "brand", href "https://search.nixos.org" ] [ img [ src "https://nixos.org/logo/nix-wiki.png", class "logo" ] [] ] + , viewNavigation model.url ] ] ] ] , div [ class "container main" ] - [ case model.page of - SearchPage searchModel -> - searchPage searchModel + [ viewPage model , footer [] [] ] ] -searchPage : SearchModel -> Html Msg -searchPage model = - div [ class "search-page" ] - [ h1 [ class "page-header" ] [ text "Search for packages and options" ] - , div [ class "search-input" ] - [ form [ onSubmit SearchQuerySubmit ] - [ div [ class "input-append" ] - [ input - [ type_ "text" - , onInput SearchPageInput - , value <| Maybe.withDefault "" model.query - ] - [] - , div [ class "btn-group" ] - [ button [ class "btn" ] [ text "Search" ] - - --TODO: and option to select the right channel+version+evaluation - --, button [ class "btn" ] [ text "Loading ..." ] - --, div [ class "popover bottom" ] - -- [ div [ class "arrow" ] [] - -- , div [ class "popover-title" ] [ text "Select options" ] - -- , div [ class "popover-content" ] - -- [ p [] [ text "Sed posuere consectetur est at lobortis. Aenean eu leo quam. Pellentesque ornare sem lacinia quam venenatis vestibulum." ] ] - -- ] - ] - ] - ] +viewNavigation : Url.Url -> Html Msg +viewNavigation url = + ul [ class "nav" ] + (List.map + (viewNavigationItem url) + [ ( "/packages", "Packages" ) + , ( "/options", "Options" ) ] - , case model.result of - R.NotAsked -> - div [] [ text "NotAsked" ] - - R.Loading -> - div [] [ text "Loading" ] - - R.Success result -> - searchPageResult model.showDetailsFor result.hits - - R.Failure error -> - div [] - [ text "Error!" - - --, pre [] [ text (Debug.toString error) ] - ] - ] + ) -searchPageResult : Maybe String -> SearchResultHits -> Html Msg -searchPageResult showDetailsFor result = - div [ class "search-result" ] - [ table [ class "table table-hover" ] - [ thead [] - [ tr [] - [ th [] [ text "Attribute name" ] - , th [] [ text "Name" ] - , th [] [ text "Version" ] - , th [] [ text "Description" ] - ] - ] - , tbody [] <| List.concatMap (searchPageResultItem showDetailsFor) result.hits - ] - ] +viewNavigationItem : + Url.Url + -> ( String, String ) + -> Html Msg +viewNavigationItem url ( path, title ) = + li + [ classList [ ( "active", path == url.path ) ] ] + [ a [ href path ] [ text title ] ] -searchPageResultItem : Maybe String -> SearchResultItem -> List (Html Msg) -searchPageResultItem showDetailsFor item = - case item.source of - Package package -> - let - packageDetails = - if Just item.id == showDetailsFor then - [ td [ colspan 4 ] - [] - ] +viewPage : Model -> Html Msg +viewPage model = + case model.page of + NotFound -> + div [] [ text "Not Found" ] - else - [] - in - [ tr [ onClick <| SearchShowPackageDetails item.id ] - [ td [] [ text package.attr_name ] - , td [] [ text package.name ] - , td [] [ text package.version ] - , td [] [ text <| Maybe.withDefault "" package.description ] - ] - ] - ++ packageDetails + Home _ -> + div [] [ text "Welcome" ] - Option option -> - [ tr - [] - [-- td [] [ text option.option_name ] - --, td [] [ text option.name ] - --, td [] [ text option.version ] - --, td [] [ text option.description ] - ] - ] + Packages packagesModel -> + Html.map (\m -> PackagesMsg m) <| Page.Packages.view packagesModel + + Options optionsModel -> + Html.map (\m -> OptionsMsg m) <| Page.Options.view optionsModel + + + +-- SUBSCRIPTIONS + + +subscriptions : Model -> Sub Msg +subscriptions _ = + Sub.none --- --------------------------- -- MAIN --- --------------------------- main : Program Flags Model Msg main = Browser.application { init = init + , onUrlRequest = ClickedLink + , onUrlChange = ChangedUrl + , subscriptions = subscriptions , update = update , view = \m -> { title = "NixOS Search" , body = [ view m ] } - , subscriptions = \_ -> Sub.none - , onUrlRequest = OnUrlRequest - , onUrlChange = OnUrlChange } diff --git a/src/Page/Home.elm b/src/Page/Home.elm new file mode 100644 index 0000000..42b5520 --- /dev/null +++ b/src/Page/Home.elm @@ -0,0 +1,28 @@ +module Page.Home exposing (Model, Msg, init, update, view) + +import Html exposing (Html, text, div ) + +-- MODEL + +type alias Model = () + + +init : (Model, Cmd Msg) +init = + ((), Cmd.none) + + +-- UPDATE + +type Msg = NoOp + +update : Msg -> Model -> ( Model, Cmd Msg ) +update msg model = + (model, Cmd.none) + +-- VIEW + +view : Model -> Html Msg +view model = + div [] [text "Home"] + diff --git a/src/Page/Options.elm b/src/Page/Options.elm new file mode 100644 index 0000000..6ab6fad --- /dev/null +++ b/src/Page/Options.elm @@ -0,0 +1,169 @@ +module Page.Options exposing + ( Model + , Msg + , decodeResultItemSource + , init + , makeRequest + , update + , view + ) + +import Browser.Navigation +import ElasticSearch +import Html + exposing + ( Html + , div + , table + , tbody + , td + , text + , th + , thead + , tr + ) +import Html.Attributes + exposing + ( class + , colspan + ) +import Html.Events + exposing + ( onClick + ) +import Json.Decode + + + +-- MODEL + + +type alias Model = + ElasticSearch.Model ResultItemSource + + +type alias ResultItemSource = + { option_name : String + , description : String + , type_ : String + , default : String + , example : String + , source : String + } + + +init : + Maybe String + -> Maybe String + -> ( Model, Cmd Msg ) +init = + ElasticSearch.init + + + +-- UPDATE + + +type Msg + = SearchMsg (ElasticSearch.Msg ResultItemSource) + + +update : Browser.Navigation.Key -> Msg -> Model -> ( Model, Cmd Msg ) +update navKey msg model = + case msg of + SearchMsg subMsg -> + let + ( newModel, newCmd ) = + ElasticSearch.update "options" navKey subMsg model + in + ( newModel, Cmd.map SearchMsg newCmd ) + + + +-- VIEW + + +view : Model -> Html Msg +view model = + ElasticSearch.view + { title = "Search NixOS options" } + model + viewSuccess + SearchMsg + + +viewSuccess : + Maybe String + -> ElasticSearch.Result ResultItemSource + -> Html Msg +viewSuccess showDetailsFor result = + div [ class "search-result" ] + [ table [ class "table table-hover" ] + [ thead [] + [ tr [] + [ th [] [ text "Option name" ] + ] + ] + , tbody + [] + (List.concatMap + (viewResultItem showDetailsFor) + result.hits.hits + ) + ] + ] + + +viewResultItem : + Maybe String + -> ElasticSearch.ResultItem ResultItemSource + -> List (Html Msg) +viewResultItem showDetailsFor item = + let + packageDetails = + if Just item.id == showDetailsFor then + [ td [ colspan 1 ] + [ text "This are details!" ] + ] + + else + [] + in + tr [ onClick (SearchMsg (ElasticSearch.ShowDetails item.id)) ] + [ td [] [ text item.source.option_name ] + ] + :: packageDetails + + + +-- API + + +makeRequest : + ElasticSearch.Options + -> String + -> Cmd Msg +makeRequest options query = + ElasticSearch.makeRequest + "option_name" + -- TODO: add support for different channels + "nixos-unstable-options" + decodeResultItemSource + options + query + |> Cmd.map SearchMsg + + + +-- JSON + + +decodeResultItemSource : Json.Decode.Decoder ResultItemSource +decodeResultItemSource = + Json.Decode.map6 ResultItemSource + (Json.Decode.field "option_name" Json.Decode.string) + (Json.Decode.field "description" Json.Decode.string) + (Json.Decode.field "type" Json.Decode.string) + (Json.Decode.field "default" Json.Decode.string) + (Json.Decode.field "example" Json.Decode.string) + (Json.Decode.field "source" Json.Decode.string) diff --git a/src/Page/Packages.elm b/src/Page/Packages.elm new file mode 100644 index 0000000..00fc347 --- /dev/null +++ b/src/Page/Packages.elm @@ -0,0 +1,211 @@ +module Page.Packages exposing + ( Model + , Msg + , decodeResultItemSource + , init + , makeRequest + , update + , view + ) + +import Browser.Navigation +import ElasticSearch +import Html + exposing + ( Html + , div + , table + , tbody + , td + , text + , th + , thead + , tr + ) +import Html.Attributes + exposing + ( class + , colspan + ) +import Html.Events + exposing + ( onClick + ) +import Json.Decode +import Json.Decode.Pipeline + + + +-- MODEL + + +type alias Model = + ElasticSearch.Model ResultItemSource + + +type alias ResultItemSource = + { attr_name : String + , name : String + , version : String + , description : Maybe String + , longDescription : Maybe String + , licenses : List ResultPackageLicense + , maintainers : List ResultPackageMaintainer + , platforms : List String + , position : Maybe String + , homepage : Maybe String + } + + +type alias ResultPackageLicense = + { fullName : Maybe String + , url : Maybe String + } + + +type alias ResultPackageMaintainer = + { name : String + , email : String + , github : String + } + + +init : + Maybe String + -> Maybe String + -> ( Model, Cmd Msg ) +init = + ElasticSearch.init + + + +-- UPDATE + + +type Msg + = SearchMsg (ElasticSearch.Msg ResultItemSource) + + +update : Browser.Navigation.Key -> Msg -> Model -> ( Model, Cmd Msg ) +update navKey msg model = + case msg of + SearchMsg subMsg -> + let + ( newModel, newCmd ) = + ElasticSearch.update "packages" navKey subMsg model + in + ( newModel, Cmd.map SearchMsg newCmd ) + + + +-- VIEW + + +view : Model -> Html Msg +view model = + ElasticSearch.view + { title = "Search NixOS packages" } + model + viewSuccess + SearchMsg + + +viewSuccess : + Maybe String + -> ElasticSearch.Result ResultItemSource + -> Html Msg +viewSuccess showDetailsFor result = + div [ class "search-result" ] + [ table [ class "table table-hover" ] + [ thead [] + [ tr [] + [ th [] [ text "Attribute name" ] + , th [] [ text "Name" ] + , th [] [ text "Version" ] + , th [] [ text "Description" ] + ] + ] + , tbody + [] + (List.concatMap + (viewResultItem showDetailsFor) + result.hits.hits + ) + ] + ] + + +viewResultItem : + Maybe String + -> ElasticSearch.ResultItem ResultItemSource + -> List (Html Msg) +viewResultItem showDetailsFor item = + let + packageDetails = + if Just item.id == showDetailsFor then + [ td [ colspan 4 ] + [ text "This are details!" ] + ] + + else + [] + in + tr [ onClick (SearchMsg (ElasticSearch.ShowDetails item.id)) ] + [ td [] [ text item.source.attr_name ] + , td [] [ text item.source.name ] + , td [] [ text item.source.version ] + , td [] [ text <| Maybe.withDefault "" item.source.description ] + ] + :: packageDetails + + + +-- API + + +makeRequest : + ElasticSearch.Options + -> String + -> Cmd Msg +makeRequest options query = + ElasticSearch.makeRequest + "attr_name" + "nixos-unstable-packages" + decodeResultItemSource + options + query + |> Cmd.map SearchMsg + + + +-- JSON + + +decodeResultItemSource : Json.Decode.Decoder ResultItemSource +decodeResultItemSource = + Json.Decode.succeed ResultItemSource + |> Json.Decode.Pipeline.required "attr_name" Json.Decode.string + |> Json.Decode.Pipeline.required "name" Json.Decode.string + |> Json.Decode.Pipeline.required "version" Json.Decode.string + |> Json.Decode.Pipeline.required "description" (Json.Decode.nullable Json.Decode.string) + |> Json.Decode.Pipeline.required "longDescription" (Json.Decode.nullable Json.Decode.string) + |> Json.Decode.Pipeline.required "license" (Json.Decode.list decodeResultPackageLicense) + |> Json.Decode.Pipeline.required "maintainers" (Json.Decode.list decodeResultPackageMaintainer) + |> Json.Decode.Pipeline.required "platforms" (Json.Decode.list Json.Decode.string) + |> Json.Decode.Pipeline.required "position" (Json.Decode.nullable Json.Decode.string) + |> Json.Decode.Pipeline.required "homepage" (Json.Decode.nullable Json.Decode.string) + + +decodeResultPackageLicense : Json.Decode.Decoder ResultPackageLicense +decodeResultPackageLicense = + Json.Decode.map2 ResultPackageLicense + (Json.Decode.field "fullName" (Json.Decode.nullable Json.Decode.string)) + (Json.Decode.field "url" (Json.Decode.nullable Json.Decode.string)) + + +decodeResultPackageMaintainer : Json.Decode.Decoder ResultPackageMaintainer +decodeResultPackageMaintainer = + Json.Decode.map3 ResultPackageMaintainer + (Json.Decode.field "name" Json.Decode.string) + (Json.Decode.field "email" Json.Decode.string) + (Json.Decode.field "github" Json.Decode.string) diff --git a/src/Route.elm b/src/Route.elm new file mode 100644 index 0000000..1e3080f --- /dev/null +++ b/src/Route.elm @@ -0,0 +1,95 @@ +module Route exposing (Route(..), fromUrl, href, replaceUrl) + +import Browser.Navigation +import Html +import Html.Attributes +import Url +import Url.Parser exposing (()) +import Url.Parser.Query + + + +-- ROUTING + + +type Route + = NotFound + | Home + | Packages (Maybe String) (Maybe String) + | Options (Maybe String) (Maybe String) + + +parser : Url.Parser.Parser (Route -> msg) msg +parser = + Url.Parser.oneOf + [ Url.Parser.map + Home + Url.Parser.top + , Url.Parser.map + NotFound + (Url.Parser.s "not-found") + , Url.Parser.map + Packages + (Url.Parser.s "packages" + Url.Parser.Query.string "query" + Url.Parser.Query.string "showDetailsFor" + ) + , Url.Parser.map + Options + (Url.Parser.s "options" + Url.Parser.Query.string "query" + Url.Parser.Query.string "showDetailsFor" + ) + ] + + + +-- PUBLIC HELPERS + + +href : Route -> Html.Attribute msg +href targetRoute = + Html.Attributes.href (routeToString targetRoute) + + +replaceUrl : Browser.Navigation.Key -> Route -> Cmd msg +replaceUrl navKey route = + Browser.Navigation.replaceUrl navKey (routeToString route) + + +fromUrl : Url.Url -> Maybe Route +fromUrl url = + -- The RealWorld spec treats the fragment like a path. + -- This makes it *literally* the path, so we can proceed + -- with parsing as if it had been a normal path all along. + --{ url | path = Maybe.withDefault "" url.fragment, fragment = Nothing } + Url.Parser.parse parser url + + + +-- INTERNAL + + +routeToString : Route -> String +routeToString page = + let + ( path, query ) = + routeToPieces page + in + "/" ++ String.join "/" path ++ "?" ++ String.join "&" (List.filterMap Basics.identity query) + + +routeToPieces : Route -> ( List String, List (Maybe String) ) +routeToPieces page = + case page of + Home -> + ( [], [] ) + + NotFound -> + ( [ "not-found" ], [] ) + + Packages query showDetailsFor -> + ( [ "packages" ], [ query, showDetailsFor ] ) + + Options query showDetailsFor -> + ( [ "options" ], [ query, showDetailsFor ] ) diff --git a/src/index.js b/src/index.js index a1c16fb..2396618 100644 --- a/src/index.js +++ b/src/index.js @@ -1,6 +1,6 @@ 'use strict'; -require("./styles.scss"); +require("./index.scss"); const {Elm} = require('./Main'); diff --git a/src/styles.scss b/src/index.scss similarity index 76% rename from src/styles.scss rename to src/index.scss index f6e8167..ab746f7 100644 --- a/src/styles.scss +++ b/src/index.scss @@ -1,10 +1,15 @@ -header .navbar a.brand { - line-height: 1.5em; +header .navbar { + a.brand { + line-height: 1.5em; + } img.logo { height: 1.5em; margin-right: 0.5em; } + ul.nav > li > a { + line-height: 2.5em; + } } .search-page {