From ea5a98e8464ec22f03be576cfe9a042dd22701e5 Mon Sep 17 00:00:00 2001
From: martian56
Date: Thu, 9 Jul 2026 02:57:23 +0400
Subject: [PATCH 1/2] feat(exports): add server-side XLSX issue exports with
export history
Excel export previously fell back to a client-side CSV ("will download as
CSV for now"). This adds a real server-side .xlsx generator and records each
request as export history.
Backend: Exporter model + store over the existing exporters table; ExportService
generates an .xlsx of the selected projects' issues (paged, with project/state
names) via excelize and records a completed history row; membership and project
scope enforced. Routes POST/GET /api/workspaces/:slug/exports/.
Frontend: exportService.createXlsx downloads the workbook as a blob; the export
modal routes the Excel option to the server (CSV/JSON stay client-side).
Closes #198
Co-Authored-By: Claude Opus 4.8 (1M context)
---
apps/api/go.mod | 16 +-
apps/api/go.sum | 23 +++
apps/api/internal/handler/export.go | 86 +++++++++
apps/api/internal/handler/export_test.go | 70 ++++++++
apps/api/internal/model/exporter.go | 34 ++++
apps/api/internal/router/router.go | 5 +
apps/api/internal/service/export.go | 165 ++++++++++++++++++
apps/api/internal/store/exporter.go | 32 ++++
.../settings/modals/ExportModal.tsx | 33 ++--
apps/web/src/services/exportService.ts | 32 ++++
10 files changed, 481 insertions(+), 15 deletions(-)
create mode 100644 apps/api/internal/handler/export.go
create mode 100644 apps/api/internal/handler/export_test.go
create mode 100644 apps/api/internal/model/exporter.go
create mode 100644 apps/api/internal/service/export.go
create mode 100644 apps/api/internal/store/exporter.go
create mode 100644 apps/web/src/services/exportService.ts
diff --git a/apps/api/go.mod b/apps/api/go.mod
index 47d7bc43..3b66c0ef 100644
--- a/apps/api/go.mod
+++ b/apps/api/go.mod
@@ -14,8 +14,8 @@ require (
github.com/stretchr/testify v1.11.1
github.com/testcontainers/testcontainers-go v0.42.0
github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0
- golang.org/x/crypto v0.52.0
- golang.org/x/net v0.54.0
+ golang.org/x/crypto v0.53.0
+ golang.org/x/net v0.56.0
gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.31.1
)
@@ -91,14 +91,20 @@ require (
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
+ github.com/richardlehane/mscfb v1.0.7 // indirect
+ github.com/richardlehane/msoleps v1.0.6 // indirect
github.com/rs/xid v1.6.0 // indirect
github.com/shirou/gopsutil/v4 v4.26.3 // indirect
github.com/sirupsen/logrus v1.9.4 // indirect
+ github.com/tiendc/go-deepcopy v1.7.2 // indirect
github.com/tinylib/msgp v1.6.1 // indirect
github.com/tklauser/go-sysconf v0.3.16 // indirect
github.com/tklauser/numcpus v0.11.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect
+ github.com/xuri/efp v0.0.1 // indirect
+ github.com/xuri/excelize/v2 v2.11.0 // indirect
+ github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
@@ -107,9 +113,9 @@ require (
go.opentelemetry.io/otel/trace v1.41.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/arch v0.20.0 // indirect
- golang.org/x/sync v0.20.0 // indirect
- golang.org/x/sys v0.45.0 // indirect
- golang.org/x/text v0.37.0 // indirect
+ golang.org/x/sync v0.21.0 // indirect
+ golang.org/x/sys v0.46.0 // indirect
+ golang.org/x/text v0.38.0 // indirect
google.golang.org/protobuf v1.36.9 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.22.5 // indirect
diff --git a/apps/api/go.sum b/apps/api/go.sum
index eb5aad67..972cf3c3 100644
--- a/apps/api/go.sum
+++ b/apps/api/go.sum
@@ -192,6 +192,10 @@ github.com/redis/go-redis/v9 v9.17.3/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
+github.com/richardlehane/mscfb v1.0.7 h1:oeoiM0WE79vHwE8RpIYYvIAc8ajTH2mb6UZm55/+EB0=
+github.com/richardlehane/mscfb v1.0.7/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo=
+github.com/richardlehane/msoleps v1.0.6 h1:9BvkpjvD+iUBalUY4esMwv6uBkfOip/Lzvd93jvR9gg=
+github.com/richardlehane/msoleps v1.0.6/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
@@ -216,6 +220,8 @@ github.com/testcontainers/testcontainers-go v0.42.0 h1:He3IhTzTZOygSXLJPMX7n44Xt
github.com/testcontainers/testcontainers-go v0.42.0/go.mod h1:vZjdY1YmUA1qEForxOIOazfsrdyORJAbhi0bp8plN30=
github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0 h1:GCbb1ndrF7OTDiIvxXyItaDab4qkzTFJ48LKFdM7EIo=
github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0/go.mod h1:IRPBaI8jXdrNfD0e4Zm7Fbcgaz5shKxOQv4axiL09xs=
+github.com/tiendc/go-deepcopy v1.7.2 h1:Ut2yYR7W9tWjTQitganoIue4UGxZwCcJy3orjrrIj44=
+github.com/tiendc/go-deepcopy v1.7.2/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ=
github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY=
github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=
@@ -226,6 +232,12 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
+github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8=
+github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
+github.com/xuri/excelize/v2 v2.11.0 h1:HxaEFl6sRN2+8J5a8HaKq+0M4FsjBGMnWWtjOCPSG88=
+github.com/xuri/excelize/v2 v2.11.0/go.mod h1:jxFLbzaIwGQ5ufFNvYfUOHqXhfPaNmP14KWfmNz2Uak=
+github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE=
+github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
@@ -252,20 +264,31 @@ golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
+golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
+golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
+golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
+golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
+golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
+golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
+golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
+golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
+golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
+golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
diff --git a/apps/api/internal/handler/export.go b/apps/api/internal/handler/export.go
new file mode 100644
index 00000000..5ef48e2b
--- /dev/null
+++ b/apps/api/internal/handler/export.go
@@ -0,0 +1,86 @@
+package handler
+
+import (
+ "net/http"
+
+ "github.com/Devlaner/devlane/api/internal/middleware"
+ "github.com/Devlaner/devlane/api/internal/service"
+ "github.com/gin-gonic/gin"
+ "github.com/google/uuid"
+)
+
+const xlsxContentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
+
+// ExportHandler serves server-side issue exports.
+type ExportHandler struct {
+ Export *service.ExportService
+}
+
+// CreateExport generates an .xlsx of the selected projects' issues and streams
+// it back, recording the request in the export history.
+// POST /api/workspaces/:slug/exports/
+func (h *ExportHandler) CreateExport(c *gin.Context) {
+ user := middleware.GetUser(c)
+ if user == nil {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
+ return
+ }
+ slug := c.Param("slug")
+ var body struct {
+ ProjectIDs []string `json:"project_ids"`
+ Name string `json:"name"`
+ }
+ if err := c.ShouldBindJSON(&body); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request", "detail": err.Error()})
+ return
+ }
+ projectIDs := make([]uuid.UUID, 0, len(body.ProjectIDs))
+ for _, s := range body.ProjectIDs {
+ id, err := uuid.Parse(s)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project ID: " + s})
+ return
+ }
+ projectIDs = append(projectIDs, id)
+ }
+ filename, data, err := h.Export.ExportIssues(c.Request.Context(), slug, user.ID, projectIDs, body.Name)
+ if err != nil {
+ switch err {
+ case service.ErrWorkspaceNotFound, service.ErrProjectNotFound:
+ c.JSON(http.StatusNotFound, gin.H{"error": "Not found"})
+ case service.ErrWorkspaceForbidden:
+ c.JSON(http.StatusForbidden, gin.H{"error": "Not a member of this workspace"})
+ case service.ErrNoProjectsSelected:
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Select at least one project to export"})
+ default:
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate export"})
+ }
+ return
+ }
+ c.Header("Content-Disposition", `attachment; filename="`+filename+`"`)
+ c.Data(http.StatusOK, xlsxContentType, data)
+}
+
+// ListExports returns the workspace's export history.
+// GET /api/workspaces/:slug/exports/
+func (h *ExportHandler) ListExports(c *gin.Context) {
+ user := middleware.GetUser(c)
+ if user == nil {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
+ return
+ }
+ slug := c.Param("slug")
+ list, err := h.Export.ListHistory(c.Request.Context(), slug, user.ID)
+ if err != nil {
+ switch err {
+ case service.ErrWorkspaceNotFound:
+ c.JSON(http.StatusNotFound, gin.H{"error": "Workspace not found"})
+ case service.ErrWorkspaceForbidden:
+ c.JSON(http.StatusForbidden, gin.H{"error": "Not a member of this workspace"})
+ default:
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list exports"})
+ }
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"exports": list})
+}
diff --git a/apps/api/internal/handler/export_test.go b/apps/api/internal/handler/export_test.go
new file mode 100644
index 00000000..9f0f24ab
--- /dev/null
+++ b/apps/api/internal/handler/export_test.go
@@ -0,0 +1,70 @@
+package handler_test
+
+import (
+ "bytes"
+ "encoding/json"
+ "net/http"
+ "testing"
+
+ "github.com/Devlaner/devlane/api/internal/testutil"
+ "github.com/stretchr/testify/require"
+ "github.com/xuri/excelize/v2"
+)
+
+// A workspace member can export a project's issues as a real .xlsx workbook that
+// parses back and contains the issues, the request is recorded in the export
+// history, and non-members / empty selections are refused. Covers #198.
+func TestExport_IssuesXLSXAndHistory(t *testing.T) {
+ ts := testutil.NewTestServer(t)
+ w := testutil.SeedWorld(t, ts.DB)
+ url := "/api/workspaces/" + w.Workspace.Slug + "/exports/"
+
+ i1 := testutil.CreateIssue(t, ts.DB, w.Project.ID, w.Workspace.ID, w.User.ID)
+ testutil.CreateIssue(t, ts.DB, w.Project.ID, w.Workspace.ID, w.User.ID)
+
+ rr := ts.POST(url, map[string]any{"project_ids": []string{w.Project.ID.String()}}, w.Session)
+ require.Equal(t, http.StatusOK, rr.Code, "body=%s", rr.Body.String())
+ require.Contains(t, rr.Header().Get("Content-Type"), "spreadsheetml.sheet")
+
+ body := rr.Body.Bytes()
+ require.True(t, len(body) > 4 && body[0] == 'P' && body[1] == 'K', "response should be an xlsx (zip) file")
+
+ // It opens as a real workbook and contains the exported issue.
+ f, err := excelize.OpenReader(bytes.NewReader(body))
+ require.NoError(t, err)
+ rows, err := f.GetRows("Issues")
+ require.NoError(t, err)
+ require.GreaterOrEqual(t, len(rows), 3, "header row + two issues")
+ require.Equal(t, "Title", rows[0][2])
+ found := false
+ for _, r := range rows[1:] {
+ if len(r) > 2 && r[2] == i1.Name {
+ found = true
+ }
+ }
+ require.True(t, found, "exported sheet should contain the issue title")
+
+ // The request is recorded in the export history.
+ lr := ts.GET(url, w.Session)
+ require.Equal(t, http.StatusOK, lr.Code)
+ var hist struct {
+ Exports []struct {
+ Provider string `json:"provider"`
+ Status string `json:"status"`
+ } `json:"exports"`
+ }
+ require.NoError(t, json.Unmarshal(lr.Body.Bytes(), &hist))
+ require.Len(t, hist.Exports, 1)
+ require.Equal(t, "xlsx", hist.Exports[0].Provider)
+ require.Equal(t, "completed", hist.Exports[0].Status)
+
+ // A non-member is refused.
+ outsider := testutil.CreateUser(t, ts.DB)
+ outsiderSession := testutil.LoginAs(t, ts.DB, outsider)
+ require.Equal(t, http.StatusForbidden,
+ ts.POST(url, map[string]any{"project_ids": []string{w.Project.ID.String()}}, outsiderSession).Code)
+
+ // An empty selection is a 400.
+ require.Equal(t, http.StatusBadRequest,
+ ts.POST(url, map[string]any{"project_ids": []string{}}, w.Session).Code)
+}
diff --git a/apps/api/internal/model/exporter.go b/apps/api/internal/model/exporter.go
new file mode 100644
index 00000000..64012643
--- /dev/null
+++ b/apps/api/internal/model/exporter.go
@@ -0,0 +1,34 @@
+package model
+
+import (
+ "time"
+
+ "github.com/google/uuid"
+ "gorm.io/gorm"
+)
+
+// Exporter matches the exporters table: one row per issue-export request, kept
+// as the export history. project_ids are recorded inside Filters so we avoid the
+// UUID[] column's array-driver quirks.
+type Exporter struct {
+ ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
+ Name string `gorm:"type:varchar(255)" json:"name,omitempty"`
+ Type string `gorm:"type:varchar(50);default:issue_exports" json:"type"`
+ Provider string `gorm:"type:varchar(50);not null" json:"provider"`
+ Status string `gorm:"type:varchar(50);not null;default:queued" json:"status"`
+ Reason string `gorm:"type:text;default:''" json:"reason,omitempty"`
+ Filters JSONMap `gorm:"type:jsonb;serializer:json" json:"filters,omitempty"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+ WorkspaceID uuid.UUID `gorm:"type:uuid;not null" json:"workspace_id"`
+ InitiatedByID uuid.UUID `gorm:"type:uuid;not null" json:"initiated_by_id"`
+}
+
+func (Exporter) TableName() string { return "exporters" }
+
+func (e *Exporter) BeforeCreate(tx *gorm.DB) error {
+ if e.ID == uuid.Nil {
+ e.ID = uuid.New()
+ }
+ return nil
+}
diff --git a/apps/api/internal/router/router.go b/apps/api/internal/router/router.go
index 49d0e124..1dc36bec 100644
--- a/apps/api/internal/router/router.go
+++ b/apps/api/internal/router/router.go
@@ -145,6 +145,8 @@ func New(cfg Config) *gin.Engine {
attachmentSvc := service.NewAttachmentService(issueStore, projectStore, workspaceStore, cfg.Minio)
attachmentSvc.SetActivityStore(issueActivityStore)
cycleSvc := service.NewCycleService(cycleStore, projectStore, workspaceStore)
+ exporterStore := store.NewExporterStore(cfg.DB)
+ exportSvc := service.NewExportService(exporterStore, issueStore, stateStore, projectStore, workspaceStore)
cycleSvc.SetIssueStore(issueStore)
moduleSvc := service.NewModuleService(moduleStore, projectStore, workspaceStore)
moduleSvc.SetIssueStore(issueStore)
@@ -241,6 +243,7 @@ func New(cfg Config) *gin.Engine {
attachmentHandler := &handler.AttachmentHandler{Attachment: attachmentSvc}
epicHandler := &handler.EpicHandler{Issue: issueSvc}
cycleHandler := &handler.CycleHandler{Cycle: cycleSvc}
+ exportHandler := &handler.ExportHandler{Export: exportSvc}
moduleHandler := &handler.ModuleHandler{Module: moduleSvc}
issueViewHandler := &handler.IssueViewHandler{IssueView: issueViewSvc}
pageHandler := &handler.PageHandler{Page: pageSvc}
@@ -285,6 +288,8 @@ func New(cfg Config) *gin.Engine {
api.GET("/workspaces/:slug/", workspaceHandler.GetBySlug)
api.PATCH("/workspaces/:slug/", workspaceHandler.Update)
api.DELETE("/workspaces/:slug/", workspaceHandler.Delete)
+ api.POST("/workspaces/:slug/exports/", exportHandler.CreateExport)
+ api.GET("/workspaces/:slug/exports/", exportHandler.ListExports)
api.GET("/workspaces/:slug/members/", workspaceHandler.ListMembers)
api.POST("/workspaces/:slug/members/leave/", workspaceHandler.Leave)
api.GET("/workspaces/:slug/members/:pk/", workspaceHandler.GetMember)
diff --git a/apps/api/internal/service/export.go b/apps/api/internal/service/export.go
new file mode 100644
index 00000000..7bf85595
--- /dev/null
+++ b/apps/api/internal/service/export.go
@@ -0,0 +1,165 @@
+package service
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "time"
+
+ "github.com/Devlaner/devlane/api/internal/model"
+ "github.com/Devlaner/devlane/api/internal/store"
+ "github.com/google/uuid"
+ "github.com/xuri/excelize/v2"
+)
+
+// ErrNoProjectsSelected is returned when an export names no projects.
+var ErrNoProjectsSelected = errors.New("no projects selected for export")
+
+// ExportService generates issue exports and records them as history.
+type ExportService struct {
+ exporters *store.ExporterStore
+ issues *store.IssueStore
+ states *store.StateStore
+ ps *store.ProjectStore
+ ws *store.WorkspaceStore
+}
+
+func NewExportService(exporters *store.ExporterStore, issues *store.IssueStore, states *store.StateStore, ps *store.ProjectStore, ws *store.WorkspaceStore) *ExportService {
+ return &ExportService{exporters: exporters, issues: issues, states: states, ps: ps, ws: ws}
+}
+
+// ExportIssues builds an .xlsx workbook of the issues in the given projects and
+// records the request in the export history. Membership and project scope are
+// enforced. Returns the suggested filename and the file bytes.
+func (s *ExportService) ExportIssues(ctx context.Context, slug string, userID uuid.UUID, projectIDs []uuid.UUID, name string) (string, []byte, error) {
+ wrk, err := s.ws.GetBySlug(ctx, slug)
+ if err != nil {
+ return "", nil, ErrWorkspaceNotFound
+ }
+ if ok, _ := s.ws.IsMember(ctx, wrk.ID, userID); !ok {
+ return "", nil, ErrWorkspaceForbidden
+ }
+ if len(projectIDs) == 0 {
+ return "", nil, ErrNoProjectsSelected
+ }
+ for _, pid := range projectIDs {
+ if in, _ := s.ps.IsInWorkspace(ctx, pid, wrk.ID); !in {
+ return "", nil, ErrProjectNotFound
+ }
+ }
+
+ data, err := s.buildWorkbook(ctx, projectIDs)
+ if err != nil {
+ return "", nil, err
+ }
+
+ idStrings := make([]string, 0, len(projectIDs))
+ for _, pid := range projectIDs {
+ idStrings = append(idStrings, pid.String())
+ }
+ rec := &model.Exporter{
+ Name: name,
+ Type: "issue_exports",
+ Provider: "xlsx",
+ Status: "completed",
+ WorkspaceID: wrk.ID,
+ InitiatedByID: userID,
+ Filters: model.JSONMap{"project_ids": idStrings},
+ }
+ if err := s.exporters.Create(ctx, rec); err != nil {
+ return "", nil, err
+ }
+
+ return "devlane-issues-export.xlsx", data, nil
+}
+
+// ListHistory returns the workspace's export history (members only).
+func (s *ExportService) ListHistory(ctx context.Context, slug string, userID uuid.UUID) ([]model.Exporter, error) {
+ wrk, err := s.ws.GetBySlug(ctx, slug)
+ if err != nil {
+ return nil, ErrWorkspaceNotFound
+ }
+ if ok, _ := s.ws.IsMember(ctx, wrk.ID, userID); !ok {
+ return nil, ErrWorkspaceForbidden
+ }
+ return s.exporters.ListByWorkspaceID(ctx, wrk.ID)
+}
+
+const exportPageSize = 500
+
+func (s *ExportService) buildWorkbook(ctx context.Context, projectIDs []uuid.UUID) ([]byte, error) {
+ f := excelize.NewFile()
+ defer func() { _ = f.Close() }()
+ const sheet = "Issues"
+ f.SetSheetName(f.GetSheetName(0), sheet)
+
+ headers := []string{"Project", "ID", "Title", "State", "Priority", "Start Date", "Due Date", "Created At"}
+ for i, h := range headers {
+ cell, _ := excelize.CoordinatesToCellName(i+1, 1)
+ _ = f.SetCellValue(sheet, cell, h)
+ }
+
+ row := 2
+ for _, pid := range projectIDs {
+ proj, err := s.ps.GetByID(ctx, pid)
+ if err != nil {
+ return nil, err
+ }
+ states, err := s.states.ListByProjectID(ctx, pid)
+ if err != nil {
+ return nil, err
+ }
+ stateName := make(map[uuid.UUID]string, len(states))
+ for _, st := range states {
+ stateName[st.ID] = st.Name
+ }
+
+ offset := 0
+ for {
+ batch, err := s.issues.ListByProjectID(ctx, pid, exportPageSize, offset)
+ if err != nil {
+ return nil, err
+ }
+ for i := range batch {
+ iss := &batch[i]
+ displayID := fmt.Sprintf("%s-%d", proj.Identifier, iss.SequenceID)
+ st := ""
+ if iss.StateID != nil {
+ st = stateName[*iss.StateID]
+ }
+ values := []interface{}{
+ proj.Name,
+ displayID,
+ iss.Name,
+ st,
+ iss.Priority,
+ formatExportDate(iss.StartDate),
+ formatExportDate(iss.TargetDate),
+ iss.CreatedAt.UTC().Format("2006-01-02"),
+ }
+ for c, v := range values {
+ cell, _ := excelize.CoordinatesToCellName(c+1, row)
+ _ = f.SetCellValue(sheet, cell, v)
+ }
+ row++
+ }
+ if len(batch) < exportPageSize {
+ break
+ }
+ offset += exportPageSize
+ }
+ }
+
+ buf, err := f.WriteToBuffer()
+ if err != nil {
+ return nil, err
+ }
+ return buf.Bytes(), nil
+}
+
+func formatExportDate(t *time.Time) string {
+ if t == nil {
+ return ""
+ }
+ return t.UTC().Format("2006-01-02")
+}
diff --git a/apps/api/internal/store/exporter.go b/apps/api/internal/store/exporter.go
new file mode 100644
index 00000000..a67b8e6e
--- /dev/null
+++ b/apps/api/internal/store/exporter.go
@@ -0,0 +1,32 @@
+package store
+
+import (
+ "context"
+
+ "github.com/Devlaner/devlane/api/internal/model"
+ "github.com/google/uuid"
+ "gorm.io/gorm"
+)
+
+// ExporterStore persists export-history records.
+type ExporterStore struct{ db *gorm.DB }
+
+func NewExporterStore(db *gorm.DB) *ExporterStore { return &ExporterStore{db: db} }
+
+// Create records an export request.
+func (s *ExporterStore) Create(ctx context.Context, e *model.Exporter) error {
+ return s.db.WithContext(ctx).Create(e).Error
+}
+
+// ListByWorkspaceID returns a workspace's export history, newest first.
+func (s *ExporterStore) ListByWorkspaceID(ctx context.Context, workspaceID uuid.UUID) ([]model.Exporter, error) {
+ var list []model.Exporter
+ err := s.db.WithContext(ctx).
+ Where("workspace_id = ?", workspaceID).
+ Order("created_at DESC").
+ Find(&list).Error
+ if err != nil {
+ return nil, err
+ }
+ return list, nil
+}
diff --git a/apps/web/src/components/settings/modals/ExportModal.tsx b/apps/web/src/components/settings/modals/ExportModal.tsx
index 7713e10e..400b8c1f 100644
--- a/apps/web/src/components/settings/modals/ExportModal.tsx
+++ b/apps/web/src/components/settings/modals/ExportModal.tsx
@@ -1,8 +1,18 @@
import { Button, Modal } from '../../ui';
import { IconChevronDown } from '../icons';
import { issueService } from '../../../services/issueService';
+import { exportService } from '../../../services/exportService';
import type { ProjectApiResponse, WorkspaceApiResponse } from '../../../api/types';
+function downloadBlob(blob: Blob, filename: string) {
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = filename;
+ a.click();
+ setTimeout(() => URL.revokeObjectURL(url), 0);
+}
+
interface ExportModalProps {
open: boolean;
onClose: () => void;
@@ -53,6 +63,16 @@ export function ExportModal({
try {
const projectIds =
exportProjectValue === 'all' ? projects.map((p) => p.id) : [exportProjectValue];
+ const base = `export-${workspace?.slug ?? workspaceSlug}-${new Date().toISOString().slice(0, 10)}`;
+
+ // Excel is generated server-side (real .xlsx); CSV/JSON stay client-side.
+ if (exportFormat === 'xlsx') {
+ const blob = await exportService.createXlsx(workspaceSlug, projectIds);
+ downloadBlob(blob, `${base}.xlsx`);
+ setExportProjectOpen(false);
+ return;
+ }
+
const allIssues: Array<
Record & {
project_id?: string;
@@ -77,11 +97,9 @@ export function ExportModal({
offset += issues.length;
}
}
- const fmt = exportFormat === 'xlsx' ? 'csv' : exportFormat;
let blob: Blob;
let filename: string;
- const base = `export-${workspace?.slug ?? workspaceSlug}-${new Date().toISOString().slice(0, 10)}`;
- if (fmt === 'json') {
+ if (exportFormat === 'json') {
const str = JSON.stringify(allIssues, null, 2);
blob = new Blob([str], { type: 'application/json' });
filename = `${base}.json`;
@@ -118,12 +136,7 @@ export function ExportModal({
blob = new Blob([csv], { type: 'text/csv' });
filename = `${base}.csv`;
}
- const url = URL.createObjectURL(blob);
- const a = document.createElement('a');
- a.href = url;
- a.download = filename;
- a.click();
- setTimeout(() => URL.revokeObjectURL(url), 0);
+ downloadBlob(blob, filename);
setExportProjectOpen(false);
} catch {
// could set export error state
@@ -158,7 +171,7 @@ export function ExportModal({
{exportFormat === 'xlsx' && (
- Excel export will download as CSV for now.
+ Exports as a real Excel workbook (.xlsx) generated on the server.
)}
diff --git a/apps/web/src/services/exportService.ts b/apps/web/src/services/exportService.ts
new file mode 100644
index 00000000..a43af2f5
--- /dev/null
+++ b/apps/web/src/services/exportService.ts
@@ -0,0 +1,32 @@
+import { apiClient } from '../api/client';
+
+export interface ExportHistoryItem {
+ id: string;
+ name?: string;
+ type: string;
+ provider: string;
+ status: string;
+ created_at: string;
+}
+
+export const exportService = {
+ /**
+ * Generate a server-side .xlsx of the given projects' issues and return it as
+ * a Blob to download. The request is recorded in the workspace export history.
+ */
+ async createXlsx(workspaceSlug: string, projectIds: string[], name?: string): Promise {
+ const { data } = await apiClient.post(
+ `/api/workspaces/${encodeURIComponent(workspaceSlug)}/exports/`,
+ { project_ids: projectIds, name },
+ { responseType: 'blob' },
+ );
+ return data;
+ },
+
+ async listHistory(workspaceSlug: string): Promise<{ exports: ExportHistoryItem[] }> {
+ const { data } = await apiClient.get<{ exports: ExportHistoryItem[] }>(
+ `/api/workspaces/${encodeURIComponent(workspaceSlug)}/exports/`,
+ );
+ return data;
+ },
+};
From 0387b7a8e15d42ebcb1d26a62ceb57fd5d6876eb Mon Sep 17 00:00:00 2001
From: martian56
Date: Thu, 9 Jul 2026 03:13:11 +0400
Subject: [PATCH 2/2] refactor(exports): propagate DB errors and surface export
failures
Address CodeRabbit review on #198:
- ExportIssues/ListHistory no longer discard the error from IsMember/
IsInWorkspace/GetBySlug, so a transient DB failure returns 500 instead of
masquerading as a 403/404. Only gorm.ErrRecordNotFound maps to not-found.
- The export modal shows an inline error when a server-side export fails
instead of silently reverting the button.
- go mod tidy moves excelize into the direct-require block (it is imported
directly).
Co-Authored-By: Claude Opus 4.8 (1M context)
---
apps/api/go.mod | 2 +-
apps/api/go.sum | 15 ++--------
apps/api/internal/service/export.go | 29 +++++++++++++++----
.../settings/modals/ExportModal.tsx | 6 +++-
4 files changed, 33 insertions(+), 19 deletions(-)
diff --git a/apps/api/go.mod b/apps/api/go.mod
index 3b66c0ef..187059d3 100644
--- a/apps/api/go.mod
+++ b/apps/api/go.mod
@@ -14,6 +14,7 @@ require (
github.com/stretchr/testify v1.11.1
github.com/testcontainers/testcontainers-go v0.42.0
github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0
+ github.com/xuri/excelize/v2 v2.11.0
golang.org/x/crypto v0.53.0
golang.org/x/net v0.56.0
gorm.io/driver/postgres v1.6.0
@@ -103,7 +104,6 @@ require (
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect
github.com/xuri/efp v0.0.1 // indirect
- github.com/xuri/excelize/v2 v2.11.0 // indirect
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
diff --git a/apps/api/go.sum b/apps/api/go.sum
index 972cf3c3..ce07381a 100644
--- a/apps/api/go.sum
+++ b/apps/api/go.sum
@@ -262,31 +262,22 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
-golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
-golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
-golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
-golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
+golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE=
+golang.org/x/image v0.38.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
-golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
-golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
-golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
-golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
-golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
-golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
-golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
+golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
diff --git a/apps/api/internal/service/export.go b/apps/api/internal/service/export.go
index 7bf85595..d73af7b8 100644
--- a/apps/api/internal/service/export.go
+++ b/apps/api/internal/service/export.go
@@ -10,6 +10,7 @@ import (
"github.com/Devlaner/devlane/api/internal/store"
"github.com/google/uuid"
"github.com/xuri/excelize/v2"
+ "gorm.io/gorm"
)
// ErrNoProjectsSelected is returned when an export names no projects.
@@ -34,16 +35,27 @@ func NewExportService(exporters *store.ExporterStore, issues *store.IssueStore,
func (s *ExportService) ExportIssues(ctx context.Context, slug string, userID uuid.UUID, projectIDs []uuid.UUID, name string) (string, []byte, error) {
wrk, err := s.ws.GetBySlug(ctx, slug)
if err != nil {
- return "", nil, ErrWorkspaceNotFound
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return "", nil, ErrWorkspaceNotFound
+ }
+ return "", nil, err
+ }
+ ok, err := s.ws.IsMember(ctx, wrk.ID, userID)
+ if err != nil {
+ return "", nil, err
}
- if ok, _ := s.ws.IsMember(ctx, wrk.ID, userID); !ok {
+ if !ok {
return "", nil, ErrWorkspaceForbidden
}
if len(projectIDs) == 0 {
return "", nil, ErrNoProjectsSelected
}
for _, pid := range projectIDs {
- if in, _ := s.ps.IsInWorkspace(ctx, pid, wrk.ID); !in {
+ in, err := s.ps.IsInWorkspace(ctx, pid, wrk.ID)
+ if err != nil {
+ return "", nil, err
+ }
+ if !in {
return "", nil, ErrProjectNotFound
}
}
@@ -77,9 +89,16 @@ func (s *ExportService) ExportIssues(ctx context.Context, slug string, userID uu
func (s *ExportService) ListHistory(ctx context.Context, slug string, userID uuid.UUID) ([]model.Exporter, error) {
wrk, err := s.ws.GetBySlug(ctx, slug)
if err != nil {
- return nil, ErrWorkspaceNotFound
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, ErrWorkspaceNotFound
+ }
+ return nil, err
+ }
+ ok, err := s.ws.IsMember(ctx, wrk.ID, userID)
+ if err != nil {
+ return nil, err
}
- if ok, _ := s.ws.IsMember(ctx, wrk.ID, userID); !ok {
+ if !ok {
return nil, ErrWorkspaceForbidden
}
return s.exporters.ListByWorkspaceID(ctx, wrk.ID)
diff --git a/apps/web/src/components/settings/modals/ExportModal.tsx b/apps/web/src/components/settings/modals/ExportModal.tsx
index 400b8c1f..d2900cde 100644
--- a/apps/web/src/components/settings/modals/ExportModal.tsx
+++ b/apps/web/src/components/settings/modals/ExportModal.tsx
@@ -1,3 +1,4 @@
+import { useState } from 'react';
import { Button, Modal } from '../../ui';
import { IconChevronDown } from '../icons';
import { issueService } from '../../../services/issueService';
@@ -41,6 +42,7 @@ export function ExportModal({
setExporting,
setExportProjectOpen,
}: ExportModalProps) {
+ const [error, setError] = useState(null);
return (
{
if (!workspaceSlug) return;
+ setError(null);
setExporting(true);
try {
const projectIds =
@@ -139,7 +142,7 @@ export function ExportModal({
downloadBlob(blob, filename);
setExportProjectOpen(false);
} catch {
- // could set export error state
+ setError('Export failed. Please try again.');
} finally {
setExporting(false);
}
@@ -174,6 +177,7 @@ export function ExportModal({
Exports as a real Excel workbook (.xlsx) generated on the server.
)}
+ {error && {error}
}
);