Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: import exported boards #4392

Merged
merged 48 commits into from
Nov 11, 2024
Merged
Show file tree
Hide file tree
Changes from 41 commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
37c2385
import boards
mateo-ivc Jun 13, 2024
3eda046
import boards
mateo-ivc Jun 18, 2024
447af76
remove air.toml
mateo-ivc Jun 18, 2024
966ad2b
remove tmp folder
mateo-ivc Jun 18, 2024
71e83d0
display random avatar and name for imported notes, add info icons to …
mateo-ivc Jun 28, 2024
5f847aa
todo: send data to backend\n popup to ask for password
mateo-ivc Jul 19, 2024
c8575ef
implemented Popup Modal
mateo-ivc Jul 22, 2024
70f73b4
Frontend for import board -> needed refactoring
mateo-ivc Aug 2, 2024
98cb011
Refactoring and bug fixing
mateo-ivc Aug 5, 2024
8bb4ac3
remove not needed exposed port
mateo-ivc Aug 14, 2024
e63463d
remove unnecessary println
mateo-ivc Aug 14, 2024
4740edd
remove unnecessary console print
mateo-ivc Aug 14, 2024
efd4dcc
remove unused ImportBoard methode
mateo-ivc Aug 14, 2024
9c3310e
remove migration file that creates table for edited notes
mateo-ivc Aug 14, 2024
33a36a0
clean up
mateo-ivc Aug 14, 2024
6fae32b
big fixing and cleanup
mateo-ivc Aug 14, 2024
f9c0402
refactor components
mateo-ivc Aug 14, 2024
e8f6209
fix bug -> could not import on drag and drop
mateo-ivc Sep 2, 2024
f0e1504
fix some visual bugs
mateo-ivc Sep 2, 2024
74a4464
resolve merge conflicts
mateo-ivc Sep 2, 2024
10cb42e
fix icon paths
mateo-ivc Sep 2, 2024
bdce30a
fix import path
mateo-ivc Sep 2, 2024
5e44882
fix linter issues
mateo-ivc Sep 2, 2024
cbfb127
Merge branch 'main' into mi/import-board
mateo-ivc Oct 7, 2024
a9f98ca
add dark mode for passphrase modal
mateo-ivc Oct 7, 2024
6fe14a3
merge
mateo-ivc Oct 7, 2024
52341c6
Merge branch 'main' into mi/import-board
mateo-ivc Oct 7, 2024
6c89022
Merge branch 'main' into mi/import-board
mateo-ivc Oct 10, 2024
919919f
adding tests
mateo-ivc Oct 14, 2024
eef89b8
edit NoteAuthorSkeleton to display name if available
mateo-ivc Oct 14, 2024
9dc0646
fix translation key order
Schwehn42 Oct 16, 2024
de6e7a5
remove console statement
Schwehn42 Oct 16, 2024
5d4d245
replace FC comp with function
Schwehn42 Oct 16, 2024
60d7842
test: add missing expected func param
Schwehn42 Oct 16, 2024
8b7646f
Merge branch 'main' into mi/import-board
mateo-ivc Oct 16, 2024
28dd1cd
Merge branch 'main' into mi/import-board
mateo-ivc Oct 21, 2024
10e6e1f
refactorings
mateo-ivc Oct 21, 2024
ad22b88
reset configurations when aborting import
mateo-ivc Oct 21, 2024
aa36fdc
add toast error
mateo-ivc Oct 21, 2024
7b13f1f
Merge branch 'main' into mi/import-board
mateo-ivc Oct 21, 2024
a69cadd
make last board_mode full width
mateo-ivc Oct 22, 2024
2260c6c
refcator
mateo-ivc Nov 4, 2024
84a1f66
Merge branch 'main' into mi/import-board
mateo-ivc Nov 8, 2024
3ebca84
Merge branch 'main' into mi/import-board
mateo-ivc Nov 11, 2024
960774b
resolve merge conflicts
mateo-ivc Nov 11, 2024
b635f07
fix error
mateo-ivc Nov 11, 2024
e016a3f
fix error
mateo-ivc Nov 11, 2024
db116c6
update snapshot
Schwehn42 Nov 11, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.2",
"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;
mateo-ivc marked this conversation as resolved.
Show resolved Hide resolved
}

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
Loading