diff --git a/internal/server/api/accounts/router.go b/internal/server/api/accounts/router.go index 579ce53..4225d11 100644 --- a/internal/server/api/accounts/router.go +++ b/internal/server/api/accounts/router.go @@ -3,7 +3,6 @@ package accounts import ( "errors" "fmt" - "strconv" "strings" "github.com/gin-gonic/gin" @@ -13,6 +12,7 @@ import ( "ruben/inventory2/internal/domains/accounts" "ruben/inventory2/internal/logging" "ruben/inventory2/internal/server/auth" + "ruben/inventory2/internal/server/param" "ruben/inventory2/internal/server/response" "ruben/inventory2/internal/server/sse" ) @@ -41,6 +41,9 @@ func Routes( platformGroup.PUT("/order-index", response.Handler(as.setOrderOfPlatformOnAccountPage)) platformGroup.POST("/shops/mocks", response.Handler(as.createMockShop)) + mockShops := platformGroup.Group("/shops/mocks/:shop-id") + mockShops.POST("/listings", response.Handler(as.addMockListing)) + syncGroups := r.Group("/:acctID/inventory/sync-groups") syncGroups.POST("", pub.Publish("/:acctID/inventory/sync-groups"), response.Handler(as.saveNewSyncGroup)) @@ -89,7 +92,7 @@ func (s *accountSubrouter) createSyncGroupListingDraft(c *gin.Context) (response return response.StatusCreated(), nil } -// PUT /:acctID/inventory/sync-groups/draft/listings/{orderIndex}/shop +// PUT /:acctID/inventory/sync-groups/draft/listings/:orderIndex/shop // @platform string // @shopID string func (s *accountSubrouter) setShopInSyncGroupListingDraft(c *gin.Context) (response.Response, error) { @@ -97,24 +100,19 @@ func (s *accountSubrouter) setShopInSyncGroupListingDraft(c *gin.Context) (respo ctx := r.Context() acctID := auth.GetIdentity(ctx).Account.AccountID - orderIndex, err := getOrderIndexForSyncGroupListingDraftFromPath(c) + var ( + orderIndex int + platform accounts.Platform + shopID string + ) + + err := param.Path("orderIndex", param.Int(&orderIndex)). + Form("platform", param.Platform(&platform)). + Unmarshal(c) if err != nil { return nil, err } - platformStr := r.FormValue("platform") - platform, err := accounts.NewPlatform(platformStr) - if err != nil { - return nil, response.BadRequest(). - Msgf("unrecognized platform: %s", platformStr) - } - - shopID := r.FormValue("shop-id") - if shopID == "" { - return nil, response.BadRequest(). - Msg("no shop-id provided") - } - if err := s.accts.SetShopInSyncGroupListingDraft(ctx, acctID, orderIndex, platform, shopID); err != nil { return nil, response.Errorf("failed to set shop: %w", response.ErrorFromConstant(err)) } @@ -122,23 +120,24 @@ func (s *accountSubrouter) setShopInSyncGroupListingDraft(c *gin.Context) (respo return response.StatusOK(), nil } -// PUT /:acctID/inventory/sync-groups/draft/listings/{orderIndex}/listing +// PUT /:acctID/inventory/sync-groups/draft/listings/:orderIndex/listing func (s *accountSubrouter) setListingInSyncGroupListingDraft(c *gin.Context) (response.Response, error) { r := c.Request ctx := r.Context() acctID := auth.GetIdentity(ctx).Account.AccountID - orderIndex, err := getOrderIndexForSyncGroupListingDraftFromPath(c) + var ( + orderIndex int + listingID string + ) + + err := param.Path("orderIndex", param.Int(&orderIndex)). + Form("listing-id", param.Text(&listingID)). + Unmarshal(c) if err != nil { return nil, err } - listingID := r.FormValue("listing-id") - if listingID == "" { - return nil, response.BadRequest(). - Msg("no listing-id provided") - } - if err := s.accts.SetListingInSyncGroupListingDraft(ctx, acctID, orderIndex, listingID); err != nil { return nil, response.Errorf("failed to set listing: %w", response.ErrorFromConstant(err)) } @@ -146,13 +145,18 @@ func (s *accountSubrouter) setListingInSyncGroupListingDraft(c *gin.Context) (re return response.StatusOK(), nil } -// DELETE /:acctID/inventory/sync-groups/draft/listings/{orderIndex} +// DELETE /:acctID/inventory/sync-groups/draft/listings/:orderIndex func (s *accountSubrouter) deleteSyncGroupListingDraft(c *gin.Context) (response.Response, error) { r := c.Request ctx := r.Context() acctID := auth.GetIdentity(ctx).Account.AccountID - orderIndex, err := getOrderIndexForSyncGroupListingDraftFromPath(c) + var ( + orderIndex int + ) + + err := param.Path("orderIndex", param.Int(&orderIndex)). + Unmarshal(c) if err != nil { return nil, err } @@ -177,22 +181,6 @@ func (s *accountSubrouter) saveNewSyncGroup(c *gin.Context) (response.Response, return response.StatusCreated(), nil } -func getOrderIndexForSyncGroupListingDraftFromPath(c *gin.Context) (int, error) { - orderIndexStr := c.Param("orderIndex") - if orderIndexStr == "" { - return 0, response.NotFound(). - Msg("no orderIndex found") - } - - orderIndex, err := strconv.Atoi(orderIndexStr) - if err != nil { - return 0, response.NotFound(). - Msgf("invalid order index: %s", orderIndexStr) - } - - return orderIndex, nil -} - // PUT /:acctID/platforms/:platform/order-index" // this endpoint is called when dragging a platform tab in the accounts page. func (s *accountSubrouter) setOrderOfPlatformOnAccountPage(c *gin.Context) (response.Response, error) { @@ -200,30 +188,16 @@ func (s *accountSubrouter) setOrderOfPlatformOnAccountPage(c *gin.Context) (resp // validate parameters - var orderIndex int - if v, ok := c.GetPostForm("order-index"); !ok { - return nil, response.BadRequest(). - Msg("no order-index provided") - } else if i, err := strconv.Atoi(v); err != nil { - return nil, response.BadRequest(). - Msgf("order-index must be a non-negative integer: %s", v) - } else if i < 0 { - return nil, response.BadRequest(). - Msgf("order-index must be a non-negative integer: %s", v) - } else { - orderIndex = i - } + var ( + platform accounts.Platform + orderIndex int + ) - var platform accounts.Platform - if v := c.Param("platform"); v == "" { - return nil, response.BadRequest(). - Msg("no platform provided") - } else if p, err := accounts.NewPlatform(v); err != nil { - return nil, response.BadRequest(). - Wrap(err). - Msgf("unrecognized platform: %v", v) - } else { - platform = p + err := param.Path("platform", param.Platform(&platform)). + Form("orderIndex", param.Int(&orderIndex)). + Unmarshal(c) + if err != nil { + return nil, err } // update the order @@ -260,24 +234,16 @@ func (s *accountSubrouter) createMockShop(c *gin.Context) (response.Response, er // validate parameters - var platform accounts.Platform - if v := c.Param("platform"); v == "" { - return nil, response.BadRequest(). - Msg("no platform provided") - } else if p, err := accounts.NewPlatform(v); err != nil { - return nil, response.BadRequest(). - Wrap(err). - Msgf("unrecognized platform: %v", v) - } else { - platform = p - } + var ( + platform accounts.Platform + name string + ) - var name string - if v, ok := c.GetPostForm("name"); !ok || v == "" { - return nil, response.BadRequest(). - Msg("no order-index provided") - } else { - name = v + err := param.Path("platform", param.Platform(&platform)). + Form("name", param.Text(&name)). + Unmarshal(c) + if err != nil { + return nil, err } s.log.Warnf("not implemented: mock store created: account_id = %d; platform = %s; name = %s", acctID, platform, name) @@ -301,6 +267,35 @@ func (s *accountSubrouter) createMockShop(c *gin.Context) (response.Response, er return res.HXLocation(location), nil } +// POST /:acctID/platforms/:platform/shops/mocks/:shop-id/listings +func (s *accountSubrouter) addMockListing(c *gin.Context) (response.Response, error) { + var ( + platform accounts.Platform + shopID string + name string + sku string + description string + ) + + err := param.Path("platform", param.Platform(&platform)). + Path("shop-id", param.Text(&shopID)). + Form("name", param.Text(&name)). + Form("sku", param.Text(&sku)). + Form("description", param.Text(&description)). + Unmarshal(c) + if err != nil { + return nil, err + } + + s.log.Debugf("platform: %v", platform) + s.log.Debugf("shopID: %v", shopID) + s.log.Debugf("name: %v", name) + s.log.Debugf("sku: %v", sku) + s.log.Debugf("description: %v", description) + + return nil, nil +} + func lowerSnakeCase(s accounts.Platform) string { return strings.ToLower(strings.Join(strings.Split(string(s), " "), "_")) } diff --git a/internal/server/param/params.go b/internal/server/param/params.go new file mode 100644 index 0000000..4b66c64 --- /dev/null +++ b/internal/server/param/params.go @@ -0,0 +1,50 @@ +package param + +import ( + "encoding" + "ruben/inventory2/internal/domains/accounts" + "strconv" +) + +// encoding.TextUnmarshaler instances for Spec + +func Text(dst *string) encoding.TextUnmarshaler { + return (*rawText)(dst) +} + +func Int(dst *int) encoding.TextUnmarshaler { + return (*intText)(dst) +} + +func Platform(dst *accounts.Platform) encoding.TextUnmarshaler { + return (*platformText)(dst) +} + +type ( + rawText string + intText int + platformText accounts.Platform +) + +func (t *rawText) UnmarshalText(text []byte) error { + *t = rawText(text) + return nil +} + +func (n *intText) UnmarshalText(text []byte) error { + i, err := strconv.Atoi(string(text)) + if err != nil { + return err + } + *n = intText(i) + return nil +} + +func (p *platformText) UnmarshalText(text []byte) error { + v, err := accounts.NewPlatform(string(text)) + if err != nil { + return err + } + *p = platformText(v) + return nil +} diff --git a/internal/server/param/spec.go b/internal/server/param/spec.go new file mode 100644 index 0000000..2da37d0 --- /dev/null +++ b/internal/server/param/spec.go @@ -0,0 +1,76 @@ +package param + +import ( + "encoding" + + "github.com/gin-gonic/gin" + + "ruben/inventory2/internal/server/response" +) + +// Spec is the entry point of the package. +// It's typically constructed via Path() or Form(). +// But it's zero value is valid. +// +// It's methods support a builder pattern to minimize API bloat. +// +// To complete gin parameter parsing, call Unmarshal(). +type Spec struct { + path map[string]encoding.TextUnmarshaler + form map[string]encoding.TextUnmarshaler +} + +func Path(k string, dst encoding.TextUnmarshaler) Spec { + return Spec{ + path: map[string]encoding.TextUnmarshaler{ + k: dst, + }, + } +} + +func Form(k string, dst encoding.TextUnmarshaler) Spec { + return Spec{ + form: map[string]encoding.TextUnmarshaler{ + k: dst, + }, + } +} + +func (s Spec) Path(k string, dst encoding.TextUnmarshaler) Spec { + if s.path == nil { + s.path = make(map[string]encoding.TextUnmarshaler, 1) + } + s.path[k] = dst + return s +} + +func (s Spec) Form(k string, dst encoding.TextUnmarshaler) Spec { + if s.form == nil { + s.form = make(map[string]encoding.TextUnmarshaler, 1) + } + s.form[k] = dst + return s +} + +func (s Spec) Unmarshal(c *gin.Context) error { + for k, dst := range s.path { + v := c.Param(k) + if v == "" { + return response.NotFound().Msgf("no %s provided", k) + } + if err := dst.UnmarshalText([]byte(v)); err != nil { + return response.NotFound().Wrap(err).Msgf("invalid %s", k) + } + } + for k, dst := range s.form { + v, ok := c.GetPostForm(k) + if !ok || v == "" { + return response.BadRequest().Msgf("no %s provided", k) + } + if err := dst.UnmarshalText([]byte(v)); err != nil { + return response.BadRequest().Wrap(err).Msgf("invalid %s provided", k) + } + } + + return nil +} diff --git a/internal/server/ui/router.go b/internal/server/ui/router.go index a9eeb97..28673c7 100644 --- a/internal/server/ui/router.go +++ b/internal/server/ui/router.go @@ -100,6 +100,19 @@ func Routes( "rawHTML": func(s string) template.HTML { return template.HTML(s) }, + "rawHTMLAttr": func(s string) template.HTMLAttr { + return template.HTMLAttr(s) + }, + "style": func(kvs ...string) (template.HTMLAttr, error) { + if len(kvs)%2 != 0 { + return "", fmt.Errorf("expected an even number of keys: %d", len(kvs)) + } + parts := make([]string, len(kvs)/2) + for i := range parts { + parts[i] = fmt.Sprintf("%s: %s;", kvs[2*i], kvs[2*i+1]) + } + return template.HTMLAttr(strings.Join(parts, " ")), nil + }, // json "prettyPrintJSON": func(j json.RawMessage) string { diff --git a/styles/index.css b/styles/index.css index 643596d..65c9548 100644 --- a/styles/index.css +++ b/styles/index.css @@ -195,6 +195,9 @@ .col-3 { grid-column: 3; } + .col-span-2 { + grid-column: span 2 / span 2; + } .container { width: 100%; @media (width >= 40rem) { @@ -234,12 +237,6 @@ .my-\[1rem\] { margin-block: 1rem; } - .my-\[2em\] { - margin-block: 2em; - } - .my-\[5em\] { - margin-block: 5em; - } .mt-\[0\.25em\] { margin-top: 0.25em; } @@ -336,6 +333,12 @@ .basis-full { flex-basis: 100%; } + .border-collapse { + border-collapse: collapse; + } + .transform { + transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,); + } .animate-pulse { animation: var(--animate-pulse); } @@ -354,15 +357,27 @@ .list-none { list-style-type: none; } + .grid-cols-\[1fr_1fr\] { + grid-template-columns: 1fr 1fr; + } .grid-cols-\[2fr_8fr_2fr\] { grid-template-columns: 2fr 8fr 2fr; } .grid-cols-\[6rem_10fr_6rem\] { grid-template-columns: 6rem 10fr 6rem; } + .grid-cols-\[max-content_1fr\] { + grid-template-columns: max-content 1fr; + } .grid-cols-\[max-content_max-content\] { grid-template-columns: max-content max-content; } + .grid-rows-\[auto\] { + grid-template-rows: auto; + } + .grid-rows-\[auto_auto_auto\] { + grid-template-rows: auto auto auto; + } .flex-col { flex-direction: column; } @@ -381,6 +396,9 @@ .justify-start { justify-content: flex-start; } + .gap-\[0\.5em\] { + gap: 0.5em; + } .gap-\[0\.25em\] { gap: 0.25em; } @@ -421,18 +439,10 @@ border-style: var(--tw-border-style); border-width: 1px; } - .border-\[0\.5px\] { - border-style: var(--tw-border-style); - border-width: 0.5px; - } .border-\[1px\] { border-style: var(--tw-border-style); border-width: 1px; } - .border-\[10px\] { - border-style: var(--tw-border-style); - border-width: 10px; - } .border-b-\[2px\] { border-bottom-style: var(--tw-border-style); border-bottom-width: 2px; @@ -484,6 +494,9 @@ .py-\[0\.5em\] { padding-block: 0.5em; } + .py-\[1em\] { + padding-block: 1em; + } .pt-\[1em\] { padding-top: 1em; } @@ -505,6 +518,9 @@ .font-display { font-family: var(--display-family); } + .font-text { + font-family: var(--text-family); + } .text-2xl { font-size: var(--text-2xl); line-height: var(--tw-leading, var(--text-2xl--line-height)); @@ -532,6 +548,9 @@ .text-nowrap { text-wrap: nowrap; } + .text-wrap { + text-wrap: wrap; + } .capitalize { text-transform: capitalize; } @@ -541,6 +560,10 @@ .underline { text-decoration-line: underline; } + .outline { + outline-style: var(--tw-outline-style); + outline-width: 1px; + } .filter { filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); } @@ -663,11 +686,6 @@ margin-bottom: 1em; } } - .open\:mb-\[2em\] { - &:is([open], :popover-open, :open) { - margin-bottom: 2em; - } - } .open\:grid { &:is([open], :popover-open, :open) { display: grid; @@ -679,11 +697,6 @@ border-bottom-width: 1px; } } - .open\:border-b-accent { - &:is([open], :popover-open, :open) { - border-bottom-color: var(--accent); - } - } .open\:border-b-background { &:is([open], :popover-open, :open) { border-bottom-color: var(--background); @@ -694,11 +707,6 @@ padding-bottom: 1em; } } - .open\:pb-\[2em\] { - &:is([open], :popover-open, :open) { - padding-bottom: 2em; - } - } .open\:details-content\:transform-\[translate\(0px\,0px\)\] { &:is([open], :popover-open, :open) { &::details-content { @@ -1131,6 +1139,26 @@ } } } +@property --tw-rotate-x { + syntax: "*"; + inherits: false; +} +@property --tw-rotate-y { + syntax: "*"; + inherits: false; +} +@property --tw-rotate-z { + syntax: "*"; + inherits: false; +} +@property --tw-skew-x { + syntax: "*"; + inherits: false; +} +@property --tw-skew-y { + syntax: "*"; + inherits: false; +} @property --tw-border-style { syntax: "*"; inherits: false; @@ -1140,6 +1168,11 @@ syntax: "*"; inherits: false; } +@property --tw-outline-style { + syntax: "*"; + inherits: false; + initial-value: solid; +} @property --tw-blur { syntax: "*"; inherits: false; @@ -1208,26 +1241,6 @@ inherits: false; initial-value: 1; } -@property --tw-rotate-x { - syntax: "*"; - inherits: false; -} -@property --tw-rotate-y { - syntax: "*"; - inherits: false; -} -@property --tw-rotate-z { - syntax: "*"; - inherits: false; -} -@property --tw-skew-x { - syntax: "*"; - inherits: false; -} -@property --tw-skew-y { - syntax: "*"; - inherits: false; -} @property --tw-content { syntax: "*"; initial-value: ""; @@ -1245,8 +1258,14 @@ @layer properties { @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) { *, ::before, ::after, ::backdrop { + --tw-rotate-x: initial; + --tw-rotate-y: initial; + --tw-rotate-z: initial; + --tw-skew-x: initial; + --tw-skew-y: initial; --tw-border-style: solid; --tw-font-weight: initial; + --tw-outline-style: solid; --tw-blur: initial; --tw-brightness: initial; --tw-contrast: initial; @@ -1263,11 +1282,6 @@ --tw-scale-x: 1; --tw-scale-y: 1; --tw-scale-z: 1; - --tw-rotate-x: initial; - --tw-rotate-y: initial; - --tw-rotate-z: initial; - --tw-skew-x: initial; - --tw-skew-y: initial; --tw-content: ""; --tw-leading: initial; } diff --git a/templates/components/accounts/{acctID.int64}/platforms/{platform}/link-section.html.tmpl b/templates/components/accounts/{acctID.int64}/platforms/{platform}/link-section.html.tmpl index b779ec5..3a08a3c 100644 --- a/templates/components/accounts/{acctID.int64}/platforms/{platform}/link-section.html.tmpl +++ b/templates/components/accounts/{acctID.int64}/platforms/{platform}/link-section.html.tmpl @@ -61,7 +61,7 @@ {{- range $shop := $shops }}
- {{ .PathParams.platform | splitSnakeCase | capitalize }} -
- -+ {{ .PathParams.platform | splitSnakeCase | capitalize }} +
+ +