diff --git a/.hlint.yaml b/.hlint.yaml index 5b8ed14..301ec3f 100644 --- a/.hlint.yaml +++ b/.hlint.yaml @@ -29,8 +29,11 @@ - extensions: - default: false # All extension are banned by default. - name: + - DataKinds + - DeriveAnyClass - DeriveGeneric - DerivingVia + - FlexibleContexts - NamedFieldPuns - OverloadedStrings - QuasiQuotes @@ -38,6 +41,7 @@ - TemplateHaskell - TupleSections - TypeApplications + - TypeOperators ################ # CUSTOM RULES # diff --git a/default.nix b/default.nix index c7b3863..1db2dbe 100644 --- a/default.nix +++ b/default.nix @@ -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" { }; + }; }; ########### diff --git a/nix/sources.json b/nix/sources.json index 1717514..8d411c8 100644 --- a/nix/sources.json +++ b/nix/sources.json @@ -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-/hyperbole-.tar.gz", + "version": "0.3.6" + }, "nixpkgs": { "branch": "release-24.05", "description": "Nix Packages collection", @@ -10,5 +17,12 @@ "type": "tarball", "url": "https://github.com/NixOS/nixpkgs/archive/4e08cafd686c7b2a191a82e593762c3a095f88eb.tar.gz", "url_template": "https://github.com///archive/.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-/web-view-.tar.gz", + "version": "0.4.0" } } diff --git a/package.yaml b/package.yaml index 94da5ba..10ec181 100644 --- a/package.yaml +++ b/package.yaml @@ -28,10 +28,13 @@ library: - brick - bytestring - casing + - effectful - githash - hashable - hasql - hasql-th + - http-api-data + - hyperbole - optparse-applicative - pandoc - parsec diff --git a/src/Postmap/Cli.hs b/src/Postmap/Cli.hs index 121135b..7b8f7df 100644 --- a/src/Postmap/Cli.hs +++ b/src/Postmap/Cli.hs @@ -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 (..)) @@ -92,6 +93,7 @@ commandSchema = OA.hsubparser (OA.command "schema" (OA.info parser infomod) <> O parser = commandSchemaInit <|> commandSchemaTui + <|> commandSchemaServe <|> commandSchemaDiagrams @@ -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 diff --git a/src/Postmap/Serve.hs b/src/Postmap/Serve.hs new file mode 100644 index 0000000..4e9372a --- /dev/null +++ b/src/Postmap/Serve.hs @@ -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 "" recordTitle) + WH.el (WH.fontSize 16) $ labelled "Description" (WH.text $ fromMaybe "" 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 "" 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 "" 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"