- golem15.seo.issues (Site audit): current issues errors first, labels, summary and fix columns, filters, P7 issue_details, Check this address again, Ignore, Stop ignoring, bulk Ignore, ignored rows badged - golem15.seo.crawls (Crawls): newest first, P6 crawl_summary with one status callout, staging and rendered-comparison lines, stats strip and per-kind table; Cancel; a running crawl refuses delete - Start crawl toolbar action on both, refusing already_running and no_site_url; navigation Site audit (gauge) and Crawls (globe) - routes and MCP tools start/cancel/list/get/delete crawls, list_issues with filters (open by default), recheck_url, ignore and unignore - AdminOperations maps both controllers; toolbar actions get their own operation kind - TestCrawlAdmin, TestCrawlTools, TestIssueKindLabels
506 lines
18 KiB
Go
506 lines
18 KiB
Go
package controllers
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"git.golem15.com/golem15/sm-seo-plugin/classes"
|
|
"git.golem15.com/golem15/sm-seo-plugin/classes/crawl"
|
|
"git.golem15.com/golem15/sm-seo-plugin/models"
|
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
|
"git.golem15.com/golem15/summercms/modules/cabana"
|
|
"git.golem15.com/golem15/summercms/modules/lagoon"
|
|
"git.golem15.com/golem15/summercms/modules/pact"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// IssuesControllerID is the Site audit (UI-SPEC C23).
|
|
const IssuesControllerID = "golem15.seo.issues"
|
|
|
|
// The Site audit's actions: the toolbar's start, the record actions and
|
|
// the bulk ignore.
|
|
const (
|
|
ActionStartCrawl = "start_crawl"
|
|
IssueActionCheck = "recheck"
|
|
IssueActionIgnore = "ignore"
|
|
IssueActionShow = "unignore"
|
|
)
|
|
|
|
// errIssueReadOnly refuses creating or editing issues: the audit writes
|
|
// them.
|
|
var errIssueReadOnly = errors.New("golem15.seo: issues are read-only")
|
|
|
|
// issuesAdminController lists the audit's current issues, errors first,
|
|
// and previews one with P7 and its record actions (check again, ignore,
|
|
// stop ignoring); the toolbar starts a crawl and the bulk action ignores.
|
|
type issuesAdminController struct {
|
|
app func() *backpack.App
|
|
}
|
|
|
|
func (issuesAdminController) ID() string { return IssuesControllerID }
|
|
func (issuesAdminController) ModelName() string { return `Golem15\SEO\Models\Issue` }
|
|
func (issuesAdminController) ConfigDir() string { return "controllers/issues" }
|
|
func (issuesAdminController) NewRecord() any { return &models.Issue{} }
|
|
func (issuesAdminController) RequiredPermissions() []string {
|
|
return []string{PermissionManage}
|
|
}
|
|
|
|
// FormBeforeCreate refuses: the audit writes issues.
|
|
func (issuesAdminController) FormBeforeCreate(context.Context, any) error { return errIssueReadOnly }
|
|
|
|
// FormBeforeUpdate refuses: issues are never edited.
|
|
func (issuesAdminController) FormBeforeUpdate(context.Context, any) error { return errIssueReadOnly }
|
|
|
|
func (c issuesAdminController) service() *classes.Service {
|
|
svc, err := classes.NewService(appOf(c.app))
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
return svc
|
|
}
|
|
|
|
// ListExtendQuery narrows the list to the current issues (the newest
|
|
// crawl's; before any crawl the CMS-data issues, checked now) and puts
|
|
// errors first, then warnings, then notices.
|
|
func (c issuesAdminController) ListExtendQuery(ctx context.Context, q *gorm.DB) *gorm.DB {
|
|
svc := c.service()
|
|
db, err := database(ctx, c.app)
|
|
if svc == nil || err != nil {
|
|
return q.Where("1 = 0")
|
|
}
|
|
latest, err := crawl.Current(ctx, svc, db)
|
|
if err != nil {
|
|
log.Warn("golem15.seo: site audit list", "error", err)
|
|
return q.Where("1 = 0")
|
|
}
|
|
return crawl.CurrentScope(q, latest).Order("golem15_seo_issues.rank ASC")
|
|
}
|
|
|
|
// startCrawl is the toolbar's Start crawl (also on Crawls): it refuses with
|
|
// crawls.already_running or crawls.no_site_url as an action error and
|
|
// toasts crawls.started.
|
|
func startCrawl(app func() *backpack.App) pact.AdminAction {
|
|
return pact.AdminAction{
|
|
Name: ActionStartCrawl,
|
|
Label: "golem15.seo::lang.crawls.start",
|
|
Permissions: []string{PermissionManage},
|
|
Run: func(ctx context.Context, _ pact.AdminActionInput) (pact.AdminActionResult, error) {
|
|
svc, err := classes.NewService(appOf(app))
|
|
if err != nil {
|
|
return pact.AdminActionResult{}, err
|
|
}
|
|
db, err := database(ctx, app)
|
|
if err != nil {
|
|
return pact.AdminActionResult{}, err
|
|
}
|
|
err = lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error {
|
|
_, name, _ := svc.Actor(ctx, tx)
|
|
_, err := crawl.Start(ctx, svc, tx, models.TriggerAdmin, name)
|
|
return err
|
|
})
|
|
if err != nil {
|
|
return pact.AdminActionResult{}, startRefusal(ctx, app, err)
|
|
}
|
|
return pact.AdminActionResult{Message: "golem15.seo::lang.crawls.started"}, nil
|
|
},
|
|
}
|
|
}
|
|
|
|
// startRefusal turns a refused start into the admin's action error.
|
|
func startRefusal(ctx context.Context, app func() *backpack.App, err error) error {
|
|
tr := translator(app)
|
|
var running *crawl.AlreadyRunningError
|
|
switch {
|
|
case errors.As(err, &running):
|
|
return &cabana.ForbiddenError{Message: Phrase(ctx, tr, "golem15.seo::lang.crawls.already_running", map[string]string{"time": running.Since().UTC().Format("2006-01-02 15:04 UTC")})}
|
|
case errors.Is(err, crawl.ErrNoSiteURL):
|
|
return &cabana.ForbiddenError{Message: Phrase(ctx, tr, "golem15.seo::lang.crawls.no_site_url", nil)}
|
|
}
|
|
return err
|
|
}
|
|
|
|
// AdminActions is the toolbar's Start crawl.
|
|
func (c issuesAdminController) AdminActions() []pact.AdminAction {
|
|
return []pact.AdminAction{startCrawl(c.app)}
|
|
}
|
|
|
|
// ignored reports whether a record is an ignored issue.
|
|
func (c issuesAdminController) ignored(ctx context.Context, record any) (bool, error) {
|
|
issue, ok := record.(*models.Issue)
|
|
if !ok || issue == nil {
|
|
return false, nil
|
|
}
|
|
db, err := database(ctx, c.app)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return crawl.IsIgnored(db, *issue)
|
|
}
|
|
|
|
// AdminRecordActions are the preview's Check this address again and Ignore
|
|
// (while open) and Stop ignoring (while ignored).
|
|
func (c issuesAdminController) AdminRecordActions() []pact.AdminRecordAction {
|
|
open := func(ctx context.Context, record any) (bool, error) {
|
|
ig, err := c.ignored(ctx, record)
|
|
return !ig, err
|
|
}
|
|
return []pact.AdminRecordAction{
|
|
{
|
|
Name: IssueActionCheck,
|
|
Label: "golem15.seo::lang.issues.recheck",
|
|
Confirm: "golem15.seo::lang.issues.recheck_confirm",
|
|
Permissions: []string{PermissionManage},
|
|
Applies: open,
|
|
Run: func(ctx context.Context, in pact.AdminRecordActionInput) (pact.AdminRecordActionResult, error) {
|
|
issue, ok := in.Record.(*models.Issue)
|
|
svc := c.service()
|
|
if !ok || issue == nil || svc == nil {
|
|
return pact.AdminRecordActionResult{}, classes.ErrNotBooted
|
|
}
|
|
db, err := database(ctx, c.app)
|
|
if err != nil {
|
|
return pact.AdminRecordActionResult{}, err
|
|
}
|
|
res, err := crawl.Recheck(ctx, svc, db, *issue)
|
|
if err != nil {
|
|
if errors.Is(err, crawl.ErrNoSiteURL) {
|
|
return pact.AdminRecordActionResult{}, &cabana.ForbiddenError{Message: Phrase(ctx, translator(c.app), "golem15.seo::lang.crawls.no_site_url", nil)}
|
|
}
|
|
return pact.AdminRecordActionResult{}, err
|
|
}
|
|
return pact.AdminRecordActionResult{Message: RecheckMessage(ctx, svc, res)}, nil
|
|
},
|
|
},
|
|
{
|
|
Name: IssueActionIgnore,
|
|
Label: "golem15.seo::lang.issues.ignore",
|
|
Confirm: "golem15.seo::lang.issues.ignore_confirm",
|
|
Permissions: []string{PermissionManage},
|
|
Applies: open,
|
|
Run: func(ctx context.Context, in pact.AdminRecordActionInput) (pact.AdminRecordActionResult, error) {
|
|
return c.ignore(ctx, []any{in.Record})
|
|
},
|
|
},
|
|
{
|
|
Name: IssueActionShow,
|
|
Label: "golem15.seo::lang.issues.unignore",
|
|
Confirm: "golem15.seo::lang.issues.unignore_confirm",
|
|
Permissions: []string{PermissionManage},
|
|
Applies: c.ignored,
|
|
Run: func(ctx context.Context, in pact.AdminRecordActionInput) (pact.AdminRecordActionResult, error) {
|
|
issue, ok := in.Record.(*models.Issue)
|
|
if !ok || issue == nil {
|
|
return pact.AdminRecordActionResult{}, classes.ErrNotBooted
|
|
}
|
|
db, err := database(ctx, c.app)
|
|
if err != nil {
|
|
return pact.AdminRecordActionResult{}, err
|
|
}
|
|
n, err := crawl.Unignore(db, []models.Issue{*issue})
|
|
if err != nil {
|
|
return pact.AdminRecordActionResult{}, err
|
|
}
|
|
svc := c.service()
|
|
return pact.AdminRecordActionResult{Message: svc.ChoiceCtx(ctx, "golem15.seo::lang.issues.unignored", n, nil)}, nil
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
// RecheckMessage is the toast of a recheck: issues.rechecked with the
|
|
// remaining count, or issues.recheck_fixed.
|
|
func RecheckMessage(ctx context.Context, svc *classes.Service, res crawl.RecheckResult) string {
|
|
if res.Remaining == 0 {
|
|
return svc.PhraseCtx(ctx, "golem15.seo::lang.issues.recheck_fixed", nil)
|
|
}
|
|
return svc.ChoiceCtx(ctx, "golem15.seo::lang.issues.rechecked", res.Remaining, nil)
|
|
}
|
|
|
|
// ignore stores the fingerprints of records as ignored.
|
|
func (c issuesAdminController) ignore(ctx context.Context, records []any) (pact.AdminRecordActionResult, error) {
|
|
svc := c.service()
|
|
db, err := database(ctx, c.app)
|
|
if err != nil || svc == nil {
|
|
return pact.AdminRecordActionResult{}, errors.Join(err, classes.ErrNotBooted)
|
|
}
|
|
var list []models.Issue
|
|
for _, r := range records {
|
|
if issue, ok := r.(*models.Issue); ok && issue != nil {
|
|
list = append(list, *issue)
|
|
}
|
|
}
|
|
adminID, _, _ := svc.Actor(ctx, db)
|
|
n, err := crawl.Ignore(db, list, adminID)
|
|
if err != nil {
|
|
return pact.AdminRecordActionResult{}, err
|
|
}
|
|
return pact.AdminRecordActionResult{Message: svc.ChoiceCtx(ctx, "golem15.seo::lang.issues.ignored", n, nil)}, nil
|
|
}
|
|
|
|
// AdminBulkActions is the list's bulk Ignore issues.
|
|
func (c issuesAdminController) AdminBulkActions() []pact.AdminBulkAction {
|
|
return []pact.AdminBulkAction{{
|
|
Name: IssueActionIgnore,
|
|
Label: "golem15.seo::lang.issues.ignore_selected",
|
|
Confirm: "golem15.seo::lang.issues.ignore_selected_confirm",
|
|
Permissions: []string{PermissionManage},
|
|
Run: func(ctx context.Context, in pact.AdminBulkActionInput) (pact.AdminBulkActionResult, error) {
|
|
res, err := c.ignore(ctx, in.Records)
|
|
if err != nil {
|
|
return pact.AdminBulkActionResult{}, err
|
|
}
|
|
return pact.AdminBulkActionResult{Message: res.Message, Affected: len(in.Records)}, nil
|
|
},
|
|
}}
|
|
}
|
|
|
|
// ListRowStates marks ignored issues disabled (issues.state_ignored).
|
|
func (issuesAdminController) ListRowStates(ctx context.Context, db *gorm.DB, records []any) ([][]pact.RowState, error) {
|
|
var fps []string
|
|
for _, r := range records {
|
|
if issue, ok := r.(*models.Issue); ok && issue != nil {
|
|
fps = append(fps, issue.Fingerprint)
|
|
}
|
|
}
|
|
set, err := crawl.IgnoredSet(db.WithContext(ctx), fps)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([][]pact.RowState, len(records))
|
|
for i, r := range records {
|
|
if issue, ok := r.(*models.Issue); ok && issue != nil && set[issue.Fingerprint] {
|
|
out[i] = []pact.RowState{pact.RowStateDisabled}
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// labelKeys are the phrase keys of the stored severity and source values.
|
|
var labelKeys = map[string]string{
|
|
models.SeverityError: "golem15.seo::lang.issues.sev_error",
|
|
models.SeverityWarning: "golem15.seo::lang.issues.sev_warning",
|
|
models.SeverityNotice: "golem15.seo::lang.issues.sev_notice",
|
|
models.SourceCrawl: "golem15.seo::lang.issues.source_crawl",
|
|
models.SourceCMS: "golem15.seo::lang.issues.source_cms",
|
|
}
|
|
|
|
// FixCell is the Fix column: issues.fix_tool with the tool, or
|
|
// issues.fix_code.
|
|
func FixCell(ctx context.Context, svc *classes.Service, issue models.Issue) string {
|
|
if issue.Fix == models.FixTool {
|
|
return svc.PhraseCtx(ctx, "golem15.seo::lang.issues.fix_tool", map[string]string{"tool": issue.Tool})
|
|
}
|
|
return svc.PhraseCtx(ctx, "golem15.seo::lang.issues.fix_code", nil)
|
|
}
|
|
|
|
// ListExtendRecords shows the severity, kind and source labels, the
|
|
// one-line summary and the fix route in the request locale.
|
|
func (c issuesAdminController) ListExtendRecords(ctx context.Context, _ *gorm.DB, records []any) error {
|
|
svc := c.service()
|
|
if svc == nil {
|
|
return nil
|
|
}
|
|
for _, r := range records {
|
|
issue, ok := r.(*models.Issue)
|
|
if !ok || issue == nil {
|
|
continue
|
|
}
|
|
issue.Summary = svc.IssueSummary(ctx, issue.Kind, issue.DetailsMap())
|
|
issue.FixCell = FixCell(ctx, svc, *issue)
|
|
issue.KindCell = svc.PhraseCtx(ctx, "golem15.seo::lang.issues.kind_"+issue.Kind, nil)
|
|
if key, ok := labelKeys[issue.Severity]; ok {
|
|
issue.Severity = svc.PhraseCtx(ctx, key, nil)
|
|
}
|
|
if key, ok := labelKeys[issue.Source]; ok {
|
|
issue.Source = svc.PhraseCtx(ctx, key, nil)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// DropdownOptions labels the preview's read-only kind, severity and
|
|
// source fields.
|
|
func (issuesAdminController) DropdownOptions(field string) []pact.Option {
|
|
switch field {
|
|
case "kind":
|
|
out := make([]pact.Option, 0, len(crawl.Kinds()))
|
|
for _, k := range crawl.Kinds() {
|
|
out = append(out, pact.Option{Value: k, Label: "golem15.seo::lang.issues.kind_" + k})
|
|
}
|
|
return out
|
|
case "severity":
|
|
return []pact.Option{
|
|
{Value: models.SeverityError, Label: labelKeys[models.SeverityError]},
|
|
{Value: models.SeverityWarning, Label: labelKeys[models.SeverityWarning]},
|
|
{Value: models.SeverityNotice, Label: labelKeys[models.SeverityNotice]},
|
|
}
|
|
case "source":
|
|
return []pact.Option{
|
|
{Value: models.SourceCrawl, Label: labelKeys[models.SourceCrawl]},
|
|
{Value: models.SourceCMS, Label: labelKeys[models.SourceCMS]},
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// FilterOptions serves the severity, kind, source, fix and status filters.
|
|
func (c issuesAdminController) FilterOptions(scope string) []pact.Option {
|
|
switch scope {
|
|
case models.FilterIssueSeverity:
|
|
return c.DropdownOptions("severity")
|
|
case models.FilterIssueKind:
|
|
return c.DropdownOptions("kind")
|
|
case models.FilterIssueSource:
|
|
return c.DropdownOptions("source")
|
|
case models.FilterIssueFix:
|
|
return []pact.Option{
|
|
{Value: models.FixTool, Label: "golem15.seo::lang.issues.filter_fix_tool"},
|
|
{Value: models.FixCode, Label: "golem15.seo::lang.issues.filter_fix_code"},
|
|
}
|
|
case models.FilterIssueStatus:
|
|
return []pact.Option{
|
|
{Value: models.IssueOpen, Label: "golem15.seo::lang.issues.status_open"},
|
|
{Value: models.IssueIgnored, Label: "golem15.seo::lang.issues.status_ignored"},
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// maxLinkedShown caps P7's linking pages before issues.and_more.
|
|
const maxLinkedShown = 10
|
|
|
|
// PartialData serves P7 issue_details (UI-SPEC C23).
|
|
func (c issuesAdminController) PartialData(ctx context.Context, name string, record any) (any, error) {
|
|
issue, ok := record.(*models.Issue)
|
|
svc := c.service()
|
|
if name != "issue_details" || !ok || issue == nil || svc == nil {
|
|
return classes.IssueDetailsView{}, nil
|
|
}
|
|
return IssueDetails(ctx, svc, c.app, *issue), nil
|
|
}
|
|
|
|
// IssueDetails builds P7: the kind's found rows, the linking pages, the
|
|
// orphan suggestions, how to fix it and the page address.
|
|
func IssueDetails(ctx context.Context, svc *classes.Service, app func() *backpack.App, issue models.Issue) classes.IssueDetailsView {
|
|
d := issue.DetailsMap()
|
|
view := classes.IssueDetailsView{}
|
|
label := func(key string) string { return svc.PhraseCtx(ctx, "golem15.seo::lang.issues."+key, nil) }
|
|
view.Rows = append(view.Rows, classes.IssueRow{Label: label("summary"), Value: svc.IssueSummary(ctx, issue.Kind, d)})
|
|
add := func(labelKey string, value any, mono bool) {
|
|
if value == nil {
|
|
return
|
|
}
|
|
text := fmt.Sprint(value)
|
|
if list, ok := value.([]any); ok {
|
|
parts := make([]string, 0, len(list))
|
|
for _, e := range list {
|
|
switch x := e.(type) {
|
|
case map[string]any:
|
|
var kv []string
|
|
for _, k := range []string{"type", "property", "text", "target", "locale", "url", "status", "from", "to"} {
|
|
if v, ok := x[k]; ok {
|
|
kv = append(kv, fmt.Sprint(v))
|
|
}
|
|
}
|
|
parts = append(parts, strings.Join(kv, " · "))
|
|
default:
|
|
parts = append(parts, fmt.Sprint(x))
|
|
}
|
|
}
|
|
text = strings.Join(parts, "; ")
|
|
}
|
|
if strings.TrimSpace(text) == "" {
|
|
return
|
|
}
|
|
view.Rows = append(view.Rows, classes.IssueRow{Label: label(labelKey), Value: text, Mono: mono})
|
|
}
|
|
add("detail_h1", d["h1"], false)
|
|
add("detail_skips", d["skips"], false)
|
|
add("detail_missing", d["missing"], false)
|
|
add("detail_error", d["error"], true)
|
|
add("detail_anchors", d["anchors"], false)
|
|
add("detail_alternates", d["alternates"], true)
|
|
add("detail_images", d["images"], true)
|
|
add("detail_target", d["target"], true)
|
|
add("detail_what", d["what"], false)
|
|
if from, ok := d["from"].([]any); ok && issue.Kind != classes.IssueImageAlt {
|
|
if _, isLevel := d["to"]; !isLevel {
|
|
for i, f := range from {
|
|
if i >= maxLinkedShown {
|
|
break
|
|
}
|
|
view.LinkedFrom = append(view.LinkedFrom, classes.IssueLink{URL: fmt.Sprint(f)})
|
|
}
|
|
total := len(from)
|
|
if n, ok := d["count"].(float64); ok && int(n) > total {
|
|
total = int(n)
|
|
}
|
|
if total > len(view.LinkedFrom) {
|
|
view.More = svc.PhraseCtx(ctx, "golem15.seo::lang.issues.and_more", map[string]string{"count": fmt.Sprint(total - len(view.LinkedFrom))})
|
|
}
|
|
}
|
|
}
|
|
if issue.Kind == crawl.KindOrphanPage {
|
|
view.Orphan = true
|
|
if list, ok := d["suggestions"].([]any); ok {
|
|
for _, e := range list {
|
|
s, _ := e.(map[string]any)
|
|
u := fmt.Sprint(s["url"])
|
|
view.Suggestions = append(view.Suggestions, classes.IssueLink{URL: u, Phrase: fmt.Sprint(s["phrase"])})
|
|
}
|
|
}
|
|
}
|
|
if issue.Fix == models.FixTool {
|
|
view.FixText = svc.PhraseCtx(ctx, "golem15.seo::lang.issues.fix_tool_text", map[string]string{"tool": issue.Tool, "where": fixWhere(ctx, svc, issue)})
|
|
} else {
|
|
what, _ := d["what"].(string)
|
|
view.FixText = svc.PhraseCtx(ctx, "golem15.seo::lang.issues.fix_code_text", map[string]string{"what": what})
|
|
}
|
|
site := svc.SiteURL()
|
|
if strings.HasPrefix(issue.URL, "/") && site != "" {
|
|
view.PageURL = site + issue.URL
|
|
}
|
|
if strings.HasPrefix(issue.Kind, "jsonld_") && view.PageURL != "" {
|
|
view.RichResults = true
|
|
view.RichURL = "https://search.google.com/test/rich-results?url=" + view.PageURL
|
|
}
|
|
return view
|
|
}
|
|
|
|
// fixWhere names the admin screen where a CMS tool's value is changed.
|
|
func fixWhere(ctx context.Context, svc *classes.Service, issue models.Issue) string {
|
|
key := "golem15.seo::lang.issues.where_record"
|
|
switch issue.Tool {
|
|
case crawl.ToolUpdateRobots:
|
|
key = "golem15.seo::lang.issues.where_robots"
|
|
case crawl.ToolUpdateSettings:
|
|
key = "golem15.seo::lang.issues.where_settings"
|
|
case crawl.ToolCreateRedirect, "seo_update_redirect":
|
|
key = "golem15.seo::lang.issues.where_redirects"
|
|
case crawl.ToolCreatePage, crawl.ToolUpdatePage:
|
|
key = "golem15.seo::lang.issues.where_pages"
|
|
case "seo_update_rule":
|
|
key = "golem15.seo::lang.issues.where_rules"
|
|
}
|
|
return svc.PhraseCtx(ctx, key, nil)
|
|
}
|
|
|
|
var (
|
|
_ pact.AdminController = issuesAdminController{}
|
|
_ pact.AdminPermissioned = issuesAdminController{}
|
|
_ pact.AdminRecordSource = issuesAdminController{}
|
|
_ pact.FormBeforeCreate = issuesAdminController{}
|
|
_ pact.FormBeforeUpdate = issuesAdminController{}
|
|
_ pact.ListExtendQuery = issuesAdminController{}
|
|
_ pact.HasAdminActions = issuesAdminController{}
|
|
_ pact.HasAdminRecordActions = issuesAdminController{}
|
|
_ pact.HasAdminBulkActions = issuesAdminController{}
|
|
_ pact.ListRowStates = issuesAdminController{}
|
|
_ pact.ListExtendRecords = issuesAdminController{}
|
|
_ pact.DropdownOptionsProvider = issuesAdminController{}
|
|
_ pact.FilterOptions = issuesAdminController{}
|
|
_ pact.AdminPartialData = issuesAdminController{}
|
|
)
|