diff --git a/ADVANCED.md b/ADVANCED.md new file mode 100644 index 0000000..6a5a7dd --- /dev/null +++ b/ADVANCED.md @@ -0,0 +1,187 @@ +# Advanced Use Cases +The go-partial package offers advanced features to handle dynamic content rendering based on user interactions or server-side logic. Below are three advanced use cases: + +- **WithSelection**: Selecting partials based on a selection key. +- **WithAction**: Executing server-side actions during request processing. +- **WithTemplateAction**: Invoking actions from within templates. + +## WithSelection +### Purpose +WithSelection allows you to select and render one of several predefined partials based on a selection key, such as a header value or query parameter. This is useful for rendering different content based on user interaction, like tabbed interfaces. +### When to Use +Use WithSelection when you have a static set of partials and need to render one of them based on a simple key provided by the client. +### How to Use +#### Step 1: Define the Partials +Create partials for each selectable content. +```go +tab1Partial := partial.New("tab1.gohtml").ID("tab1") +tab2Partial := partial.New("tab2.gohtml").ID("tab2") +defaultPartial := partial.New("default.gohtml").ID("default") +``` + +#### Step 2: Create a Selection Map +Map selection keys to their corresponding partials. +```go +partialsMap := map[string]*partial.Partial{ +"tab1": tab1Partial, +"tab2": tab2Partial, +"default": defaultPartial, +} +``` + +#### Step 3: Set Up the Content Partial with Selection +Use WithSelection to associate the selection map with your content partial. +```go +contentPartial := partial.New("content.gohtml").ID("content").WithSelection("default", partialsMap) +``` + +#### Step 4: Update the Template +In your content.gohtml template, use the {{selection}} function to render the selected partial. +```html +
+ {{selection}} +
+``` + +#### Step 5: Handle the Request +In your handler, render the partial as usual. +```go +func yourHandler(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + err := contentPartial.WriteWithRequest(ctx, w, r) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} +``` + +#### Client-Side Usage +Set the selection key via a header (e.g., X-Select) or another method. +```html +
+ +
+``` + +## WithAction +### Purpose +WithAction allows you to execute server-side logic during request processing, such as handling form submissions or performing business logic, and then render a partial based on the result. +### When to Use +Use WithAction when you need to perform dynamic operations before rendering, such as processing form data, updating a database, or any logic that isn't just selecting a predefined partial. + +### How to Use +#### Step 1: Define the Partial with an Action +Attach an action function to the partial using WithAction. +```go +formPartial := partial.New("form.gohtml").ID("contactForm").WithAction(func(ctx context.Context, data *partial.Data) (*partial.Partial, error) { +// Access form values +name := data.Request.FormValue("name") +email := data.Request.FormValue("email") + + // Perform validation and business logic + if name == "" || email == "" { + errorPartial := partial.New("error.gohtml").AddData("Message", "Name and email are required.") + return errorPartial, nil + } + + // Simulate saving to a database or performing an action + // ... + + // Decide which partial to render next + successPartial := partial.New("success.gohtml").AddData("Name", name) + return successPartial, nil +}) +``` +#### Step 2: Handle the Request +In your handler, render the partial with the request. +```go +func submitHandler(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + err := formPartial.WriteWithRequest(ctx, w, r) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} +``` +#### Step 3: Client-Side Usage +Submit the form with an X-Action header specifying the partial ID. +```html +
+ + + +
+``` +#### Explanation + +- The form submission triggers the server to execute the action associated with contactForm. +- The action processes the form data and returns a partial to render. +- The server responds with the rendered partial (e.g., a success message). + +## WithTemplateAction +### Purpose +WithTemplateAction allows actions to be executed from within the template using a function like {{action "actionName"}}. This provides flexibility to execute actions conditionally or at specific points in your template. +### When to Use +Use WithTemplateAction when you need to invoke actions directly from the template, possibly under certain conditions, or when you have multiple actions within a single template. +### How to Use + +#### Step 1: Define the Partial with Template Actions +Attach template actions to your partial. +```go +myPartial := partial.New("mytemplate.gohtml").ID("myPartial").WithTemplateAction("loadDynamicContent", func(ctx context.Context, data *partial.Data) (*partial.Partial, error) { + // Load dynamic content + dynamicPartial := partial.New("dynamic.gohtml") + // Add data or perform operations + return dynamicPartial, nil +}) +``` +#### Step 2: Update the Template +In your mytemplate.gohtml template, invoke the action using the action function. +```html +
+ + {{action "loadDynamicContent"}} + +
+``` +#### Step 3: Handle the Request +Render the partial as usual. +```go +func yourHandler(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + err := myPartial.WriteWithRequest(ctx, w, r) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} +``` +#### Use Cases + +- Conditional Execution + ```gotemplate + {{if .Data.ShowSpecialContent}} + {{action "loadSpecialContent"}} + {{end}} + ``` +- Lazy Loading + ```gotemplate +
+ {{action "loadHeavyContent"}} +
+ ``` +#### Explanation + +- Actions are only executed when the {{action "actionName"}} function is called in the template. +- This allows for conditional or multiple action executions within the same template. +- The server-side action returns a partial to render, which is then included at that point in the template. + +## Choosing the Right Approach +- Use `WithSelection` when you have a set of predefined partials and want to select one based on a simple key. +- Use `WithAction` when you need to perform server-side logic during request processing and render a partial based on the result. +- Use `WithTemplateAction` when you want to invoke actions directly from within the template, especially for conditional execution or multiple actions. + +## Notes + +- **Separation of Concerns**: While WithTemplateAction provides flexibility, be cautious not to overload templates with business logic. Keep templates focused on presentation as much as possible. +- **Error Handling**: Ensure that your actions handle errors gracefully and that your templates can render appropriately even if an action fails. +- **Thread Safety**: If your application is concurrent, ensure that shared data is properly synchronized. \ No newline at end of file diff --git a/README.md b/README.md index a33e286..1734adb 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,9 @@ To install the package, run: go get github.com/donseba/go-partial ``` +## Advanced use cases +Advanced usecases are documented in the [ADVANCED.md](ADVANCED.md) file + ## Basic Usage Here's a simple example of how to use the package to render a template. diff --git a/js/htmx.partial.js b/js/htmx.partial.js index 6a16283..2abd5ad 100644 --- a/js/htmx.partial.js +++ b/js/htmx.partial.js @@ -1,9 +1,19 @@ (function() { htmx.on('htmx:configRequest', function(event) { - var element = event.detail.elt; - var partialValue = element.getAttribute('hx-partial'); + let element = event.detail.elt; + let partialValue = element.getAttribute('hx-partial'); if (partialValue !== null) { event.detail.headers['X-Partial'] = partialValue; } + + let selectValue = element.getAttribute('hx-select'); + if (selectValue !== null) { + event.detail.headers['X-Select'] = selectValue; + } + + let actionValue = element.getAttribute('hx-action'); + if (actionValue !== null) { + event.detail.headers['X-Action'] = actionValue; + } }); })(); \ No newline at end of file diff --git a/partial.go b/partial.go index c3729b1..f23a659 100644 --- a/partial.go +++ b/partial.go @@ -7,6 +7,7 @@ import ( "fmt" "html/template" "io/fs" + "log/slog" "net/http" "net/url" "path" @@ -22,12 +23,18 @@ var ( mutexCache = sync.Map{} // protectedFunctionNames is a set of function names that are protected from being overridden protectedFunctionNames = map[string]struct{}{ - "child": {}, - "context": {}, - "partialHeader": {}, - "requestHeader": {}, - "swapOOB": {}, - "url": {}, + "action": {}, + "actionHeader": {}, + "child": {}, + "context": {}, + "partialHeader": {}, + "requestedPartial": {}, + "requestedAction": {}, + "requestedSelect": {}, + "selectHeader": {}, + "selection": {}, + "swapOOB": {}, + "url": {}, } ) @@ -36,11 +43,16 @@ type ( Partial struct { id string parent *Partial + request *http.Request swapOOB bool fs fs.FS logger Logger partialHeader string - requestHeader string + selectHeader string + actionHeader string + requestedPartial string + requestedAction string + requestedSelect string useCache bool templates []string combinedFunctions template.FuncMap @@ -50,6 +62,14 @@ type ( mu sync.RWMutex children map[string]*Partial oobChildren map[string]struct{} + selection *Selection + templateAction func(ctx context.Context, data *Data) (*Partial, error) + action func(ctx context.Context, data *Data) (*Partial, error) + } + + Selection struct { + Partials map[string]*Partial + Default string } // Data represents the data available to the partial. @@ -139,9 +159,7 @@ func (p *Partial) MergeData(data map[string]any, override bool) *Partial { // AddFunc adds a function to the partial. func (p *Partial) AddFunc(name string, fn interface{}) *Partial { if _, ok := protectedFunctionNames[name]; ok { - if p.logger != nil { - p.logger.Warn("function name is protected and cannot be overwritten", "function", name) - } + p.getLogger().Warn("function name is protected and cannot be overwritten", "function", name) return p } @@ -159,9 +177,7 @@ func (p *Partial) MergeFuncMap(funcMap template.FuncMap) { for k, v := range funcMap { if _, ok := protectedFunctionNames[k]; ok { - if p.logger != nil { - p.logger.Warn("function name is protected and cannot be overwritten", "function", k) - } + p.getLogger().Warn("function name is protected and cannot be overwritten", "function", k) continue } @@ -217,6 +233,30 @@ func (p *Partial) With(child *Partial) *Partial { return p } +// WithAction adds callback action to the partial, which can do some logic and return a partial to render. +func (p *Partial) WithAction(action func(ctx context.Context, data *Data) (*Partial, error)) *Partial { + p.action = action + return p +} + +func (p *Partial) WithTemplateAction(templateAction func(ctx context.Context, data *Data) (*Partial, error)) *Partial { + p.templateAction = templateAction + return p +} + +// WithSelectMap adds a selection partial to the partial. +func (p *Partial) WithSelectMap(defaultKey string, partialsMap map[string]*Partial) *Partial { + p.mu.Lock() + defer p.mu.Unlock() + + p.selection = &Selection{ + Default: defaultKey, + Partials: partialsMap, + } + + return p +} + // SetParent sets the parent of the partial. func (p *Partial) SetParent(parent *Partial) *Partial { p.parent = parent @@ -239,9 +279,12 @@ func (p *Partial) RenderWithRequest(ctx context.Context, r *http.Request) (templ return "", errors.New("partial is not initialized") } - renderTarget := r.Header.Get(p.getPartialHeader()) + p.request = r + p.requestedPartial = r.Header.Get(p.getPartialHeader()) + p.requestedAction = r.Header.Get(p.getActionHeader()) + p.requestedSelect = r.Header.Get(p.getSelectHeader()) - return p.renderWithTarget(ctx, r, renderTarget) + return p.renderWithTarget(ctx, r) } // WriteWithRequest writes the partial to the http.ResponseWriter. @@ -253,17 +296,13 @@ func (p *Partial) WriteWithRequest(ctx context.Context, w http.ResponseWriter, r out, err := p.RenderWithRequest(ctx, r) if err != nil { - if p.logger != nil { - p.logger.Error("error rendering partial", "error", err) - } + p.getLogger().Error("error rendering partial", "error", err) return err } _, err = w.Write([]byte(out)) if err != nil { - if p.logger != nil { - p.logger.Error("error writing partial to response", "error", err) - } + p.getLogger().Error("error writing partial to response", "error", err) return err } @@ -313,37 +352,9 @@ func (p *Partial) getFuncs(data *Data) template.FuncMap { return p.swapOOB } - funcs["child"] = func(id string, vals ...any) template.HTML { - if len(vals) > 0 && len(vals)%2 != 0 { - if p.logger != nil { - p.logger.Warn("invalid child data for partial, they come in key-value pairs", "id", id) - } - return template.HTML(fmt.Sprintf("invalid child data for partial '%s'", id)) - } - - d := make(map[string]any) - for i := 0; i < len(vals); i += 2 { - key, ok := vals[i].(string) - if !ok { - if p.logger != nil { - p.logger.Warn("invalid child data key for partial, it must be a string", "id", id, "key", vals[i]) - } - return template.HTML(fmt.Sprintf("invalid child data key for partial '%s', want string, got %T", id, vals[i])) - } - d[key] = vals[i+1] - } - - html, err := p.renderChildPartial(data.Ctx, id, d) - if err != nil { - if p.logger != nil { - p.logger.Error("error rendering partial", "id", id, "error", err) - } - // Handle error: you can log it and return an empty string or an error message - return template.HTML(fmt.Sprintf("error rendering partial '%s': %v", id, err)) - } - - return html - } + funcs["child"] = childFunc(p, data) + funcs["selection"] = selectionFunc(p, data) + funcs["action"] = actionFunc(p, data) funcs["url"] = func() *url.URL { return data.URL @@ -353,14 +364,30 @@ func (p *Partial) getFuncs(data *Data) template.FuncMap { return data.Ctx } - funcs["requestHeader"] = func() string { - return p.getRequestHeader() - } - funcs["partialHeader"] = func() string { return p.getPartialHeader() } + funcs["requestedPartial"] = func() string { + return p.getRequestedPartial() + } + + funcs["selectHeader"] = func() string { + return p.getSelectHeader() + } + + funcs["requestedSelect"] = func() string { + return p.getRequestedSelect() + } + + funcs["actionHeader"] = func() string { + return p.getActionHeader() + } + + funcs["requestedAction"] = func() string { + return p.getRequestedAction() + } + return funcs } @@ -393,17 +420,44 @@ func (p *Partial) getPartialHeader() string { if p.parent != nil { return p.parent.getPartialHeader() } - return "" + return defaultPartialHeader } -func (p *Partial) getRequestHeader() string { - if p.requestHeader != "" { - return p.requestHeader +func (p *Partial) getSelectHeader() string { + if p.selectHeader != "" { + return p.selectHeader } if p.parent != nil { - return p.parent.getRequestHeader() + return p.parent.getSelectHeader() } - return "" + return defaultSelectHeader +} + +func (p *Partial) getSelectionPartials() map[string]*Partial { + if p.selection != nil { + return p.selection.Partials + } + return nil +} + +func (p *Partial) getActionHeader() string { + if p.actionHeader != "" { + return p.actionHeader + } + if p.parent != nil { + return p.parent.getActionHeader() + } + return defaultActionHeader +} + +func (p *Partial) getRequest() *http.Request { + if p.request != nil { + return p.request + } + if p.parent != nil { + return p.parent.getRequest() + } + return &http.Request{} } func (p *Partial) getFS() fs.FS { @@ -416,33 +470,78 @@ func (p *Partial) getFS() fs.FS { return nil } -func (p *Partial) renderWithTarget(ctx context.Context, r *http.Request, renderTarget string) (template.HTML, error) { - if renderTarget == "" || renderTarget == p.id { - out, err := p.renderSelf(ctx, r.URL) +func (p *Partial) getLogger() Logger { + if p == nil { + return slog.Default().WithGroup("partial") + } + + if p.logger != nil { + return p.logger + } + + if p.parent != nil { + return p.parent.getLogger() + } + + // Cache the default logger in p.logger + p.logger = slog.Default().WithGroup("partial") + + return p.logger +} + +func (p *Partial) getRequestedPartial() string { + if p.requestedPartial != "" { + return p.requestedPartial + } + if p.parent != nil { + return p.parent.getRequestedPartial() + } + return "" +} + +func (p *Partial) getRequestedAction() string { + if p.requestedAction != "" { + return p.requestedAction + } + if p.parent != nil { + return p.parent.getRequestedAction() + } + return "" +} + +func (p *Partial) getRequestedSelect() string { + if p.requestedSelect != "" { + return p.requestedSelect + } + if p.parent != nil { + return p.parent.getRequestedSelect() + } + return "" +} + +func (p *Partial) renderWithTarget(ctx context.Context, r *http.Request) (template.HTML, error) { + if p.getRequestedPartial() == "" || p.getRequestedPartial() == p.id { + out, err := p.renderSelf(ctx, r) if err != nil { return "", err } // Render OOB children of parent if necessary if p.parent != nil { - oobOut, oobErr := p.parent.renderOOBChildren(ctx, r.URL, true) + oobOut, oobErr := p.parent.renderOOBChildren(ctx, r, true) if oobErr != nil { - if p.logger != nil { - p.logger.Error("error rendering OOB children of parent", "error", oobErr, "parent", p.parent.id) - } + p.getLogger().Error("error rendering OOB children of parent", "error", oobErr, "parent", p.parent.id) return "", fmt.Errorf("error rendering OOB children of parent with ID '%s': %w", p.parent.id, oobErr) } out += oobOut } return out, nil } else { - c := p.recursiveChildLookup(renderTarget, make(map[string]bool)) + c := p.recursiveChildLookup(p.getRequestedPartial(), make(map[string]bool)) if c == nil { - if p.logger != nil { - p.logger.Error("requested partial not found in parent", "id", renderTarget, "parent", p.id) - } - return "", fmt.Errorf("requested partial %s not found in parent %s", renderTarget, p.id) + p.getLogger().Error("requested partial not found in parent", "id", p.getRequestedPartial(), "parent", p.id) + return "", fmt.Errorf("requested partial %s not found in parent %s", p.getRequestedPartial(), p.id) } - return c.renderWithTarget(ctx, r, renderTarget) + return c.renderWithTarget(ctx, r) } } @@ -474,9 +573,7 @@ func (p *Partial) renderChildPartial(ctx context.Context, id string, data map[st child, ok := p.children[id] p.mu.RUnlock() if !ok { - if p.logger != nil { - p.logger.Warn("child partial not found", "id", id) - } + p.getLogger().Warn("child partial not found", "id", id) return "", nil } @@ -492,18 +589,21 @@ func (p *Partial) renderChildPartial(ctx context.Context, id string, data map[st } // Render the cloned child partial - return childClone.renderSelf(ctx, nil) + return childClone.renderSelf(ctx, p.getRequest()) } // renderNamed renders the partial with the given name and templates. -func (p *Partial) renderSelf(ctx context.Context, currentURL *url.URL) (template.HTML, error) { +func (p *Partial) renderSelf(ctx context.Context, r *http.Request) (template.HTML, error) { if len(p.templates) == 0 { - if p.logger != nil { - p.logger.Error("no templates provided for rendering") - } + p.getLogger().Error("no templates provided for rendering") return "", errors.New("no templates provided for rendering") } + var currentURL *url.URL + if r != nil { + currentURL = r.URL + } + data := &Data{ URL: currentURL, Ctx: ctx, @@ -512,30 +612,35 @@ func (p *Partial) renderSelf(ctx context.Context, currentURL *url.URL) (template Layout: p.getLayoutData(), } + if p.action != nil { + actionPartial, err := p.action(ctx, data) + if err != nil { + p.getLogger().Error("error in action function", "error", err) + return "", fmt.Errorf("error in action function: %w", err) + } + return actionPartial.renderSelf(ctx, r) + } + functions := p.getFuncs(data) funcMapPtr := reflect.ValueOf(functions).Pointer() cacheKey := p.generateCacheKey(p.templates, funcMapPtr) tmpl, err := p.getOrParseTemplate(cacheKey, functions) if err != nil { - if p.logger != nil { - p.logger.Error("error getting or parsing template", "error", err) - } + p.getLogger().Error("error getting or parsing template", "error", err) return "", err } var buf bytes.Buffer if err = tmpl.Execute(&buf, data); err != nil { - if p.logger != nil { - p.logger.Error("error executing template", "template", p.templates[0], "error", err) - } + p.getLogger().Error("error executing template", "template", p.templates[0], "error", err) return "", fmt.Errorf("error executing template '%s': %w", p.templates[0], err) } return template.HTML(buf.String()), nil } -func (p *Partial) renderOOBChildren(ctx context.Context, currentURL *url.URL, swapOOB bool) (template.HTML, error) { +func (p *Partial) renderOOBChildren(ctx context.Context, r *http.Request, swapOOB bool) (template.HTML, error) { var out template.HTML p.mu.RLock() defer p.mu.RUnlock() @@ -543,7 +648,7 @@ func (p *Partial) renderOOBChildren(ctx context.Context, currentURL *url.URL, sw for id := range p.oobChildren { if child, ok := p.children[id]; ok { child.swapOOB = swapOOB - childData, err := child.renderSelf(ctx, currentURL) + childData, err := child.renderSelf(ctx, r) if err != nil { return "", fmt.Errorf("error rendering OOB child '%s': %w", id, err) } @@ -601,12 +706,16 @@ func (p *Partial) clone() *Partial { clone := &Partial{ id: p.id, parent: p.parent, + request: p.request, swapOOB: p.swapOOB, fs: p.fs, logger: p.logger, partialHeader: p.partialHeader, - requestHeader: p.requestHeader, + selectHeader: p.selectHeader, + actionHeader: p.actionHeader, + requestedPartial: p.requestedPartial, useCache: p.useCache, + selection: p.selection, templates: append([]string{}, p.templates...), // Copy the slice combinedFunctions: make(template.FuncMap), data: make(map[string]any), diff --git a/partial_test.go b/partial_test.go index 6b684da..2d6b941 100644 --- a/partial_test.go +++ b/partial_test.go @@ -3,6 +3,7 @@ package partial import ( "context" "html/template" + "io" "net/http" "net/http/httptest" "strings" @@ -419,6 +420,155 @@ func TestDataInTemplates(t *testing.T) { } } +func TestWithSelectMap(t *testing.T) { + fsys := &InMemoryFS{ + Files: map[string]string{ + "index.gohtml": `{{ child "content" }}`, + "content.gohtml": `
{{selection}}
`, + "tab1.gohtml": "Tab 1 Content", + "tab2.gohtml": "Tab 2 Content", + "default.gohtml": "Default Tab Content", + }, + } + + // Create a map of selection keys to partials + partialsMap := map[string]*Partial{ + "tab1": New("tab1.gohtml").ID("tab1"), + "tab2": New("tab2.gohtml").ID("tab2"), + "default": New("default.gohtml").ID("default"), + } + + // Create the content partial with the selection map + contentPartial := New("content.gohtml"). + ID("content"). + WithSelectMap("default", partialsMap) + + // Create the layout partial + index := New("index.gohtml") + + // Set up the service and layout + svc := NewService(&Config{ + fs: fsys, // Set the file system in the service config + }) + layout := svc.NewLayout(). + Set(contentPartial). + Wrap(index) + + // Set up a test server + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + err := layout.WriteWithRequest(ctx, w, r) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + }) + + // Create a test server + server := httptest.NewServer(handler) + defer server.Close() + + // Define test cases + testCases := []struct { + name string + selectHeader string + expectedContent string + }{ + { + name: "Select tab1", + selectHeader: "tab1", + expectedContent: "Tab 1 Content", + }, + { + name: "Select tab2", + selectHeader: "tab2", + expectedContent: "Tab 2 Content", + }, + { + name: "Select default", + selectHeader: "", + expectedContent: "Default Tab Content", + }, + { + name: "Invalid selection", + selectHeader: "invalid", + expectedContent: "selected partial 'invalid' not found in parent 'content'", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + req, err := http.NewRequest("GET", server.URL, nil) + if err != nil { + t.Fatalf("Failed to create request: %v", err) + } + + if tc.selectHeader != "" { + req.Header.Set("X-Select", tc.selectHeader) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("Failed to send request: %v", err) + } + defer resp.Body.Close() + + // Read response body + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("Failed to read response body: %v", err) + } + bodyString := string(bodyBytes) + + // Check if the expected content is in the response + if !strings.Contains(bodyString, tc.expectedContent) { + t.Errorf("Expected response to contain %q, but got %q", tc.expectedContent, bodyString) + } + }) + } +} + +func BenchmarkWithSelectMap(b *testing.B) { + fsys := &InMemoryFS{ + Files: map[string]string{ + "index.gohtml": `{{ child "content" }}`, + "content.gohtml": `
{{selection}}
`, + "tab1.gohtml": "Tab 1 Content", + "tab2.gohtml": "Tab 2 Content", + "default.gohtml": "Default Tab Content", + }, + } + + service := NewService(&Config{ + PartialHeader: "X-Partial", + UseCache: false, + }) + layout := service.NewLayout().FS(fsys) + + content := New("content.gohtml"). + ID("content"). + WithSelectMap("default", map[string]*Partial{ + "tab1": New("tab1.gohtml").ID("tab1"), + "tab2": New("tab2.gohtml").ID("tab2"), + "default": New("default.gohtml").ID("default"), + }) + + index := New("index.gohtml") + + layout.Set(content).Wrap(index) + + req := httptest.NewRequest("GET", "/", nil) + + b.ResetTimer() + + for i := 0; i < b.N; i++ { + // Call the function you want to benchmark + _, err := layout.RenderWithRequest(context.Background(), req) + if err != nil { + b.Fatalf("Error rendering: %v", err) + } + } +} + func BenchmarkRenderWithRequest(b *testing.B) { // Setup configuration and service cfg := &Config{ @@ -426,18 +576,6 @@ func BenchmarkRenderWithRequest(b *testing.B) { UseCache: false, } - // Benchmark results before function passing - // with cache : BenchmarkRenderWithRequest-12 169927 6551 ns/op - // without cache : BenchmarkRenderWithRequest-12 51270 22398 ns/op - - // Benchmark results after function passing - // with cache : BenchmarkRenderWithRequest-12 65800 2240 ns/op - // without cache : BenchmarkRenderWithRequest-12 42045 17857 ns/op - - // Benchmark results after function passing and optimization - // with cache : BenchmarkRenderWithRequest-12 530058 2240 ns/op - // without cache : BenchmarkRenderWithRequest-12 65800 17857 ns/op - service := NewService(cfg) fsys := &InMemoryFS{ @@ -457,8 +595,10 @@ func BenchmarkRenderWithRequest(b *testing.B) { "Message": "This is a benchmark test.", }) + index := NewID("index", "templates/index.html") + // Set the content partial in the layout - layout.Set(content) + layout.Set(content).Wrap(index) // Create a sample HTTP request req := httptest.NewRequest("GET", "/", nil) diff --git a/service.go b/service.go index 5610c2d..6863912 100644 --- a/service.go +++ b/service.go @@ -12,6 +12,10 @@ import ( var ( // defaultPartialHeader is the default header used to determine which partial to render. defaultPartialHeader = "X-Partial" + // defaultSelectHeader is the default header used to determine which partial to select. + defaultSelectHeader = "X-Select" + // defaultActionHeader is the default header used to determine which action to take. + defaultActionHeader = "X-Action" ) type ( @@ -22,6 +26,8 @@ type ( Config struct { PartialHeader string + SelectHeader string + ActionHeader string UseCache bool FuncMap template.FuncMap Logger Logger @@ -41,7 +47,10 @@ type ( content *Partial wrapper *Partial data map[string]any - requestHeader string + requestedPartial string + requestedAction string + requestedSelect string + request *http.Request combinedFunctions template.FuncMap funcMapLock sync.RWMutex // Add a read-write mutex } @@ -57,6 +66,14 @@ func NewService(cfg *Config) *Service { cfg.PartialHeader = defaultPartialHeader } + if cfg.SelectHeader == "" { + cfg.SelectHeader = defaultSelectHeader + } + + if cfg.ActionHeader == "" { + cfg.ActionHeader = defaultActionHeader + } + if cfg.Logger == nil { cfg.Logger = slog.Default().WithGroup("partial") } @@ -168,18 +185,16 @@ func (l *Layout) getFuncMap() template.FuncMap { // RenderWithRequest renders the partial with the given http.Request. func (l *Layout) RenderWithRequest(ctx context.Context, r *http.Request) (template.HTML, error) { - // get partial header from request - header := r.Header.Get(l.service.config.PartialHeader) - // add header to data - l.requestHeader = header + l.requestedPartial = r.Header.Get(l.service.config.PartialHeader) + l.requestedAction = r.Header.Get(l.service.config.ActionHeader) + l.requestedSelect = r.Header.Get(l.service.config.SelectHeader) + l.request = r if l.wrapper != nil { - l.wrapper.requestHeader = l.requestHeader l.wrapper.With(l.content) // Render the wrapper return l.wrapper.RenderWithRequest(ctx, r) } else { - l.content.requestHeader = l.requestHeader // Render the content directly return l.content.RenderWithRequest(ctx, r) } @@ -221,6 +236,9 @@ func (l *Layout) applyConfigToPartial(p *Partial) { p.useCache = l.service.config.UseCache p.globalData = l.service.data p.layoutData = l.data + p.request = l.request p.partialHeader = l.service.config.PartialHeader - p.requestHeader = l.requestHeader + p.selectHeader = l.service.config.SelectHeader + p.actionHeader = l.service.config.ActionHeader + p.requestedPartial = l.requestedPartial } diff --git a/template_functions.go b/template_functions.go index 3460eeb..7e651ef 100644 --- a/template_functions.go +++ b/template_functions.go @@ -144,3 +144,90 @@ func parseDate(layout, value string) (time.Time, error) { func debug(v any) string { return fmt.Sprintf("%+v", v) } + +func selectionFunc(p *Partial, data *Data) func() template.HTML { + return func() template.HTML { + var selectedPartial *Partial + + partials := p.getSelectionPartials() + if partials == nil { + p.getLogger().Error("no selection partials found", "id", p.id) + return template.HTML(fmt.Sprintf("no selection partials found in parent '%s'", p.id)) + } + + requestedSelect := p.getRequestedSelect() + if requestedSelect != "" { + selectedPartial = partials[requestedSelect] + } else { + selectedPartial = partials[p.selection.Default] + } + + if selectedPartial == nil { + p.getLogger().Error("selected partial not found", "id", requestedSelect, "parent", p.id) + return template.HTML(fmt.Sprintf("selected partial '%s' not found in parent '%s'", requestedSelect, p.id)) + } + + selectedPartial.fs = p.fs + selectedPartial.parent = p + + html, err := selectedPartial.renderSelf(data.Ctx, p.getRequest()) + if err != nil { + p.getLogger().Error("error rendering selected partial", "id", requestedSelect, "parent", p.id, "error", err) + return template.HTML(fmt.Sprintf("error rendering selected partial '%s'", requestedSelect)) + } + + return html + } +} + +func childFunc(p *Partial, data *Data) func(id string, vals ...any) template.HTML { + return func(id string, vals ...any) template.HTML { + if len(vals) > 0 && len(vals)%2 != 0 { + p.getLogger().Warn("invalid child data for partial, they come in key-value pairs", "id", id) + return template.HTML(fmt.Sprintf("invalid child data for partial '%s'", id)) + } + + d := make(map[string]any) + for i := 0; i < len(vals); i += 2 { + key, ok := vals[i].(string) + if !ok { + p.getLogger().Warn("invalid child data key for partial, it must be a string", "id", id, "key", vals[i]) + return template.HTML(fmt.Sprintf("invalid child data key for partial '%s', want string, got %T", id, vals[i])) + } + d[key] = vals[i+1] + } + + html, err := p.renderChildPartial(data.Ctx, id, d) + if err != nil { + p.getLogger().Error("error rendering partial", "id", id, "error", err) + // Handle error: you can log it and return an empty string or an error message + return template.HTML(fmt.Sprintf("error rendering partial '%s': %v", id, err)) + } + + return html + } +} + +func actionFunc(p *Partial, data *Data) func() template.HTML { + return func() template.HTML { + if p.templateAction == nil { + p.getLogger().Error("no action callback found", "id", p.id) + return template.HTML(fmt.Sprintf("no action callback found in partial '%s'", p.id)) + } + + // Use the selector to get the appropriate partial + actionPartial, err := p.templateAction(data.Ctx, data) + if err != nil { + p.getLogger().Error("error in selector function", "error", err) + return template.HTML(fmt.Sprintf("error in action function: %v", err)) + } + + // Render the selected partial instead + html, err := actionPartial.renderSelf(data.Ctx, p.getRequest()) + if err != nil { + p.getLogger().Error("error rendering action partial", "error", err) + return template.HTML(fmt.Sprintf("error rendering action partial: %v", err)) + } + return html + } +}