Skip to content

Commit

Permalink
feat(web): add read-only Webapp based on hyperbole
Browse files Browse the repository at this point in the history
Re. hyperbole: It looks and feels quite interesting. I like the
discipline. I did not try forms or any sophisticated interactions yet.
  • Loading branch information
vst committed Jun 12, 2024
1 parent f118e6c commit f4c9c79
Show file tree
Hide file tree
Showing 6 changed files with 268 additions and 1 deletion.
4 changes: 4 additions & 0 deletions .hlint.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -29,15 +29,19 @@
- extensions:
- default: false # All extension are banned by default.
- name:
- DataKinds
- DeriveAnyClass
- DeriveGeneric
- DerivingVia
- FlexibleContexts
- NamedFieldPuns
- OverloadedStrings
- QuasiQuotes
- RecordWildCards
- TemplateHaskell
- TupleSections
- TypeApplications
- TypeOperators

################
# CUSTOM RULES #
Expand Down
6 changes: 5 additions & 1 deletion default.nix
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,11 @@ let
thisHaskell = mkHaskell {
haskell = baseHaskell;
packages = thisHaskellPackagesAll;
overrides = self: super: { };
overrides = self: super: {
http-api-data = super.http-api-data_0_6;
hyperbole = self.callCabal2nixWithOptions "hyperbole" sources.hyperbole "--no-check" { };
web-view = self.callCabal2nixWithOptions "web-view" sources.web-view "--no-check" { };
};
};

###########
Expand Down
14 changes: 14 additions & 0 deletions nix/sources.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
{
"hyperbole": {
"sha256": "0lfdginsy4pjwbrbjhsac4la1gnmp8s34pn8bwv2pmw9zrwhw8cw",
"type": "tarball",
"url": "https://hackage.haskell.org/package/hyperbole-0.3.6/hyperbole-0.3.6.tar.gz",
"url_template": "https://hackage.haskell.org/package/hyperbole-<version>/hyperbole-<version>.tar.gz",
"version": "0.3.6"
},
"nixpkgs": {
"branch": "release-24.05",
"description": "Nix Packages collection",
Expand All @@ -10,5 +17,12 @@
"type": "tarball",
"url": "https://github.com/NixOS/nixpkgs/archive/4e08cafd686c7b2a191a82e593762c3a095f88eb.tar.gz",
"url_template": "https://github.com/<owner>/<repo>/archive/<rev>.tar.gz"
},
"web-view": {
"sha256": "11xig8xfhd1rmj7m8ajmcf6bfr4qskzk90qh54fgh4ahwiv6j3ms",
"type": "tarball",
"url": "https://hackage.haskell.org/package/web-view-0.4.0/web-view-0.4.0.tar.gz",
"url_template": "https://hackage.haskell.org/package/web-view-<version>/web-view-<version>.tar.gz",
"version": "0.4.0"
}
}
3 changes: 3 additions & 0 deletions package.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,13 @@ library:
- brick
- bytestring
- casing
- effectful
- githash
- hashable
- hasql
- hasql-th
- http-api-data
- hyperbole
- optparse-applicative
- pandoc
- parsec
Expand Down
27 changes: 27 additions & 0 deletions src/Postmap/Cli.hs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import qualified Postmap.Gencode.Haskell as Gencode.Haskell
import Postmap.Introspect (mkColumnName)
import qualified Postmap.Introspect as Introspect
import qualified Postmap.Meta as Meta
import qualified Postmap.Serve as Serve
import qualified Postmap.Spec as Spec
import qualified Postmap.Tui as Tui
import System.Exit (ExitCode (..))
Expand Down Expand Up @@ -92,6 +93,7 @@ commandSchema = OA.hsubparser (OA.command "schema" (OA.info parser infomod) <> O
parser =
commandSchemaInit
<|> commandSchemaTui
<|> commandSchemaServe
<|> commandSchemaDiagrams


Expand Down Expand Up @@ -154,6 +156,31 @@ doSchemaTui fp = do
pure ExitSuccess


-- ** schema serve


-- | Definition for @schema serve@ CLI command.
commandSchemaServe :: OA.Parser (IO ExitCode)
commandSchemaServe = OA.hsubparser (OA.command "serve" (OA.info parser infomod) <> OA.metavar "serve")
where
infomod = OA.fullDesc <> infoModHeader <> OA.progDesc "Run Web-based schema editor." <> OA.footer "This command runs the Web-based schema editor."
parser =
doSchemaServe
<$> OA.strOption (OA.short 'f' <> OA.long "file" <> OA.help "Path to the schema file.")


doSchemaServe :: FilePath -> IO ExitCode
doSchemaServe fp = do
eSchema <- ADC.Yaml.eitherDecodeYamlViaCodec @Spec.Spec <$> B.readFile fp
case eSchema of
Left err -> do
TIO.putStrLn ("Error while parsing schema file: " <> Z.Text.tshow err)
pure (ExitFailure 1)
Right schema -> do
Serve.runWeb schema
pure ExitSuccess


-- ** schema diagrams


Expand Down
215 changes: 215 additions & 0 deletions src/Postmap/Serve.hs
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE TypeOperators #-}

module Postmap.Serve where

import Data.Foldable (forM_)
import Data.List (find)
import Data.Maybe (fromMaybe)
import qualified Data.Text as T
import Effectful (IOE, (:>))
import Effectful.Concurrent (Concurrent, runConcurrent)
import GHC.Generics (Generic)
import Postmap.Introspect (ColumnName (..), TableName (..), TableSchemaName (unTableSchemaName))
import Postmap.Spec
import Text.Read (readMaybe)
import Web.HttpApiData (FromHttpApiData (..), ToHttpApiData (..))
import Web.Hyperbole (Eff)
import qualified Web.Hyperbole as WH
import Web.Hyperbole.Route (Route (..))
import qualified Zamazingo.Text as Z.Text


runWeb :: Spec -> IO ()
runWeb spec = do
putStrLn "Starting schema editor on http://localhost:3003"
WH.run 3003 (app spec)


app :: Spec -> WH.Application
app spec =
WH.liveApp
(WH.basicDocument "Postmap Schema Editor")
(runApp . WH.routeRequest $ router spec)


runApp :: IOE :> es => Eff (Concurrent : es) a -> Eff es a
runApp =
runConcurrent


data AppRoute
= AppRouteHome
| AppRouteRecord !RecordName
| AppRouteAbout
deriving (Eq, Generic)


instance Route AppRoute where
defRoute = AppRouteHome


routePath AppRouteHome = []
routePath (AppRouteRecord name) = ["records", unRecordName name]
routePath AppRouteAbout = ["about"]


matchRoute [] = Just AppRouteHome
matchRoute ["records", name] = either (const Nothing) (Just . AppRouteRecord) (mkRecordName name)
matchRoute ["about"] = Just AppRouteAbout
matchRoute _ = Nothing


router
:: WH.Hyperbole :> es
=> Concurrent :> es
=> IOE :> es
=> Spec
-> AppRoute
-> Eff es WH.Response
router spec AppRouteHome = pageHome spec
router spec@Spec {..} (AppRouteRecord x) = pageRecord spec x $ find (\Record {..} -> recordName == x) specRecords
router _spec AppRouteAbout = pageAbout


pageHome
:: WH.Hyperbole :> es
=> Spec
-> Eff es WH.Response
pageHome spec = WH.view $ do
canvas (mkSideBar Nothing spec) "hello"


mkSideBar :: Maybe RecordName -> Spec -> WH.View c ()
mkSideBar mrn Spec {..} = do
WH.el (WH.bold . WH.fontSize 20) "Records"
forM_ specRecords $ \Record {..} -> do
WH.link
(WH.routeUrl (AppRouteRecord recordName))
(WH.fontSize 16 . (if Just recordName == mrn then WH.bold else id) . WH.color Primary)
(WH.text $ unRecordName recordName)


pageRecord :: WH.Hyperbole :> es => Spec -> RecordName -> Maybe Record -> Eff es WH.Response
pageRecord spec name mRecord = WH.view $ do
canvas (mkSideBar (Just name) spec) . WH.el (WH.pad 10) $ do
WH.el (WH.bold . WH.fontSize 24) (WH.text $ "Record: " <> unRecordName name)
case mRecord of
Nothing -> WH.el (WH.fontSize 16) "Record not found."
Just Record {..} -> WH.col (WH.gap 10) $ do
WH.el (WH.fontSize 16) $ labelled "Title" (WH.text $ fromMaybe "<untitled>" recordTitle)
WH.el (WH.fontSize 16) $ labelled "Description" (WH.text $ fromMaybe "<no description>" recordDescription)
WH.el (WH.fontSize 16) $ labelled "Table Schema" (WH.text $ unTableSchemaName recordTableSchema)
WH.el (WH.fontSize 16) $ labelled "Table Name" (WH.text $ unTableName recordTableName)
WH.el (WH.fontSize 16) $ labelled "Is View?" (WH.text $ if recordIsView then "Yes" else "No")
WH.el (WH.bold . WH.fontSize 18) "Uniques"
uniquesTable recordUniques
WH.el (WH.bold . WH.fontSize 18) "Fields"
fieldsTable recordFields
where
labelled x s = WH.row id $ do
WH.col id $ WH.el (WH.bold . WH.pad 6) (WH.text x)
WH.col WH.grow $ WH.el (WH.pad 6) s
uniquesTable uniques =
WH.table id uniques $ do
WH.tcol (WH.th hd "Unique / Unique Together") $ \unique -> WH.td cell . WH.text $ T.intercalate ", " (unFieldName <$> unique)
where
hd = cell . WH.bold
cell = WH.pad 4 . WH.border 1
fieldsTable fields =
WH.table id fields $ do
WH.tcol (WH.th hd "Name") $ \Field {..} -> WH.td cell . WH.text $ unFieldName fieldName
WH.tcol (WH.th hd "Type") $ \Field {..} -> WH.td cell . WH.text $ fromMaybe "<no-type-given>" fieldType
WH.tcol (WH.th hd "Column") $ \Field {..} -> WH.td cell . WH.text $ unColumnName fieldColumnName
WH.tcol (WH.th hd "Column Type") $ \Field {..} -> WH.td cell . WH.text $ fieldColumnType
WH.tcol (WH.th hd "Nullable") $ \Field {..} -> WH.td cell . WH.text $ if fieldNotNullable then "NOT NULL" else "NULL"
WH.tcol (WH.th hd "Primary Key") $ \Field {..} -> WH.td cell . WH.text $ if fieldIsPrimaryKey then "PRIMARY KEY" else ""
WH.tcol (WH.th hd "Unique") $ \Field {..} -> WH.td cell . WH.text $ if fieldIsUnique then "UNIQUE" else ""
WH.tcol (WH.th hd "Reference") $ \Field {..} -> WH.td cell . WH.text $ maybe "" (\FieldReference {..} -> unRecordName fieldReferenceRecord <> "." <> unFieldName fieldReferenceField) fieldReference
WH.tcol (WH.th hd "Description") $ \Field {..} -> WH.td cell . WH.text $ fromMaybe "<no-description>" fieldDescription
where
hd = cell . WH.bold
cell = WH.pad 4 . WH.border 1


pageAbout
:: WH.Hyperbole :> es
=> Eff es WH.Response
pageAbout = WH.view $ do
canvas "Nothing" $ do
WH.el (WH.bold . WH.fontSize 32) "About"


canvas :: WH.View c () -> WH.View c () -> WH.View c ()
canvas s x = WH.row WH.root $ do
WH.col sideStyle $ do
WH.link (WH.routeUrl AppRouteHome) logoStyle "postmap"
s
WH.space
WH.link (WH.routeUrl AppRouteAbout) (WH.color Primary) "About"
WH.col WH.grow x
where
logoStyle =
WH.fontSize 32
. WH.bold
. WH.color Primary
. WH.textAlign WH.Center
. WH.border (WH.TRBL 0 0 1 0)
. WH.borderColor Primary
styBorderColor = WH.borderColor SecondaryLight
sideStyle =
WH.border (WH.TRBL 0 1 0 0)
. styBorderColor
. WH.pad 8
. WH.gap (WH.PxRem 6)
. WH.bg GrayLight
. WH.color GrayDark
. WH.fontSize 16
. WH.scroll


data AppColor
= White
| Light
| GrayLight
| GrayDark
| Dark
| Success
| Danger
| Warning
| Primary
| PrimaryLight
| Secondary
| SecondaryLight
deriving (Show, Read, Generic, WH.Param)


instance ToHttpApiData AppColor where
toQueryParam = Z.Text.tshow


instance FromHttpApiData AppColor where
parseQueryParam t = do
case readMaybe (T.unpack t) of
Nothing -> Left $ "Invalid AppColor: " <> t
(Just c) -> pure c


instance WH.ToColor AppColor where
colorValue White = "#FFF"
colorValue Light = "#F2F2F3"
colorValue GrayLight = "#E3E5E9"
colorValue GrayDark = "#2C3C44"
colorValue Dark = "#2E3842"
colorValue Primary = "#4171b7"
colorValue PrimaryLight = "#6D9BD3"
colorValue Secondary = "#5D5A5C"
colorValue SecondaryLight = "#9D999C"
colorValue Success = "#149e5a"
colorValue Danger = "#ef1509"
colorValue Warning = "#e1c915"

0 comments on commit f4c9c79

Please sign in to comment.