diff --git a/server/api.postman_collection.json b/server/api.postman_collection.json index 2c75650133..ab92bea2eb 100644 --- a/server/api.postman_collection.json +++ b/server/api.postman_collection.json @@ -1,10 +1,10 @@ { "info": { - "_postman_id": "8a37e0fe-f06b-41e4-a538-6689616bd5a6", + "_postman_id": "45d5f690-f459-4371-a09f-8aff874591f2", "name": "scrumlr.io", "description": "This is the documentation for the REST API server of the application [scrumlr.io](https://scrumlr.io). You get in touch with us and send an email to [info@scrumlr.io](https://info@scrumlr.io). The software is [MIT licensed](https://opensource.org/licenses/MIT) so do whatever you want with it. If you want to checkout the progress of our development and take a peek into our backlog you can checkout our [GitHub repository](https://github.com/inovex/scrumlr.io). By the way, this already the third iteration of our server and we're still working on the interface and on further improvements. Since the API is mainly intended for our web client we won't start with API versions at the moment so breaking changes may be incoming. Once it got stable we'll maybe start with that.\n\nIf you're using the postman collection in order to explore the different resources you should also checkout the variables of the collection. Anytime you'll create new resources (e.g. your login or a board) variables will be stored and used for subsequent calls on other resources.\n\nAccess to protected resources will be authorized if a bearer token is sent or it is included in the `jwt` Cookie, which will be automatically set upon login.\n\n## Getting started\n\nLet's try to explain the basic flow of how a new board can will be created and someone tries to join the board as a participant.\n\nFirst you can check whether you are already logged in by a `GET` request on `/user`. See the _User_ section for more information.\n\n1. A user signs into the application (see _Login_ section)\n2. The user creates a new board (`POST` on `/boards`, checkout _Boards_ section)\n3. Another logged in user tries to join the board (`POST` on `/boards/{id}/participants`, checkout _Participants_ section)\n 1. If the boards access policy is set to `PUBLIC` the participant will be added to the board and afterwards all resources will be available\n 2. If the board requires a passphrase and the access policy is set to `BY_PASSPHRASE` a client error will be reported until the user sends the correct passphrase within the payload of the request\n 3. If the boards access policy is set to `BY_INVITE` a session request will be created instead and the user will be redirected to the new resource. The board owner now needs to accept or reject the request until the user can continue\n\nThese are just the basic steps of how sessions can be created. You can also have a look into the section _Realtime_ to see how you can open websockets and listen to live updates on the data.", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", - "_exporter_id": "32423964" + "_exporter_id": "32837949" }, "item": [ { @@ -1962,15 +1962,6 @@ "originalRequest": { "method": "GET", "header": [], - "body": { - "mode": "raw", - "raw": "", - "options": { - "raw": { - "language": "json" - } - } - }, "url": { "raw": "{{base_url}}/boards", "host": [ @@ -1981,8 +1972,8 @@ ] } }, - "_postman_previewlanguage": null, - "header": null, + "_postman_previewlanguage": "Text", + "header": [], "cookie": [], "body": "[\n {\n \"board\": {\n \"id\": \"4b08e0e9-f141-49b0-a75f-17d181f96969\",\n \"name\": \"My board\",\n \"description\": \"This is a test description\",\n \"accessPolicy\": \"PUBLIC\",\n \"showAuthors\": true,\n \"showNotesOfOtherUsers\": true,\n \"showNoteReactions\": true,\n \"allowStacking\": true,\n \"allowEditing\": true,\n \"sharedNote\": null,\n \"showVoting\": null\n },\n \"columnsNumber\": 2,\n \"createdAt\": \"0001-01-01T00:00:00Z\",\n \"participants\": 1\n }\n]" } @@ -2393,13 +2384,97 @@ " var allowStacking = pm.response.json().allowStacking;", " pm.expect(allowStacking).to.eql(false);", "})", - "", + "" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "protocolProfileBehavior": { + "disabledSystemHeaders": {} + }, + "request": { + "method": "PUT", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"sharedNote\": null,\n \"showVoting\": null,\n \"timerStart\": \"2024-01-18T13:38:49.304Z\",\n \"timerEnd\": \"2024-01-18T13:39:49.304Z\",\n \"showAuthors\": false,\n \"showNotesOfOtherUsers\": false,\n \"showNoteReactions\": false,\n \"allowStacking\": false\n // \"isLocked\": true // tested separately in next request\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{base_url}}/boards/{{board_id}}", + "host": [ + "{{base_url}}" + ], + "path": [ + "boards", + "{{board_id}}" + ] + } + }, + "response": [ + { + "name": "Success", + "originalRequest": { + "method": "PUT", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"sharedNote\":null,\n \"showVoting\":null,\n \"timerStart\":\"2024-01-18T13:38:49.304Z\",\n \"timerEnd\":\"2024-01-18T13:39:49.304Z\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{base_url}}/boards/{{board_id}}", + "host": [ + "{{base_url}}" + ], + "path": [ + "boards", + "{{board_id}}" + ] + } + }, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "description": "", + "type": "text" + } + ], + "cookie": [], + "body": "{\n \"id\": \"{{board_id}}\",\n \"name\": \"My board\",\n \"description\": \"Updated to new description\",\n \"accessPolicy\": \"BY_PASSPHRASE\",\n \"showAuthors\": false,\n \"showNotesOfOtherUsers\": false,\n \"showNoteReactions\": false,\n \"allowStacking\": false,\n \"isLocked\": false,\n \"timerStart\": \"2024-01-18T13:38:49.304Z\",\n \"timerEnd\": \"2024-01-18T13:39:49.304Z\",\n \"sharedNote\": null,\n \"showVoting\": null\n}" + } + ] + }, + { + "name": "Lock Board", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Successful PUT request\", function () {", + " pm.expect(pm.response).to.have.status(200);", + "});", "", "pm.test(\"Check allowEditing changed\", function () {", - " var allowEditing = pm.response.json().allowEditing;", - " pm.expect(allowEditing).to.eql(false);", + " var isLocked = pm.response.json().isLocked;", + " pm.expect(isLocked).to.eql(true);", "})", - "" + "", + "// one could add a test which checks whether the lock actually works, ", + "// but you'd need to change your role from moderator to normal participant or add another participant to verify" ], "type": "text/javascript", "packages": {} @@ -2414,7 +2489,7 @@ "header": [], "body": { "mode": "raw", - "raw": "{\n \"sharedNote\": null,\n \"showVoting\": null,\n \"timerStart\": \"2024-01-18T13:38:49.304Z\",\n \"timerEnd\": \"2024-01-18T13:39:49.304Z\",\n \"showAuthors\": false,\n \"showNotesOfOtherUsers\": false,\n \"showNoteReactions\": false,\n \"allowStacking\": false,\n \"allowEditing\": false\n}", + "raw": "{\n \"isLocked\": true\n}", "options": { "raw": { "language": "json" @@ -2440,7 +2515,7 @@ "header": [], "body": { "mode": "raw", - "raw": "{\n \"sharedNote\":null,\n \"showVoting\":null,\n \"timerStart\":\"2024-01-18T13:38:49.304Z\",\n \"timerEnd\":\"2024-01-18T13:39:49.304Z\",\n \"allowEditing\":true\n}", + "raw": "{\n \"isLocked\": true\n}", "options": { "raw": { "language": "json" @@ -2463,13 +2538,12 @@ { "key": "Content-Type", "value": "application/json", - "name": "Content-Type", "description": "", "type": "text" } ], "cookie": [], - "body": "{\n \"id\": \"{{board_id}}\",\n \"name\": \"My board\",\n \"description\": \"Updated to new description\",\n \"accessPolicy\": \"BY_PASSPHRASE\",\n \"showAuthors\": false,\n \"showNotesOfOtherUsers\": false,\n \"showNoteReactions\": false,\n \"allowStacking\": false,\n \"allowEditing\": false,\n \"timerStart\": \"2024-01-18T13:38:49.304Z\",\n \"timerEnd\": \"2024-01-18T13:39:49.304Z\",\n \"sharedNote\": null,\n \"showVoting\": null\n}" + "body": "{\n \"id\": \"{{board_id}}\",\n \"name\": \"My board\",\n \"description\": \"Updated to new description\",\n \"accessPolicy\": \"BY_PASSPHRASE\",\n \"showAuthors\": false,\n \"showNotesOfOtherUsers\": false,\n \"showNoteReactions\": false,\n \"isLocked\": true,\n \"allowEditing\": false,\n \"timerStart\": \"2024-01-18T13:38:49.304Z\",\n \"timerEnd\": \"2024-01-18T13:39:49.304Z\",\n \"sharedNote\": null,\n \"showVoting\": null\n}" } ] } diff --git a/server/src/api/context.go b/server/src/api/context.go index 63a5517d1f..4d7ea6229a 100644 --- a/server/src/api/context.go +++ b/server/src/api/context.go @@ -133,13 +133,13 @@ func (s *Server) BoardEditableContext(next http.Handler) http.Handler { return } - if !isMod && !settings.AllowEditing { + if !isMod && settings.IsLocked { log.Errorw("not allowed to edit board", "err", err) common.Throw(w, r, common.ForbiddenError(errors.New("not authorized to change board"))) return } - boardEditable := context.WithValue(r.Context(), identifiers.BoardEditableIdentifier, settings.AllowEditing) + boardEditable := context.WithValue(r.Context(), identifiers.BoardEditableIdentifier, settings.IsLocked) next.ServeHTTP(w, r.WithContext(boardEditable)) }) } diff --git a/server/src/api/notes_test.go b/server/src/api/notes_test.go index 31fa458a3d..e7343dc0c1 100644 --- a/server/src/api/notes_test.go +++ b/server/src/api/notes_test.go @@ -254,12 +254,12 @@ func (suite *NotesTestSuite) TestDeleteNote() { name string expectedCode int err error - allowEditing bool + isLocked bool }{ { name: "Delete Note when board is unlocked", expectedCode: http.StatusNoContent, - allowEditing: true, + isLocked: true, }, { name: "Delete Note when board is locked", @@ -270,7 +270,7 @@ func (suite *NotesTestSuite) TestDeleteNote() { StatusText: "Bad request", ErrorText: "something", }, - allowEditing: false, + isLocked: false, }, } for _, tt := range tests { @@ -289,8 +289,8 @@ func (suite *NotesTestSuite) TestDeleteNote() { r := chi.NewRouter() s.initNoteResources(r) boardMock.On("Get", boardID).Return(&dto.Board{ - ID: boardID, - AllowEditing: tt.allowEditing, + ID: boardID, + IsLocked: tt.isLocked, }, nil) // Mock the SessionExists method @@ -302,12 +302,12 @@ func (suite *NotesTestSuite) TestDeleteNote() { // Mock the ParticipantBanned method sessionMock.On("ParticipantBanned", mock.Anything, boardID, userID).Return(false, nil) - if tt.allowEditing { + if tt.isLocked { noteMock.On("Delete", mock.Anything, mock.Anything).Return(nil) } else { boardMock.On("Get", boardID).Return(&dto.Board{ - ID: boardID, - AllowEditing: tt.allowEditing, + ID: boardID, + IsLocked: tt.isLocked, }, tt.err) noteMock.On("Delete", mock.Anything, mock.Anything).Return(tt.err) } diff --git a/server/src/api/router.go b/server/src/api/router.go index 489cd71b77..2c79e264b7 100644 --- a/server/src/api/router.go +++ b/server/src/api/router.go @@ -306,7 +306,6 @@ func (s *Server) initReactionResources(r chi.Router) { func (s *Server) initBoardReactionResources(r chi.Router) { r.Route("/board-reactions", func(r chi.Router) { r.Use(s.BoardParticipantContext) - r.Use(s.BoardEditableContext) r.Post("/", s.createBoardReaction) }) diff --git a/server/src/common/dto/boards.go b/server/src/common/dto/boards.go index f79b579ece..ff8347e6ab 100644 --- a/server/src/common/dto/boards.go +++ b/server/src/common/dto/boards.go @@ -33,7 +33,7 @@ type Board struct { AllowStacking bool `json:"allowStacking"` - AllowEditing bool `json:"allowEditing"` + IsLocked bool `json:"isLocked"` TimerStart *time.Time `json:"timerStart,omitempty"` TimerEnd *time.Time `json:"timerEnd,omitempty"` @@ -57,7 +57,7 @@ func (b *Board) From(board database.Board) *Board { b.ShowNotesOfOtherUsers = board.ShowNotesOfOtherUsers b.ShowNoteReactions = board.ShowNoteReactions b.AllowStacking = board.AllowStacking - b.AllowEditing = board.AllowEditing + b.IsLocked = board.IsLocked b.SharedNote = board.SharedNote b.ShowVoting = board.ShowVoting b.TimerStart = board.TimerStart @@ -121,7 +121,7 @@ type BoardUpdateRequest struct { AllowStacking *bool `json:"allowStacking"` // Set whether changes to board should be allowed to all users or only moderators. - AllowEditing *bool `json:"allowEditing"` + IsLocked *bool `json:"isLocked"` // Set the timer start. TimerStart *time.Time `json:"timerStart"` diff --git a/server/src/database/boards.go b/server/src/database/boards.go index 109e99df48..38638d7ef6 100644 --- a/server/src/database/boards.go +++ b/server/src/database/boards.go @@ -24,7 +24,7 @@ type Board struct { ShowNotesOfOtherUsers bool ShowNoteReactions bool AllowStacking bool - AllowEditing bool + IsLocked bool CreatedAt time.Time TimerStart *time.Time TimerEnd *time.Time @@ -60,7 +60,7 @@ type BoardUpdate struct { ShowNotesOfOtherUsers *bool ShowNoteReactions *bool AllowStacking *bool - AllowEditing *bool + IsLocked *bool TimerStart *time.Time TimerEnd *time.Time SharedNote uuid.NullUUID @@ -143,8 +143,8 @@ func (d *Database) UpdateBoard(update BoardUpdate) (Board, error) { if update.AllowStacking != nil { query.Column("allow_stacking") } - if update.AllowEditing != nil { - query.Column("allow_editing") + if update.IsLocked != nil { + query.Column("is_locked") } var board Board diff --git a/server/src/database/boards_test.go b/server/src/database/boards_test.go index 9ad4409075..c6932de0b8 100644 --- a/server/src/database/boards_test.go +++ b/server/src/database/boards_test.go @@ -442,13 +442,13 @@ func testUpdateBoardSettings(t *testing.T) { showAuthors := true showNotesOfOtherUsers := true - allowEditing := true - updatedBoard, err := testDb.UpdateBoard(BoardUpdate{ID: board.ID, ShowAuthors: &showAuthors, ShowNotesOfOtherUsers: &showNotesOfOtherUsers, AllowEditing: &allowEditing}) + isLocked := true + updatedBoard, err := testDb.UpdateBoard(BoardUpdate{ID: board.ID, ShowAuthors: &showAuthors, ShowNotesOfOtherUsers: &showNotesOfOtherUsers, IsLocked: &isLocked}) assert.Nil(t, err) assert.Equal(t, showAuthors, updatedBoard.ShowAuthors) assert.Equal(t, showNotesOfOtherUsers, updatedBoard.ShowNotesOfOtherUsers) - assert.Equal(t, allowEditing, updatedBoard.AllowEditing) + assert.Equal(t, isLocked, updatedBoard.IsLocked) } func testGetBoard(t *testing.T) { diff --git a/server/src/database/migrations/sql/18_rename_lock_board_column.down.sql b/server/src/database/migrations/sql/18_rename_lock_board_column.down.sql new file mode 100644 index 0000000000..648d4e0d5f --- /dev/null +++ b/server/src/database/migrations/sql/18_rename_lock_board_column.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE boards ALTER COLUMN is_locked SET DEFAULT TRUE; +ALTER TABLE boards RENAME COLUMN is_locked TO allow_editing; diff --git a/server/src/database/migrations/sql/18_rename_lock_board_column.up.sql b/server/src/database/migrations/sql/18_rename_lock_board_column.up.sql new file mode 100644 index 0000000000..5638268c64 --- /dev/null +++ b/server/src/database/migrations/sql/18_rename_lock_board_column.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE boards ALTER COLUMN allow_editing SET DEFAULT FALSE; +ALTER TABLE boards RENAME COLUMN allow_editing TO is_locked; diff --git a/server/src/services/boards/boards.go b/server/src/services/boards/boards.go index d3b523f17b..99d48d3535 100644 --- a/server/src/services/boards/boards.go +++ b/server/src/services/boards/boards.go @@ -151,7 +151,7 @@ func (s *BoardService) Update(ctx context.Context, body dto.BoardUpdateRequest) ShowNotesOfOtherUsers: body.ShowNotesOfOtherUsers, ShowNoteReactions: body.ShowNoteReactions, AllowStacking: body.AllowStacking, - AllowEditing: body.AllowEditing, + IsLocked: body.IsLocked, TimerStart: body.TimerStart, TimerEnd: body.TimerEnd, SharedNote: body.SharedNote, diff --git a/src/components/AccessPolicySelection/AccessPolicySelection.tsx b/src/components/AccessPolicySelection/AccessPolicySelection.tsx index 8d02f44314..78b284f105 100644 --- a/src/components/AccessPolicySelection/AccessPolicySelection.tsx +++ b/src/components/AccessPolicySelection/AccessPolicySelection.tsx @@ -3,7 +3,7 @@ import "./AccessPolicySelection.scss"; import {AccessPolicy} from "types/board"; import {generateRandomString} from "utils/random"; import {useTranslation} from "react-i18next"; -import {Visible, Hidden, Duplicate,Refresh} from "components/Icon"; +import {Visible, Hidden, Duplicate, Refresh} from "components/Icon"; import {TextInputAdornment} from "components/TextInputAdornment"; import {TextInputLabel} from "../TextInputLabel"; import {TextInput} from "../TextInput"; diff --git a/src/components/Board/Board.tsx b/src/components/Board/Board.tsx index a95ee0c24d..6bc9f821f1 100644 --- a/src/components/Board/Board.tsx +++ b/src/components/Board/Board.tsx @@ -9,11 +9,14 @@ import "./Board.scss"; import {useDndMonitor} from "@dnd-kit/core"; import classNames from "classnames"; import {useStripeOffset} from "utils/hooks/useStripeOffset"; +import {Toast} from "utils/Toast"; +import {useTranslation} from "react-i18next"; export interface BoardProps { children: React.ReactElement | React.ReactElement[]; currentUserIsModerator: boolean; moderating: boolean; + locked: boolean; } export interface BoardState { @@ -26,7 +29,8 @@ export interface ColumnState { lastVisibleColumnIndex: number; } -export const BoardComponent = ({children, currentUserIsModerator, moderating}: BoardProps) => { +export const BoardComponent = ({children, currentUserIsModerator, moderating, locked}: BoardProps) => { + const {t} = useTranslation(); const [state, setState] = useState({ firstVisibleColumnIndex: 0, lastVisibleColumnIndex: React.Children.count(children), @@ -135,6 +139,12 @@ export const BoardComponent = ({children, currentUserIsModerator, moderating}: B // eslint-disable-next-line react-hooks/exhaustive-deps }, [columnState]); + useEffect(() => { + if (locked) { + Toast.info({title: t("Toast.lockedBoard"), autoClose: 10_000}); + } + }, [t, locked]); + if (!children || columnsCount === 0) { // Empty board return ( diff --git a/src/components/BoardHeader/HeaderMenu/BoardOptions/LockBoard.tsx b/src/components/BoardHeader/HeaderMenu/BoardOptions/LockBoard.tsx new file mode 100644 index 0000000000..9c263e5426 --- /dev/null +++ b/src/components/BoardHeader/HeaderMenu/BoardOptions/LockBoard.tsx @@ -0,0 +1,20 @@ +import store, {useAppSelector} from "store"; +import {useTranslation} from "react-i18next"; +import {Actions} from "store/action"; +import {BoardOption} from "./BoardOption"; +import {BoardOptionButton} from "./BoardOptionButton"; +import {BoardOptionToggle} from "./BoardOptionToggle"; +import "../BoardSettings/BoardSettings.scss"; + +export const LockBoard = () => { + const {t} = useTranslation(); + const allowEditing = useAppSelector((state) => state.board.data!.isLocked); + + return ( + + store.dispatch(Actions.editBoard({isLocked: !allowEditing}))}> + + + + ); +}; diff --git a/src/components/BoardHeader/HeaderMenu/BoardOptions/index.ts b/src/components/BoardHeader/HeaderMenu/BoardOptions/index.ts index 833b3647d3..ff9894219c 100644 --- a/src/components/BoardHeader/HeaderMenu/BoardOptions/index.ts +++ b/src/components/BoardHeader/HeaderMenu/BoardOptions/index.ts @@ -2,10 +2,12 @@ import {ShowAuthorOption} from "./ShowAuthorOption"; import {ShowHiddenColumnsOption} from "./ShowHiddenColumnsOption"; import {ShowOtherUsersNotesOption} from "./ShowOtherUsersNotesOption"; import {ShowAllBoardSettings} from "./ShowAllBoardSettings"; +import {LockBoard} from "./LockBoard"; export const BoardOption = { ShowAuthorOption, ShowHiddenColumnsOption, ShowOtherUsersNotesOption, ShowAllBoardSettings, + LockBoard, }; diff --git a/src/components/BoardHeader/HeaderMenu/HeaderMenu.tsx b/src/components/BoardHeader/HeaderMenu/HeaderMenu.tsx index 39c36aba02..e04e43d8cc 100644 --- a/src/components/BoardHeader/HeaderMenu/HeaderMenu.tsx +++ b/src/components/BoardHeader/HeaderMenu/HeaderMenu.tsx @@ -31,6 +31,7 @@ const HeaderMenu = (props: HeaderMenuProps) => { + )} diff --git a/src/components/BoardHeader/HeaderMenu/__tests__/HeaderMenu.test.tsx b/src/components/BoardHeader/HeaderMenu/__tests__/HeaderMenu.test.tsx index c6d42a2616..7dcd43c38b 100644 --- a/src/components/BoardHeader/HeaderMenu/__tests__/HeaderMenu.test.tsx +++ b/src/components/BoardHeader/HeaderMenu/__tests__/HeaderMenu.test.tsx @@ -3,6 +3,7 @@ import {screen} from "@testing-library/dom"; import {HeaderMenu} from "components/BoardHeader/HeaderMenu"; import {render} from "testUtils"; import getTestStore from "utils/test/getTestStore"; +import i18n from "i18nTest"; Object.assign(navigator, { clipboard: { @@ -77,5 +78,17 @@ describe("", () => { expect(label.innerHTML).toEqual("Show hidden columns for me"); }); }); + + describe("Allow participant changes", () => { + it("should display button if participant has moderation permission", () => { + render(createHeaderMenu(true), {container: global.document.querySelector("#portal")!}); + expect(screen.queryByText(i18n.t("BoardSettings.IsLocked"))).toBeInTheDocument(); + }); + + it("should not display button if participant does not have moderation permission", () => { + render(createHeaderMenu(false), {container: global.document.querySelector("#portal")!}); + expect(screen.queryByText(i18n.t("BoardSettings.IsLocked"))).not.toBeInTheDocument(); + }); + }); }); }); diff --git a/src/components/BoardHeader/HeaderMenu/__tests__/__snapshots__/HeaderMenu.test.tsx.snap b/src/components/BoardHeader/HeaderMenu/__tests__/__snapshots__/HeaderMenu.test.tsx.snap index 02d1b7d1ba..402541dd19 100644 --- a/src/components/BoardHeader/HeaderMenu/__tests__/__snapshots__/HeaderMenu.test.tsx.snap +++ b/src/components/BoardHeader/HeaderMenu/__tests__/__snapshots__/HeaderMenu.test.tsx.snap @@ -107,6 +107,28 @@ exports[` should render correctly for moderator 1`] = ` +
  • + +
  • { const isStack = useAppSelector((state) => state.notes.filter((n) => n.position.stack === props.noteId).length > 0); const isShared = useAppSelector((state) => state.board.data?.sharedNote === props.noteId); const allowStacking = useAppSelector((state) => state.board.data?.allowStacking ?? true); + const boardIsLocked = useAppSelector((state) => state.board.data!.isLocked); const showNoteReactions = useAppSelector((state) => state.board.data?.showNoteReactions ?? true); const showAuthors = useAppSelector((state) => !!state.board.data?.showAuthors); const me = useAppSelector((state) => state.participants?.self); @@ -100,7 +101,7 @@ export const Note = (props: NoteProps) => { id={props.noteId} columnId={note.position.column} className={classNames("note__root", props.colorClassName)} - disabled={!(isModerator || allowStacking)} + disabled={!isModerator && (!allowStacking || boardIsLocked)} >
    diff --git a/src/components/Note/NoteReactionList/NoteReactionChip/NoteReactionChip.tsx b/src/components/Note/NoteReactionList/NoteReactionChip/NoteReactionChip.tsx index e5b7659735..6cbf1b1cf4 100644 --- a/src/components/Note/NoteReactionList/NoteReactionChip/NoteReactionChip.tsx +++ b/src/components/Note/NoteReactionList/NoteReactionChip/NoteReactionChip.tsx @@ -3,8 +3,8 @@ import React from "react"; import {LongPressReactEvents, useLongPress} from "use-long-press"; import {uniqueId} from "underscore"; import {REACTION_EMOJI_MAP, ReactionType} from "types/reaction"; -import {TooltipPortal} from "components/TooltipPortal/TooltipPortal"; import {useAppSelector} from "store"; +import {TooltipPortal} from "components/TooltipPortal/TooltipPortal"; import {getEmojiWithSkinTone} from "utils/reactions"; import {ReactionModeled} from "../NoteReactionList"; import "./NoteReactionChip.scss"; @@ -23,6 +23,9 @@ export const NoteReactionChip = (props: NoteReactionChipProps) => { // guarantee unique labels. without it tooltip may anchor at multiple places (ReactionList and ReactionPopup) const anchorId = uniqueId(`reaction-${props.reaction.noteId}-${props.reaction.reactionType}`); const skinTone = useAppSelector((state) => state.skinTone); + const boardLocked = useAppSelector((state) => state.board.data!.isLocked); + const isModerator = useAppSelector((state) => ["OWNER", "MODERATOR"].some((role) => state.participants!.self.role === role)); + const bindLongPress = useLongPress((e) => { if (props.handleLongPressReaction) { props.handleLongPressReaction(e); @@ -32,6 +35,7 @@ export const NoteReactionChip = (props: NoteReactionChipProps) => { return ( <> - {showReactionBar && } -
    + }} + onKeyDown={(e) => { + if (e.code === "Enter" || e.code === "Space") { + // Stop the default, because it will dispatch a click event on + // the first reaction + e.preventDefault(); + // Stop propagation of the event so that it does not bubble up + // to the note component and opens the stack view + e.stopPropagation(); + // Open the reaction bar + setShowReactionBar((show) => !show); + } + }} + > + + + {showReactionBar && } + + )}
    {!showReactionBar && // show either condensed or normal reaction chips diff --git a/src/components/NoteDialogComponents/NoteDialogNote.tsx b/src/components/NoteDialogComponents/NoteDialogNote.tsx index f292bf4c94..ab6f0ff625 100644 --- a/src/components/NoteDialogComponents/NoteDialogNote.tsx +++ b/src/components/NoteDialogComponents/NoteDialogNote.tsx @@ -2,6 +2,7 @@ import classNames from "classnames"; import {FC} from "react"; import {AvataaarProps} from "components/Avatar"; import {Participant} from "types/participant"; +import {useAppSelector} from "store"; import {NoteDialogNoteComponents} from "./NoteDialogNoteComponents"; import "./NoteDialogNote.scss"; @@ -23,20 +24,31 @@ export type NoteDialogNoteProps = { viewer: Participant; }; -export const NoteDialogNote: FC = (props: NoteDialogNoteProps) => ( - // eslint-disable-next-line jsx-a11y/no-static-element-interactions -
    e.stopPropagation()}> -
    - -
    -
    - -
    - -
    - -
    -
    -); +export const NoteDialogNote: FC = (props: NoteDialogNoteProps) => { + const boardLocked = useAppSelector((state) => state.board.data!.isLocked); + const isModerator = useAppSelector((state) => ["OWNER", "MODERATOR"].some((role) => role === state.participants!.self.role)); + + /* eslint-disable jsx-a11y/no-static-element-interactions */ + return ( +
    e.stopPropagation()} + > +
    + +
    +
    + +
    + {(isModerator || !boardLocked) && ( + + )} +
    + +
    +
    + ); + /* eslint-enable jsx-a11y/no-static-element-interactions */ +}; diff --git a/src/components/NoteDialogComponents/NoteDialogNoteComponents/NoteDialogNoteContent.tsx b/src/components/NoteDialogComponents/NoteDialogNoteComponents/NoteDialogNoteContent.tsx index ab8d41f020..707dee5251 100644 --- a/src/components/NoteDialogComponents/NoteDialogNoteComponents/NoteDialogNoteContent.tsx +++ b/src/components/NoteDialogComponents/NoteDialogNoteComponents/NoteDialogNoteContent.tsx @@ -29,6 +29,8 @@ export const NoteDialogNoteContent: FC = ({noteId, a const dispatch = useDispatch(); const {t} = useTranslation(); const editable = viewer.user.id === authorId || viewer.role === "OWNER" || viewer.role === "MODERATOR"; + const boardLocked = useAppSelector((state) => state.board.data!.isLocked); + const isModerator = viewer.role === "OWNER" || viewer.role === "MODERATOR"; const note = useAppSelector((state) => state.notes.find((n) => n.id === noteId)); @@ -94,7 +96,7 @@ export const NoteDialogNoteContent: FC = ({noteId, a <> onEdit(noteId!, e.target.value ?? "")} onFocus={onFocus} {...emoji.inputBindings} diff --git a/src/components/NoteInput/NoteInput.scss b/src/components/NoteInput/NoteInput.scss index ddaba791ae..e28a31b711 100644 --- a/src/components/NoteInput/NoteInput.scss +++ b/src/components/NoteInput/NoteInput.scss @@ -5,7 +5,9 @@ $note-input__input-right: 40px; .note-input { display: flex; + padding-inline: $spacing--base; justify-content: space-between; + align-items: center; min-height: 36px; width: 100%; position: relative; @@ -14,17 +16,23 @@ $note-input__input-right: 40px; border-radius: $rounded--medium; transition: all 0.12s ease-in-out; border: 2px solid transparent; + container-type: inline-size; - &:hover, - &:focus-within { + &:not(:has(.note-input__input:disabled)):hover, + &:not(:has(.note-input__input:disabled)):focus-within { border-color: rgba(var(--accent-color--light-rgb), 0.5); box-shadow: 0 6px 9px 0 rgba(var(--accent-color--light-rgb), 0.16); } } +.note-input__lock-icon { + width: $icon--large; + height: $icon--large; + color: $gray--800; +} + .note-input__input { color: $navy--900; - font-size: $text-size--medium; font-weight: bold; line-height: 24px; max-height: 70vh; @@ -33,17 +41,30 @@ $note-input__input-right: 40px; font-family: Raleway, sans-serif; background-color: transparent; border: none; - margin-left: $spacing--base; width: calc(100% - #{$note-input__input-left} - #{$note-input__input-right}); outline: none; - resize: none; + font-size: $text--xs; &:focus::placeholder { color: transparent; } } +@container (width > 280px) { + .note-input__input { + font-size: $text-size--medium; + } +} + +.note-input__add-button { + cursor: pointer; + + &:hover > .note-input__icon--add { + filter: $brighten--slightly; + } +} + .note-input__image-indicator, .note-input__add-button { all: unset; @@ -51,13 +72,11 @@ $note-input__input-right: 40px; display: flex; align-self: flex-start; padding: $spacing--xxs 0; -} + color: var(--accent-color); -.note-input__add-button { - cursor: pointer; - - &:hover > .note-input__icon--add { - filter: $brighten--slightly; + &:disabled { + color: $gray--800; + cursor: default; } } @@ -126,9 +145,9 @@ $note-input__input-right: 40px; .note-input { background-color: $navy--400; - &:hover, - &:focus-within { - box-shadow: 0 6px 9px 0 #232323; + &:not(:has(.note-input__input:disabled)):hover, + &:not(:has(.note-input__input:disabled)):focus-within { + box-shadow: 0 6px 9px 0 $navy--700; } } diff --git a/src/components/NoteInput/NoteInput.tsx b/src/components/NoteInput/NoteInput.tsx index 386c4367df..122f6ead62 100644 --- a/src/components/NoteInput/NoteInput.tsx +++ b/src/components/NoteInput/NoteInput.tsx @@ -1,6 +1,5 @@ import {useRef, useState} from "react"; -import "./NoteInput.scss"; -import {Plus, AddImage, Star} from "components/Icon"; +import {AddImage, LockClosed, Plus, Star} from "components/Icon"; import {Actions} from "store/action"; import {useTranslation} from "react-i18next"; import {useHotkeys} from "react-hotkeys-hook"; @@ -11,6 +10,8 @@ import TextareaAutosize from "react-autosize-textarea"; import {hotkeyMap} from "constants/hotkeys"; import {useEmojiAutocomplete} from "utils/hooks/useEmojiAutocomplete"; import {EmojiSuggestions} from "components/EmojiSuggestions"; +import {useAppSelector} from "store"; +import "./NoteInput.scss"; export interface NoteInputProps { columnId: string; @@ -24,6 +25,8 @@ export const NoteInput = ({columnIndex, columnId, columnIsVisible, toggleColumnV const dispatch = useDispatch(); const {t} = useTranslation(); const [toastDisplayed, setToastDisplayed] = useState(false); + const boardLocked = useAppSelector((state) => state.board.data!.isLocked); + const isModerator = useAppSelector((state) => ["OWNER", "MODERATOR"].some((role) => state.participants!.self.role === role)); const addNote = (content: string) => { if (!content.trim()) return; @@ -66,7 +69,9 @@ export const NoteInput = ({columnIndex, columnId, columnIsVisible, toggleColumnV }} ref={emoji.containerRef} > + {!isModerator && boardLocked && } )}
    {state.currentUserIsModerator && ( <> +
    + store.dispatch(Actions.editBoard({allowStacking: !state.board.allowStacking}))} + role="switch" + aria-checked={state.board.allowStacking} + > +
    + +
    +
    + store.dispatch(Actions.editBoard({isLocked: !state.board.isLocked}))} + role="switch" + aria-checked={state.board.isLocked} + > +
    + +
    +
    +
    {
    -
    - store.dispatch(Actions.editBoard({allowStacking: !state.board.allowStacking}))} - role="switch" - aria-checked={state.board.allowStacking} - > -
    - -
    -
    +
    + + +
    @@ -202,30 +247,6 @@ exports[`BoardSettings should match snapshot 1`] = ` />
    -
    -