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..3af31767e 100644 --- a/server/model/board.go +++ b/server/model/board.go @@ -32,6 +32,7 @@ const ( BoardSearchFieldNone BoardSearchField = "" BoardSearchFieldTitle BoardSearchField = "title" BoardSearchFieldPropertyName BoardSearchField = "property_name" + BoardSearchFieldCardPrefix BoardSearchField = "card_prefix" ) // Board groups a set of blocks and its layout @@ -97,6 +98,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 +165,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 +310,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 +394,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 } @@ -447,6 +468,8 @@ func BoardSearchFieldFromString(field string) (BoardSearchField, error) { return BoardSearchFieldTitle, nil case string(BoardSearchFieldPropertyName): return BoardSearchFieldPropertyName, nil + case string(BoardSearchFieldCardPrefix): + return BoardSearchFieldCardPrefix, nil } return BoardSearchFieldNone, ErrInvalidBoardSearchField } 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..eba54e60d 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, @@ -1004,16 +1016,16 @@ func (s *SQLStore) searchBoardsForUserInTeam(db sq.BaseRunner, teamID, term, use }) if term != "" { - // break search query into space separated words - // and search for all words. - // This should later be upgraded to industrial-strength - // word tokenizer, that uses much more than space - // to break words. - + // Search both title and card_prefix (OR logic) so users can find + // boards by their ticket code prefix (e.g. searching "PROJ" finds + // the board with card_prefix=PROJ). conditions := sq.And{} - for _, word := range strings.Split(strings.TrimSpace(term), " ") { - conditions = append(conditions, sq.Like{"lower(b.title)": "%" + strings.ToLower(word) + "%"}) + lowerWord := "%" + strings.ToLower(word) + "%" + conditions = append(conditions, sq.Or{ + sq.Like{"lower(b.title)": lowerWord}, + sq.Like{"lower(b.card_prefix)": lowerWord}, + }) } openBoardsQ = openBoardsQ.Where(conditions) @@ -1114,15 +1126,23 @@ func (s *SQLStore) searchBoardsForUser(db sq.BaseRunner, term string, searchFiel boardMembersQ = boardMembersQ.Where(where, whereTerm) teamMembersQ = teamMembersQ.Where(where, whereTerm) channelMembersQ = channelMembersQ.Where(where, whereTerm) + } else if searchField == model.BoardSearchFieldCardPrefix { + conditions := sq.Like{"lower(b.card_prefix)": "%" + strings.ToLower(term) + "%"} + boardMembersQ = boardMembersQ.Where(conditions) + teamMembersQ = teamMembersQ.Where(conditions) + channelMembersQ = channelMembersQ.Where(conditions) } else { // model.BoardSearchFieldTitle // break search query into space separated words // and search for all words. - // This should later be upgraded to industrial-strength - // word tokenizer, that uses much more than space - // to break words. + // Also match card_prefix so users can find boards + // by typing a ticket code prefix. conditions := sq.And{} for _, word := range strings.Split(strings.TrimSpace(term), " ") { - conditions = append(conditions, sq.Like{"lower(b.title)": "%" + strings.ToLower(word) + "%"}) + lowerWord := "%" + strings.ToLower(word) + "%" + conditions = append(conditions, sq.Or{ + sq.Like{"lower(b.title)": lowerWord}, + sq.Like{"lower(b.card_prefix)": lowerWord}, + }) } boardMembersQ = boardMembersQ.Where(conditions) @@ -1186,3 +1206,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 }