From e25c4520d478901a7a690b9c2f5200941950f68a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Mar 2026 09:27:54 +0000 Subject: [PATCH 1/2] Add ticket code feature for cards (like Jira project keys) Each board can now have a card prefix (e.g. "PROJ", "BUG") and cards get auto-incrementing numbers, producing ticket codes like PROJ-1, PROJ-2, etc. This enables git branch naming, commit references, and quick card identification. Backend changes: - DB migration 000041: adds card_prefix, card_count to boards table - Board model: new CardPrefix and CardCount fields with patch support - Card model: new CardNumber field stored in block fields, computed TicketCode field, and ParseTicketCode helper - Store: atomic IncrementBoardCardCount and GetCardByTicketCode methods - App: auto-assigns ticket numbers on card creation, populates ticket codes on card retrieval - API: new GET /teams/{teamID}/cards/by-ticket-code/{ticketCode} endpoint to look up cards by ticket code Frontend changes: - Board/Card TypeScript types updated with new fields - Kanban cards show ticket code badge above title - Table rows show ticket code inline before title - Card detail shows clickable ticket code (copies to clipboard) - Board view title has new "Ticket prefix" setting with live preview - Mutator method for changing board card prefix with undo support https://claude.ai/code/session_017e9AHfd7FTjFDHSTaEZYvW --- server/api/cards.go | 72 +++++++++++++++++ server/app/cards.go | 52 +++++++++++++ server/model/board.go | 20 +++++ server/model/card.go | 48 ++++++++++++ server/services/store/mockstore/mockstore.go | 30 ++++++++ server/services/store/sqlstore/board.go | 77 +++++++++++++++++++ .../000041_add_ticket_codes.down.sql | 1 + .../migrations/000041_add_ticket_codes.up.sql | 5 ++ .../services/store/sqlstore/public_methods.go | 10 +++ server/services/store/store.go | 4 + webapp/src/blocks/board.ts | 5 ++ webapp/src/blocks/card.ts | 5 ++ .../src/components/cardDetail/cardDetail.scss | 19 +++++ .../src/components/cardDetail/cardDetail.tsx | 13 ++++ webapp/src/components/kanban/kanbanCard.scss | 14 ++++ webapp/src/components/kanban/kanbanCard.tsx | 5 ++ webapp/src/components/table/tableRow.scss | 15 ++++ webapp/src/components/table/tableRow.tsx | 5 ++ webapp/src/components/viewTitle.scss | 41 ++++++++++ webapp/src/components/viewTitle.tsx | 38 +++++++++ webapp/src/mutator.ts | 13 ++++ 21 files changed, 492 insertions(+) create mode 100644 server/services/store/sqlstore/migrations/000041_add_ticket_codes.down.sql create mode 100644 server/services/store/sqlstore/migrations/000041_add_ticket_codes.up.sql diff --git a/server/api/cards.go b/server/api/cards.go index 18689ee5c..52405c5bb 100644 --- a/server/api/cards.go +++ b/server/api/cards.go @@ -28,6 +28,7 @@ func (a *API) registerCardsRoutes(r *mux.Router) { r.HandleFunc("/boards/{boardID}/cards", a.sessionRequired(a.handleGetCards)).Methods("GET") r.HandleFunc("/cards/{cardID}", a.sessionRequired(a.handlePatchCard)).Methods("PATCH") r.HandleFunc("/cards/{cardID}", a.sessionRequired(a.handleGetCard)).Methods("GET") + r.HandleFunc("/teams/{teamID}/cards/by-ticket-code/{ticketCode}", a.sessionRequired(a.handleGetCardByTicketCode)).Methods("GET") } func (a *API) handleCreateCard(w http.ResponseWriter, r *http.Request) { @@ -390,3 +391,74 @@ func (a *API) handleGetCard(w http.ResponseWriter, r *http.Request) { auditRec.Success() } + +func (a *API) handleGetCardByTicketCode(w http.ResponseWriter, r *http.Request) { + // swagger:operation GET /teams/{teamID}/cards/by-ticket-code/{ticketCode} getCardByTicketCode + // + // Fetches a card by its ticket code (e.g. PROJ-42). + // + // --- + // produces: + // - application/json + // parameters: + // - name: teamID + // in: path + // description: Team ID + // required: true + // type: string + // - name: ticketCode + // in: path + // description: Ticket code (e.g. PROJ-42) + // required: true + // type: string + // security: + // - BearerAuth: [] + // responses: + // '200': + // description: success + // schema: + // $ref: '#/definitions/Card' + // default: + // description: internal error + // schema: + // "$ref": "#/definitions/ErrorResponse" + + userID := getUserID(r) + teamID := mux.Vars(r)["teamID"] + ticketCode := mux.Vars(r)["ticketCode"] + + card, err := a.app.GetCardByTicketCode(ticketCode, teamID) + if err != nil { + a.errorResponse(w, r, err) + return + } + + if !a.permissions.HasPermissionToBoard(userID, card.BoardID, model.PermissionViewBoard) { + a.errorResponse(w, r, model.NewErrPermission("access denied to fetch card")) + return + } + + auditRec := a.makeAuditRecord(r, "getCardByTicketCode", audit.Fail) + defer a.audit.LogRecord(audit.LevelRead, auditRec) + auditRec.AddMeta("teamID", teamID) + auditRec.AddMeta("ticketCode", ticketCode) + auditRec.AddMeta("cardID", card.ID) + + a.logger.Debug("GetCardByTicketCode", + mlog.String("teamID", teamID), + mlog.String("ticketCode", ticketCode), + mlog.String("cardID", card.ID), + mlog.String("userID", userID), + ) + + data, err := json.Marshal(card) + if err != nil { + a.errorResponse(w, r, err) + return + } + + // response + jsonBytesResponse(w, http.StatusOK, data) + + auditRec.Success() +} diff --git a/server/app/cards.go b/server/app/cards.go index cfdea304c..44cc730df 100644 --- a/server/app/cards.go +++ b/server/app/cards.go @@ -22,6 +22,19 @@ func (a *App) CreateCard(card *model.Card, boardID string, userID string, disabl card.UpdateAt = now card.DeleteAt = 0 + // Auto-assign ticket number: atomically increment the board's card counter + cardNumber, err := a.store.IncrementBoardCardCount(boardID) + if err != nil { + return nil, fmt.Errorf("cannot assign ticket number: %w", err) + } + card.CardNumber = cardNumber + + // Generate the ticket code if the board has a prefix + board, err := a.store.GetBoard(boardID) + if err == nil && board.CardPrefix != "" { + card.TicketCode = fmt.Sprintf("%s-%d", board.CardPrefix, cardNumber) + } + block := model.Card2Block(card) newBlocks, err := a.InsertBlocksAndNotify([]*model.Block{block}, userID, disableNotify) @@ -34,6 +47,11 @@ func (a *App) CreateCard(card *model.Card, boardID string, userID string, disabl return nil, err } + // Re-attach the ticket code (it's not stored in block fields, it's computed) + if board != nil && board.CardPrefix != "" { + newCard.TicketCode = fmt.Sprintf("%s-%d", board.CardPrefix, newCard.CardNumber) + } + return newCard, nil } @@ -50,12 +68,18 @@ func (a *App) GetCardsForBoard(boardID string, page int, perPage int) ([]*model. return nil, err } + // Fetch board prefix for ticket code computation + board, _ := a.store.GetBoard(boardID) + cards := make([]*model.Card, 0, len(blocks)) for _, blk := range blocks { b := blk if card, err := model.Block2Card(b); err != nil { return nil, fmt.Errorf("Block2Card fail: %w", err) } else { + if board != nil && board.CardPrefix != "" && card.CardNumber > 0 { + card.TicketCode = fmt.Sprintf("%s-%d", board.CardPrefix, card.CardNumber) + } cards = append(cards, card) } } @@ -92,5 +116,33 @@ func (a *App) GetCardByID(cardID string) (*model.Card, error) { return nil, err } + // Populate ticket code from board prefix + if card.CardNumber > 0 { + board, boardErr := a.store.GetBoard(card.BoardID) + if boardErr == nil && board.CardPrefix != "" { + card.TicketCode = fmt.Sprintf("%s-%d", board.CardPrefix, card.CardNumber) + } + } + + return card, nil +} + +func (a *App) GetCardByTicketCode(ticketCode string, teamID string) (*model.Card, error) { + prefix, number, err := model.ParseTicketCode(ticketCode) + if err != nil { + return nil, err + } + + block, err := a.store.GetCardByTicketCode(prefix, number, teamID) + if err != nil { + return nil, err + } + + card, err := model.Block2Card(block) + if err != nil { + return nil, err + } + + card.TicketCode = ticketCode return card, nil } diff --git a/server/model/board.go b/server/model/board.go index b17f198d9..c0483a188 100644 --- a/server/model/board.go +++ b/server/model/board.go @@ -97,6 +97,14 @@ type Board struct { // required: false CardProperties []map[string]interface{} `json:"cardProperties"` + // The short prefix used for ticket codes (e.g. "PROJ", "BUG") + // required: false + CardPrefix string `json:"cardPrefix"` + + // The auto-incrementing counter for card ticket numbers + // required: false + CardCount int64 `json:"cardCount"` + // The creation time in miliseconds since the current epoch // required: true CreateAt int64 `json:"createAt"` @@ -156,6 +164,10 @@ type BoardPatch struct { // required: false ChannelID *string `json:"channelId"` + // The short prefix used for ticket codes (e.g. "PROJ", "BUG") + // required: false + CardPrefix *string `json:"cardPrefix"` + // The board updated properties // required: false UpdatedProperties map[string]interface{} `json:"updatedProperties"` @@ -297,6 +309,10 @@ func (p *BoardPatch) Patch(board *Board) *Board { board.ChannelID = *p.ChannelID } + if p.CardPrefix != nil { + board.CardPrefix = *p.CardPrefix + } + for key, property := range p.UpdatedProperties { board.Properties[key] = property } @@ -377,6 +393,10 @@ func (p *BoardPatch) IsValid() error { return InvalidBoardErr{"invalid-channel-id"} } + if p.CardPrefix != nil && len(*p.CardPrefix) > 10 { + return InvalidBoardErr{"invalid-card-prefix-too-long"} + } + return nil } diff --git a/server/model/card.go b/server/model/card.go index a87e0edf6..13b6c9661 100644 --- a/server/model/card.go +++ b/server/model/card.go @@ -4,14 +4,38 @@ package model import ( + "encoding/json" "errors" "fmt" + "strconv" + "strings" "github.com/mattermost/mattermost-plugin-boards/server/utils" "github.com/rivo/uniseg" ) var ErrBoardIDMismatch = errors.New("Board IDs do not match") +var ErrInvalidTicketCode = errors.New("invalid ticket code format, expected PREFIX-NUMBER") + +// ParseTicketCode parses a ticket code like "PROJ-42" into its prefix ("PROJ") and number (42). +func ParseTicketCode(ticketCode string) (string, int64, error) { + parts := strings.SplitN(ticketCode, "-", 2) + if len(parts) != 2 { + return "", 0, ErrInvalidTicketCode + } + + prefix := strings.TrimSpace(parts[0]) + if prefix == "" { + return "", 0, ErrInvalidTicketCode + } + + number, err := strconv.ParseInt(strings.TrimSpace(parts[1]), 10, 64) + if err != nil || number <= 0 { + return "", 0, ErrInvalidTicketCode + } + + return strings.ToUpper(prefix), number, nil +} type ErrInvalidCard struct { msg string @@ -76,6 +100,14 @@ type Card struct { // required: false Properties map[string]any `json:"properties"` + // The sequential card number within the board (auto-assigned) + // required: false + CardNumber int64 `json:"cardNumber"` + + // The full ticket code combining board prefix and card number (e.g. "PROJ-42") + // required: false + TicketCode string `json:"ticketCode,omitempty"` + // The creation time in milliseconds since the current epoch // required: false CreateAt int64 `json:"createAt"` @@ -203,6 +235,7 @@ func Card2Block(card *Card) *Block { fields["icon"] = card.Icon fields["isTemplate"] = card.IsTemplate fields["properties"] = card.Properties + fields["cardNumber"] = card.CardNumber return &Block{ ID: card.ID, @@ -230,6 +263,7 @@ func Block2Card(block *Block) (*Card, error) { icon := "" isTemplate := false properties := make(map[string]any) + var cardNumber int64 if co, ok := block.Fields["contentOrder"]; ok { switch arr := co.(type) { @@ -274,6 +308,19 @@ func Block2Card(block *Block) (*Card, error) { } } + if cn, ok := block.Fields["cardNumber"]; ok { + switch v := cn.(type) { + case float64: + cardNumber = int64(v) + case int64: + cardNumber = v + case json.Number: + if n, err := v.Int64(); err == nil { + cardNumber = n + } + } + } + card := &Card{ ID: block.ID, BoardID: block.BoardID, @@ -284,6 +331,7 @@ func Block2Card(block *Block) (*Card, error) { Icon: icon, IsTemplate: isTemplate, Properties: properties, + CardNumber: cardNumber, CreateAt: block.CreateAt, UpdateAt: block.UpdateAt, DeleteAt: block.DeleteAt, diff --git a/server/services/store/mockstore/mockstore.go b/server/services/store/mockstore/mockstore.go index 2a2535979..9faa5b72f 100644 --- a/server/services/store/mockstore/mockstore.go +++ b/server/services/store/mockstore/mockstore.go @@ -1244,6 +1244,36 @@ func (mr *MockStoreMockRecorder) InsertBoardWithAdmin(board, userID interface{}) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertBoardWithAdmin", reflect.TypeOf((*MockStore)(nil).InsertBoardWithAdmin), board, userID) } +// IncrementBoardCardCount mocks base method. +func (m *MockStore) IncrementBoardCardCount(boardID string) (int64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "IncrementBoardCardCount", boardID) + ret0, _ := ret[0].(int64) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// IncrementBoardCardCount indicates an expected call of IncrementBoardCardCount. +func (mr *MockStoreMockRecorder) IncrementBoardCardCount(boardID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IncrementBoardCardCount", reflect.TypeOf((*MockStore)(nil).IncrementBoardCardCount), boardID) +} + +// GetCardByTicketCode mocks base method. +func (m *MockStore) GetCardByTicketCode(boardPrefix string, cardNumber int64, teamID string) (*model.Block, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetCardByTicketCode", boardPrefix, cardNumber, teamID) + ret0, _ := ret[0].(*model.Block) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetCardByTicketCode indicates an expected call of GetCardByTicketCode. +func (mr *MockStoreMockRecorder) GetCardByTicketCode(boardPrefix, cardNumber, teamID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetCardByTicketCode", reflect.TypeOf((*MockStore)(nil).GetCardByTicketCode), boardPrefix, cardNumber, teamID) +} + // PatchBlock mocks base method. func (m *MockStore) PatchBlock(blockID string, blockPatch *model.BlockPatch, userID string) error { m.ctrl.T.Helper() diff --git a/server/services/store/sqlstore/board.go b/server/services/store/sqlstore/board.go index 4bbde3f6d..4c6dc3406 100644 --- a/server/services/store/sqlstore/board.go +++ b/server/services/store/sqlstore/board.go @@ -43,6 +43,8 @@ func boardFields(tableAlias string) []string { tableAlias + "template_version", "COALESCE(" + tableAlias + "properties, '{}')", "COALESCE(" + tableAlias + "card_properties, '[]')", + "COALESCE(" + tableAlias + "card_prefix, '')", + "COALESCE(" + tableAlias + "card_count, 0)", tableAlias + "create_at", tableAlias + "update_at", tableAlias + "delete_at", @@ -66,6 +68,8 @@ func boardHistoryFields() []string { "template_version", "COALESCE(properties, '{}')", "COALESCE(card_properties, '[]')", + "COALESCE(card_prefix, '')", + "COALESCE(card_count, 0)", "COALESCE(create_at, 0)", "COALESCE(update_at, 0)", "COALESCE(delete_at, 0)", @@ -109,6 +113,8 @@ func (s *SQLStore) boardsFromRows(rows *sql.Rows) ([]*model.Board, error) { &board.TemplateVersion, &propertiesBytes, &cardPropertiesBytes, + &board.CardPrefix, + &board.CardCount, &board.CreateAt, &board.UpdateAt, &board.DeleteAt, @@ -326,6 +332,8 @@ func (s *SQLStore) insertBoard(db sq.BaseRunner, board *model.Board, userID stri "template_version": board.TemplateVersion, "properties": propertiesBytes, "card_properties": cardPropertiesBytes, + "card_prefix": board.CardPrefix, + "card_count": board.CardCount, "create_at": board.CreateAt, "update_at": board.UpdateAt, "delete_at": board.DeleteAt, @@ -346,6 +354,8 @@ func (s *SQLStore) insertBoard(db sq.BaseRunner, board *model.Board, userID stri Set("template_version", board.TemplateVersion). Set("properties", propertiesBytes). Set("card_properties", cardPropertiesBytes). + Set("card_prefix", board.CardPrefix). + Set("card_count", board.CardCount). Set("update_at", board.UpdateAt). Set("delete_at", board.DeleteAt) @@ -422,6 +432,8 @@ func (s *SQLStore) deleteBoardAndChildren(db sq.BaseRunner, boardID, userID stri "template_version": board.TemplateVersion, "properties": propertiesBytes, "card_properties": cardPropertiesBytes, + "card_prefix": board.CardPrefix, + "card_count": board.CardCount, "create_at": board.CreateAt, "update_at": now, "delete_at": now, @@ -1186,3 +1198,68 @@ func (s *SQLStore) searchBoardsForUser(db sq.BaseRunner, term string, searchFiel return s.boardsFromRows(rows) } + +func (s *SQLStore) incrementBoardCardCount(db sq.BaseRunner, boardID string) (int64, error) { + query := s.getQueryBuilder(db). + Update(s.tablePrefix+"boards"). + Set("card_count", sq.Expr("COALESCE(card_count, 0) + 1")). + Where(sq.Eq{"id": boardID}) + + if _, err := query.Exec(); err != nil { + return 0, fmt.Errorf("incrementBoardCardCount error for board %s: %w", boardID, err) + } + + // Read back the new count + row := s.getQueryBuilder(db). + Select("COALESCE(card_count, 0)"). + From(s.tablePrefix + "boards"). + Where(sq.Eq{"id": boardID}). + QueryRow() + + var count int64 + if err := row.Scan(&count); err != nil { + return 0, fmt.Errorf("incrementBoardCardCount read error for board %s: %w", boardID, err) + } + + return count, nil +} + +func (s *SQLStore) getCardByTicketCode(db sq.BaseRunner, boardPrefix string, cardNumber int64, teamID string) (*model.Block, error) { + query := s.getQueryBuilder(db). + Select(s.blockFields("b")...). + From(s.tablePrefix + "blocks AS b"). + Join(s.tablePrefix + "boards AS bo ON b.board_id = bo.id"). + Where(sq.Eq{"bo.card_prefix": boardPrefix}). + Where(sq.Eq{"bo.team_id": teamID}). + Where(sq.Eq{"b.type": model.TypeCard}). + Where(sq.Eq{"b.delete_at": 0}) + + rows, err := query.Query() + if err != nil { + return nil, fmt.Errorf("getCardByTicketCode error: %w", err) + } + defer s.CloseRows(rows) + + blocks, err := s.blocksFromRows(rows) + if err != nil { + return nil, err + } + + // Find the block whose fields.cardNumber matches + for _, block := range blocks { + if cn, ok := block.Fields["cardNumber"]; ok { + var num int64 + switch v := cn.(type) { + case float64: + num = int64(v) + case int64: + num = v + } + if num == cardNumber { + return block, nil + } + } + } + + return nil, model.NewErrNotFound("card with ticket code") +} diff --git a/server/services/store/sqlstore/migrations/000041_add_ticket_codes.down.sql b/server/services/store/sqlstore/migrations/000041_add_ticket_codes.down.sql new file mode 100644 index 000000000..e0ac49d1e --- /dev/null +++ b/server/services/store/sqlstore/migrations/000041_add_ticket_codes.down.sql @@ -0,0 +1 @@ +SELECT 1; diff --git a/server/services/store/sqlstore/migrations/000041_add_ticket_codes.up.sql b/server/services/store/sqlstore/migrations/000041_add_ticket_codes.up.sql new file mode 100644 index 000000000..25f46478d --- /dev/null +++ b/server/services/store/sqlstore/migrations/000041_add_ticket_codes.up.sql @@ -0,0 +1,5 @@ +{{- /* Add ticket code support: card_prefix and card_count columns to boards and boards_history tables */ -}} +{{ addColumnIfNeeded "boards" "card_prefix" "varchar(10)" "" }} +{{ addColumnIfNeeded "boards" "card_count" "bigint" "" }} +{{ addColumnIfNeeded "boards_history" "card_prefix" "varchar(10)" "" }} +{{ addColumnIfNeeded "boards_history" "card_count" "bigint" "" }} diff --git a/server/services/store/sqlstore/public_methods.go b/server/services/store/sqlstore/public_methods.go index 75edfeb8b..a18ad4ea6 100644 --- a/server/services/store/sqlstore/public_methods.go +++ b/server/services/store/sqlstore/public_methods.go @@ -922,3 +922,13 @@ func (s *SQLStore) UpsertTeamSignupToken(team model.Team) error { return s.upsertTeamSignupToken(s.db, team) } + +func (s *SQLStore) IncrementBoardCardCount(boardID string) (int64, error) { + return s.incrementBoardCardCount(s.db, boardID) + +} + +func (s *SQLStore) GetCardByTicketCode(boardPrefix string, cardNumber int64, teamID string) (*model.Block, error) { + return s.getCardByTicketCode(s.db, boardPrefix, cardNumber, teamID) + +} diff --git a/server/services/store/store.go b/server/services/store/store.go index f7e2aa0c0..059141b40 100644 --- a/server/services/store/store.go +++ b/server/services/store/store.go @@ -83,6 +83,10 @@ type Store interface { InsertBoard(board *model.Board, userID string) (*model.Board, error) // @withTransaction InsertBoardWithAdmin(board *model.Board, userID string) (*model.Board, *model.BoardMember, error) + // IncrementBoardCardCount atomically increments the card_count for a board and returns the new count. + IncrementBoardCardCount(boardID string) (int64, error) + // GetCardByTicketCode returns a card block matching the given board prefix and card number. + GetCardByTicketCode(boardPrefix string, cardNumber int64, teamID string) (*model.Block, error) // @withTransaction PatchBoard(boardID string, boardPatch *model.BoardPatch, userID string) (*model.Board, error) GetBoard(id string) (*model.Board, error) diff --git a/webapp/src/blocks/board.ts b/webapp/src/blocks/board.ts index b9ea389c5..47344513c 100644 --- a/webapp/src/blocks/board.ts +++ b/webapp/src/blocks/board.ts @@ -39,6 +39,8 @@ type Board = { templateVersion: number properties: Record cardProperties: IPropertyTemplate[] + cardPrefix: string + cardCount: number createAt: number updateAt: number @@ -52,6 +54,7 @@ type BoardPatch = { description?: string icon?: string showDescription?: boolean + cardPrefix?: string // eslint-disable-next-line @typescript-eslint/no-explicit-any updatedProperties?: Record deletedProperties?: string[] @@ -142,6 +145,8 @@ function createBoard(board?: Board): Board { templateVersion: board?.templateVersion || 0, properties: board?.properties || {}, cardProperties, + cardPrefix: board?.cardPrefix || '', + cardCount: board?.cardCount || 0, createAt: board?.createAt || now, updateAt: board?.updateAt || now, deleteAt: board?.deleteAt || 0, diff --git a/webapp/src/blocks/card.ts b/webapp/src/blocks/card.ts index d755b67cd..5a0db2da7 100644 --- a/webapp/src/blocks/card.ts +++ b/webapp/src/blocks/card.ts @@ -9,10 +9,13 @@ type CardFields = { isTemplate?: boolean properties: Record contentOrder: Array + cardNumber?: number } type Card = Block & { fields: CardFields + cardNumber?: number + ticketCode?: string } function createCard(block?: Block): Card { @@ -36,7 +39,9 @@ function createCard(block?: Block): Card { properties: {...(block?.fields.properties || {})}, contentOrder, isTemplate: block?.fields.isTemplate || false, + cardNumber: block?.fields.cardNumber || 0, }, + cardNumber: block?.fields.cardNumber || 0, } } diff --git a/webapp/src/components/cardDetail/cardDetail.scss b/webapp/src/components/cardDetail/cardDetail.scss index 91ad4ee3f..3e681b1cb 100644 --- a/webapp/src/components/cardDetail/cardDetail.scss +++ b/webapp/src/components/cardDetail/cardDetail.scss @@ -1,6 +1,25 @@ @import '../../styles/z-index'; .CardDetail { + .ticket-code { + display: inline-block; + font-size: 12px; + font-weight: 700; + font-family: monospace; + color: rgba(var(--center-channel-color-rgb), 0.56); + background: rgba(var(--center-channel-color-rgb), 0.08); + border-radius: 4px; + padding: 2px 8px; + margin-bottom: 4px; + cursor: pointer; + user-select: all; + letter-spacing: 0.5px; + + &:hover { + background: rgba(var(--center-channel-color-rgb), 0.16); + } + } + .title { width: 100%; font-size: 32px; diff --git a/webapp/src/components/cardDetail/cardDetail.tsx b/webapp/src/components/cardDetail/cardDetail.tsx index 0f251a9ee..bb8ee0f33 100644 --- a/webapp/src/components/cardDetail/cardDetail.tsx +++ b/webapp/src/components/cardDetail/cardDetail.tsx @@ -221,6 +221,19 @@ const CardDetail = (props: Props): JSX.Element|null => { } + {props.board.cardPrefix && card.fields.cardNumber ? ( +
{ + const code = `${props.board.cardPrefix}-${card.fields.cardNumber}` + Utils.copyTextToClipboard(code) + }} + > + {`${props.board.cardPrefix}-${card.fields.cardNumber}`} +
+ ) : null} + { } + {board.cardPrefix && card.fields.cardNumber ? ( +
+ {`${board.cardPrefix}-${card.fields.cardNumber}`} +
+ ) : null}
{ card.fields.icon ?
{emojiData?.native || card.fields.icon}
: undefined }