Skip to content

Commit

Permalink
feat: import exported boards (#4392)
Browse files Browse the repository at this point in the history
Co-authored-by: Jakob Schwehn <[email protected]>
  • Loading branch information
mateo-ivc and Schwehn42 authored Nov 11, 2024
1 parent 791e502 commit b35e3b3
Show file tree
Hide file tree
Showing 30 changed files with 855 additions and 65 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"i18next": "^23.16.4",
"i18next-browser-languagedetector": "^8.0.0",
"js-cookie": "^3.0.5",
"js-md5": "^0.8.3",
"linkify-react": "^4.1.3",
"linkifyjs": "^4.1.3",
"marked": "14.1.3",
Expand Down
116 changes: 115 additions & 1 deletion server/src/api/boards.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ import (
func (s *Server) createBoard(w http.ResponseWriter, r *http.Request) {
log := logger.FromRequest(r)
owner := r.Context().Value(identifiers.UserIdentifier).(uuid.UUID)

// parse request
var body dto.CreateBoardRequest
if err := render.Decode(r, &body); err != nil {
Expand Down Expand Up @@ -415,3 +414,118 @@ func (s *Server) exportBoard(w http.ResponseWriter, r *http.Request) {
render.Status(r, http.StatusNotAcceptable)
render.Respond(w, r, nil)
}

func (s *Server) importBoard(w http.ResponseWriter, r *http.Request) {
log := logger.FromRequest(r)
owner := r.Context().Value(identifiers.UserIdentifier).(uuid.UUID)
var body dto.ImportBoardRequest
if err := render.Decode(r, &body); err != nil {
log.Errorw("Could not read body", "err", err)
common.Throw(w, r, common.BadRequestError(err))
return
}

body.Board.Owner = owner

columns := make([]dto.ColumnRequest, 0, len(body.Notes))

for _, column := range body.Columns {
columns = append(columns, dto.ColumnRequest{
Name: column.Name,
Color: column.Color,
Visible: &column.Visible,
Index: &column.Index,
})
}
b, err := s.boards.Create(r.Context(), dto.CreateBoardRequest{
Name: body.Board.Name,
Description: body.Board.Description,
AccessPolicy: body.Board.AccessPolicy,
Passphrase: body.Board.Passphrase,
Columns: columns,
Owner: owner,
})

if err != nil {
log.Errorw("Could not import board", "err", err)
common.Throw(w, r, err)
return
}

cols, err := s.boards.ListColumns(r.Context(), b.ID)
if err != nil {
_ = s.boards.Delete(r.Context(), b.ID)

}

type ParentChildNotes struct {
Parent dto.Note
Children []dto.Note
}
parentNotes := make(map[uuid.UUID]dto.Note)
childNotes := make(map[uuid.UUID][]dto.Note)

for _, note := range body.Notes {
if !note.Position.Stack.Valid {
parentNotes[note.ID] = note
} else {
childNotes[note.Position.Stack.UUID] = append(childNotes[note.Position.Stack.UUID], note)
}
}

var organizedNotes []ParentChildNotes
for parentID, parentNote := range parentNotes {
for i, column := range body.Columns {
if parentNote.Position.Column == column.ID {

note, err := s.notes.Import(r.Context(), dto.NoteImportRequest{
Text: parentNote.Text,
Position: dto.NotePosition{
Column: cols[i].ID,
Stack: uuid.NullUUID{},
Rank: 0,
},
Board: b.ID,
User: parentNote.Author,
})
if err != nil {
_ = s.boards.Delete(r.Context(), b.ID)
common.Throw(w, r, err)
return
}
parentNote = *note
}
}
organizedNotes = append(organizedNotes, ParentChildNotes{
Parent: parentNote,
Children: childNotes[parentID],
})
}

for _, node := range organizedNotes {
for _, note := range node.Children {
_, err := s.notes.Import(r.Context(), dto.NoteImportRequest{
Text: note.Text,
Board: b.ID,
User: note.Author,
Position: dto.NotePosition{
Column: node.Parent.Position.Column,
Rank: note.Position.Rank,
Stack: uuid.NullUUID{
UUID: node.Parent.ID,
Valid: true,
},
},
})
if err != nil {
_ = s.boards.Delete(r.Context(), b.ID)
common.Throw(w, r, err)
return
}

}
}

render.Status(r, http.StatusCreated)
render.Respond(w, r, b)
}
1 change: 1 addition & 0 deletions server/src/api/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@ func (s *Server) protectedRoutes(r chi.Router) {
})

r.Post("/boards", s.createBoard)
r.Post("/import", s.importBoard)
r.Get("/boards", s.getBoards)
r.Route("/boards/{id}", func(r chi.Router) {
r.With(s.BoardParticipantContext).Get("/", s.getBoard)
Expand Down
7 changes: 7 additions & 0 deletions server/src/common/dto/boards.go
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,13 @@ type BoardOverview struct {
Participants int `json:"participants"`
}

type ImportBoardRequest struct {
Board *CreateBoardRequest `json:"board"`
Columns []Column `json:"columns"`
Notes []Note `json:"notes"`
Votings []Voting `json:"votings"`
}

type FullBoard struct {
Board *Board `json:"board"`
BoardSessionRequests []*BoardSessionRequest `json:"requests"`
Expand Down
9 changes: 9 additions & 0 deletions server/src/common/dto/notes.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,15 @@ type NoteCreateRequest struct {
User uuid.UUID `json:"-"`
}

type NoteImportRequest struct {
// The text of the note.
Text string `json:"text"`
Position NotePosition `json:"position"`

Board uuid.UUID `json:"-"`
User uuid.UUID `json:"-"`
}

// NoteUpdateRequest represents the request to update a note.
type NoteUpdateRequest struct {

Expand Down
18 changes: 18 additions & 0 deletions server/src/database/notes.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,14 @@ type NoteInsert struct {
Text string
}

type NoteImport struct {
bun.BaseModel `bun:"table:notes"`
Author uuid.UUID
Board uuid.UUID
Text string
Position *NoteUpdatePosition `bun:",embed"`
}

type NoteUpdatePosition struct {
Column uuid.UUID
Rank int
Expand All @@ -58,6 +66,16 @@ func (d *Database) CreateNote(insert NoteInsert) (Note, error) {
return note, err
}

func (d *Database) ImportNote(insert NoteImport) (Note, error) {
var note Note
query := d.db.NewInsert().
Model(&insert).
Returning("*")
_, err := query.Exec(common.ContextWithValues(context.Background(), "Database", d, identifiers.BoardIdentifier, insert.Board), &note)

return note, err
}

func (d *Database) GetNote(id uuid.UUID) (Note, error) {
var note Note
err := d.db.NewSelect().Model((*Note)(nil)).Where("id = ?", id).Scan(context.Background(), &note)
Expand Down
22 changes: 21 additions & 1 deletion server/src/services/notes/notes.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package notes
import (
"context"
"database/sql"

"scrumlr.io/server/common"
"scrumlr.io/server/identifiers"
"scrumlr.io/server/services"
Expand All @@ -25,6 +24,7 @@ type NoteService struct {

type DB interface {
CreateNote(insert database.NoteInsert) (database.Note, error)
ImportNote(note database.NoteImport) (database.Note, error)
GetNote(id uuid.UUID) (database.Note, error)
GetNotes(board uuid.UUID, columns ...uuid.UUID) ([]database.Note, error)
UpdateNote(caller uuid.UUID, update database.NoteUpdate) (database.Note, error)
Expand All @@ -50,6 +50,26 @@ func (s *NoteService) Create(ctx context.Context, body dto.NoteCreateRequest) (*
return new(dto.Note).From(note), err
}

func (s *NoteService) Import(ctx context.Context, body dto.NoteImportRequest) (*dto.Note, error) {
log := logger.FromContext(ctx)

note, err := s.database.ImportNote(database.NoteImport{
Author: body.User,
Board: body.Board,
Position: &database.NoteUpdatePosition{
Column: body.Position.Column,
Rank: body.Position.Rank,
Stack: body.Position.Stack,
},
Text: body.Text,
})
if err != nil {
log.Errorw("Could not import notes", "err", err)
return nil, err
}
return new(dto.Note).From(note), err
}

func (s *NoteService) Get(ctx context.Context, id uuid.UUID) (*dto.Note, error) {
log := logger.FromContext(ctx)
note, err := s.database.GetNote(id)
Expand Down
1 change: 1 addition & 0 deletions server/src/services/services.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ type BoardSessions interface {

type Notes interface {
Create(ctx context.Context, body dto.NoteCreateRequest) (*dto.Note, error)
Import(ctx context.Context, body dto.NoteImportRequest) (*dto.Note, error)
Get(ctx context.Context, id uuid.UUID) (*dto.Note, error)
Update(ctx context.Context, body dto.NoteUpdateRequest) (*dto.Note, error)
List(ctx context.Context, id uuid.UUID) ([]*dto.Note, error)
Expand Down
23 changes: 22 additions & 1 deletion src/api/board.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {Color} from "constants/colors";
import {EditBoardRequest} from "store/features/board/types";
import {Board, EditBoardRequest} from "store/features/board/types";
import {SERVER_HTTP_URL} from "../config";

export const BoardAPI = {
Expand Down Expand Up @@ -35,6 +35,27 @@ export const BoardAPI = {
throw new Error(`unable to create board: ${error}`);
}
},
importBoard: async (boardJson: string) => {
try {
const response = await fetch(`${SERVER_HTTP_URL}/import`, {
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/json",
},
body: boardJson,
});

if (response.status === 201) {
const body = (await response.json()) as Board;
return body.id;
}

throw new Error(`request resulted in response status ${response.status}`);
} catch (error) {
throw new Error(`unable to import board: ${error}`);
}
},

/**
* Edits the board with the specified parameters.
Expand Down
Empty file added src/assets/icon-info.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion src/components/Note/Note.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ export const Note = (props: NoteProps) => {
<div tabIndex={0} role="button" className={`note note--${stackSetting}`} onClick={handleClick} onKeyDown={handleKeyPress} ref={noteRef}>
<header className="note__header">
<div className="note__author-container">
<NoteAuthorList authors={authors} showAuthors={showAuthors} viewer={props.viewer} />
<NoteAuthorList authors={authors} authorID={note.author} showAuthors={showAuthors} viewer={props.viewer} />
</div>
<Votes noteId={props.noteId!} aggregateVotes />
</header>
Expand Down
8 changes: 5 additions & 3 deletions src/components/Note/NoteAuthorList/NoteAuthorList.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,22 @@
import classNames from "classnames";
import {Participant, ParticipantExtendedInfo} from "store/features/";
import {useTranslation} from "react-i18next";
import {UserAvatar} from "../../BoardUsers";
import {UserAvatar} from "components/BoardUsers";
import {NoteAuthorSkeleton} from "./NoteAuthorSkeleton/NoteAuthorSkeleton";
import "./NoteAuthorList.scss";

type NoteAuthorListProps = {
authors: Participant[];
showAuthors: boolean;
viewer?: Participant;
authorID: string;
};

export const NoteAuthorList = (props: NoteAuthorListProps) => {
const {t} = useTranslation();

if (!props.authors[0] || props.authors.length === 0) {
return <NoteAuthorSkeleton />;
return <NoteAuthorSkeleton authorID={props.authorID} />;
}

// next to the Participant object there's also helper properties (displayName, isSelf) for easier identification.
Expand Down Expand Up @@ -47,6 +49,7 @@ export const NoteAuthorList = (props: NoteAuthorListProps) => {

return allAuthors;
};

const authorExtendedInfo = prepareAuthors(props.authors);
// expected behaviour:
// 1 => avatar1 name
Expand Down Expand Up @@ -85,7 +88,6 @@ export const NoteAuthorList = (props: NoteAuthorListProps) => {
avatarClassName="note__user-avatar"
/>
</figure>

<div className={classNames("note__author-name", {"note__author-name--self": stackAuthor.isSelf})}>{stackAuthor.displayName}</div>
</div>

Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
import "./NoteAuthorSkeleton.scss";
import stanAvatar from "assets/stan/Stan_Avatar.png";
import {getRandomNameWithSeed} from "utils/random";
import classNames from "classnames";

export const NoteAuthorSkeleton = () => (
interface NoteAuthorSkeletonProps {
authorID?: string;
}

export const NoteAuthorSkeleton = ({authorID}: NoteAuthorSkeletonProps) => (
<div className="note-author-skeleton">
<div className="note-author-skeleton__avatar">
<img src={stanAvatar} alt="Note Author" className="note-author-skeleton__avatar" />
</div>
<div className="note-author-skeleton__name" />
{authorID ? <div className={classNames("note__author-name")}>{getRandomNameWithSeed(authorID)}</div> : <div className="note__author-name" />}
</div>
);
Original file line number Diff line number Diff line change
@@ -1,22 +1,22 @@
import {ApplicationState} from "store";
import {ApplicationState} from "types";
import {Provider} from "react-redux";
import getTestStore from "utils/test/getTestStore";
import {NoteAuthorList} from "../NoteAuthorList";
import {Participant} from "store/features/participants/types";
import {Participant} from "types/participant";
import {render} from "testUtils";
import getTestParticipant from "utils/test/getTestParticipant";

const AUTHOR1: Participant = getTestParticipant({user: {id: "test-participant-id-1", name: "test-participant-name-1", isAnonymous: true}});
const AUTHOR2: Participant = getTestParticipant({user: {id: "test-participant-id-2", name: "test-participant-name-2", isAnonymous: true}});
const AUTHOR3: Participant = getTestParticipant({user: {id: "test-participant-id-3", name: "test-participant-name-3", isAnonymous: true}});
const AUTHOR4: Participant = getTestParticipant({user: {id: "test-participant-id-4", name: "test-participant-name-4", isAnonymous: true}});
const AUTHOR5: Participant = getTestParticipant({user: {id: "test-participant-id-5", name: "test-participant-name-5", isAnonymous: true}});
const VIEWER1: Participant = getTestParticipant({user: {id: "test-participant-id-1", name: "test-participant-name-1", isAnonymous: true}});
const AUTHOR1: Participant = getTestParticipant({user: {id: "test-participant-id-1", name: "test-participant-name-1", isAnonymous: false}});
const AUTHOR2: Participant = getTestParticipant({user: {id: "test-participant-id-2", name: "test-participant-name-2", isAnonymous: false}});
const AUTHOR3: Participant = getTestParticipant({user: {id: "test-participant-id-3", name: "test-participant-name-3", isAnonymous: false}});
const AUTHOR4: Participant = getTestParticipant({user: {id: "test-participant-id-4", name: "test-participant-name-4", isAnonymous: false}});
const AUTHOR5: Participant = getTestParticipant({user: {id: "test-participant-id-5", name: "test-participant-name-5", isAnonymous: false}});
const VIEWER1: Participant = getTestParticipant({user: {id: "test-participant-id-1", name: "test-participant-name-1", isAnonymous: false}});

const createNoteAuthorList = (authors: Participant[], showAuthors: boolean, overwrite?: Partial<ApplicationState>) => {
return (
<Provider store={getTestStore(overwrite)}>
<NoteAuthorList authors={authors} showAuthors={showAuthors} viewer={VIEWER1} />
<NoteAuthorList authors={authors} authorID={""} showAuthors={showAuthors} viewer={VIEWER1} />
</Provider>
);
};
Expand Down
Loading

0 comments on commit b35e3b3

Please sign in to comment.