Skip to content

Commit d7c20e6

Browse files
authoredApr 12, 2024··
Merge pull request #57 from calvinmclean/revert-event-rsvp-delete
2 parents ab9ee78 + 6dd7e84 commit d7c20e6

File tree

5 files changed

+1259
-0
lines changed

5 files changed

+1259
-0
lines changed
 

‎examples/event-rsvp/Dockerfile

+13
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
FROM golang:1.21-alpine AS build
2+
RUN mkdir /build
3+
ADD . /build
4+
WORKDIR /build
5+
RUN go mod init event-rsvp && \
6+
go mod tidy && \
7+
go build -o event-rsvp .
8+
9+
FROM alpine:latest AS production
10+
RUN mkdir /app
11+
WORKDIR /app
12+
COPY --from=build /build/event-rsvp .
13+
ENTRYPOINT ["/app/event-rsvp", "serve"]

‎examples/event-rsvp/README.md

+31
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# Event RSVP Example
2+
3+
This example implements a simple application for managing event invites and RSVPs. An `Event` is created with a password so only the owner can modify it. Then, an `Invite` each includes a unique identifier to ID the RSVPer and grant them read-only access to the `Event`.
4+
5+
> [!CAUTION]
6+
> This example application deals with passwords, salts, and hashes but is not intended to be 100% cryptographically secure. Passwords are included in visible query params and sent without encryption. The salt and hash are stored in plain text. Invite IDs are used to grant read-only access to the `Event` and [`rs/xid`](https://github.com/rs/xid) is not cryptographically secure
7+
8+
You can use the CLI the create Events and Invites:
9+
10+
```shell
11+
# Create a new Event
12+
go run examples/event-rsvp/main.go \
13+
post Event '{"Name": "Party", "Password": "password", "Address": "My House", "Details": "Party on!", "Date": "2024-01-01T20:00:00-07:00"}'
14+
15+
# Add Invite to the Event
16+
go run examples/event-rsvp/main.go \
17+
-q 'password=password' \
18+
post Invite '{"Name": "Firstname Lastname"}' cm2vm15o4026969kq690
19+
```
20+
21+
Or use the UI!
22+
23+
24+
## How To
25+
26+
Run the application:
27+
```shell
28+
go run main.go
29+
```
30+
31+
Then, use the UI at http://localhost:8080/events

‎examples/event-rsvp/main.go

+454
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,454 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"crypto/rand"
6+
"crypto/sha256"
7+
"encoding/base64"
8+
"encoding/csv"
9+
"encoding/hex"
10+
"errors"
11+
"fmt"
12+
"html/template"
13+
"net/http"
14+
"os"
15+
"strings"
16+
17+
"github.com/calvinmclean/babyapi"
18+
"github.com/calvinmclean/babyapi/extensions"
19+
"github.com/go-chi/render"
20+
21+
_ "embed"
22+
)
23+
24+
//go:embed template.html
25+
var templates []byte
26+
27+
const (
28+
invitesCtxKey babyapi.ContextKey = "invites"
29+
passwordCtxKey babyapi.ContextKey = "password"
30+
)
31+
32+
type API struct {
33+
Events *babyapi.API[*Event]
34+
Invites *babyapi.API[*Invite]
35+
}
36+
37+
// Export invites to CSV format for use with external tools
38+
func (api *API) export(w http.ResponseWriter, r *http.Request) render.Renderer {
39+
event, httpErr := api.Events.GetRequestedResource(r)
40+
if httpErr != nil {
41+
return httpErr
42+
}
43+
44+
invites, err := api.Invites.Storage.GetAll(func(i *Invite) bool {
45+
return i.EventID == event.GetID()
46+
})
47+
if err != nil {
48+
return babyapi.InternalServerError(err)
49+
}
50+
51+
w.Header().Set("Content-Type", "text/csv")
52+
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment;filename=event_%s_invites.csv", event.GetID()))
53+
54+
csvWriter := csv.NewWriter(w)
55+
err = csvWriter.Write([]string{"ID", "Name", "Contact", "RSVP", "Link"})
56+
if err != nil {
57+
return babyapi.InternalServerError(err)
58+
}
59+
60+
for _, invite := range invites {
61+
rsvp := ""
62+
if invite.RSVP != nil {
63+
rsvp = fmt.Sprintf("%t", *invite.RSVP)
64+
}
65+
err = csvWriter.Write([]string{
66+
invite.GetID(),
67+
invite.Name,
68+
invite.Contact,
69+
rsvp,
70+
invite.link(r),
71+
})
72+
if err != nil {
73+
return babyapi.InternalServerError(err)
74+
}
75+
}
76+
77+
csvWriter.Flush()
78+
79+
err = csvWriter.Error()
80+
if err != nil {
81+
return babyapi.InternalServerError(err)
82+
}
83+
84+
return nil
85+
}
86+
87+
// Use a custom route to set RSVP so rsvpResponse can be used to return HTML buttons
88+
func (api *API) rsvp(r *http.Request, invite *Invite) (render.Renderer, *babyapi.ErrResponse) {
89+
if err := r.ParseForm(); err != nil {
90+
return nil, babyapi.ErrInvalidRequest(fmt.Errorf("error parsing form data: %w", err))
91+
}
92+
93+
rsvp := r.Form.Get("RSVP") == "true"
94+
invite.RSVP = &rsvp
95+
96+
err := api.Invites.Storage.Set(invite)
97+
if err != nil {
98+
return nil, babyapi.InternalServerError(err)
99+
}
100+
return &rsvpResponse{invite}, nil
101+
}
102+
103+
// Allow adding bulk invites with a single request
104+
func (api *API) addBulkInvites(r *http.Request, event *Event) (render.Renderer, *babyapi.ErrResponse) {
105+
if err := r.ParseForm(); err != nil {
106+
return nil, babyapi.ErrInvalidRequest(fmt.Errorf("error parsing form data: %w", err))
107+
}
108+
109+
inputs := strings.Split(r.Form.Get("invites"), ";")
110+
111+
invites := []*Invite{}
112+
for _, invite := range inputs {
113+
split := strings.Split(invite, ",")
114+
115+
name := split[0]
116+
117+
var contact string
118+
if len(split) > 1 {
119+
contact = split[1]
120+
}
121+
122+
inv := &Invite{
123+
DefaultResource: babyapi.NewDefaultResource(),
124+
Name: strings.TrimSpace(name),
125+
Contact: strings.TrimSpace(contact),
126+
EventID: event.GetID(),
127+
}
128+
invites = append(invites, inv)
129+
130+
err := api.Invites.Storage.Set(inv)
131+
if err != nil {
132+
return nil, babyapi.InternalServerError(err)
133+
}
134+
}
135+
136+
return &bulkInvitesResponse{nil, invites}, nil
137+
}
138+
139+
// authenticationMiddleware enforces access to Events and Invites. Admin access to an Event requires a password query parameter.
140+
// Access to Invites is allowed by the invite ID and requires no extra auth. The invite ID in the path or query parameter allows
141+
// read-only access to the Event
142+
func (api *API) authenticationMiddleware(r *http.Request, event *Event) (*http.Request, *babyapi.ErrResponse) {
143+
password := r.URL.Query().Get("password")
144+
inviteID := r.URL.Query().Get("invite")
145+
if inviteID == "" {
146+
inviteID = api.Invites.GetIDParam(r)
147+
}
148+
149+
switch {
150+
case password != "":
151+
err := event.Authenticate(password)
152+
if err == nil {
153+
return r, nil
154+
}
155+
case inviteID != "":
156+
invite, err := api.Invites.Storage.Get(inviteID)
157+
if err != nil {
158+
if errors.Is(err, babyapi.ErrNotFound) {
159+
return r, babyapi.ErrForbidden
160+
}
161+
return r, babyapi.InternalServerError(err)
162+
}
163+
if invite.EventID == event.GetID() {
164+
return r, nil
165+
}
166+
}
167+
168+
return r, babyapi.ErrForbidden
169+
}
170+
171+
// getAllInvitesMiddleware will get all invites when rendering HTML so it is accessible to the endpoint
172+
func (api *API) getAllInvitesMiddleware(r *http.Request, event *Event) (*http.Request, *babyapi.ErrResponse) {
173+
if render.GetAcceptedContentType(r) != render.ContentTypeHTML {
174+
return r, nil
175+
}
176+
// If password auth is used and this middleware is reached, we know it's admin
177+
// Otherwise, don't fetch invites
178+
if r.URL.Query().Get("password") == "" {
179+
return r, nil
180+
}
181+
182+
invites, err := api.Invites.Storage.GetAll(func(i *Invite) bool {
183+
return i.EventID == event.GetID()
184+
})
185+
if err != nil {
186+
return r, babyapi.InternalServerError(err)
187+
}
188+
189+
ctx := context.WithValue(r.Context(), invitesCtxKey, invites)
190+
r = r.WithContext(ctx)
191+
return r, nil
192+
}
193+
194+
type Event struct {
195+
babyapi.DefaultResource
196+
197+
Name string
198+
Contact string
199+
Date string
200+
Location string
201+
Details string
202+
203+
// Password should only be used in POST requests to create new Events and then is removed
204+
Password string `json:",omitempty"`
205+
// this unexported password allows using it internally without exporting to storage or responses
206+
password string
207+
208+
// These fields are excluded from responses
209+
Salt string `json:",omitempty"`
210+
Key string `json:",omitempty"`
211+
}
212+
213+
func (e *Event) Render(w http.ResponseWriter, r *http.Request) error {
214+
// Keep Salt and Key private when creating responses
215+
e.Salt = ""
216+
e.Key = ""
217+
218+
if r.Method == http.MethodPost {
219+
path := fmt.Sprintf("/events/%s?password=%s", e.GetID(), e.password)
220+
headers := `{"Accept": "text/html"}`
221+
w.Header().Add("HX-Location", fmt.Sprintf(`{"path": "%s", "headers": %s}`, path, headers))
222+
}
223+
224+
return nil
225+
}
226+
227+
// Disable PUT requests for Events because it complicates things with passwords
228+
// When creating a new resource with POST, salt and hash the password for storing
229+
func (e *Event) Bind(r *http.Request) error {
230+
switch r.Method {
231+
case http.MethodPut:
232+
render.Status(r, http.StatusMethodNotAllowed)
233+
return fmt.Errorf("PUT not allowed")
234+
case http.MethodPost:
235+
if e.Password == "" {
236+
return errors.New("missing required 'password' field")
237+
}
238+
239+
var err error
240+
e.Salt, err = randomSalt()
241+
if err != nil {
242+
return fmt.Errorf("error generating random salt: %w", err)
243+
}
244+
245+
e.Key = hash(e.Salt, e.Password)
246+
247+
e.password = e.Password
248+
e.Password = ""
249+
}
250+
251+
return e.DefaultResource.Bind(r)
252+
}
253+
254+
func (e *Event) HTML(r *http.Request) string {
255+
return renderTemplate(r, "eventPage", struct {
256+
Password string
257+
*Event
258+
Invites []*Invite
259+
}{r.URL.Query().Get("password"), e, getInvitesFromContext(r.Context())})
260+
}
261+
262+
func (e *Event) Authenticate(password string) error {
263+
if hash(e.Salt, password) != e.Key {
264+
return errors.New("invalid password")
265+
}
266+
return nil
267+
}
268+
269+
type Invite struct {
270+
babyapi.DefaultResource
271+
272+
Name string
273+
Contact string
274+
275+
EventID string
276+
RSVP *bool // nil = no response, otherwise true/false
277+
}
278+
279+
// Set EventID to event from URL path when creating a new Invite
280+
func (i *Invite) Bind(r *http.Request) error {
281+
switch r.Method {
282+
case http.MethodPost:
283+
i.EventID = babyapi.GetIDParam(r, "Event")
284+
}
285+
286+
return i.DefaultResource.Bind(r)
287+
}
288+
289+
func (i *Invite) HTML(r *http.Request) string {
290+
event, _ := babyapi.GetResourceFromContext[*Event](r.Context(), babyapi.ContextKey("Event"))
291+
292+
return renderTemplate(r, "invitePage", struct {
293+
*Invite
294+
Attending string
295+
Event *Event
296+
}{i, i.attending(), event})
297+
}
298+
299+
// get RSVP status as a string for easier template processing
300+
func (i *Invite) attending() string {
301+
attending := "unknown"
302+
if i.RSVP != nil && *i.RSVP {
303+
attending = "attending"
304+
}
305+
if i.RSVP != nil && !*i.RSVP {
306+
attending = "not attending"
307+
}
308+
309+
return attending
310+
}
311+
312+
func (i *Invite) link(r *http.Request) string {
313+
return fmt.Sprintf("%s/events/%s/invites/%s", r.Host, i.EventID, i.GetID())
314+
}
315+
316+
// rsvpResponse is a custom response struct that allows implementing a different HTML method for HTMLer
317+
// This will just render the HTML buttons for an HTMX partial swap
318+
type rsvpResponse struct {
319+
*Invite
320+
}
321+
322+
func (rsvp *rsvpResponse) HTML(r *http.Request) string {
323+
return renderTemplate(
324+
r,
325+
"rsvpButtons",
326+
struct {
327+
*Invite
328+
Attending string
329+
}{rsvp.Invite, rsvp.attending()},
330+
)
331+
}
332+
333+
type bulkInvitesResponse struct {
334+
*babyapi.DefaultRenderer
335+
336+
Invites []*Invite
337+
}
338+
339+
func (bi *bulkInvitesResponse) HTML(r *http.Request) string {
340+
return renderTemplate(r, "bulkInvites", bi)
341+
}
342+
343+
func main() {
344+
api := createAPI()
345+
api.Events.RunCLI()
346+
}
347+
348+
func createAPI() *API {
349+
api := &API{
350+
Events: babyapi.NewAPI(
351+
"Event", "/events",
352+
func() *Event { return &Event{} },
353+
),
354+
Invites: babyapi.NewAPI(
355+
"Invite", "/invites",
356+
func() *Invite { return &Invite{} },
357+
),
358+
}
359+
360+
api.Invites.ApplyExtension(extensions.HTMX[*Invite]{})
361+
362+
api.Invites.AddCustomRoute(http.MethodPost, "/bulk", api.Events.GetRequestedResourceAndDo(api.addBulkInvites))
363+
364+
api.Invites.AddCustomRoute(http.MethodGet, "/export", babyapi.Handler(api.export))
365+
366+
api.Invites.AddCustomIDRoute(http.MethodPut, "/rsvp", api.Invites.GetRequestedResourceAndDo(api.rsvp))
367+
368+
api.Events.AddCustomRootRoute(http.MethodGet, "/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
369+
http.Redirect(w, r, api.Events.Base(), http.StatusSeeOther)
370+
}))
371+
372+
api.Events.AddNestedAPI(api.Invites)
373+
374+
api.Events.GetAll = func(w http.ResponseWriter, r *http.Request) {
375+
if render.GetAcceptedContentType(r) != render.ContentTypeHTML {
376+
render.Render(w, r, babyapi.ErrForbidden)
377+
return
378+
}
379+
380+
render.HTML(w, r, renderTemplate(r, "createEventPage", map[string]any{}))
381+
}
382+
383+
api.Events.AddIDMiddleware(api.Events.GetRequestedResourceAndDoMiddleware(api.authenticationMiddleware))
384+
385+
api.Events.AddIDMiddleware(api.Events.GetRequestedResourceAndDoMiddleware(api.getAllInvitesMiddleware))
386+
387+
filename := os.Getenv("STORAGE_FILE")
388+
if filename == "" {
389+
filename = "storage.json"
390+
}
391+
392+
dbConfig := extensions.KVConnectionConfig{
393+
Filename: filename,
394+
RedisHost: os.Getenv("REDIS_HOST"),
395+
RedisPassword: os.Getenv("REDIS_PASS"),
396+
}
397+
398+
db, err := dbConfig.CreateDB()
399+
if err != nil {
400+
panic(err)
401+
}
402+
403+
api.Events.ApplyExtension(extensions.KeyValueStorage[*Event]{DB: db})
404+
api.Invites.ApplyExtension(extensions.KeyValueStorage[*Invite]{DB: db})
405+
406+
return api
407+
}
408+
409+
func hash(salt, password string) string {
410+
hasher := sha256.New()
411+
hasher.Write([]byte(salt + password))
412+
413+
return hex.EncodeToString(hasher.Sum(nil))
414+
}
415+
416+
func randomSalt() (string, error) {
417+
randomBytes := make([]byte, 24)
418+
_, err := rand.Read(randomBytes)
419+
if err != nil {
420+
return "", err
421+
}
422+
423+
return base64.URLEncoding.EncodeToString(randomBytes), nil
424+
}
425+
426+
func getInvitesFromContext(ctx context.Context) []*Invite {
427+
ctxData := ctx.Value(invitesCtxKey)
428+
if ctxData == nil {
429+
return nil
430+
}
431+
432+
invites, ok := ctxData.([]*Invite)
433+
if !ok {
434+
return nil
435+
}
436+
437+
return invites
438+
}
439+
440+
func renderTemplate(r *http.Request, name string, data any) string {
441+
tmpl, err := template.New(name).Funcs(map[string]any{
442+
"serverURL": func() string {
443+
return r.Host
444+
},
445+
"attending": func(i *Invite) string {
446+
return i.attending()
447+
},
448+
}).Parse(string(templates))
449+
if err != nil {
450+
panic(err)
451+
}
452+
453+
return babyapi.MustRenderHTML(tmpl, data)
454+
}

‎examples/event-rsvp/main_test.go

+526
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,526 @@
1+
package main
2+
3+
import (
4+
"fmt"
5+
"net/http"
6+
"os"
7+
"testing"
8+
9+
"github.com/calvinmclean/babyapi"
10+
babytest "github.com/calvinmclean/babyapi/test"
11+
"github.com/stretchr/testify/require"
12+
)
13+
14+
func TestAPI(t *testing.T) {
15+
defer os.RemoveAll("storage.json")
16+
17+
api := createAPI()
18+
19+
babytest.RunTableTest(t, api.Events, []babytest.TestCase[*babyapi.AnyResource]{
20+
{
21+
Name: "ErrorCreatingEventWithoutPassword",
22+
Test: babytest.RequestTest[*babyapi.AnyResource]{
23+
Method: http.MethodPost,
24+
Body: `{"Name": "Party"}`,
25+
},
26+
ExpectedResponse: babytest.ExpectedResponse{
27+
Status: http.StatusBadRequest,
28+
Body: `{"status":"Invalid request.","error":"missing required 'password' field"}`,
29+
Error: "error posting resource: unexpected response with text: Invalid request.",
30+
},
31+
},
32+
{
33+
Name: "CreateEvent",
34+
Test: babytest.RequestTest[*babyapi.AnyResource]{
35+
Method: http.MethodPost,
36+
Body: `{"Name": "Party", "Password": "secret"}`,
37+
},
38+
ExpectedResponse: babytest.ExpectedResponse{
39+
Status: http.StatusCreated,
40+
BodyRegexp: `{"id":"[0-9a-v]{20}","Name":"Party","Contact":"","Date":"","Location":"","Details":""}`,
41+
},
42+
},
43+
{
44+
Name: "GetEventForbidden",
45+
Test: babytest.RequestFuncTest[*babyapi.AnyResource](func(getResponse babytest.PreviousResponseGetter, address string) *http.Request {
46+
id := getResponse("CreateEvent").Data.GetID()
47+
address = fmt.Sprintf("%s/events/%s", address, id)
48+
49+
r, err := http.NewRequest(http.MethodGet, address, http.NoBody)
50+
require.NoError(t, err)
51+
return r
52+
}),
53+
ExpectedResponse: babytest.ExpectedResponse{
54+
Status: http.StatusForbidden,
55+
Body: `{"status":"Forbidden"}`,
56+
},
57+
},
58+
{
59+
Name: "GetEvent",
60+
Test: babytest.RequestTest[*babyapi.AnyResource]{
61+
Method: http.MethodGet,
62+
RawQuery: "password=secret",
63+
IDFunc: func(getResponse babytest.PreviousResponseGetter) string {
64+
return getResponse("CreateEvent").Data.GetID()
65+
},
66+
},
67+
ExpectedResponse: babytest.ExpectedResponse{
68+
Status: http.StatusOK,
69+
BodyRegexp: `{"id":"[0-9a-v]{20}","Name":"Party","Contact":"","Date":"","Location":"","Details":""}`,
70+
},
71+
},
72+
{
73+
Name: "GetAllEventsForbidden",
74+
Test: babytest.RequestTest[*babyapi.AnyResource]{
75+
Method: babytest.MethodGetAll,
76+
RawQuery: "password=secret",
77+
IDFunc: func(getResponse babytest.PreviousResponseGetter) string {
78+
return getResponse("CreateEvent").Data.GetID()
79+
},
80+
},
81+
ExpectedResponse: babytest.ExpectedResponse{
82+
Status: http.StatusForbidden,
83+
Body: `{"status":"Forbidden"}`,
84+
Error: "error getting all resources: unexpected response with text: Forbidden",
85+
},
86+
},
87+
{
88+
Name: "GetAllEventsForbiddenUsingRequestFuncTest",
89+
Test: babytest.RequestFuncTest[*babyapi.AnyResource](func(getResponse babytest.PreviousResponseGetter, address string) *http.Request {
90+
r, err := http.NewRequest(http.MethodGet, address+"/events", http.NoBody)
91+
require.NoError(t, err)
92+
return r
93+
}),
94+
ExpectedResponse: babytest.ExpectedResponse{
95+
Status: http.StatusForbidden,
96+
Body: `{"status":"Forbidden"}`,
97+
},
98+
},
99+
{
100+
Name: "GetEventWithInvalidInvite",
101+
Test: babytest.RequestTest[*babyapi.AnyResource]{
102+
Method: http.MethodGet,
103+
RawQuery: "invite=DoesNotExist",
104+
IDFunc: func(getResponse babytest.PreviousResponseGetter) string {
105+
return getResponse("CreateEvent").Data.GetID()
106+
},
107+
},
108+
ExpectedResponse: babytest.ExpectedResponse{
109+
Status: http.StatusForbidden,
110+
Body: `{"status":"Forbidden"}`,
111+
Error: "error getting resource: unexpected response with text: Forbidden",
112+
},
113+
},
114+
{
115+
Name: "PUTNotAllowed",
116+
Test: babytest.RequestTest[*babyapi.AnyResource]{
117+
Method: http.MethodPut,
118+
RawQuery: "password=secret",
119+
IDFunc: func(getResponse babytest.PreviousResponseGetter) string {
120+
return getResponse("CreateEvent").Data.GetID()
121+
},
122+
BodyFunc: func(getResponse babytest.PreviousResponseGetter) string {
123+
return fmt.Sprintf(`{"id": "%s", "name": "New Name"}`, getResponse("CreateEvent").Data.GetID())
124+
},
125+
},
126+
ExpectedResponse: babytest.ExpectedResponse{
127+
Status: http.StatusBadRequest,
128+
Body: `{"status":"Invalid request.","error":"PUT not allowed"}`,
129+
Error: "error putting resource: unexpected response with text: Invalid request.",
130+
},
131+
},
132+
{
133+
Name: "CannotCreateInviteWithoutEventPassword",
134+
Test: babytest.RequestTest[*babyapi.AnyResource]{
135+
Method: http.MethodPost,
136+
ParentIDsFunc: func(getResponse babytest.PreviousResponseGetter) []string {
137+
return []string{getResponse("CreateEvent").Data.GetID()}
138+
},
139+
Body: `{"Name": "Name"}`,
140+
},
141+
ClientName: "Invite",
142+
ExpectedResponse: babytest.ExpectedResponse{
143+
Status: http.StatusForbidden,
144+
Body: `{"status":"Forbidden"}`,
145+
Error: "error posting resource: unexpected response with text: Forbidden",
146+
},
147+
},
148+
{
149+
Name: "CreateInvite",
150+
Test: babytest.RequestTest[*babyapi.AnyResource]{
151+
Method: http.MethodPost,
152+
RawQuery: "password=secret",
153+
ParentIDsFunc: func(getResponse babytest.PreviousResponseGetter) []string {
154+
return []string{getResponse("CreateEvent").Data.GetID()}
155+
},
156+
Body: `{"Name": "Firstname Lastname"}`,
157+
},
158+
ClientName: "Invite",
159+
ExpectedResponse: babytest.ExpectedResponse{
160+
Status: http.StatusCreated,
161+
BodyRegexp: `{"id":"[0-9a-v]{20}","Name":"Firstname Lastname","Contact":"","EventID":"[0-9a-v]{20}","RSVP":null}`,
162+
},
163+
},
164+
{
165+
Name: "GetInvite",
166+
Test: babytest.RequestTest[*babyapi.AnyResource]{
167+
Method: http.MethodGet,
168+
ParentIDsFunc: func(getResponse babytest.PreviousResponseGetter) []string {
169+
return []string{getResponse("CreateEvent").Data.GetID()}
170+
},
171+
IDFunc: func(getResponse babytest.PreviousResponseGetter) string {
172+
return getResponse("CreateInvite").Data.GetID()
173+
},
174+
},
175+
ClientName: "Invite",
176+
ExpectedResponse: babytest.ExpectedResponse{
177+
Status: http.StatusOK,
178+
BodyRegexp: `{"id":"[0-9a-v]{20}","Name":"Firstname Lastname","Contact":"","EventID":"[0-9a-v]{20}","RSVP":null}`,
179+
},
180+
},
181+
{
182+
Name: "ListInvites",
183+
Test: babytest.RequestTest[*babyapi.AnyResource]{
184+
Method: babytest.MethodGetAll,
185+
RawQuery: "password=secret",
186+
ParentIDsFunc: func(getResponse babytest.PreviousResponseGetter) []string {
187+
return []string{getResponse("CreateEvent").Data.GetID()}
188+
},
189+
IDFunc: func(getResponse babytest.PreviousResponseGetter) string {
190+
return getResponse("CreateInvite").Data.GetID()
191+
},
192+
},
193+
ClientName: "Invite",
194+
ExpectedResponse: babytest.ExpectedResponse{
195+
Status: http.StatusOK,
196+
BodyRegexp: `{"items":\[{"id":"[0-9a-v]{20}","Name":"Firstname Lastname","Contact":"","EventID":"[0-9a-v]{20}","RSVP":null}]`,
197+
},
198+
},
199+
{
200+
Name: "ListInviteUsingRequestFuncTest",
201+
Test: babytest.RequestFuncTest[*babyapi.AnyResource](func(getResponse babytest.PreviousResponseGetter, address string) *http.Request {
202+
id := getResponse("CreateEvent").Data.GetID()
203+
address = fmt.Sprintf("%s/events/%s/invites", address, id)
204+
205+
r, err := http.NewRequest(babytest.MethodGetAll, address, http.NoBody)
206+
require.NoError(t, err)
207+
208+
q := r.URL.Query()
209+
q.Add("password", "secret")
210+
r.URL.RawQuery = q.Encode()
211+
212+
return r
213+
}),
214+
ClientName: "Invite",
215+
ExpectedResponse: babytest.ExpectedResponse{
216+
Status: http.StatusOK,
217+
BodyRegexp: `{"items":\[{"id":"[0-9a-v]{20}","Name":"Firstname Lastname","Contact":"","EventID":"[0-9a-v]{20}","RSVP":null}]`,
218+
},
219+
},
220+
{
221+
Name: "GetEventWithInviteIDAsPassword",
222+
Test: babytest.RequestTest[*babyapi.AnyResource]{
223+
Method: http.MethodGet,
224+
RawQueryFunc: func(getResponse babytest.PreviousResponseGetter) string {
225+
return "invite=" + getResponse("CreateInvite").Data.GetID()
226+
},
227+
ParentIDsFunc: func(getResponse babytest.PreviousResponseGetter) []string {
228+
return []string{getResponse("CreateEvent").Data.GetID()}
229+
},
230+
IDFunc: func(getResponse babytest.PreviousResponseGetter) string {
231+
return getResponse("CreateInvite").Data.GetID()
232+
},
233+
},
234+
ClientName: "Invite",
235+
ExpectedResponse: babytest.ExpectedResponse{
236+
Status: http.StatusOK,
237+
BodyRegexp: `{"id":"[0-9a-v]{20}","Name":"Firstname Lastname","Contact":"","EventID":"[0-9a-v]{20}","RSVP":null}`,
238+
},
239+
},
240+
{
241+
Name: "DeleteInvite",
242+
Test: babytest.RequestTest[*babyapi.AnyResource]{
243+
Method: http.MethodDelete,
244+
ParentIDsFunc: func(getResponse babytest.PreviousResponseGetter) []string {
245+
return []string{getResponse("CreateEvent").Data.GetID()}
246+
},
247+
IDFunc: func(getResponse babytest.PreviousResponseGetter) string {
248+
return getResponse("CreateInvite").Data.GetID()
249+
},
250+
},
251+
ClientName: "Invite",
252+
ExpectedResponse: babytest.ExpectedResponse{
253+
Status: http.StatusOK,
254+
NoBody: true,
255+
},
256+
},
257+
{
258+
Name: "PatchErrorNotConfigured",
259+
Test: babytest.RequestTest[*babyapi.AnyResource]{
260+
Method: http.MethodPatch,
261+
RawQuery: "password=secret",
262+
IDFunc: func(getResponse babytest.PreviousResponseGetter) string {
263+
return getResponse("CreateEvent").Data.GetID()
264+
},
265+
Body: `{"Name": "NEW"}`,
266+
},
267+
ExpectedResponse: babytest.ExpectedResponse{
268+
Status: http.StatusMethodNotAllowed,
269+
Error: "error patching resource: unexpected response with text: Method not allowed.",
270+
Body: `{"status":"Method not allowed."}`,
271+
},
272+
},
273+
})
274+
}
275+
276+
func TestIndividualTest(t *testing.T) {
277+
defer os.RemoveAll("storage.json")
278+
279+
api := createAPI()
280+
281+
client, stop := babytest.NewTestClient[*Event](t, api.Events)
282+
defer stop()
283+
284+
babytest.TestCase[*Event]{
285+
Name: "CreateEvent",
286+
Test: babytest.RequestTest[*Event]{
287+
Method: http.MethodPost,
288+
Body: `{"Name": "Party", "Password": "secret"}`,
289+
},
290+
ExpectedResponse: babytest.ExpectedResponse{
291+
Status: http.StatusCreated,
292+
BodyRegexp: `{"id":"[0-9a-v]{20}","Name":"Party","Contact":"","Date":"","Location":"","Details":""}`,
293+
},
294+
Assert: func(r *babytest.Response[*Event]) {
295+
require.Equal(t, "Party", r.Data.Name)
296+
},
297+
}.Run(t, client)
298+
299+
resp := babytest.TestCase[*Event]{
300+
Name: "CreateEvent",
301+
Test: babytest.RequestTest[*Event]{
302+
Method: http.MethodPost,
303+
Body: `{"Name": "Party", "Password": "secret"}`,
304+
},
305+
ExpectedResponse: babytest.ExpectedResponse{
306+
Status: http.StatusCreated,
307+
BodyRegexp: `{"id":"[0-9a-v]{20}","Name":"Party","Contact":"","Date":"","Location":"","Details":""}`,
308+
},
309+
Assert: func(r *babytest.Response[*Event]) {
310+
require.Equal(t, "Party", r.Data.Name)
311+
},
312+
}.RunWithResponse(t, client)
313+
require.NotNil(t, resp)
314+
}
315+
316+
func TestCLI(t *testing.T) {
317+
defer os.RemoveAll("storage.json")
318+
319+
api := createAPI()
320+
321+
babytest.RunTableTest(t, api.Events, []babytest.TestCase[*babyapi.AnyResource]{
322+
{
323+
Name: "ErrorCreatingEventWithoutPassword",
324+
Test: babytest.CommandLineTest[*babyapi.AnyResource]{
325+
Command: api.Events.Command,
326+
Args: []string{"event", "post", "-d", `{"Name": "Party"}`},
327+
},
328+
ExpectedResponse: babytest.ExpectedResponse{
329+
Status: http.StatusBadRequest,
330+
Body: `{"status":"Invalid request.","error":"missing required 'password' field"}`,
331+
Error: "error running client from CLI: error running Post: error posting resource: unexpected response with text: Invalid request.",
332+
},
333+
},
334+
{
335+
Name: "CreateEvent",
336+
Test: babytest.CommandLineTest[*babyapi.AnyResource]{
337+
Command: api.Events.Command,
338+
Args: []string{"event", "post", "-d", `{"Name": "Party", "Password": "secret"}`},
339+
},
340+
ExpectedResponse: babytest.ExpectedResponse{
341+
Status: http.StatusCreated,
342+
BodyRegexp: `{"id":"[0-9a-v]{20}","Name":"Party","Contact":"","Date":"","Location":"","Details":""}`,
343+
CLIOutRegexp: `{\s+"Contact": "",\s+"Date": "",\s+"Details": "",\s+"Location": "",\s+"Name": "Party",\s+"id": "[0-9a-v]{20}"\s+}\s*`,
344+
},
345+
},
346+
{
347+
Name: "GetEventForbidden",
348+
Test: babytest.CommandLineTest[*babyapi.AnyResource]{
349+
Command: api.Events.Command,
350+
ArgsFunc: func(getResponse babytest.PreviousResponseGetter) []string {
351+
return []string{"event", "get", getResponse("CreateEvent").Data.GetID()}
352+
},
353+
},
354+
ExpectedResponse: babytest.ExpectedResponse{
355+
Status: http.StatusForbidden,
356+
Body: `{"status":"Forbidden"}`,
357+
Error: "error running client from CLI: error running Get: error getting resource: unexpected response with text: Forbidden",
358+
},
359+
},
360+
{
361+
Name: "GetEvent",
362+
Test: babytest.CommandLineTest[*babyapi.AnyResource]{
363+
Command: api.Events.Command,
364+
ArgsFunc: func(getResponse babytest.PreviousResponseGetter) []string {
365+
return []string{"event", "get", getResponse("CreateEvent").Data.GetID(), "-q", "password=secret"}
366+
},
367+
},
368+
ExpectedResponse: babytest.ExpectedResponse{
369+
Status: http.StatusOK,
370+
BodyRegexp: `{"id":"[0-9a-v]{20}","Name":"Party","Contact":"","Date":"","Location":"","Details":""}`,
371+
},
372+
},
373+
{
374+
Name: "GetEventWithInvalidInvite",
375+
Test: babytest.CommandLineTest[*babyapi.AnyResource]{
376+
Command: api.Events.Command,
377+
ArgsFunc: func(getResponse babytest.PreviousResponseGetter) []string {
378+
return []string{"event", "get", getResponse("CreateEvent").Data.GetID(), "-q", "invite=DoesNotExist"}
379+
},
380+
},
381+
ExpectedResponse: babytest.ExpectedResponse{
382+
Status: http.StatusForbidden,
383+
Body: `{"status":"Forbidden"}`,
384+
Error: "error running client from CLI: error running Get: error getting resource: unexpected response with text: Forbidden",
385+
},
386+
},
387+
{
388+
Name: "PUTNotAllowed",
389+
Test: babytest.CommandLineTest[*babyapi.AnyResource]{
390+
Command: api.Events.Command,
391+
ArgsFunc: func(getResponse babytest.PreviousResponseGetter) []string {
392+
return []string{
393+
"event", "put",
394+
getResponse("CreateEvent").Data.GetID(),
395+
"-d", fmt.Sprintf(`{"id": "%s", "name": "New Name"}`, getResponse("CreateEvent").Data.GetID()),
396+
"-q", "password=secret",
397+
}
398+
},
399+
},
400+
ExpectedResponse: babytest.ExpectedResponse{
401+
Status: http.StatusBadRequest,
402+
Body: `{"status":"Invalid request.","error":"PUT not allowed"}`,
403+
Error: "error running client from CLI: error running Put: error putting resource: unexpected response with text: Invalid request.",
404+
},
405+
},
406+
{
407+
Name: "CannotCreateInviteWithoutEventPassword",
408+
Test: babytest.CommandLineTest[*babyapi.AnyResource]{
409+
Command: api.Events.Command,
410+
ArgsFunc: func(getResponse babytest.PreviousResponseGetter) []string {
411+
eventID := getResponse("CreateEvent").Data.GetID()
412+
return []string{"invite", "post", "--event-id", eventID, "-d", `{"Name": "Name"}`}
413+
},
414+
},
415+
ClientName: "Invite",
416+
ExpectedResponse: babytest.ExpectedResponse{
417+
Status: http.StatusForbidden,
418+
Body: `{"status":"Forbidden"}`,
419+
Error: "error running client from CLI: error running Post: error posting resource: unexpected response with text: Forbidden",
420+
},
421+
},
422+
{
423+
Name: "CreateInvite",
424+
Test: babytest.CommandLineTest[*babyapi.AnyResource]{
425+
Command: api.Events.Command,
426+
ArgsFunc: func(getResponse babytest.PreviousResponseGetter) []string {
427+
eventID := getResponse("CreateEvent").Data.GetID()
428+
return []string{"invite", "post", "--event-id", eventID, "-d", `{"Name": "Firstname Lastname"}`, "-q", "password=secret"}
429+
},
430+
},
431+
ClientName: "Invite",
432+
ExpectedResponse: babytest.ExpectedResponse{
433+
Status: http.StatusCreated,
434+
BodyRegexp: `{"id":"[0-9a-v]{20}","Name":"Firstname Lastname","Contact":"","EventID":"[0-9a-v]{20}","RSVP":null}`,
435+
},
436+
},
437+
{
438+
Name: "GetInvite",
439+
Test: babytest.CommandLineTest[*babyapi.AnyResource]{
440+
Command: api.Events.Command,
441+
ArgsFunc: func(getResponse babytest.PreviousResponseGetter) []string {
442+
eventID := getResponse("CreateEvent").Data.GetID()
443+
return []string{
444+
"invite", "get",
445+
getResponse("CreateInvite").Data.GetID(), "--event-id", eventID,
446+
"-q", "password=secret",
447+
}
448+
},
449+
},
450+
ClientName: "Invite",
451+
ExpectedResponse: babytest.ExpectedResponse{
452+
Status: http.StatusOK,
453+
BodyRegexp: `{"id":"[0-9a-v]{20}","Name":"Firstname Lastname","Contact":"","EventID":"[0-9a-v]{20}","RSVP":null}`,
454+
},
455+
},
456+
{
457+
Name: "ListInvites",
458+
Test: babytest.CommandLineTest[*babyapi.AnyResource]{
459+
Command: api.Events.Command,
460+
ArgsFunc: func(getResponse babytest.PreviousResponseGetter) []string {
461+
eventID := getResponse("CreateEvent").Data.GetID()
462+
return []string{
463+
"invite", "list", "--event-id", eventID, "-q", "password=secret",
464+
}
465+
},
466+
},
467+
ClientName: "Invite",
468+
ExpectedResponse: babytest.ExpectedResponse{
469+
Status: http.StatusOK,
470+
BodyRegexp: `{"items":\[{"id":"[0-9a-v]{20}","Name":"Firstname Lastname","Contact":"","EventID":"[0-9a-v]{20}","RSVP":null}]`,
471+
},
472+
},
473+
{
474+
Name: "GetEventWithInviteIDAsPassword",
475+
Test: babytest.CommandLineTest[*babyapi.AnyResource]{
476+
Command: api.Events.Command,
477+
ArgsFunc: func(getResponse babytest.PreviousResponseGetter) []string {
478+
eventID := getResponse("CreateEvent").Data.GetID()
479+
return []string{
480+
"invite", "get",
481+
getResponse("CreateInvite").Data.GetID(), "--event-id", eventID,
482+
"-q", "invite=" + getResponse("CreateInvite").Data.GetID(),
483+
}
484+
},
485+
},
486+
ClientName: "Invite",
487+
ExpectedResponse: babytest.ExpectedResponse{
488+
Status: http.StatusOK,
489+
BodyRegexp: `{"id":"[0-9a-v]{20}","Name":"Firstname Lastname","Contact":"","EventID":"[0-9a-v]{20}","RSVP":null}`,
490+
},
491+
},
492+
{
493+
Name: "DeleteInvite",
494+
Test: babytest.CommandLineTest[*babyapi.AnyResource]{
495+
Command: api.Events.Command,
496+
ArgsFunc: func(getResponse babytest.PreviousResponseGetter) []string {
497+
eventID := getResponse("CreateEvent").Data.GetID()
498+
return []string{
499+
"invite", "delete",
500+
getResponse("CreateInvite").Data.GetID(), "--event-id", eventID,
501+
}
502+
},
503+
},
504+
ClientName: "Invite",
505+
ExpectedResponse: babytest.ExpectedResponse{
506+
Status: http.StatusOK,
507+
NoBody: true,
508+
},
509+
},
510+
{
511+
Name: "PatchErrorNotConfigured",
512+
Test: babytest.CommandLineTest[*babyapi.AnyResource]{
513+
Command: api.Events.Command,
514+
ArgsFunc: func(getResponse babytest.PreviousResponseGetter) []string {
515+
eventID := getResponse("CreateEvent").Data.GetID()
516+
return []string{"event", "patch", eventID, "-d", `{"Name": "NEW"}`, "-q", "password=secret"}
517+
},
518+
},
519+
ExpectedResponse: babytest.ExpectedResponse{
520+
Status: http.StatusMethodNotAllowed,
521+
Body: `{"status":"Method not allowed."}`,
522+
Error: "error running client from CLI: error running Patch: error patching resource: unexpected response with text: Method not allowed.",
523+
},
524+
},
525+
})
526+
}

‎examples/event-rsvp/template.html

+235
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
{{ define "header" }}
2+
<!doctype html>
3+
<html>
4+
5+
<head>
6+
<meta charset="UTF-8">
7+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
8+
<title>Event RSVP</title>
9+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/uikit@3.17.11/dist/css/uikit.min.css" />
10+
<script src="https://unpkg.com/htmx.org@1.9.8"></script>
11+
</head>
12+
13+
<style>
14+
tr.htmx-swapping td {
15+
opacity: 0;
16+
transition: opacity 1s ease-out;
17+
}
18+
</style>
19+
20+
<body>
21+
{{ end }}
22+
23+
24+
{{ define "footer" }}
25+
</body>
26+
27+
</html>
28+
{{ end }}
29+
30+
{{ define "eventPage" }}
31+
{{ template "header" . }}
32+
{{ template "eventDetailsTable" . }}
33+
{{ if .Password }}
34+
{{ template "eventAdminPage" . }}
35+
{{ end }}
36+
{{ template "footer" . }}
37+
{{ end }}
38+
39+
{{ define "eventDetailsTable" }}
40+
<div class="uk-card uk-card-body uk-card-default uk-margin-left uk-margin-right uk-margin-top">
41+
<h2 class="uk-card-title uk-heading-medium uk-text-center">{{ .Name }}</h2>
42+
<table class="uk-table uk-table-divider">
43+
<thead>
44+
</thead>
45+
46+
<tbody>
47+
<tr>
48+
<td><b>Name</b></td>
49+
<td>{{ .Name }}</td>
50+
</tr>
51+
<tr>
52+
<td><b>Date</b></td>
53+
<td>{{ .Date }}</td>
54+
</tr>
55+
<tr>
56+
<td><b>Location</b></td>
57+
<td>{{ .Location }}</td>
58+
</tr>
59+
<tr>
60+
<td><b>Details</b></td>
61+
<td>{{ .Details }}</td>
62+
</tr>
63+
</tbody>
64+
</table>
65+
</div>
66+
{{ end }}
67+
68+
{{/* Show list of invites and RSVP status */}}
69+
{{/* Form to create new invite */}}
70+
{{/* Form for bulk invite from a list of names */}}
71+
{{ define "eventAdminPage" }}
72+
<div class="uk-card uk-card-body uk-card-default uk-margin-left uk-margin-right uk-margin-top">
73+
<h2 class="uk-card-title uk-heading-medium uk-text-center">Add Invites</h2>
74+
<form class="uk-form-horizontal" hx-headers='{"Accept": "text/html"}'
75+
hx-post="/events/{{ .ID }}/invites/bulk?password={{ .Password }}" hx-on::after-request="this.reset()"
76+
hx-swap="none">
77+
78+
<input class="uk-input uk-width-3-4@s" type="text" name="invites"
79+
placeholder="e.g. Invite 1, invite1@email.com; Invite 2, invite2@email.com; ...">
80+
<button type="submit" class="uk-button uk-button-primary uk-margin-left uk-width-1-5@s">Submit</button>
81+
</form>
82+
</div>
83+
84+
<div class="uk-card uk-card-body uk-card-default uk-margin-left uk-margin-right uk-margin-top">
85+
<h2 class="uk-card-title uk-heading-medium uk-text-center">Invites</h2>
86+
<table class="uk-table uk-table-divider uk-margin-left uk-margin-right">
87+
<colgroup>
88+
<col>
89+
<col>
90+
<col>
91+
<col style="width: 200px;">
92+
</colgroup>
93+
<thead>
94+
<tr>
95+
<th>Name</th>
96+
<th>Contact</th>
97+
<th>RSVP</th>
98+
<th>
99+
<a class="uk-button uk-button-small uk-button-default uk-margin-top"
100+
href="/events/{{ .ID }}/invites/export?password={{ .Password }}" download>
101+
Export CSV
102+
</a>
103+
</th>
104+
</tr>
105+
</thead>
106+
107+
<tbody id="invites-table">
108+
{{ range .Invites }}
109+
<tr>
110+
{{ template "inviteRow" . }}
111+
</tr>
112+
{{ end }}
113+
</tbody>
114+
</table>
115+
</div>
116+
{{ end }}
117+
118+
{{/* Like inviteRow, but uses HTMX out of band swap to append to the table */}}
119+
{{ define "inviteRowOOB" }}
120+
<tbody hx-swap-oob="beforeend:#invites-table">
121+
<tr>
122+
{{ template "inviteRow" . }}
123+
</tr>
124+
</tbody>
125+
{{ end }}
126+
127+
{{/* Used in eventAdminTemplate to show individual invite, delete button, and button to copy invite link */}}
128+
{{ define "inviteRow" }}
129+
<td>{{ .Name }}</td>
130+
<td>{{ .Contact }}</td>
131+
132+
<td>
133+
{{ attending . }}
134+
</td>
135+
136+
<td>
137+
<button hx-on:click="navigator.clipboard.writeText('{{ serverURL }}/events/{{ .EventID }}/invites/{{ .ID }}')"
138+
class="uk-button uk-button-primary uk-button-small">
139+
Copy
140+
</button>
141+
<button class="uk-button uk-button-danger uk-button-small" hx-delete="/events/{{ .EventID }}/invites/{{ .ID }}"
142+
hx-swap="swap:1s" hx-target="closest tr">
143+
Delete
144+
</button>
145+
</td>
146+
{{ end }}
147+
148+
{{/* // This renders multiple invite rows and uses HTMX out of band responses */}}
149+
{{ define "bulkInvites" }}
150+
{{ range .Invites }}
151+
{{ template "inviteRowOOB" . }}
152+
{{ end }}
153+
{{ end }}
154+
155+
{{/* /invites/{InviteID}: display event details and include buttons to set RSVP */}}
156+
{{ define "invitePage" }}
157+
{{ template "header" . }}
158+
159+
{{ template "eventDetailsTable" .Event }}
160+
161+
<div class="uk-card uk-card-body uk-card-default uk-margin-left uk-margin-right uk-margin-top">
162+
<h2 class="uk-card-title uk-heading-medium uk-text-center">
163+
Hello {{ .Name }}!
164+
</h2>
165+
166+
{{ template "rsvpButtons" . }}
167+
</div>
168+
{{ end }}
169+
170+
{{ define "rsvpButtons" }}
171+
<div class="uk-text-center" id="rsvp-buttons">
172+
<p>Your current status is: {{ .Attending }}</p>
173+
174+
{{ $attendDisabled := "" }}
175+
{{ $unattendDisabled := "" }}
176+
{{ if eq .Attending "attending" }}
177+
{{ $attendDisabled = "disabled" }}
178+
{{ end }}
179+
{{ if eq .Attending "not attending" }}
180+
{{ $unattendDisabled = "disabled" }}
181+
{{ end }}
182+
183+
<div hx-headers='{"Accept": "text/html"}' hx-target="#rsvp-buttons" hx-swap="outerHTML">
184+
185+
<button class="uk-button uk-button-primary uk-button-large"
186+
hx-put="/events/{{ .EventID }}/invites/{{ .ID }}/rsvp" hx-vals='{"RSVP": "true"}' {{ $attendDisabled }}>
187+
188+
Attend
189+
</button>
190+
191+
<button class="uk-button uk-button-danger uk-button-large"
192+
hx-put="/events/{{ .EventID }}/invites/{{ .ID }}/rsvp" hx-vals='{"RSVP": "false"}' {{ $unattendDisabled }}>
193+
194+
Do Not Attend
195+
</button>
196+
</div>
197+
</div>
198+
{{ template "footer" . }}
199+
{{ end }}
200+
201+
{{ define "createEventPage" }}
202+
{{ template "header" . }}
203+
204+
<div class="uk-card uk-card-body uk-card-default uk-margin-left uk-margin-right uk-margin-top">
205+
<h2 class="uk-card-title uk-heading-medium uk-text-center">Create New Event</h2>
206+
<form hx-post="/events" hx-headers='{"Accept": "text/html"}'>
207+
<fieldset class="uk-fieldset">
208+
209+
<div class="uk-margin">
210+
<input class="uk-input" type="text" placeholder="Name" name="Name">
211+
</div>
212+
213+
<div class="uk-margin">
214+
<input class="uk-input" type="text" placeholder="Date" name="Date">
215+
</div>
216+
217+
<div class="uk-margin">
218+
<input class="uk-input" type="text" placeholder="Location" name="Location">
219+
</div>
220+
221+
<div class="uk-margin">
222+
<textarea class="uk-textarea" rows="5" placeholder="Details" name="Details"></textarea>
223+
</div>
224+
225+
<div class="uk-margin">
226+
<input class="uk-input" type="password" placeholder="Password" name="Password">
227+
</div>
228+
</fieldset>
229+
230+
<button type="submit" class="uk-button uk-button-primary uk-margin-top">Submit</button>
231+
</form>
232+
</div>
233+
234+
{{ template "footer" . }}
235+
{{ end }}

0 commit comments

Comments
 (0)
Please sign in to comment.