diff --git a/.circleci/config.yml b/.circleci/config.yml index 4ca2fcb0f1..de7cc6f0ea 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -110,6 +110,20 @@ jobs: cd mattermost-server make app-layers if [[ -n $(git status --porcelain) ]]; then echo "Please update the app layers using make app-layers"; exit 1; fi + + check-generate-worktemplates: + docker: + - image: cimg/go:1.18 + resource_class: medium + working_directory: /mnt/ramdisk + steps: + - attach_workspace: + at: /mnt/ramdisk + - run: + command: | + cd mattermost-server + make generate-worktemplates + if [[ -n $(git status --porcelain) ]]; then echo "Please update the worktemplates using make generate-worktemplates"; exit 1; fi check-go-mod-tidy: docker: - image: cimg/go:1.18 @@ -501,6 +515,9 @@ workflows: - check-app-layers: requires: - setup-multi-product-repositories + - check-generate-worktemplates: + requires: + - setup-multi-product-repositories - check-store-layers: requires: - setup-multi-product-repositories @@ -527,6 +544,7 @@ workflows: - check-go-mod-tidy - check-golangci-lint - check-app-layers + - check-generate-worktemplates - check-store-layers - check-mocks - check-email-templates @@ -541,6 +559,7 @@ workflows: - check-go-mod-tidy - check-golangci-lint - check-app-layers + - check-generate-worktemplates - check-store-layers - check-mocks - check-email-templates @@ -558,6 +577,7 @@ workflows: - check-go-mod-tidy - check-golangci-lint - check-app-layers + - check-generate-worktemplates - check-store-layers - check-mocks - check-email-templates @@ -576,6 +596,7 @@ workflows: - check-go-mod-tidy - check-golangci-lint - check-app-layers + - check-generate-worktemplates - check-store-layers - check-mocks - check-email-templates @@ -594,6 +615,7 @@ workflows: - check-go-mod-tidy - check-golangci-lint - check-app-layers + - check-generate-worktemplates - check-store-layers - check-mocks - check-email-templates @@ -608,6 +630,7 @@ workflows: - check-go-mod-tidy - check-golangci-lint - check-app-layers + - check-generate-worktemplates - check-store-layers - check-mocks - check-email-templates @@ -660,6 +683,9 @@ workflows: - check-app-layers: requires: - setup-multi-product-repositories + - check-generate-worktemplates: + requires: + - setup-multi-product-repositories - check-store-layers: requires: - setup-multi-product-repositories @@ -690,6 +716,7 @@ workflows: - check-go-mod-tidy - check-golangci-lint - check-app-layers + - check-generate-worktemplates - check-store-layers - check-mocks - check-email-templates @@ -704,6 +731,7 @@ workflows: - check-go-mod-tidy - check-golangci-lint - check-app-layers + - check-generate-worktemplates - check-store-layers - check-mocks - check-email-templates @@ -721,6 +749,7 @@ workflows: - check-go-mod-tidy - check-golangci-lint - check-app-layers + - check-generate-worktemplates - check-store-layers - check-mocks - check-email-templates @@ -737,6 +766,7 @@ workflows: - check-go-mod-tidy - check-golangci-lint - check-app-layers + - check-generate-worktemplates - check-store-layers - check-mocks - check-email-templates @@ -753,6 +783,7 @@ workflows: - check-go-mod-tidy - check-golangci-lint - check-app-layers + - check-generate-worktemplates - check-store-layers - check-mocks - check-email-templates diff --git a/Makefile b/Makefile index 1eed321800..d0eb7575a1 100644 --- a/Makefile +++ b/Makefile @@ -349,6 +349,9 @@ telemetry-mocks: ## Creates mock files. store-layers: ## Generate layers for the store $(GO) generate $(GOFLAGS) ./store +generate-worktemplates: ## Generate work templates + $(GO) generate $(GOFLAGS) ./app/worktemplates + new-migration: ## Creates a new migration. Run with make new-migration name=<> $(GO) install github.com/mattermost/morph/cmd/morph@master @echo "Generating new migration for mysql" diff --git a/api4/api.go b/api4/api.go index d94e2d450b..b50f119e19 100644 --- a/api4/api.go +++ b/api4/api.go @@ -140,6 +140,8 @@ type Routes struct { Usage *mux.Router // 'api/v4/usage' + WorkTemplates *mux.Router // 'api/v4/worktemplates' + HostedCustomer *mux.Router // 'api/v4/hosted_customer' Drafts *mux.Router // 'api/v4/drafts' @@ -269,6 +271,8 @@ func Init(srv *app.Server) (*API, error) { api.BaseRoutes.Usage = api.BaseRoutes.APIRoot.PathPrefix("/usage").Subrouter() + api.BaseRoutes.WorkTemplates = api.BaseRoutes.APIRoot.PathPrefix("/worktemplates").Subrouter() + api.BaseRoutes.HostedCustomer = api.BaseRoutes.APIRoot.PathPrefix("/hosted_customer").Subrouter() api.BaseRoutes.Drafts = api.BaseRoutes.APIRoot.PathPrefix("/drafts").Subrouter() @@ -316,6 +320,7 @@ func Init(srv *app.Server) (*API, error) { api.InitExport() api.InitInsights() api.InitUsage() + api.InitWorkTemplate() api.InitHostedCustomer() api.InitDrafts() if err := api.InitGraphQL(); err != nil { diff --git a/api4/worktemplates.go b/api4/worktemplates.go new file mode 100644 index 0000000000..37af4bfdfb --- /dev/null +++ b/api4/worktemplates.go @@ -0,0 +1,67 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package api4 + +import ( + "encoding/json" + "net/http" + + "github.com/mattermost/mattermost-server/v6/model" +) + +func (api *API) InitWorkTemplate() { + api.BaseRoutes.WorkTemplates.Handle("/categories", api.APISessionRequired(needsWorkTemplateFeatureFlag(getWorkTemplateCategories))).Methods("GET") + api.BaseRoutes.WorkTemplates.Handle("/categories/{category}/templates", api.APISessionRequired(needsWorkTemplateFeatureFlag(getWorkTemplates))).Methods("GET") +} + +func needsWorkTemplateFeatureFlag(h handlerFunc) handlerFunc { + return func(c *Context, w http.ResponseWriter, r *http.Request) { + if !c.App.Config().FeatureFlags.WorkTemplate { + http.NotFound(w, r) + return + } + + h(c, w, r) + } +} + +func getWorkTemplateCategories(c *Context, w http.ResponseWriter, r *http.Request) { + t := c.AppContext.GetT() + + categories, appErr := c.App.GetWorkTemplateCategories(t) + if appErr != nil { + c.Err = appErr + return + } + + b, err := json.Marshal(categories) + if err != nil { + c.Err = model.NewAppError("getWorkTemplateCategories", "api.marshal_error", nil, err.Error(), http.StatusInternalServerError) + return + } + + w.Write(b) +} + +func getWorkTemplates(c *Context, w http.ResponseWriter, r *http.Request) { + c.RequireCategory() + if c.Err != nil { + return + } + t := c.AppContext.GetT() + + workTemplates, appErr := c.App.GetWorkTemplates(c.Params.Category, c.App.Config().FeatureFlags.ToMap(), t) + if appErr != nil { + c.Err = appErr + return + } + + b, err := json.Marshal(workTemplates) + if err != nil { + c.Err = model.NewAppError("getWorkTemplates", "api.marshal_error", nil, err.Error(), http.StatusInternalServerError) + return + } + + w.Write(b) +} diff --git a/api4/worktemplates_test.go b/api4/worktemplates_test.go new file mode 100644 index 0000000000..6f919d321c --- /dev/null +++ b/api4/worktemplates_test.go @@ -0,0 +1,110 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package api4 + +import ( + "os" + "testing" + + "github.com/mattermost/mattermost-server/v6/app/worktemplates" + "github.com/stretchr/testify/require" +) + +func TestWorkTemplateCategories(t *testing.T) { + // Setup + cleanup := setupWorktemplateFeatureFlag(t) + defer cleanup() + + th := Setup(t).InitBasic() + defer th.TearDown() + assert := require.New(t) + + worktemplates.OrderedWorkTemplateCategories = []*worktemplates.WorkTemplateCategory{ + { + ID: "test-category", + Name: "Test Category", + }, + { + ID: "test-category-2", + Name: "Test Category 2", + }, + } + + // Act + categories, _, clientErr := th.Client.GetWorktemplateCategories() + + // Assert + require.NoError(t, clientErr) + require.Len(t, categories, 2) + assert.Equal("test-category", categories[0].ID) + assert.Equal("test-category-2", categories[1].ID) +} + +func TestGetWorkTemplatesByCategory(t *testing.T) { + // Setup + cleanup := setupWorktemplateFeatureFlag(t) + defer cleanup() + + th := Setup(t).InitBasic() + defer th.TearDown() + assert := require.New(t) + + worktemplates.OrderedWorkTemplateCategories = []*worktemplates.WorkTemplateCategory{ + { + ID: "test-category", + Name: "Test Category", + }, + { + ID: "test-category-2", + Name: "Test Category 2", + }, + } + + worktemplates.OrderedWorkTemplates = []*worktemplates.WorkTemplate{ + { + ID: "test-template", + Category: "test-category", + UseCase: "Test Template", + }, + { + ID: "test-template-2", + Category: "test-category", + UseCase: "Test Template 2", + }, + { // This one should not be returned because of the feature flag + ID: "test-template-3", + Category: "test-category", + UseCase: "Test Template 3", + FeatureFlag: &worktemplates.FeatureFlag{ + Name: "random-nonexistant-feature-flag", + Value: "true", + }, + }, + { // this one should not be returned because of the category + ID: "test-template-4", + Category: "test-category-2", + UseCase: "Test Template 4", + }, + } + + // Act + workTemplates, _, clientErr := th.Client.GetWorkTemplatesByCategory("test-category") + + // Assert + assert.NoError(clientErr, "error while retrieve worktemplates list") + assert.Len(workTemplates, 2) + assert.Equal("test-template", workTemplates[0].ID) + assert.Equal("test-template-2", workTemplates[1].ID) +} + +func setupWorktemplateFeatureFlag(t *testing.T) func() { + t.Helper() + + oldFFValue := os.Getenv("MM_FEATUREFLAGS_WORKTEMPLATE") + os.Setenv("MM_FEATUREFLAGS_WORKTEMPLATE", "true") + + return func() { + os.Setenv("MM_FEATUREFLAGS_WORKTEMPLATE", oldFFValue) + } +} diff --git a/app/app_iface.go b/app/app_iface.go index 0ce3e27e33..aca9f1e795 100644 --- a/app/app_iface.go +++ b/app/app_iface.go @@ -850,6 +850,8 @@ type AppIface interface { GetViewUsersRestrictions(userID string) (*model.ViewUsersRestrictions, *model.AppError) GetWarnMetricsBot() (*model.Bot, *model.AppError) GetWarnMetricsStatus() (map[string]*model.WarnMetricStatus, *model.AppError) + GetWorkTemplateCategories(t i18n.TranslateFunc) ([]*model.WorkTemplateCategory, *model.AppError) + GetWorkTemplates(category string, featureFlags map[string]string, t i18n.TranslateFunc) ([]*model.WorkTemplate, *model.AppError) HTTPService() httpservice.HTTPService Handle404(w http.ResponseWriter, r *http.Request) HandleCommandResponse(c request.CTX, command *model.Command, args *model.CommandArgs, response *model.CommandResponse, builtIn bool) (*model.CommandResponse, *model.AppError) diff --git a/app/opentracing/opentracing_layer.go b/app/opentracing/opentracing_layer.go index 311543fb78..35664d638c 100644 --- a/app/opentracing/opentracing_layer.go +++ b/app/opentracing/opentracing_layer.go @@ -11214,6 +11214,50 @@ func (a *OpenTracingAppLayer) GetWarnMetricsStatus() (map[string]*model.WarnMetr return resultVar0, resultVar1 } +func (a *OpenTracingAppLayer) GetWorkTemplateCategories(t i18n.TranslateFunc) ([]*model.WorkTemplateCategory, *model.AppError) { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetWorkTemplateCategories") + + a.ctx = newCtx + a.app.Srv().Store().SetContext(newCtx) + defer func() { + a.app.Srv().Store().SetContext(origCtx) + a.ctx = origCtx + }() + + defer span.Finish() + resultVar0, resultVar1 := a.app.GetWorkTemplateCategories(t) + + if resultVar1 != nil { + span.LogFields(spanlog.Error(resultVar1)) + ext.Error.Set(span, true) + } + + return resultVar0, resultVar1 +} + +func (a *OpenTracingAppLayer) GetWorkTemplates(category string, featureFlags map[string]string, t i18n.TranslateFunc) ([]*model.WorkTemplate, *model.AppError) { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetWorkTemplates") + + a.ctx = newCtx + a.app.Srv().Store().SetContext(newCtx) + defer func() { + a.app.Srv().Store().SetContext(origCtx) + a.ctx = origCtx + }() + + defer span.Finish() + resultVar0, resultVar1 := a.app.GetWorkTemplates(category, featureFlags, t) + + if resultVar1 != nil { + span.LogFields(spanlog.Error(resultVar1)) + ext.Error.Set(span, true) + } + + return resultVar0, resultVar1 +} + func (a *OpenTracingAppLayer) Handle404(w http.ResponseWriter, r *http.Request) { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.Handle404") diff --git a/app/worktemplates.go b/app/worktemplates.go new file mode 100644 index 0000000000..e52c05a7aa --- /dev/null +++ b/app/worktemplates.go @@ -0,0 +1,52 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +import ( + "net/http" + + "github.com/mattermost/mattermost-server/v6/app/worktemplates" + "github.com/mattermost/mattermost-server/v6/model" + "github.com/mattermost/mattermost-server/v6/shared/i18n" +) + +func (a *App) GetWorkTemplateCategories(t i18n.TranslateFunc) ([]*model.WorkTemplateCategory, *model.AppError) { + categories, err := worktemplates.ListCategories() + if err != nil { + return nil, model.NewAppError("GetWorkTemplateCategories", "app.worktemplates.get_categories.app_error", nil, err.Error(), http.StatusInternalServerError) + } + + modelCategories := make([]*model.WorkTemplateCategory, len(categories)) + for i := range categories { + modelCategories[i] = &model.WorkTemplateCategory{ + ID: categories[i].ID, + Name: t(categories[i].Name), + } + } + + return modelCategories, nil +} + +func (a *App) GetWorkTemplates(category string, featureFlags map[string]string, t i18n.TranslateFunc) ([]*model.WorkTemplate, *model.AppError) { + templates, err := worktemplates.ListByCategory(category) + if err != nil { + return nil, model.NewAppError("GetWorkTemplates", "app.worktemplates.get_templates.app_error", nil, err.Error(), http.StatusInternalServerError) + } + + // filter out templates that are not enabled by feature Flag + enabledTemplates := []*model.WorkTemplate{} + for _, template := range templates { + mTemplate := template.ToModelWorkTemplate(t) + if template.FeatureFlag == nil { + enabledTemplates = append(enabledTemplates, mTemplate) + continue + } + + if featureFlags[template.FeatureFlag.Name] == template.FeatureFlag.Value { + enabledTemplates = append(enabledTemplates, mTemplate) + } + } + + return enabledTemplates, nil +} diff --git a/app/worktemplates/categories.yaml b/app/worktemplates/categories.yaml new file mode 100644 index 0000000000..ab76f31f1a --- /dev/null +++ b/app/worktemplates/categories.yaml @@ -0,0 +1,2 @@ +- id: product_teams + name: worktemplate.category.product_teams diff --git a/app/worktemplates/generator/main.go b/app/worktemplates/generator/main.go new file mode 100644 index 0000000000..14a447b09a --- /dev/null +++ b/app/worktemplates/generator/main.go @@ -0,0 +1,154 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package main + +import ( + "bytes" + "crypto/md5" + _ "embed" + "fmt" + "html/template" + "io" + "log" + "os" + "path" + + "github.com/mattermost/mattermost-server/v6/app/worktemplates" + "github.com/pkg/errors" + "golang.org/x/tools/imports" + "gopkg.in/yaml.v3" +) + +type WorkTemplateWithMD5 struct { + worktemplates.WorkTemplate + MD5 string +} + +type WorkTemplateCategoryWithMD5 struct { + worktemplates.WorkTemplateCategory `yaml:",inline"` + MD5 string +} + +func getFileContent(filename string) ([]byte, error) { + return os.ReadFile(path.Join(filename)) +} + +func main() { + // parse categories first + dat, err := getFileContent("categories.yaml") + if err != nil { + log.Fatal(errors.Wrap(err, "failed to read categories.yaml")) + } + + h := md5.New() + + cats := []WorkTemplateCategoryWithMD5{} // meow + err = yaml.Unmarshal(dat, &cats) + if err != nil { + log.Fatal(errors.Wrap(err, "failed to unmarshal categories.yaml")) + } + + // validate categories + categoryIds := map[string]struct{}{} + for id := range cats { + cat := cats[id] + + if cat.ID == "" && cat.Name == "" { + // skip empty array element + continue + } + + if cat.ID == "" { + log.Fatal(errors.New("category ID cannot be empty")) + } + if cat.Name == "" { + log.Fatal(errors.New("category name cannot be empty")) + } + categoryIds[cat.ID] = struct{}{} + + h.Write([]byte(cat.ID)) + cats[id].MD5 = fmt.Sprintf("%x", h.Sum(nil)) + h.Reset() + } + + dat, err = getFileContent("templates.yaml") + if err != nil { + log.Fatal(errors.Wrap(err, "failed to read templates.yaml")) + } + + dec := yaml.NewDecoder(bytes.NewReader(dat)) + ts := []WorkTemplateWithMD5{} + for { + t := worktemplates.WorkTemplate{} + err = dec.Decode(&t) + if err != nil { + if err == io.EOF { + break + } + log.Fatal(err) + } + if t.ID == "" { + continue + } + + h.Write([]byte(t.ID)) + err = t.Validate(categoryIds) + if err != nil { + log.Fatal(errors.Wrap(err, "failed to validate template")) + } + + ts = append(ts, WorkTemplateWithMD5{ + WorkTemplate: t, + MD5: fmt.Sprintf("%x", h.Sum(nil)), + }) + h.Reset() + } + + code := bytes.NewBuffer(nil) + tmpl, err := template.New("worktemplates").Parse(tpl) + if err != nil { + log.Fatal(err) + } + tmpl.Execute(code, struct { + Templates []WorkTemplateWithMD5 + Categories []WorkTemplateCategoryWithMD5 + }{ + Templates: ts, + Categories: cats, + }) + + formattedCode, err := imports.Process(path.Join("worktemplate_generated.go"), code.Bytes(), &imports.Options{Comments: true}) + if err != nil { + log.Fatal(errors.Wrap(err, "failed to format code")) + } + + err = os.WriteFile(path.Join("worktemplate_generated.go"), formattedCode, 0644) + if err != nil { + log.Fatal(err) + } + + // print all translatable content + fmt.Println("\nTranslation helpers:\n====================") + for _, t := range ts { + translationHelper(t.Description.Channel) + translationHelper(t.Description.Board) + translationHelper(t.Description.Playbook) + translationHelper(t.Description.Integration) + } +} + +var translationHelperTemplate = `{ + "id": %q, + "translation": %q +},` + +func translationHelper(t *worktemplates.TranslatableString) { + if t != nil && t.ID != "" && t.DefaultMessage != "" { + fmt.Printf(translationHelperTemplate, t.ID, t.DefaultMessage) + fmt.Println("") + } +} + +//go:embed worktemplate.tmpl +var tpl string diff --git a/app/worktemplates/generator/worktemplate.tmpl b/app/worktemplates/generator/worktemplate.tmpl new file mode 100644 index 0000000000..60f40b5301 --- /dev/null +++ b/app/worktemplates/generator/worktemplate.tmpl @@ -0,0 +1,101 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +// Code generated by "make generate-worktemplates" +// DO NOT EDIT + +package worktemplates + +func init() { + {{- range .Categories}} + registerWorkTemplateCategory("{{.ID}}", wtc{{.MD5}}) + {{- end -}} + {{range .Templates}} + registerWorkTemplate("{{.ID}}", wt{{.MD5}}) + {{- end}} + + // Register categories strings + {{range .Categories -}} + _ = T("{{.Name}}") + {{end}} + + // Register translation strings + {{range .Templates -}} + {{if and (.Description.Channel) (ne .Description.Channel.ID "")}}_ = T("{{.Description.Channel.ID}}") + {{end -}} + {{if and (.Description.Board) (ne .Description.Board.ID "")}}_ = T("{{.Description.Board.ID}}") + {{end -}} + {{if and (.Description.Playbook) (ne .Description.Playbook.ID "")}}_ = T("{{.Description.Playbook.ID}}") + {{end -}} + {{if and (.Description.Integration) (ne .Description.Integration.ID "")}}_ = T("{{.Description.Integration.ID}}") + {{end -}} + {{end -}} +} + +{{range .Categories}} +var wtc{{.MD5}} = &WorkTemplateCategory{ + ID: "{{.ID}}", + Name: "{{.Name}}", +} +{{end}} + +{{range .Templates}} +var wt{{.MD5}} = &WorkTemplate{ + ID: "{{.ID}}", + Category: "{{.Category}}", + UseCase: "{{.UseCase}}", + Illustration: "{{.Illustration}}", + Visibility: "{{.Visibility}}", + {{if .FeatureFlag}}FeatureFlag: &FeatureFlag{ + Name: "{{.FeatureFlag.Name}}", + Value: "{{.FeatureFlag.Value}}", + },{{end}} + Description: Description{ + {{if .Description.Channel}}Channel: &TranslatableString{ + ID: "{{.Description.Channel.ID}}", + DefaultMessage: "{{.Description.Channel.DefaultMessage}}", + Illustration: "{{.Description.Channel.Illustration}}", + },{{end}} + {{if .Description.Board}}Board: &TranslatableString{ + ID: "{{.Description.Board.ID}}", + DefaultMessage: "{{.Description.Board.DefaultMessage}}", + Illustration: "{{.Description.Board.Illustration}}", + },{{end}} + {{if .Description.Playbook}}Playbook: &TranslatableString{ + ID: "{{.Description.Playbook.ID}}", + DefaultMessage: "{{.Description.Playbook.DefaultMessage}}", + Illustration: "{{.Description.Playbook.Illustration}}", + },{{end}} + {{if .Description.Integration}}Integration: &TranslatableString{ + ID: "{{.Description.Integration.ID}}", + DefaultMessage: "{{.Description.Integration.DefaultMessage}}", + Illustration: "{{.Description.Integration.Illustration}}", + },{{end}} + }, + Content: []Content{ + {{range .Content}}{ + {{if .Channel}}Channel: &Channel{ + ID: "{{.Channel.ID}}", + Name: "{{.Channel.Name}}", + Purpose: "{{.Channel.Purpose}}", + Playbook: "{{.Channel.Playbook}}", + Illustration: "{{.Channel.Illustration}}", + },{{end}}{{if .Board}}Board: &Board{ + ID: "{{.Board.ID}}", + Template: "{{.Board.Template}}", + Name: "{{.Board.Name}}", + Channel: "{{.Board.Channel}}", + Illustration: "{{.Board.Illustration}}", + },{{end}}{{if .Playbook}}Playbook: &Playbook{ + Template: "{{.Playbook.Template}}", + Name: "{{.Playbook.Name}}", + ID: "{{.Playbook.ID}}", + Illustration: "{{.Playbook.Illustration}}", + },{{end}}{{if .Integration}}Integration: &Integration{ + ID: "{{.Integration.ID}}", + },{{end}} + }, + {{end}} + }, +} +{{end}} diff --git a/app/worktemplates/templates.yaml b/app/worktemplates/templates.yaml new file mode 100644 index 0000000000..bf188cae73 --- /dev/null +++ b/app/worktemplates/templates.yaml @@ -0,0 +1,46 @@ +id: "product_teams/feature_release:v1" +category: product_teams +useCase: Feature Release +illustration: https://via.placeholder.com/204x123.png +visibility: public +description: + channel: + id: "worktemplate.product_teams.feature_release.description.channel" + defaultMessage: "Chat with your team in a Feature Release channel that connects easily with your boards, playbooks and app bots." + board: + id: "worktemplate.product_teams.feature_release.description.board" + defaultMessage: "Use our Meeting Agenda board template for recurring meetings like standup and our Project Tasks board to manage the progress of tasks along the way." + playbook: + id: "worktemplate.product_teams.feature_release.description.playbook" + defaultMessage: "Create transparent workflows across development teams to ensure your feature development process is seamless." + integration: + id: "worktemplate.product_teams.feature_release.description.integration" + defaultMessage: "Increase productivity in your channel by integrating a Jira bot and Github bot. These will be downloaded for you." + illustration: "https://via.placeholder.com/509x352.png?text=Integrations" +content: + - channel: + id: feature-release + name: Feature Release + playbook: product-release-playbook # playbook id. if set the channel will be created by the playbook run. + illustration: "https://via.placeholder.com/509x352.png?text=Channel+feature+release" + - board: + id: "board-meeting-agenda" + template: "meeting agenda|bwps66irhr7b9dxgayf9kz33g5o" # <-- have to find a way to target the board template... could hardcode the ids but need to verify that they don't change? + name: Meeting Agenda + channel: feature-release # <-- optional. we use the channel "id" from above + illustration: "https://via.placeholder.com/509x352.png?text=Board+meeting+agenda" + - board: + id: "board-project-task" + template: "project task|bmttiziw35irgtmztewd9upyqdy" + name: project task board + channel: feature-release + illustration: "https://via.placeholder.com/509x352.png?text=Board+project+task" + - playbook: + template: "product release" # <-- playbooks templates don't have ids, have to rely on name + name: "Feature release" + id: product-release-playbook + illustration: "https://via.placeholder.com/509x352.png?text=Playbook+feature+release" + - integration: + id: jira + - integration: + id: github diff --git a/app/worktemplates/types.go b/app/worktemplates/types.go new file mode 100644 index 0000000000..162f569053 --- /dev/null +++ b/app/worktemplates/types.go @@ -0,0 +1,342 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package worktemplates + +import ( + "fmt" + + "github.com/mattermost/mattermost-server/v6/model" + "github.com/mattermost/mattermost-server/v6/shared/i18n" + "github.com/pkg/errors" +) + +type WorkTemplateCategory struct { + ID string `yaml:"id"` + Name string `yaml:"name"` +} + +type WorkTemplate struct { + ID string `yaml:"id"` + Category string `yaml:"category"` + UseCase string `yaml:"useCase"` + Illustration string `yaml:"illustration"` + Visibility string `yaml:"visibility"` + FeatureFlag *FeatureFlag `yaml:"featureFlag,omitempty"` + Description Description `yaml:"description"` + Content []Content `yaml:"content"` +} + +func (wt WorkTemplate) ToModelWorkTemplate(t i18n.TranslateFunc) *model.WorkTemplate { + mwt := &model.WorkTemplate{ + ID: wt.ID, + Category: wt.Category, + UseCase: wt.UseCase, + Illustration: wt.Illustration, + Visibility: wt.Visibility, + } + + if wt.FeatureFlag != nil { + mwt.FeatureFlag = &model.WorkTemplateFeatureFlag{ + Name: wt.FeatureFlag.Name, + Value: wt.FeatureFlag.Value, + } + } + + if wt.Description.Channel != nil { + mwt.Description.Channel = &model.DescriptionContent{ + Message: wt.Description.Channel.Translate(t), + Illustration: wt.Description.Channel.Illustration, + } + } + + if wt.Description.Board != nil { + mwt.Description.Board = &model.DescriptionContent{ + Message: wt.Description.Board.Translate(t), + Illustration: wt.Description.Board.Illustration, + } + } + + if wt.Description.Playbook != nil { + mwt.Description.Playbook = &model.DescriptionContent{ + Message: wt.Description.Playbook.Translate(t), + Illustration: wt.Description.Playbook.Illustration, + } + } + + if wt.Description.Integration != nil { + mwt.Description.Integration = &model.DescriptionContent{ + Message: wt.Description.Integration.Translate(t), + Illustration: wt.Description.Integration.Illustration, + } + } + + for _, content := range wt.Content { + if content.Channel != nil { + mwt.Content = append(mwt.Content, model.WorkTemplateContent{ + Channel: &model.WorkTemplateChannel{ + ID: content.Channel.ID, + Name: content.Channel.Name, + Purpose: content.Channel.Purpose, + Playbook: content.Channel.Playbook, + Illustration: content.Channel.Illustration, + }, + }) + } + if content.Board != nil { + mwt.Content = append(mwt.Content, model.WorkTemplateContent{ + Board: &model.WorkTemplateBoard{ + ID: content.Board.ID, + Name: content.Board.Name, + Template: content.Board.Template, + Channel: content.Board.Channel, + Illustration: content.Board.Illustration, + }, + }) + } + if content.Playbook != nil { + mwt.Content = append(mwt.Content, model.WorkTemplateContent{ + Playbook: &model.WorkTemplatePlaybook{ + ID: content.Playbook.ID, + Name: content.Playbook.Name, + Template: content.Playbook.Template, + Illustration: content.Playbook.Illustration, + }, + }) + } + if content.Integration != nil { + mwt.Content = append(mwt.Content, model.WorkTemplateContent{ + Integration: &model.WorkTemplateIntegration{ + ID: content.Integration.ID, + }, + }) + } + } + + return mwt +} + +func (wt WorkTemplate) Validate(categoryIds map[string]struct{}) error { + if wt.ID == "" { + return errors.New("id is required") + } + if wt.Category == "" { + return errors.New("category is required") + } + if _, ok := categoryIds[wt.Category]; !ok { + return fmt.Errorf("category %s does not exist", wt.Category) + } + if wt.UseCase == "" { + return errors.New("useCase is required") + } + if wt.Illustration == "" { + return errors.New("illustration is required") + } + if wt.Visibility == "" { + return errors.New("visibility is required") + } + hasChannel := false + hasBoard := false + hasPlaybook := false + hasIntegration := false + foundChannels := map[string]struct{}{} + foundPlaybooks := map[string]struct{}{} + foundBoards := map[string]struct{}{} + foundIntegrations := map[string]struct{}{} + mustHaveChannels := []string{} + mustHavePlaybooks := []string{} + + currentIdx := 0 + for _, content := range wt.Content { + if content.Channel != nil { + hasChannel = true + if cErr := content.Channel.Validate(); cErr != nil { + return wrapContentError(cErr, currentIdx) + } + if _, ok := foundChannels[content.Channel.ID]; ok { + return wrapContentError(fmt.Errorf("duplicate channel %s found", content.Channel.ID), currentIdx) + } + foundChannels[content.Channel.ID] = struct{}{} + + if content.Channel.Playbook != "" { + mustHavePlaybooks = append(mustHavePlaybooks, content.Channel.Playbook) + } + } + + if content.Board != nil { + hasBoard = true + if cErr := content.Board.Validate(); cErr != nil { + return wrapContentError(cErr, currentIdx) + } + if _, ok := foundBoards[content.Board.ID]; ok { + return wrapContentError(fmt.Errorf("duplicate board %s found", content.Board.ID), currentIdx) + } + foundBoards[content.Board.ID] = struct{}{} + + if content.Board.Channel != "" { + mustHaveChannels = append(mustHaveChannels, content.Board.Channel) + } + } + if content.Playbook != nil { + hasPlaybook = true + if cErr := content.Playbook.Validate(); cErr != nil { + return wrapContentError(cErr, currentIdx) + } + if _, ok := foundPlaybooks[content.Playbook.ID]; ok { + return wrapContentError(fmt.Errorf("duplicate playbook %s found", content.Playbook.ID), currentIdx) + } + foundPlaybooks[content.Playbook.ID] = struct{}{} + } + if content.Integration != nil { + hasIntegration = true + if cErr := content.Integration.Validate(); cErr != nil { + return wrapContentError(cErr, currentIdx) + } + if _, ok := foundIntegrations[content.Integration.ID]; ok { + return wrapContentError(fmt.Errorf("duplicate integration %s found", content.Integration.ID), currentIdx) + } + foundIntegrations[content.Integration.ID] = struct{}{} + } + } + + if hasChannel && wt.Description.Channel == nil { + return errors.New("description.channel is required") + } + if hasBoard && wt.Description.Board == nil { + return errors.New("description.board is required") + } + if hasPlaybook && wt.Description.Playbook == nil { + return errors.New("description.playbook is required") + } + if hasIntegration && wt.Description.Integration == nil { + return errors.New("description.integration is required") + } + + for _, channel := range mustHaveChannels { + if _, ok := foundChannels[channel]; !ok { + return fmt.Errorf("channel %s is required", channel) + } + } + + for _, playbook := range mustHavePlaybooks { + if _, ok := foundPlaybooks[playbook]; !ok { + return fmt.Errorf("playbook %s is required", playbook) + } + } + + return nil +} + +type FeatureFlag struct { + Name string `json:"name"` + Value string `json:"value"` +} + +type TranslatableString struct { + ID string `yaml:"id"` + DefaultMessage string `yaml:"defaultMessage"` + Illustration string `yaml:"illustration"` +} + +func (ts TranslatableString) Translate(t i18n.TranslateFunc) string { + if ts.ID != "" { + msg := t(ts.ID) + if msg != ts.ID && msg != "" { + return msg + } + } + + return ts.DefaultMessage +} + +type Description struct { + Channel *TranslatableString `yaml:"channel"` + Board *TranslatableString `yaml:"board"` + Playbook *TranslatableString `yaml:"playbook"` + Integration *TranslatableString `yaml:"integration"` +} + +type Channel struct { + ID string `yaml:"id"` + Name string `yaml:"name"` + Purpose string `yaml:"purpose"` + Playbook string `yaml:"playbook"` + Illustration string `yaml:"illustration"` +} + +func (c *Channel) Validate() error { + if c.ID == "" { + return errors.New("id is required") + } + if c.Name == "" { + return errors.New("name is required") + } + + return nil +} + +type Board struct { + ID string `yaml:"id"` + Template string `yaml:"template"` + Name string `yaml:"name"` + Channel string `yaml:"channel"` + Illustration string `yaml:"illustration"` +} + +func (b Board) Validate() error { + if b.ID == "" { + return errors.New("id is required") + } + if b.Template == "" { + return errors.New("template is required") + } + if b.Name == "" { + return errors.New("name is required") + } + + return nil +} + +type Playbook struct { + Template string `yaml:"template"` + Name string `yaml:"name"` + ID string `yaml:"id"` + Illustration string `yaml:"illustration"` +} + +func (p *Playbook) Validate() error { + if p.ID == "" { + return errors.New("id is required") + } + if p.Template == "" { + return errors.New("template is required") + } + if p.Name == "" { + return errors.New("name is required") + } + + return nil +} + +type Integration struct { + ID string `yaml:"id"` +} + +func (i *Integration) Validate() error { + if i.ID == "" { + return errors.New("id is required") + } + + return nil +} + +type Content struct { + Channel *Channel `yaml:"channel,omitempty"` + Board *Board `yaml:"board,omitempty"` + Playbook *Playbook `yaml:"playbook,omitempty"` + Integration *Integration `yaml:"integration,omitempty"` +} + +func wrapContentError(err error, index int) error { + return errors.Wrapf(err, "content #%d validation failed", index) +} diff --git a/app/worktemplates/worktemplate_generated.go b/app/worktemplates/worktemplate_generated.go new file mode 100644 index 0000000000..77bd419653 --- /dev/null +++ b/app/worktemplates/worktemplate_generated.go @@ -0,0 +1,104 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +// Code generated by "make generate-worktemplates" +// DO NOT EDIT + +package worktemplates + +func init() { + registerWorkTemplateCategory("product_teams", wtc846b565cd80043537945134a54812e07) + registerWorkTemplate("product_teams/feature_release:v1", wt00a1b44a5831c0a3acb14787b3fdd352) + + // Register categories strings + _ = T("worktemplate.category.product_teams") + + // Register translation strings + _ = T("worktemplate.product_teams.feature_release.description.channel") + _ = T("worktemplate.product_teams.feature_release.description.board") + _ = T("worktemplate.product_teams.feature_release.description.playbook") + _ = T("worktemplate.product_teams.feature_release.description.integration") +} + +var wtc846b565cd80043537945134a54812e07 = &WorkTemplateCategory{ + ID: "product_teams", + Name: "worktemplate.category.product_teams", +} + +var wt00a1b44a5831c0a3acb14787b3fdd352 = &WorkTemplate{ + ID: "product_teams/feature_release:v1", + Category: "product_teams", + UseCase: "Feature Release", + Illustration: "https://via.placeholder.com/204x123.png", + Visibility: "public", + + Description: Description{ + Channel: &TranslatableString{ + ID: "worktemplate.product_teams.feature_release.description.channel", + DefaultMessage: "Chat with your team in a Feature Release channel that connects easily with your boards, playbooks and app bots.", + Illustration: "", + }, + Board: &TranslatableString{ + ID: "worktemplate.product_teams.feature_release.description.board", + DefaultMessage: "Use our Meeting Agenda board template for recurring meetings like standup and our Project Tasks board to manage the progress of tasks along the way.", + Illustration: "", + }, + Playbook: &TranslatableString{ + ID: "worktemplate.product_teams.feature_release.description.playbook", + DefaultMessage: "Create transparent workflows across development teams to ensure your feature development process is seamless.", + Illustration: "", + }, + Integration: &TranslatableString{ + ID: "worktemplate.product_teams.feature_release.description.integration", + DefaultMessage: "Increase productivity in your channel by integrating a Jira bot and Github bot. These will be downloaded for you.", + Illustration: "https://via.placeholder.com/509x352.png?text=Integrations", + }, + }, + Content: []Content{ + { + Channel: &Channel{ + ID: "feature-release", + Name: "Feature Release", + Purpose: "", + Playbook: "product-release-playbook", + Illustration: "https://via.placeholder.com/509x352.png?text=Channel+feature+release", + }, + }, + { + Board: &Board{ + ID: "board-meeting-agenda", + Template: "meeting agenda|bwps66irhr7b9dxgayf9kz33g5o", + Name: "Meeting Agenda", + Channel: "feature-release", + Illustration: "https://via.placeholder.com/509x352.png?text=Board+meeting+agenda", + }, + }, + { + Board: &Board{ + ID: "board-project-task", + Template: "project task|bmttiziw35irgtmztewd9upyqdy", + Name: "project task board", + Channel: "feature-release", + Illustration: "https://via.placeholder.com/509x352.png?text=Board+project+task", + }, + }, + { + Playbook: &Playbook{ + Template: "product release", + Name: "Feature release", + ID: "product-release-playbook", + Illustration: "https://via.placeholder.com/509x352.png?text=Playbook+feature+release", + }, + }, + { + Integration: &Integration{ + ID: "jira", + }, + }, + { + Integration: &Integration{ + ID: "github", + }, + }, + }, +} diff --git a/app/worktemplates/worktemplates.go b/app/worktemplates/worktemplates.go new file mode 100644 index 0000000000..44e7931844 --- /dev/null +++ b/app/worktemplates/worktemplates.go @@ -0,0 +1,37 @@ +//go:generate go run generator/main.go + +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package worktemplates + +var OrderedWorkTemplates = []*WorkTemplate{} +var OrderedWorkTemplateCategories = []*WorkTemplateCategory{} + +// T is a placeholder to allow the translation tool to register the strings +func T(id string) string { + return id +} + +func registerWorkTemplate(id string, wt *WorkTemplate) { + OrderedWorkTemplates = append(OrderedWorkTemplates, wt) +} + +func registerWorkTemplateCategory(id string, wtc *WorkTemplateCategory) { + OrderedWorkTemplateCategories = append(OrderedWorkTemplateCategories, wtc) +} + +func ListCategories() ([]*WorkTemplateCategory, error) { + return OrderedWorkTemplateCategories, nil +} + +func ListByCategory(category string) ([]*WorkTemplate, error) { + wts := []*WorkTemplate{} + for i := range OrderedWorkTemplates { + if OrderedWorkTemplates[i].Category == category { + wts = append(wts, OrderedWorkTemplates[i]) + } + } + + return wts, nil +} diff --git a/app/worktemplates_test.go b/app/worktemplates_test.go new file mode 100644 index 0000000000..eb0342fa99 --- /dev/null +++ b/app/worktemplates_test.go @@ -0,0 +1,125 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/mattermost/mattermost-server/v6/app/worktemplates" +) + +func TestGetWorkTemplateCategories(t *testing.T) { + th := SetupWithStoreMock(t) + defer th.TearDown() + assert := require.New(t) + + worktemplates.OrderedWorkTemplateCategories = wtGetCategories() + + categories, appErr := th.App.GetWorkTemplateCategories(wtTranslationFunc) + assert.Nil(appErr) + assert.Len(categories, 2) + assert.Equal("Translated test.1", categories[0].Name) + assert.Equal("Translated test.2", categories[1].Name) +} + +func TestGetWorkTemplatesByCategory(t *testing.T) { + // Setup + th := SetupWithStoreMock(t) + defer th.TearDown() + assert := require.New(t) + + existingFFkey := "test-feature-flag" + existingFFvalue := "true" + ff := map[string]string{ + existingFFkey: existingFFvalue, + } + + worktemplates.OrderedWorkTemplateCategories = wtGetCategories() + firstCat := worktemplates.OrderedWorkTemplateCategories[0] + worktemplates.OrderedWorkTemplates = []*worktemplates.WorkTemplate{ + { + ID: "test-template", + Category: firstCat.ID, + UseCase: "test use case", + Description: worktemplates.Description{ + Channel: &worktemplates.TranslatableString{ + ID: "test-template-channel-description", + DefaultMessage: "test template channel description", + }, + }, + }, + { // this one should not be returned because of the FF + ID: "test-template-2", + Category: firstCat.ID, + UseCase: "test use case 2", + FeatureFlag: &worktemplates.FeatureFlag{ + Name: "nonexistant-random-test-feature-flag", + Value: "hi", + }, + Description: worktemplates.Description{ + Channel: &worktemplates.TranslatableString{ + ID: "test-template-2-channel-description", + DefaultMessage: "test template 2 channel description", + }, + }, + }, + { // this one should be present and match the FF + ID: "test-template-3", + Category: firstCat.ID, + UseCase: "test use case 3", + FeatureFlag: &worktemplates.FeatureFlag{ + Name: existingFFkey, + Value: existingFFvalue, + }, + Description: worktemplates.Description{ + Channel: &worktemplates.TranslatableString{ + ID: "unknown", // simulating an unknown translation, we return the default message in this case + DefaultMessage: "default message picked for unknown", + }, + }, + }, + { // this one should not be returned because of the category + ID: "test-template-4", + Category: "cat-test2", + UseCase: "test use case 4", + }, + } + + // Act + worktemplates, appErr := th.App.GetWorkTemplates(firstCat.ID, ff, wtTranslationFunc) + + // Assert + assert.Nil(appErr) + assert.Len(worktemplates, 2) + // assert the correct work templates have been returned + assert.Equal("test-template", worktemplates[0].ID) + assert.Equal("test-template-3", worktemplates[1].ID) + // assert the descriptions have been translated + assert.Equal("Translated test-template-channel-description", worktemplates[0].Description.Channel.Message) + assert.Equal("default message picked for unknown", worktemplates[1].Description.Channel.Message) +} + +// helpers +func wtTranslationFunc(id string, args ...interface{}) string { + if id == "unknown" { + return "" + } + + return "Translated " + id +} + +func wtGetCategories() []*worktemplates.WorkTemplateCategory { + return []*worktemplates.WorkTemplateCategory{ + { + ID: "cat-test1", + Name: "test.1", + }, + { + ID: "cat-test2", + Name: "test.2", + }, + } +} diff --git a/go.mod b/go.mod index ad8a4fbb28..c157155e51 100644 --- a/go.mod +++ b/go.mod @@ -70,6 +70,7 @@ require ( golang.org/x/tools v0.3.0 gopkg.in/mail.v2 v2.3.1 gopkg.in/yaml.v2 v2.4.0 + gopkg.in/yaml.v3 v3.0.1 ) require ( @@ -178,7 +179,6 @@ require ( gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/natefinch/lumberjack.v2 v2.0.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect lukechampine.com/uint128 v1.1.1 // indirect modernc.org/cc/v3 v3.36.0 // indirect modernc.org/ccgo/v3 v3.16.6 // indirect diff --git a/i18n/en.json b/i18n/en.json index 240aa7aac1..91c3351aaf 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -6991,6 +6991,14 @@ "id": "app.webhooks.update_outgoing.app_error", "translation": "Unable to update the webhook." }, + { + "id": "app.worktemplates.get_categories.app_error", + "translation": "Unable to get work template categories" + }, + { + "id": "app.worktemplates.get_templates.app_error", + "translation": "Unable to get work templates" + }, { "id": "bleveengine.already_started.error", "translation": "Bleve is already started." @@ -9686,5 +9694,25 @@ { "id": "web.incoming_webhook.user.app_error", "translation": "Couldn't find the user." + }, + { + "id": "worktemplate.category.product_teams", + "translation": "Product Teams" + }, + { + "id": "worktemplate.product_teams.feature_release.description.board", + "translation": "Use our Meeting Agenda board template for recurring meetings like standup and our Project Tasks board to manage the progress of tasks along the way." + }, + { + "id": "worktemplate.product_teams.feature_release.description.channel", + "translation": "Chat with your team in a Feature Release channel that connects easily with your boards, playbooks and app bots." + }, + { + "id": "worktemplate.product_teams.feature_release.description.integration", + "translation": "Increase productivity in your channel by integrating a Jira bot and Github bot. These will be downloaded for you." + }, + { + "id": "worktemplate.product_teams.feature_release.description.playbook", + "translation": "Create transparent workflows across development teams to ensure your feature development process is seamless." } ] diff --git a/model/client4.go b/model/client4.go index 6d12fbd916..794969c0d6 100644 --- a/model/client4.go +++ b/model/client4.go @@ -8557,3 +8557,34 @@ func (c *Client4) AddUserToGroupSyncables(userID string) (*Response, error) { defer closeBody(r) return BuildResponse(r), nil } + +// Worktemplates sections + +func (c *Client4) worktemplatesRoute() string { + return "/worktemplates" +} + +// GetWorktemplateCategories returns categories of worktemplates +func (c *Client4) GetWorktemplateCategories() ([]*WorkTemplateCategory, *Response, error) { + r, err := c.DoAPIGet(c.worktemplatesRoute()+"/categories", "") + if err != nil { + return nil, BuildResponse(r), err + } + defer closeBody(r) + + var categories []*WorkTemplateCategory + err = json.NewDecoder(r.Body).Decode(&categories) + return categories, BuildResponse(r), err +} + +func (c *Client4) GetWorkTemplatesByCategory(category string) ([]*WorkTemplate, *Response, error) { + r, err := c.DoAPIGet(c.worktemplatesRoute()+"/categories/"+category+"/templates", "") + if err != nil { + return nil, BuildResponse(r), err + } + defer closeBody(r) + + var templates []*WorkTemplate + err = json.NewDecoder(r.Body).Decode(&templates) + return templates, BuildResponse(r), err +} diff --git a/model/worktemplate.go b/model/worktemplate.go new file mode 100644 index 0000000000..0e8add822b --- /dev/null +++ b/model/worktemplate.go @@ -0,0 +1,71 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package model + +type WorkTemplateCategory struct { + ID string `json:"id"` + Name string `json:"name"` +} + +type WorkTemplate struct { + ID string `json:"id"` + Category string `json:"category"` + UseCase string `json:"useCase"` + Illustration string `json:"illustration"` + Visibility string `json:"visibility"` + FeatureFlag *WorkTemplateFeatureFlag `json:"featureFlag,omitempty"` + Description Description `json:"description"` + Content []WorkTemplateContent `json:"content"` +} + +type WorkTemplateFeatureFlag struct { + Name string `json:"name"` + Value string `json:"value"` +} + +type DescriptionContent struct { + Message string `json:"message"` + Illustration string `json:"illustration"` +} + +type Description struct { + Channel *DescriptionContent `json:"channel"` + Board *DescriptionContent `json:"board"` + Playbook *DescriptionContent `json:"playbook"` + Integration *DescriptionContent `json:"integration"` +} + +type WorkTemplateChannel struct { + ID string `json:"id"` + Name string `json:"name"` + Purpose string `json:"purpose"` + Playbook string `json:"playbook"` + Illustration string `json:"illustration"` +} + +type WorkTemplateBoard struct { + ID string `json:"id"` + Template string `json:"template"` + Name string `json:"name"` + Channel string `json:"channel"` + Illustration string `json:"illustration"` +} + +type WorkTemplatePlaybook struct { + Template string `json:"template"` + Name string `json:"name"` + ID string `json:"id"` + Illustration string `json:"illustration"` +} + +type WorkTemplateIntegration struct { + ID string `json:"id"` +} + +type WorkTemplateContent struct { + Channel *WorkTemplateChannel `json:"channel,omitempty"` + Board *WorkTemplateBoard `json:"board,omitempty"` + Playbook *WorkTemplatePlaybook `json:"playbook,omitempty"` + Integration *WorkTemplateIntegration `json:"integration,omitempty"` +}