1
0
mirror of https://github.com/mattermost/focalboard.git synced 2025-03-03 15:32:14 +02:00

Merge branch 'main' into MM-40430-redirect-login

This commit is contained in:
Mattermod 2022-03-29 23:57:23 +03:00 committed by GitHub
commit 9e4be43545
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
55 changed files with 1492 additions and 807 deletions

View File

@ -1,24 +1,22 @@
# Code Contribution Guidelines
Thank you for your interest in contributing! Please see the [Focalboard Contribution Guide](https://mattermost.github.io/focalboard/) which describes the process for making code contributions, and [join our Focalboard community channel](https://community.mattermost.com/core/channels/focalboard) to ask questions from community members and the core team.
Thank you for your interest in contributing! Please read the [Focalboard Contribution Guide](https://developers.mattermost.com/contribute/focalboard/) to learn the process for making code contributions, and [join our Focalboard community channel](https://community.mattermost.com/core/channels/focalboard) to get help from community members and the core team.
When you submit a pull request, it goes through a [code review process outlined here](https://mattermost.github.io/focalboard/code-review).
When you submit a pull request, it goes through a code review process outlined [here](https://developers.mattermost.com/contribute/getting-started/code-review/).
# Updating Changelog
After a noteable bug fix or improvement is merged, submit a pull request to the [CHANGELOG](CHANGELOG.md) under the next release section.
After a noteable bug fix or an improvement you've submitted is merged, please consider making a pull request to the [CHANGELOG.md](https://github.com/mattermost/focalboard/blob/main/CHANGELOG.md) under the next release section.
# Bug reports
## Bug Reports
Please file a [GitHub issue](https://github.com/mattermost/focalboard/issues) if anything isn't working the way you expect.
# Documentation
## Documentation
You can contribute to our documentation in the [Mattermost Boards documentation](https://docs.mattermost.com/guides/boards.html). Read more about how the contribution process works in the repo [README](https://github.com/mattermost/docs/blob/master/README.md). Visit the [Documentation Working Group channel](https://community.mattermost.com/core/channels/dwg-documentation-working-group) on our Community server if you have any questions!
You can contribute to the [Mattermost Boards documentation](https://docs.mattermost.com/guides/boards.html). Read more about how the contribution process works in the repository's [README](https://github.com/mattermost/docs/blob/master/README.md). Visit the [Documentation Working Group channel](https://community.mattermost.com/core/channels/dwg-documentation-working-group) on our community server if you have any questions!
# Contributors
## Contributors
**Core Committers**: A core committer is a maintainer on the Focalboard project who has merge access to the repositories. They are responsible for reviewing pull requests, cultivating the developer community, and guiding the technical vision of Focalboard. If you have a question or need some help, these are the people to ask.
**Core Committers**: Maintains the Focalboard project and has merge access to the repositories. They are responsible for reviewing pull requests, cultivating the developer community, and guiding the technical vision of Focalboard. If you have a question or need some help, these are the people to ask.
- **<a name="scott.bishel">Scott Bishel</a>**
- @scott.bishel on [community.mattermost.com](https://community.mattermost.com/core/messages/@scott.bishel) and [@sbishel](https://github.com/sbishel) on GitHub
@ -38,12 +36,12 @@ You can contribute to our documentation in the [Mattermost Boards documentation]
- **<a name="ogi.marusic">Ogi Marušić</a>**
- @ogi.marusic on [community.mattermost.com](https://community.mattermost.com/core/messages/@ogi.marusic) and [@ogi-m](https://github.com/ogi-m) on GitHub
**Community Organizers**: Responds with comments to Bug Reports, Issues, and Pull Requests with tags, edits and mentions to Core Committers and contributors.
**Community Organizers**: Responds with comments to bug reports, issues, and pull requests with tags, edits and mentions to core committers and contributors.
- **<a name="winson.wu">Winson Wu</a>**
- @winson.wu on [community.mattermost.com](https://community.mattermost.com/core/messages/@winson.wu) and [@wuwinson](https://github.com/wuwinson) on GitHub
**Documentation**: Verifies documentation changes, and updates documentation for new features.
**Documentation**: Verifies documentation changes and updates documentation for new features.
- **<a name="justine.geffen">Justine Geffen</a>**
- @justine.geffen on [community.mattermost.com](https://community.mattermost.com/core/messages/@justine.geffen) and [@justinegeffen ](https://github.com/justinegeffen) on GitHub

128
README.md
View File

@ -16,108 +16,106 @@ Like what you see? :eyes: Give us a GitHub Star! :star:
It helps define, organize, track and manage work across individuals and teams. Focalboard comes in two main editions:
* **[Personal Desktop](https://www.focalboard.com/download/personal-edition/desktop/)**: A stand-alone single-user Mac, Windows, or Linux desktop app for your todos and personal projects.
* **[Personal Desktop](https://www.focalboard.com/download/personal-edition/desktop/)**: A standalone, single-user Mac, Windows, or Linux desktop app for your own todos and personal projects.
* **[Mattermost Boards](https://www.focalboard.com/download/mattermost/)**: A self-hosted or cloud server for your team to plan and collaborate.
Focalboard can also be installed as a standalone [personal server](https://www.focalboard.com/download/personal-edition/ubuntu/) for development and personal use.
Focalboard can also be installed as a standalone **[Personal Server](https://www.focalboard.com/download/personal-edition/ubuntu/)** for development and personal use.
## Try out Focalboard
## Try Focalboard
### Focalboard Personal Desktop (Windows, Mac or Linux Desktop)
### Personal Desktop (Windows, Mac or Linux Desktop)
Try out the single-user **Focalboard Personal Desktop**:
* macOS: Download from the [Mac App Store](https://apps.apple.com/us/app/focalboard-insiders/id1556908618?mt=12).
* Windows: Download from the [Windows App Store](https://www.microsoft.com/store/productId/9NLN2T0SX9VF) or download `focalboard-win.zip` from the [latest release](https://github.com/mattermost/focalboard/releases), unpack, and run `Focalboard.exe`
* Linux Desktop: Download `focalboard-linux.tar.gz` from the [latest release](https://github.com/mattermost/focalboard/releases), unpack, and open `focalboard-app`
* **Windows**: Download from the [Windows App Store](https://www.microsoft.com/store/productId/9NLN2T0SX9VF) or download `focalboard-win.zip` from the [latest release](https://github.com/mattermost/focalboard/releases), unpack, and run `Focalboard.exe`.
* **Mac**: Download from the [Mac App Store](https://apps.apple.com/us/app/focalboard-insiders/id1556908618?mt=12).
* **Linux Desktop**: Download `focalboard-linux.tar.gz` from the [latest release](https://github.com/mattermost/focalboard/releases), unpack, and open `focalboard-app`.
### Mattermost Boards
Mattermost Boards combines project management tools with messaging and collaboration for teams of all sizes. To access and use Boards, install or upgrade to Mattermost v6.0 or later as a [self-hosted server](https://docs.mattermost.com/guides/deployment.html?utm_source=focalboard&utm_campaign=focalboard) or [Cloud server](https://mattermost.com/get-started/?utm_source=focalboard&utm_campaign=focalboard). After logging into Mattermost, select the menu in the top left corner of Mattermost and choose **Boards**.
**Mattermost Boards** is the Mattermost plugin version of Focalboard that combines project management tools with messaging and collaboration for teams of all sizes. To access and use **Mattermost Boards**, install or upgrade to Mattermost v6.0 or later as a [self-hosted server](https://docs.mattermost.com/guides/deployment.html?utm_source=focalboard&utm_campaign=focalboard) or [Cloud server](https://mattermost.com/get-started/?utm_source=focalboard&utm_campaign=focalboard). After logging into Mattermost, select the menu in the top left corner and select **Boards**.
See the [setup guide](https://www.focalboard.com/download/mattermost/) for more details.
***Mattermost Boards** is installed and enabled by default in Mattermost v6.0 and later.*
### Focalboard Personal Server (Ubuntu)
See the [plugin setup guide](https://www.focalboard.com/download/mattermost/) for more details.
You can download and run the compiled **Focalboard Personal Server** by following [our latest install guide](https://www.focalboard.com/download/personal-edition/ubuntu/).
### Personal Server
Download the latest server release from [GitHub releases](https://github.com/mattermost/focalboard/releases)
**Ubuntu**: You can download and run the compiled Focalboard **Personal Server** on Ubuntu by following [our latest install guide](https://www.focalboard.com/download/personal-edition/ubuntu/).
## Building the server
## Contribute to Focalboard
Most development can be done on the Personal Server edition. Please refer to the [Developer's Tips & Tricks](https://mattermost.github.io/focalboard/dev-tips) for more detailed steps. Here's a summary:
Contribute code, bug reports, and ideas to the future of the Focalboard project. We welcome your input! Please see [CONTRIBUTING](CONTRIBUTING.md) for details on how to get involved.
First, install basic dependencies:
* Go 1.15+
* Node 16.3+ and npm
* Mingw64 on Windows
### Getting started
Our [developer guide](https://developers.mattermost.com/contribute/focalboard/personal-server-setup-guide) has detailed instructions on how to set up your development environment for the **Personal Server**. It also provides more information about contributing to our open source community.
To build the server:
```
make prebuild
make
```
## Running and testing the server
To run the server:
To start the server, run `./bin/focalboard-server`
```
./bin/focalboard-server
```
Server settings are in config.json (or the path specified with --config).
Then navigate your browser to [`http://localhost:8000`](http://localhost:8000) to access your Focalboard server. The port is configured in `config.json`.
Open a browser to [http://localhost:8000](http://localhost:8000) to start.
Once the server is running, you can rebuild just the web app via `make webapp` in a separate terminal window. Reload your browser to see the changes.
## Building and running standalone desktop apps
### Building and running standalone desktop apps
You can build standalone apps that package the server to run locally against SQLite:
* Mac:
* **Windows**:
* *Requires Windows 10, [Windows 10 SDK](https://developer.microsoft.com/en-us/windows/downloads/sdk-archive/) 10.0.19041.0, and .NET 4.8 developer pack*
* Open a `git-bash` prompt.
* Run `make win-wpf-app`
* Run `cd win-wpf/msix && focalboard.exe`
* **Mac**:
* *Requires macOS 11.3+ and Xcode 13.2.1+*
* `make mac-app`
* run `mac/dist/Focalboard.app`
* *Requires: macOS 11.3+, Xcode 13.2.1+*
* Linux:
* Install webgtk dependencies
* `open mac/dist/Focalboard.app`
* **Linux**:
* *Tested on Ubuntu 18.04*
* Install `webgtk` dependencies
* `sudo apt-get install libgtk-3-dev`
* `sudo apt-get install libwebkit2gtk-4.0-dev`
* `make linux-app`
* uncompress `linux/dist/focalboard-linux.tar.gz` to a directory of your choice
* run `focalboard-app` from the directory you have chosen
* *Tested with: Ubuntu 18.04*
* Windows:
* Open a git-bash prompt
* `make win-wpf-app`
* run `cd win-wpf/msix && focalboard.exe`
* *Requires: Windows 10, [Windows 10 SDK](https://developer.microsoft.com/en-us/windows/downloads/sdk-archive/) 10.0.19041.0, .NET 4.8 developer pack*
* Docker:
* To run it locally from Offical Image
* `docker run -it -p 80:8000 mattermost/focalboard`
* To Build it for your Current Architecture
* `docker build -f docker/Dockerfile .`
* To Build it for a custom Architecture (Experimental)
* `docker build -f docker/Dockerfile --platform linux/arm64 .`
* Uncompress `linux/dist/focalboard-linux.tar.gz` to a directory of your choice
* Run `focalboard-app` from the directory you have chosen
* **Docker**:
* To run it locally from offical image:
* `docker run -it -p 80:8000 mattermost/focalboard`
* To build it for your current architecture:
* `docker build -f docker/Dockerfile .`
* To build it for a custom architecture (experimental):
* `docker build -f docker/Dockerfile --platform linux/arm64 .`
Cross-compilation currently isn't fully supported, so please build on the appropriate platform. Refer to the GitHub Actions workflows (build-mac.yml, build-win.yml, build-ubuntu.yml) for the detailed list of steps on each platform.
Cross-compilation currently isn't fully supported, so please build on the appropriate platform. Refer to the GitHub Actions workflows (`build-mac.yml`, `build-win.yml`, `build-ubuntu.yml`) for the detailed list of steps on each platform.
## Unit tests
### Unit testing
Before checking-in commits, run: `make ci`, which is similar to the ci.yml workflow and includes:
* Server unit tests: `make server-test`
* Webapp eslint: `cd webapp; npm run check`
* Webapp unit tests: `cd webapp; npm run test`
* Webapp UI tests: `cd webapp; npm run cypress:ci`
Before checking in commits, run `make ci`, which is similar to the `.gitlab-ci.yml` workflow and includes:
## Stay informed on progress
* **Server unit tests**: `make server-test`
* **Web app ESLint**: `cd webapp; npm run check`
* **Web app unit tests**: `cd webapp; npm run test`
* **Web app UI tests**: `cd webapp; npm run cypress:ci`
* **Changelog**: See [CHANGELOG.md](CHANGELOG.md) for the latest updates
* **Developer Discussion**: Join the [Developer Discussion](https://github.com/mattermost/focalboard/discussions) board
* **Chat**: Join the [Focalboard community channel](https://community.mattermost.com/core/channels/focalboard)
## Share your feedback
File bugs, suggest features, join our forum, learn more [here](https://github.com/mattermost/focalboard/wiki/Share-your-feedback)!
## Contributing
Contribute code, bug reports, and ideas to the future of the Focalboard project. We welcome your input! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for details on how to get involved.
## Translating
### Translating
Help translate Focalboard! The app is already translated into several languages. We welcome corrections and new language translations! You can add new languages or improve existing translations at [Weblate](https://translate.mattermost.com/engage/focalboard/).
### Staying informed
Are you interested in influencing the future of the Focalboard open source project? Here's how you can get involved:
* **Changes**: See the [CHANGELOG](CHANGELOG.md) for the latest updates
* **GitHub Discussions**: Join the [Developer Discussion](https://github.com/mattermost/focalboard/discussions) board
* **Bug Reports**: [File a bug report](https://github.com/mattermost/focalboard/issues/new?assignees=&labels=bug&template=bug_report.md&title=)
* **Chat**: Join the [Focalboard community channel](https://community.mattermost.com/core/channels/focalboard)

View File

@ -1,3 +1,7 @@
# Focalboard Plugin for Mattermost
# Mattermost Boards (Focalboard Plugin)
This plugin allows to run focalboard inside your mattermost instance as a plugin.
**[Mattermost Boards](https://mattermost.com/boards/)** is the Mattermost plugin version of Focalboard that combines project management tools with messaging and collaboration for teams of all sizes. To access and use **Mattermost Boards**, install or upgrade to Mattermost v6.0 or later as a [self-hosted server](https://docs.mattermost.com/guides/deployment.html?utm_source=focalboard&utm_campaign=focalboard) or [Cloud server](https://mattermost.com/get-started/?utm_source=focalboard&utm_campaign=focalboard). After logging into Mattermost, select the menu in the top left corner of Mattermost and select **Boards**.
***Mattermost Boards** is installed and enabled by default in Mattermost v6.0 and later.*
To build your own version of Matterboard Boards and upload it to your own Mattermost server, follow the instructions [here](https://developers.mattermost.com/contribute/focalboard/mattermost-boards-setup-guide/).

View File

@ -189,8 +189,11 @@ export default class Plugin {
if (currentTeamID && currentTeamID !== prevTeamID) {
prevTeamID = currentTeamID
store.dispatch(setTeam(currentTeamID))
browserHistory.push(`/team/${currentTeamID}`)
wsClient.subscribeToTeam(currentTeamID)
if (window.location.pathname.startsWith(windowAny.frontendBaseURL || '')) {
console.log("REDIRECTING HERE")
browserHistory.push(`/team/${currentTeamID}`)
wsClient.subscribeToTeam(currentTeamID)
}
}
})

View File

@ -99,6 +99,7 @@ func (a *API) RegisterRoutes(r *mux.Router) {
apiv1.HandleFunc("/boards/{boardID}/members", a.sessionRequired(a.handleAddMember)).Methods("POST")
apiv1.HandleFunc("/boards/{boardID}/members/{userID}", a.sessionRequired(a.handleUpdateMember)).Methods("PUT")
apiv1.HandleFunc("/boards/{boardID}/members/{userID}", a.sessionRequired(a.handleDeleteMember)).Methods("DELETE")
apiv1.HandleFunc("/boards/{boardID}/join", a.sessionRequired(a.handleJoinBoard)).Methods("POST")
// Sharing APIs
apiv1.HandleFunc("/boards/{boardID}/sharing", a.sessionRequired(a.handlePostSharing)).Methods("POST")
@ -3214,6 +3215,98 @@ func (a *API) handleAddMember(w http.ResponseWriter, r *http.Request) {
auditRec.Success()
}
func (a *API) handleJoinBoard(w http.ResponseWriter, r *http.Request) {
// swagger:operation POST /boards/{boardID}/join joinBoard
//
// Become a member of a board
//
// ---
// produces:
// - application/json
// parameters:
// - name: boardID
// in: path
// description: Board ID
// required: true
// type: string
// security:
// - BearerAuth: []
// responses:
// '200':
// description: success
// schema:
// $ref: '#/definitions/BoardMember'
// '404':
// description: board not found
// '503':
// description: access denied
// default:
// description: internal error
// schema:
// "$ref": "#/definitions/ErrorResponse"
userID := getUserID(r)
if userID == "" {
a.errorResponse(w, r.URL.Path, http.StatusBadRequest, "", nil)
return
}
boardID := mux.Vars(r)["boardID"]
board, err := a.app.GetBoard(boardID)
if err != nil {
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err)
return
}
if board == nil {
a.errorResponse(w, r.URL.Path, http.StatusNotFound, "", nil)
return
}
if board.Type != model.BoardTypeOpen {
a.errorResponse(w, r.URL.Path, http.StatusForbidden, "", nil)
return
}
if !a.permissions.HasPermissionToTeam(userID, board.TeamID, model.PermissionViewTeam) {
a.errorResponse(w, r.URL.Path, http.StatusForbidden, "", nil)
return
}
// currently all memberships are created as editors by default
// TODO: Support different public roles
newBoardMember := &model.BoardMember{
UserID: userID,
BoardID: boardID,
SchemeEditor: true,
}
auditRec := a.makeAuditRecord(r, "joinBoard", audit.Fail)
defer a.audit.LogRecord(audit.LevelModify, auditRec)
auditRec.AddMeta("boardID", boardID)
auditRec.AddMeta("addedUserID", userID)
member, err := a.app.AddMemberToBoard(newBoardMember)
if err != nil {
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err)
return
}
a.logger.Debug("AddMember",
mlog.String("boardID", board.ID),
mlog.String("addedUserID", userID),
)
data, err := json.Marshal(member)
if err != nil {
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err)
return
}
// response
jsonBytesResponse(w, http.StatusOK, data)
auditRec.Success()
}
func (a *API) handleUpdateMember(w http.ResponseWriter, r *http.Request) {
// swagger:operation PUT /boards/{boardID}/members/{userID} updateMember
//

View File

@ -176,6 +176,10 @@ func (c *Client) GetBoardRoute(boardID string) string {
return fmt.Sprintf("%s/%s", c.GetBoardsRoute(), boardID)
}
func (c *Client) GetJoinBoardRoute(boardID string) string {
return fmt.Sprintf("%s/%s/join", c.GetBoardsRoute(), boardID)
}
func (c *Client) GetBlocksRoute(boardID string) string {
return fmt.Sprintf("%s/blocks", c.GetBoardRoute(boardID))
}
@ -512,6 +516,16 @@ func (c *Client) AddMemberToBoard(member *model.BoardMember) (*model.BoardMember
return model.BoardMemberFromJSON(r.Body), BuildResponse(r)
}
func (c *Client) JoinBoard(boardID string) (*model.BoardMember, *Response) {
r, err := c.DoAPIPost(c.GetJoinBoardRoute(boardID), "")
if err != nil {
return nil, BuildErrorResponse(r, err)
}
defer closeBody(r)
return model.BoardMemberFromJSON(r.Body), BuildResponse(r)
}
func (c *Client) UpdateBoardMember(member *model.BoardMember) (*model.BoardMember, *Response) {
r, err := c.DoAPIPut(c.GetBoardRoute(member.BoardID)+"/members/"+member.UserID, toJSON(member))
if err != nil {

View File

@ -1,6 +1,7 @@
package integrationtests
import (
"encoding/json"
"testing"
"github.com/mattermost/focalboard/server/client"
@ -1268,3 +1269,78 @@ func TestDeleteMember(t *testing.T) {
require.True(t, members[0].SchemeAdmin)
})
}
func TestJoinBoard(t *testing.T) {
t.Run("create and join public board", func(t *testing.T) {
th := SetupTestHelper(t).InitBasic()
defer th.TearDown()
me := th.GetUser1()
title := "Public board"
teamID := testTeamID
newBoard := &model.Board{
Title: title,
Type: model.BoardTypeOpen,
TeamID: teamID,
}
board, resp := th.Client.CreateBoard(newBoard)
th.CheckOK(resp)
require.NoError(t, resp.Error)
require.NotNil(t, board)
require.NotNil(t, board.ID)
require.Equal(t, title, board.Title)
require.Equal(t, model.BoardTypeOpen, board.Type)
require.Equal(t, teamID, board.TeamID)
require.Equal(t, me.ID, board.CreatedBy)
require.Equal(t, me.ID, board.ModifiedBy)
member, resp := th.Client2.JoinBoard(board.ID)
th.CheckOK(resp)
require.NoError(t, resp.Error)
require.NotNil(t, member)
require.Equal(t, board.ID, member.BoardID)
require.Equal(t, th.GetUser2().ID, member.UserID)
s, _ := json.MarshalIndent(member, "", "\t")
t.Log(string(s))
})
t.Run("create and join private board (should not succeed)", func(t *testing.T) {
th := SetupTestHelper(t).InitBasic()
defer th.TearDown()
me := th.GetUser1()
title := "Private board"
teamID := testTeamID
newBoard := &model.Board{
Title: title,
Type: model.BoardTypePrivate,
TeamID: teamID,
}
board, resp := th.Client.CreateBoard(newBoard)
th.CheckOK(resp)
require.NoError(t, resp.Error)
require.NotNil(t, board)
require.NotNil(t, board.ID)
require.Equal(t, title, board.Title)
require.Equal(t, model.BoardTypePrivate, board.Type)
require.Equal(t, teamID, board.TeamID)
require.Equal(t, me.ID, board.CreatedBy)
require.Equal(t, me.ID, board.ModifiedBy)
member, resp := th.Client2.JoinBoard(board.ID)
th.CheckForbidden(resp)
require.Nil(t, member)
})
t.Run("join invalid board", func(t *testing.T) {
th := SetupTestHelper(t).InitBasic()
defer th.TearDown()
member, resp := th.Client2.JoinBoard("nonexistent-board-ID")
th.CheckNotFound(resp)
require.Nil(t, member)
})
}

View File

@ -311,3 +311,23 @@ func (b *Board) IsValid() error {
}
return nil
}
// BoardMemberHistoryEntry stores the information of the membership of a user on a board
// swagger:model
type BoardMemberHistoryEntry struct {
// The ID of the board
// required: true
BoardID string `json:"boardId"`
// The ID of the user
// required: true
UserID string `json:"userId"`
// The action that added this history entry (created or deleted)
// required: false
Action string `json:"action"`
// The insertion time
// required: true
InsertAt int64 `json:"insertAt"`
}

View File

@ -505,6 +505,21 @@ func (mr *MockStoreMockRecorder) GetBoardAndCardByID(arg0 interface{}) *gomock.C
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetBoardAndCardByID", reflect.TypeOf((*MockStore)(nil).GetBoardAndCardByID), arg0)
}
// GetBoardMemberHistory mocks base method.
func (m *MockStore) GetBoardMemberHistory(arg0, arg1 string, arg2 uint64) ([]*model.BoardMemberHistoryEntry, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetBoardMemberHistory", arg0, arg1, arg2)
ret0, _ := ret[0].([]*model.BoardMemberHistoryEntry)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetBoardMemberHistory indicates an expected call of GetBoardMemberHistory.
func (mr *MockStoreMockRecorder) GetBoardMemberHistory(arg0, arg1, arg2 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetBoardMemberHistory", reflect.TypeOf((*MockStore)(nil).GetBoardMemberHistory), arg0, arg1, arg2)
}
// GetBoardsForUserAndTeam mocks base method.
func (m *MockStore) GetBoardsForUserAndTeam(arg0, arg1 string) ([]*model.Board, error) {
m.ctrl.T.Helper()

View File

@ -151,6 +151,28 @@ func (s *SQLStore) boardMembersFromRows(rows *sql.Rows) ([]*model.BoardMember, e
return boardMembers, nil
}
func (s *SQLStore) boardMemberHistoryEntriesFromRows(rows *sql.Rows) ([]*model.BoardMemberHistoryEntry, error) {
boardMemberHistoryEntries := []*model.BoardMemberHistoryEntry{}
for rows.Next() {
var boardMemberHistoryEntry model.BoardMemberHistoryEntry
err := rows.Scan(
&boardMemberHistoryEntry.BoardID,
&boardMemberHistoryEntry.UserID,
&boardMemberHistoryEntry.Action,
&boardMemberHistoryEntry.InsertAt,
)
if err != nil {
return nil, err
}
boardMemberHistoryEntries = append(boardMemberHistoryEntries, &boardMemberHistoryEntry)
}
return boardMemberHistoryEntries, nil
}
func (s *SQLStore) getBoardByCondition(db sq.BaseRunner, conditions ...interface{}) (*model.Board, error) {
boards, err := s.getBoardsByCondition(db, conditions...)
if err != nil {
@ -405,6 +427,11 @@ func (s *SQLStore) saveMember(db sq.BaseRunner, bm *model.BoardMember) (*model.B
"scheme_viewer": bm.SchemeViewer,
}
oldMember, err := s.getMemberForBoard(db, bm.BoardID, bm.UserID)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return nil, err
}
query := s.getQueryBuilder(db).
Insert(s.tablePrefix + "board_members").
SetMap(queryValues)
@ -425,6 +452,17 @@ func (s *SQLStore) saveMember(db sq.BaseRunner, bm *model.BoardMember) (*model.B
return nil, err
}
if oldMember == nil {
addToMembersHistory := s.getQueryBuilder(db).
Insert(s.tablePrefix+"board_members_history").
Columns("board_id", "user_id", "action", "insert_at").
Values(bm.BoardID, bm.UserID, "created", model.GetMillis())
if _, err := addToMembersHistory.Exec(); err != nil {
return nil, err
}
}
return bm, nil
}
@ -434,10 +472,27 @@ func (s *SQLStore) deleteMember(db sq.BaseRunner, boardID, userID string) error
Where(sq.Eq{"board_id": boardID}).
Where(sq.Eq{"user_id": userID})
if _, err := deleteQuery.Exec(); err != nil {
result, err := deleteQuery.Exec()
if err != nil {
return err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
if rowsAffected > 0 {
addToMembersHistory := s.getQueryBuilder(db).
Insert(s.tablePrefix+"board_members_history").
Columns("board_id", "user_id", "action", "insert_at").
Values(boardID, userID, "deleted", model.GetMillis())
if _, err := addToMembersHistory.Exec(); err != nil {
return err
}
}
return nil
}
@ -549,3 +604,30 @@ func (s *SQLStore) searchBoardsForUserAndTeam(db sq.BaseRunner, term, userID, te
return s.boardsFromRows(rows)
}
func (s *SQLStore) getBoardMemberHistory(db sq.BaseRunner, boardID, userID string, limit uint64) ([]*model.BoardMemberHistoryEntry, error) {
query := s.getQueryBuilder(db).
Select("board_id", "user_id", "action", "insert_at").
From(s.tablePrefix + "board_members_history").
Where(sq.Eq{"board_id": boardID}).
Where(sq.Eq{"user_id": userID}).
OrderBy("insert_at DESC")
if limit > 0 {
query = query.Limit(limit)
}
rows, err := query.Query()
if err != nil {
s.logger.Error(`getBoardMemberHistory ERROR`, mlog.Err(err))
return nil, err
}
defer s.CloseRows(rows)
memberHistory, err := s.boardMemberHistoryEntriesFromRows(rows)
if err != nil {
return nil, err
}
return memberHistory, nil
}

View File

@ -557,7 +557,12 @@ func (s *SQLStore) getDMBoards(tx sq.BaseRunner) ([]*model.Board, error) {
},
}
return s.getBoardsByCondition(tx, conditions)
boards, err := s.getBoardsByCondition(tx, conditions)
if err != nil && errors.Is(err, sql.ErrNoRows) {
return []*model.Board{}, nil
}
return boards, err
}
// The destination is selected as the first team where all members

View File

@ -0,0 +1 @@
DROP TABLE {{.prefix}}board_members_history;

View File

@ -0,0 +1,18 @@
CREATE TABLE {{.prefix}}board_members_history (
{{if .postgres}}id SERIAL PRIMARY KEY,{{end}}
{{if .sqlite}}id INTEGER PRIMARY KEY AUTOINCREMENT,{{end}}
{{if .mysql}}id INT PRIMARY KEY AUTO_INCREMENT,{{end}}
board_id VARCHAR(36) NOT NULL,
user_id VARCHAR(36) NOT NULL,
action VARCHAR(10),
insert_at BIGINT NOT NULL
) {{if .mysql}}DEFAULT CHARACTER SET utf8mb4{{end}};
CREATE INDEX idx_boardmembershistory_user_id ON {{.prefix}}board_members_history(user_id);
CREATE INDEX idx_boardmembershistory_board_id_userid ON {{.prefix}}board_members_history(board_id, user_id);
INSERT INTO {{.prefix}}board_members_history (board_id, user_id, action, insert_at) SELECT board_id, user_id, 'created',
{{if .postgres}}CAST(extract(epoch from now()) * 1000 AS BIGINT){{end}}
{{if .sqlite}}strftime('%s')*1000{{end}}
{{if .mysql}}UNIX_TIMESTAMP(now())*1000{{end}}
from {{.prefix}}board_members;

View File

@ -309,6 +309,11 @@ func (s *SQLStore) GetBoardAndCardByID(blockID string) (*model.Board, *model.Blo
}
func (s *SQLStore) GetBoardMemberHistory(boardID string, userID string, limit uint64) ([]*model.BoardMemberHistoryEntry, error) {
return s.getBoardMemberHistory(s.db, boardID, userID, limit)
}
func (s *SQLStore) GetBoardsForUserAndTeam(userID string, teamID string) ([]*model.Board, error) {
return s.getBoardsForUserAndTeam(s.db, userID, teamID)

View File

@ -90,6 +90,7 @@ type Store interface {
SaveMember(bm *model.BoardMember) (*model.BoardMember, error)
DeleteMember(boardID, userID string) error
GetMemberForBoard(boardID, userID string) (*model.BoardMember, error)
GetBoardMemberHistory(boardID, userID string, limit uint64) ([]*model.BoardMemberHistoryEntry, error)
GetMembersForBoard(boardID string) ([]*model.BoardMember, error)
GetMembersForUser(userID string) ([]*model.BoardMember, error)
SearchBoardsForUserAndTeam(term, userID, teamID string) ([]*model.Board, error)

View File

@ -517,12 +517,20 @@ func testSaveMember(t *testing.T, store store.Store) {
SchemeAdmin: true,
}
memberHistory, err := store.GetBoardMemberHistory(boardID, userID, 0)
require.NoError(t, err)
initialMemberHistory := len(memberHistory)
nbm, err := store.SaveMember(bm)
require.NoError(t, err)
require.Equal(t, userID, nbm.UserID)
require.Equal(t, boardID, nbm.BoardID)
require.True(t, nbm.SchemeAdmin)
memberHistory, err = store.GetBoardMemberHistory(boardID, userID, 0)
require.NoError(t, err)
require.Len(t, memberHistory, initialMemberHistory+1)
})
t.Run("should correctly update a member", func(t *testing.T) {
@ -533,6 +541,10 @@ func testSaveMember(t *testing.T, store store.Store) {
SchemeViewer: true,
}
memberHistory, err := store.GetBoardMemberHistory(boardID, userID, 0)
require.NoError(t, err)
initialMemberHistory := len(memberHistory)
nbm, err := store.SaveMember(bm)
require.NoError(t, err)
require.Equal(t, userID, nbm.UserID)
@ -541,6 +553,10 @@ func testSaveMember(t *testing.T, store store.Store) {
require.False(t, nbm.SchemeAdmin)
require.True(t, nbm.SchemeEditor)
require.True(t, nbm.SchemeViewer)
memberHistory, err = store.GetBoardMemberHistory(boardID, userID, 0)
require.NoError(t, err)
require.Len(t, memberHistory, initialMemberHistory)
})
}
@ -626,7 +642,15 @@ func testDeleteMember(t *testing.T, store store.Store) {
boardID := testBoardID
t.Run("should return nil if deleting a nonexistent member", func(t *testing.T) {
memberHistory, err := store.GetBoardMemberHistory(boardID, userID, 0)
require.NoError(t, err)
initialMemberHistory := len(memberHistory)
require.NoError(t, store.DeleteMember(boardID, userID))
memberHistory, err = store.GetBoardMemberHistory(boardID, userID, 0)
require.NoError(t, err)
require.Len(t, memberHistory, initialMemberHistory)
})
t.Run("should correctly delete a member", func(t *testing.T) {
@ -640,11 +664,19 @@ func testDeleteMember(t *testing.T, store store.Store) {
require.NoError(t, err)
require.NotNil(t, nbm)
memberHistory, err := store.GetBoardMemberHistory(boardID, userID, 0)
require.NoError(t, err)
initialMemberHistory := len(memberHistory)
require.NoError(t, store.DeleteMember(boardID, userID))
rbm, err := store.GetMemberForBoard(boardID, userID)
require.ErrorIs(t, err, sql.ErrNoRows)
require.Nil(t, rbm)
memberHistory, err = store.GetBoardMemberHistory(boardID, userID, 0)
require.NoError(t, err)
require.Len(t, memberHistory, initialMemberHistory+1)
})
}

View File

@ -1 +1 @@
5.3.0
5.4.0

View File

@ -1840,6 +1840,25 @@ paths:
$ref: '#/definitions/ErrorResponse'
security:
- BearerAuth: []
/api/v1/users/me/memberships:
get:
description: Returns the currently users board memberships
operationId: getMyMemberships
produces:
- application/json
responses:
"200":
description: success
schema:
items:
$ref: '#/definitions/BoardMember'
type: array
default:
description: internal error
schema:
$ref: '#/definitions/ErrorResponse'
security:
- BearerAuth: []
/api/v1/workspaces/{workspaceID}/blocks/{blockID}/undelete:
post:
description: Undeletes a block
@ -1900,6 +1919,33 @@ paths:
$ref: '#/definitions/ErrorResponse'
security:
- BearerAuth: []
/boards/{boardID}/join:
post:
description: Become a member of a board
operationId: joinBoard
parameters:
- description: Board ID
in: path
name: boardID
required: true
type: string
produces:
- application/json
responses:
"200":
description: success
schema:
$ref: '#/definitions/BoardMember'
"404":
description: board not found
"503":
description: access denied
default:
description: internal error
schema:
$ref: '#/definitions/ErrorResponse'
security:
- BearerAuth: []
/boards/{boardID}/members:
post:
description: Adds a new member to a board

View File

@ -15,7 +15,7 @@ declare namespace Cypress {
apiGetMe: () => Chainable<string>
apiChangePassword: (userId: string, oldPassword: string, newPassword: string) => Chainable
apiInitServer: () => Chainable
apiDeleteBlock: (id: string) => Chainable
apiDeleteBoard: (id: string) => Chainable
apiResetBoards: () => Chainable
apiSkipTour: (userID: string) => Chainable

View File

@ -97,7 +97,7 @@ describe('Card URL Property', () => {
const addView = (type: ViewType) => {
cy.log(`**Add ${type} view**`)
cy.findByRole('button', {name: 'View menu'}).click()
cy.findByText('Add view').click()
cy.findByText('Add view').realHover()
cy.findByRole('button', {name: type}).click()
cy.findByRole('textbox', {name: `${type} view`}).should('exist')
}

View File

@ -98,7 +98,7 @@ describe('Create and delete board / card', () => {
// Create table view
cy.log('**Create table view**')
cy.get('.ViewHeader').get('.DropdownIcon').first().parent().click()
cy.get('.ViewHeader').contains('Add view').click()
cy.get('.ViewHeader').contains('Add view').realHover()
cy.get('.ViewHeader').
contains('Add view').
parent().

View File

@ -52,32 +52,32 @@ Cypress.Commands.add('apiInitServer', () => {
return cy.apiRegisterUser(data, '', false).apiLoginUser(data)
})
Cypress.Commands.add('apiDeleteBlock', (id: string) => {
Cypress.Commands.add('apiDeleteBoard', (id: string) => {
return cy.request({
method: 'DELETE',
url: `/api/v1/workspaces/0/blocks/${encodeURIComponent(id)}`,
url: `/api/v1/boards/${encodeURIComponent(id)}`,
...headers(),
})
})
const deleteBlocks = (ids: string[]) => {
const deleteBoards = (ids: string[]) => {
if (ids.length === 0) {
return
}
const [id, ...other] = ids
cy.apiDeleteBlock(id).then(() => deleteBlocks(other))
cy.apiDeleteBoard(id).then(() => deleteBoards(other))
}
Cypress.Commands.add('apiResetBoards', () => {
return cy.request({
method: 'GET',
url: '/api/v1/workspaces/0/blocks?type=board',
url: '/api/v1/teams/0/boards',
...headers(),
}).then((response) => {
if (Array.isArray(response.body)) {
const boards = response.body as Board[]
const toDelete = boards.filter((b) => !b.isTemplate).map((b) => b.id)
deleteBlocks(toDelete)
deleteBoards(toDelete)
}
})
})

546
webapp/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -61,7 +61,7 @@
"react-intl": "^5.24.7",
"react-redux": "^7.2.6",
"react-router-dom": "^5.2.1",
"react-select": "^4.3.0",
"react-select": "^5.2.2",
"trim-newlines": "^4.0.2"
},
"jest": {
@ -108,7 +108,7 @@
"@types/react-intl": "^3.0.0",
"@types/react-redux": "^7.1.23",
"@types/react-router-dom": "^5.3.3",
"@types/react-select": "^4.0.13",
"@types/react-select": "^5.0.0",
"@types/redux-mock-store": "^1.0.3",
"@typescript-eslint/eslint-plugin": "^5.16.0",
"@typescript-eslint/parser": "^5.16.0",

View File

@ -88,6 +88,8 @@ Object {
<div
class="MenuOption MenuSeparator menu-separator"
/>
</div>
<div>
<div
aria-label="Duplicate view"
class="MenuOption TextOption menu-option"
@ -105,6 +107,8 @@ Object {
class="noicon"
/>
</div>
</div>
<div>
<div
aria-label="Delete view"
class="MenuOption TextOption menu-option"
@ -122,6 +126,8 @@ Object {
class="noicon"
/>
</div>
</div>
<div>
<div
class="MenuOption SubMenuOption menu-option"
id="__addView"
@ -258,6 +264,8 @@ Object {
<div
class="MenuOption MenuSeparator menu-separator"
/>
</div>
<div>
<div
aria-label="Duplicate view"
class="MenuOption TextOption menu-option"
@ -275,6 +283,8 @@ Object {
class="noicon"
/>
</div>
</div>
<div>
<div
aria-label="Delete view"
class="MenuOption TextOption menu-option"
@ -292,6 +302,8 @@ Object {
class="noicon"
/>
</div>
</div>
<div>
<div
class="MenuOption SubMenuOption menu-option"
id="__addView"
@ -486,6 +498,9 @@ Object {
class="MenuOption MenuSeparator menu-separator"
/>
</div>
<div />
<div />
<div />
</div>
<div
class="menu-spacer hideOnWidescreen"
@ -600,6 +615,9 @@ Object {
class="MenuOption MenuSeparator menu-separator"
/>
</div>
<div />
<div />
<div />
</div>
<div
class="menu-spacer hideOnWidescreen"

View File

@ -27,21 +27,27 @@ const BoardTemplateSelectorPreview = (props: Props) => {
const [activeTemplateCards, setActiveTemplateCards] = useState<Card[]>([])
useEffect(() => {
let isSubscribed = true
if (activeTemplate) {
setActiveTemplateCards([])
setActiveView(null)
setActiveTemplateCards([])
octoClient.getAllBlocks(activeTemplate.id).then((blocks) => {
const cards = blocks.filter((b) => b.type === 'card')
const views = blocks.filter((b) => b.type === 'view').sort((a, b) => a.title.localeCompare(b.title))
if (views.length > 0) {
setActiveView(views[0] as BoardView)
}
if (cards.length > 0) {
setActiveTemplateCards(cards as Card[])
if (isSubscribed) {
const cards = blocks.filter((b) => b.type === 'card')
const views = blocks.filter((b) => b.type === 'view').sort((a, b) => a.title.localeCompare(b.title))
if (views.length > 0) {
setActiveView(views[0] as BoardView)
}
if (cards.length > 0) {
setActiveTemplateCards(cards as Card[])
}
}
})
}
return () => {
isSubscribed = false
}
}, [activeTemplate])
const dateDisplayProperty = useMemo(() => {

View File

@ -86,8 +86,12 @@ exports[`components/calculations/Calculation should match snapshot - option chan
>
<div>
<div
class="CalculationOptions css-2b097c-container"
class="CalculationOptions css-b62m3t-container"
>
<span
class="css-1f43avz-a11yText-A11yText"
id="react-select-2-live-region"
/>
<span
aria-atomic="false"
aria-live="polite"
@ -101,15 +105,21 @@ exports[`components/calculations/Calculation should match snapshot - option chan
class="CalculationOptions__value-container CalculationOptions__value-container--has-value css-1mxrbau-ValueContainer"
>
<div
class="CalculationOptions__single-value css-1brck82-singleValue"
class="CalculationOptions__single-value css-1qlwihv-singleValue"
>
Calculate
</div>
<input
aria-autocomplete="list"
class="css-wmatm6-dummyInput-DummyInput"
aria-controls="react-select-2-listbox"
aria-expanded="false"
aria-haspopup="true"
aria-owns="react-select-2-listbox"
aria-readonly="true"
class="css-mohuvp-dummyInput-DummyInput"
id="react-select-2-input"
readonly=""
inputmode="none"
role="combobox"
tabindex="0"
value=""
/>

View File

@ -3,8 +3,12 @@
exports[`components/calculations/Options should match snapshot 1`] = `
<div>
<div
class="CalculationOptions css-2b097c-container"
class="CalculationOptions css-b62m3t-container"
>
<span
class="css-1f43avz-a11yText-A11yText"
id="react-select-2-live-region"
/>
<span
aria-atomic="false"
aria-live="polite"
@ -18,15 +22,21 @@ exports[`components/calculations/Options should match snapshot 1`] = `
class="CalculationOptions__value-container CalculationOptions__value-container--has-value css-1mxrbau-ValueContainer"
>
<div
class="CalculationOptions__single-value css-1brck82-singleValue"
class="CalculationOptions__single-value css-1qlwihv-singleValue"
>
Calculate
</div>
<input
aria-autocomplete="list"
class="css-wmatm6-dummyInput-DummyInput"
aria-controls="react-select-2-listbox"
aria-expanded="false"
aria-haspopup="true"
aria-owns="react-select-2-listbox"
aria-readonly="true"
class="css-mohuvp-dummyInput-DummyInput"
id="react-select-2-input"
readonly=""
inputmode="none"
role="combobox"
tabindex="0"
value=""
/>
@ -76,8 +86,12 @@ exports[`components/calculations/Options should match snapshot 1`] = `
exports[`components/calculations/Options should match snapshot menu open 1`] = `
<div>
<div
class="CalculationOptions css-2b097c-container"
class="CalculationOptions css-b62m3t-container"
>
<span
class="css-1f43avz-a11yText-A11yText"
id="react-select-3-live-region"
/>
<span
aria-atomic="false"
aria-live="polite"
@ -91,15 +105,21 @@ exports[`components/calculations/Options should match snapshot menu open 1`] = `
class="CalculationOptions__value-container CalculationOptions__value-container--has-value css-1mxrbau-ValueContainer"
>
<div
class="CalculationOptions__single-value css-1brck82-singleValue"
class="CalculationOptions__single-value css-1qlwihv-singleValue"
>
Calculate
</div>
<input
aria-autocomplete="list"
class="css-wmatm6-dummyInput-DummyInput"
aria-controls="react-select-3-listbox"
aria-expanded="true"
aria-haspopup="true"
aria-owns="react-select-3-listbox"
aria-readonly="true"
class="css-mohuvp-dummyInput-DummyInput"
id="react-select-3-input"
readonly=""
inputmode="none"
role="combobox"
tabindex="0"
value=""
/>
@ -139,11 +159,13 @@ exports[`components/calculations/Options should match snapshot menu open 1`] = `
</div>
<div
class="CalculationOptions__menu css-1rsmi4x-menu"
id="react-select-3-listbox"
>
<div
class="CalculationOptions__menu-list css-g29tl0-MenuList"
>
<div
aria-disabled="false"
class="CalculationOptions__option css-14xsrqy-option"
id="react-select-3-option-0"
tabindex="-1"
@ -151,6 +173,7 @@ exports[`components/calculations/Options should match snapshot menu open 1`] = `
Count
</div>
<div
aria-disabled="false"
class="CalculationOptions__option css-14xsrqy-option"
id="react-select-3-option-1"
tabindex="-1"

View File

@ -2,7 +2,7 @@
// See LICENSE.txt for license information.
import React from 'react'
import Select, {components, IndicatorProps} from 'react-select'
import Select, {components, DropdownIndicatorProps} from 'react-select'
import {CSSObject} from '@emotion/serialize'
@ -151,7 +151,7 @@ const styles = {
}),
}
const DropdownIndicator = (props: IndicatorProps<Option, false>) => {
const DropdownIndicator = (props: DropdownIndicatorProps<Option, false>) => {
return (
<components.DropdownIndicator {...props}>
<ChevronUp/>

View File

@ -329,8 +329,12 @@ exports[`src/component/kanban/kanban return kanban and click on KanbanCalculatio
</span>
</button>
<div
class="CalculationOptions css-2b097c-container"
class="CalculationOptions css-b62m3t-container"
>
<span
class="css-1f43avz-a11yText-A11yText"
id="react-select-2-live-region"
/>
<span
aria-atomic="false"
aria-live="polite"
@ -344,15 +348,21 @@ exports[`src/component/kanban/kanban return kanban and click on KanbanCalculatio
class="CalculationOptions__value-container CalculationOptions__value-container--has-value css-1mxrbau-ValueContainer"
>
<div
class="CalculationOptions__single-value css-1brck82-singleValue"
class="CalculationOptions__single-value css-1qlwihv-singleValue"
>
Count
</div>
<input
aria-autocomplete="list"
class="css-wmatm6-dummyInput-DummyInput"
aria-controls="react-select-2-listbox"
aria-expanded="true"
aria-haspopup="true"
aria-owns="react-select-2-listbox"
aria-readonly="true"
class="css-mohuvp-dummyInput-DummyInput"
id="react-select-2-input"
readonly=""
inputmode="none"
role="combobox"
tabindex="0"
value=""
/>
@ -392,6 +402,7 @@ exports[`src/component/kanban/kanban return kanban and click on KanbanCalculatio
</div>
<div
class="CalculationOptions__menu css-1rsmi4x-menu"
id="react-select-2-listbox"
>
<div
class="CalculationOptions__menu-list css-g29tl0-MenuList"

View File

@ -33,8 +33,12 @@ exports[`components/kanban/calculation/KanbanCalculation calculations menu open
</span>
</button>
<div
class="CalculationOptions css-2b097c-container"
class="CalculationOptions css-b62m3t-container"
>
<span
class="css-1f43avz-a11yText-A11yText"
id="react-select-2-live-region"
/>
<span
aria-atomic="false"
aria-live="polite"
@ -48,15 +52,21 @@ exports[`components/kanban/calculation/KanbanCalculation calculations menu open
class="CalculationOptions__value-container CalculationOptions__value-container--has-value css-1mxrbau-ValueContainer"
>
<div
class="CalculationOptions__single-value css-1brck82-singleValue"
class="CalculationOptions__single-value css-1qlwihv-singleValue"
>
Count
</div>
<input
aria-autocomplete="list"
class="css-wmatm6-dummyInput-DummyInput"
aria-controls="react-select-2-listbox"
aria-expanded="true"
aria-haspopup="true"
aria-owns="react-select-2-listbox"
aria-readonly="true"
class="css-mohuvp-dummyInput-DummyInput"
id="react-select-2-input"
readonly=""
inputmode="none"
role="combobox"
tabindex="0"
value=""
/>
@ -96,6 +106,7 @@ exports[`components/kanban/calculation/KanbanCalculation calculations menu open
</div>
<div
class="CalculationOptions__menu css-1rsmi4x-menu"
id="react-select-2-listbox"
>
<div
class="CalculationOptions__menu-list css-g29tl0-MenuList"

View File

@ -3,8 +3,12 @@
exports[`components/kanban/calculations/KanbanCalculationOptions base case 1`] = `
<div>
<div
class="CalculationOptions css-2b097c-container"
class="CalculationOptions css-b62m3t-container"
>
<span
class="css-1f43avz-a11yText-A11yText"
id="react-select-2-live-region"
/>
<span
aria-atomic="false"
aria-live="polite"
@ -18,15 +22,21 @@ exports[`components/kanban/calculations/KanbanCalculationOptions base case 1`] =
class="CalculationOptions__value-container CalculationOptions__value-container--has-value css-1mxrbau-ValueContainer"
>
<div
class="CalculationOptions__single-value css-1brck82-singleValue"
class="CalculationOptions__single-value css-1qlwihv-singleValue"
>
Count
</div>
<input
aria-autocomplete="list"
class="css-wmatm6-dummyInput-DummyInput"
aria-controls="react-select-2-listbox"
aria-expanded="false"
aria-haspopup="true"
aria-owns="react-select-2-listbox"
aria-readonly="true"
class="css-mohuvp-dummyInput-DummyInput"
id="react-select-2-input"
readonly=""
inputmode="none"
role="combobox"
tabindex="0"
value=""
/>
@ -76,8 +86,12 @@ exports[`components/kanban/calculations/KanbanCalculationOptions base case 1`] =
exports[`components/kanban/calculations/KanbanCalculationOptions with menu open 1`] = `
<div>
<div
class="CalculationOptions css-2b097c-container"
class="CalculationOptions css-b62m3t-container"
>
<span
class="css-1f43avz-a11yText-A11yText"
id="react-select-3-live-region"
/>
<span
aria-atomic="false"
aria-live="polite"
@ -91,15 +105,21 @@ exports[`components/kanban/calculations/KanbanCalculationOptions with menu open
class="CalculationOptions__value-container CalculationOptions__value-container--has-value css-1mxrbau-ValueContainer"
>
<div
class="CalculationOptions__single-value css-1brck82-singleValue"
class="CalculationOptions__single-value css-1qlwihv-singleValue"
>
Count
</div>
<input
aria-autocomplete="list"
class="css-wmatm6-dummyInput-DummyInput"
aria-controls="react-select-3-listbox"
aria-expanded="true"
aria-haspopup="true"
aria-owns="react-select-3-listbox"
aria-readonly="true"
class="css-mohuvp-dummyInput-DummyInput"
id="react-select-3-input"
readonly=""
inputmode="none"
role="combobox"
tabindex="0"
value=""
/>
@ -139,6 +159,7 @@ exports[`components/kanban/calculations/KanbanCalculationOptions with menu open
</div>
<div
class="CalculationOptions__menu css-1rsmi4x-menu"
id="react-select-3-listbox"
>
<div
class="CalculationOptions__menu-list css-g29tl0-MenuList"
@ -231,8 +252,12 @@ exports[`components/kanban/calculations/KanbanCalculationOptions with menu open
exports[`components/kanban/calculations/KanbanCalculationOptions with submenu open 1`] = `
<div>
<div
class="CalculationOptions css-2b097c-container"
class="CalculationOptions css-b62m3t-container"
>
<span
class="css-1f43avz-a11yText-A11yText"
id="react-select-4-live-region"
/>
<span
aria-atomic="false"
aria-live="polite"
@ -246,15 +271,21 @@ exports[`components/kanban/calculations/KanbanCalculationOptions with submenu op
class="CalculationOptions__value-container CalculationOptions__value-container--has-value css-1mxrbau-ValueContainer"
>
<div
class="CalculationOptions__single-value css-1brck82-singleValue"
class="CalculationOptions__single-value css-1qlwihv-singleValue"
>
Count
</div>
<input
aria-autocomplete="list"
class="css-wmatm6-dummyInput-DummyInput"
aria-controls="react-select-4-listbox"
aria-expanded="true"
aria-haspopup="true"
aria-owns="react-select-4-listbox"
aria-readonly="true"
class="css-mohuvp-dummyInput-DummyInput"
id="react-select-4-input"
readonly=""
inputmode="none"
role="combobox"
tabindex="0"
value=""
/>
@ -294,6 +325,7 @@ exports[`components/kanban/calculations/KanbanCalculationOptions with submenu op
</div>
<div
class="CalculationOptions__menu css-1rsmi4x-menu"
id="react-select-4-listbox"
>
<div
class="CalculationOptions__menu-list css-g29tl0-MenuList"

View File

@ -93,7 +93,7 @@ describe('components/properties/multiSelect', () => {
userEvent.click(screen.getByTestId(nonEditableMultiSelectTestId))
expect(screen.getByRole('textbox', {name: /value selector/i})).toBeInTheDocument()
expect(screen.getByRole('combobox', {name: /value selector/i})).toBeInTheDocument()
})
it('can select a option', async () => {
@ -118,7 +118,7 @@ describe('components/properties/multiSelect', () => {
userEvent.click(screen.getByTestId(nonEditableMultiSelectTestId))
userEvent.type(screen.getByRole('textbox', {name: /value selector/i}), 'b{enter}')
userEvent.type(screen.getByRole('combobox', {name: /value selector/i}), 'b{enter}')
expect(onChange).toHaveBeenCalledWith(['multi-option-1', 'multi-option-2'])
})
@ -175,7 +175,7 @@ describe('components/properties/multiSelect', () => {
userEvent.click(screen.getByTestId(nonEditableMultiSelectTestId))
userEvent.type(screen.getByRole('textbox', {name: /value selector/i}), 'new-value{enter}')
userEvent.type(screen.getByRole('combobox', {name: /value selector/i}), 'new-value{enter}')
const selectedValues = propertyTemplate.options.filter((option: IPropertyOption) => propertyValue.includes(option.id))

View File

@ -186,7 +186,7 @@ describe('components/properties/select', () => {
))
userEvent.click(screen.getByTestId(nonEditableSelectTestId))
userEvent.type(screen.getByRole('textbox', {name: /value selector/i}), `${newOption}{enter}`)
userEvent.type(screen.getByRole('combobox', {name: /value selector/i}), `${newOption}{enter}`)
expect(onCreate).toHaveBeenCalledWith(newOption)
})

View File

@ -3,8 +3,12 @@
exports[`components/properties/user not readonly 1`] = `
<div>
<div
class="UserProperty css-2b097c-container"
class="UserProperty css-b62m3t-container"
>
<span
class="css-1f43avz-a11yText-A11yText"
id="react-select-3-live-region"
/>
<span
aria-atomic="false"
aria-live="polite"
@ -15,10 +19,10 @@ exports[`components/properties/user not readonly 1`] = `
class="react-select__control css-18140j1-Control"
>
<div
class="react-select__value-container react-select__value-container--has-value css-o7cxt9-ValueContainer"
class="react-select__value-container react-select__value-container--has-value css-433wy7-ValueContainer"
>
<div
class="react-select__single-value css-14cfm31-singleValue"
class="react-select__single-value css-1lixa2z-singleValue"
>
<div
class="UserProperty-item"
@ -27,28 +31,27 @@ exports[`components/properties/user not readonly 1`] = `
</div>
</div>
<div
class="css-1shkodo-Input"
class="react-select__input-container css-ox1y69-Input"
data-value=""
>
<div
<input
aria-autocomplete="list"
aria-controls="react-select-3-listbox"
aria-expanded="false"
aria-haspopup="true"
aria-owns="react-select-3-listbox"
autocapitalize="none"
autocomplete="off"
autocorrect="off"
class="react-select__input"
style="display: inline-block;"
>
<input
aria-autocomplete="list"
autocapitalize="none"
autocomplete="off"
autocorrect="off"
id="react-select-3-input"
spellcheck="false"
style="box-sizing: content-box; width: 2px; border: 0px; opacity: 1; outline: 0; padding: 0px;"
tabindex="0"
type="text"
value=""
/>
<div
style="position: absolute; top: 0px; left: 0px; visibility: hidden; height: 0px; overflow: scroll; white-space: pre; font-family: -webkit-small-control; letter-spacing: normal; text-transform: none;"
/>
</div>
id="react-select-3-input"
role="combobox"
spellcheck="false"
style="opacity: 1; width: 100%; grid-area: 1 / 2; min-width: 2px; border: 0px; margin: 0px; outline: 0; padding: 0px;"
tabindex="0"
type="text"
value=""
/>
</div>
</div>
<div
@ -100,8 +103,12 @@ exports[`components/properties/user not readonly 1`] = `
exports[`components/properties/user not readonly not existing user 1`] = `
<div>
<div
class="UserProperty css-2b097c-container"
class="UserProperty css-b62m3t-container"
>
<span
class="css-1f43avz-a11yText-A11yText"
id="react-select-2-live-region"
/>
<span
aria-atomic="false"
aria-live="polite"
@ -112,36 +119,37 @@ exports[`components/properties/user not readonly not existing user 1`] = `
class="react-select__control css-18140j1-Control"
>
<div
class="react-select__value-container css-o7cxt9-ValueContainer"
class="react-select__value-container css-433wy7-ValueContainer"
>
<div
class="react-select__placeholder css-1wa3eu0-placeholder"
class="react-select__placeholder css-14el2xx-placeholder"
id="react-select-2-placeholder"
>
Empty
</div>
<div
class="css-1shkodo-Input"
class="react-select__input-container css-ox1y69-Input"
data-value=""
>
<div
<input
aria-autocomplete="list"
aria-controls="react-select-2-listbox"
aria-describedby="react-select-2-placeholder"
aria-expanded="false"
aria-haspopup="true"
aria-owns="react-select-2-listbox"
autocapitalize="none"
autocomplete="off"
autocorrect="off"
class="react-select__input"
style="display: inline-block;"
>
<input
aria-autocomplete="list"
autocapitalize="none"
autocomplete="off"
autocorrect="off"
id="react-select-2-input"
spellcheck="false"
style="box-sizing: content-box; width: 2px; border: 0px; opacity: 1; outline: 0; padding: 0px;"
tabindex="0"
type="text"
value=""
/>
<div
style="position: absolute; top: 0px; left: 0px; visibility: hidden; height: 0px; overflow: scroll; white-space: pre; font-family: -webkit-small-control; letter-spacing: normal; text-transform: none;"
/>
</div>
id="react-select-2-input"
role="combobox"
spellcheck="false"
style="opacity: 1; width: 100%; grid-area: 1 / 2; min-width: 2px; border: 0px; margin: 0px; outline: 0; padding: 0px;"
tabindex="0"
type="text"
value=""
/>
</div>
</div>
<div
@ -190,8 +198,12 @@ exports[`components/properties/user readonly view 1`] = `
exports[`components/properties/user user dropdown open 1`] = `
<div>
<div
class="UserProperty css-2b097c-container"
class="UserProperty css-b62m3t-container"
>
<span
class="css-1f43avz-a11yText-A11yText"
id="react-select-4-live-region"
/>
<span
aria-atomic="false"
aria-live="polite"
@ -211,10 +223,10 @@ exports[`components/properties/user user dropdown open 1`] = `
class="react-select__control react-select__control--is-focused react-select__control--menu-is-open css-18140j1-Control"
>
<div
class="react-select__value-container react-select__value-container--has-value css-o7cxt9-ValueContainer"
class="react-select__value-container react-select__value-container--has-value css-433wy7-ValueContainer"
>
<div
class="react-select__single-value css-14cfm31-singleValue"
class="react-select__single-value css-1lixa2z-singleValue"
>
<div
class="UserProperty-item"
@ -223,28 +235,27 @@ exports[`components/properties/user user dropdown open 1`] = `
</div>
</div>
<div
class="css-1shkodo-Input"
class="react-select__input-container css-ox1y69-Input"
data-value=""
>
<div
<input
aria-autocomplete="list"
aria-controls="react-select-4-listbox"
aria-expanded="true"
aria-haspopup="true"
aria-owns="react-select-4-listbox"
autocapitalize="none"
autocomplete="off"
autocorrect="off"
class="react-select__input"
style="display: inline-block;"
>
<input
aria-autocomplete="list"
autocapitalize="none"
autocomplete="off"
autocorrect="off"
id="react-select-4-input"
spellcheck="false"
style="box-sizing: content-box; width: 2px; border: 0px; opacity: 1; outline: 0; padding: 0px;"
tabindex="0"
type="text"
value=""
/>
<div
style="position: absolute; top: 0px; left: 0px; visibility: hidden; height: 0px; overflow: scroll; white-space: pre; font-family: -webkit-small-control; letter-spacing: normal; text-transform: none;"
/>
</div>
id="react-select-4-input"
role="combobox"
spellcheck="false"
style="opacity: 1; width: 100%; grid-area: 1 / 2; min-width: 2px; border: 0px; margin: 0px; outline: 0; padding: 0px;"
tabindex="0"
type="text"
value=""
/>
</div>
</div>
<div
@ -291,11 +302,13 @@ exports[`components/properties/user user dropdown open 1`] = `
</div>
<div
class="react-select__menu css-10b6da7-menu"
id="react-select-4-listbox"
>
<div
class="react-select__menu-list css-g29tl0-MenuList"
>
<div
aria-disabled="false"
class="react-select__option react-select__option--is-focused react-select__option--is-selected css-10e3bcm-option"
id="react-select-4-option-0"
tabindex="-1"

View File

@ -127,7 +127,7 @@ describe('components/properties/user', () => {
if (container) {
// this is the actual element where the click event triggers
// opening of the dropdown
const userProperty = container.querySelector('.UserProperty > div > div:nth-child(1) > div:nth-child(2) > div > input')
const userProperty = container.querySelector('.UserProperty > div > div:nth-child(1) > div:nth-child(2) > input')
expect(userProperty).not.toBeNull()
act(() => {

View File

@ -24,6 +24,15 @@ exports[`src/components/shareBoard/shareBoard return shareBoard and click Copy l
class="CompassIcon icon-close CloseIcon"
/>
</button>
<div
class="cardToolbar"
>
<span
class="text-heading5"
>
Share Board
</span>
</div>
</div>
<div
class="share-input__container"
@ -35,8 +44,12 @@ exports[`src/components/shareBoard/shareBoard return shareBoard and click Copy l
class="CompassIcon icon-magnify MagnifyIcon"
/>
<div
class="userSearchInput css-2b097c-container"
class="userSearchInput css-b62m3t-container"
>
<span
class="css-1f43avz-a11yText-A11yText"
id="react-select-4-live-region"
/>
<span
aria-atomic="false"
aria-live="polite"
@ -47,36 +60,37 @@ exports[`src/components/shareBoard/shareBoard return shareBoard and click Copy l
class=" css-1wmrr75-Control"
>
<div
class=" css-kpfmlq-ValueContainer"
class=" css-30zlo3-ValueContainer"
>
<div
class=" css-1wa3eu0-placeholder"
class=" css-14el2xx-placeholder"
id="react-select-4-placeholder"
>
Select...
</div>
<div
class="css-1shkodo-Input"
class=" css-ox1y69-Input"
data-value=""
>
<div
<input
aria-autocomplete="list"
aria-controls="react-select-4-listbox"
aria-describedby="react-select-4-placeholder"
aria-expanded="false"
aria-haspopup="true"
aria-owns="react-select-4-listbox"
autocapitalize="none"
autocomplete="off"
autocorrect="off"
class=""
style="display: inline-block;"
>
<input
aria-autocomplete="list"
autocapitalize="none"
autocomplete="off"
autocorrect="off"
id="react-select-4-input"
spellcheck="false"
style="box-sizing: content-box; width: 2px; border: 0px; opacity: 1; outline: 0; padding: 0px;"
tabindex="0"
type="text"
value=""
/>
<div
style="position: absolute; top: 0px; left: 0px; visibility: hidden; height: 0px; overflow: scroll; white-space: pre; font-family: -webkit-small-control; letter-spacing: normal; text-transform: none;"
/>
</div>
id="react-select-4-input"
role="combobox"
spellcheck="false"
style="opacity: 1; width: 100%; grid-area: 1 / 2; min-width: 2px; border: 0px; margin: 0px; outline: 0; padding: 0px;"
tabindex="0"
type="text"
value=""
/>
</div>
</div>
<div
@ -87,7 +101,7 @@ exports[`src/components/shareBoard/shareBoard return shareBoard and click Copy l
class=" css-byrije-loadingIndicator"
>
<span
class="css-1yvy2vo-LoadingDot"
class="css-1xtdfmb-LoadingDot"
/>
<span
class="css-zoievk-LoadingDot"
@ -234,6 +248,15 @@ exports[`src/components/shareBoard/shareBoard return shareBoard and click Copy l
class="CompassIcon icon-close CloseIcon"
/>
</button>
<div
class="cardToolbar"
>
<span
class="text-heading5"
>
Share Board
</span>
</div>
</div>
<div
class="share-input__container"
@ -245,8 +268,12 @@ exports[`src/components/shareBoard/shareBoard return shareBoard and click Copy l
class="CompassIcon icon-magnify MagnifyIcon"
/>
<div
class="userSearchInput css-2b097c-container"
class="userSearchInput css-b62m3t-container"
>
<span
class="css-1f43avz-a11yText-A11yText"
id="react-select-4-live-region"
/>
<span
aria-atomic="false"
aria-live="polite"
@ -257,36 +284,37 @@ exports[`src/components/shareBoard/shareBoard return shareBoard and click Copy l
class=" css-1wmrr75-Control"
>
<div
class=" css-kpfmlq-ValueContainer"
class=" css-30zlo3-ValueContainer"
>
<div
class=" css-1wa3eu0-placeholder"
class=" css-14el2xx-placeholder"
id="react-select-4-placeholder"
>
Select...
</div>
<div
class="css-1shkodo-Input"
class=" css-ox1y69-Input"
data-value=""
>
<div
<input
aria-autocomplete="list"
aria-controls="react-select-4-listbox"
aria-describedby="react-select-4-placeholder"
aria-expanded="false"
aria-haspopup="true"
aria-owns="react-select-4-listbox"
autocapitalize="none"
autocomplete="off"
autocorrect="off"
class=""
style="display: inline-block;"
>
<input
aria-autocomplete="list"
autocapitalize="none"
autocomplete="off"
autocorrect="off"
id="react-select-4-input"
spellcheck="false"
style="box-sizing: content-box; width: 2px; border: 0px; opacity: 1; outline: 0; padding: 0px;"
tabindex="0"
type="text"
value=""
/>
<div
style="position: absolute; top: 0px; left: 0px; visibility: hidden; height: 0px; overflow: scroll; white-space: pre; font-family: -webkit-small-control; letter-spacing: normal; text-transform: none;"
/>
</div>
id="react-select-4-input"
role="combobox"
spellcheck="false"
style="opacity: 1; width: 100%; grid-area: 1 / 2; min-width: 2px; border: 0px; margin: 0px; outline: 0; padding: 0px;"
tabindex="0"
type="text"
value=""
/>
</div>
</div>
<div
@ -297,7 +325,7 @@ exports[`src/components/shareBoard/shareBoard return shareBoard and click Copy l
class=" css-byrije-loadingIndicator"
>
<span
class="css-1yvy2vo-LoadingDot"
class="css-1xtdfmb-LoadingDot"
/>
<span
class="css-zoievk-LoadingDot"
@ -444,6 +472,15 @@ exports[`src/components/shareBoard/shareBoard return shareBoard and click Regene
class="CompassIcon icon-close CloseIcon"
/>
</button>
<div
class="cardToolbar"
>
<span
class="text-heading5"
>
Share Board
</span>
</div>
</div>
<div
class="share-input__container"
@ -455,8 +492,12 @@ exports[`src/components/shareBoard/shareBoard return shareBoard and click Regene
class="CompassIcon icon-magnify MagnifyIcon"
/>
<div
class="userSearchInput css-2b097c-container"
class="userSearchInput css-b62m3t-container"
>
<span
class="css-1f43avz-a11yText-A11yText"
id="react-select-5-live-region"
/>
<span
aria-atomic="false"
aria-live="polite"
@ -467,36 +508,37 @@ exports[`src/components/shareBoard/shareBoard return shareBoard and click Regene
class=" css-1wmrr75-Control"
>
<div
class=" css-kpfmlq-ValueContainer"
class=" css-30zlo3-ValueContainer"
>
<div
class=" css-1wa3eu0-placeholder"
class=" css-14el2xx-placeholder"
id="react-select-5-placeholder"
>
Select...
</div>
<div
class="css-1shkodo-Input"
class=" css-ox1y69-Input"
data-value=""
>
<div
<input
aria-autocomplete="list"
aria-controls="react-select-5-listbox"
aria-describedby="react-select-5-placeholder"
aria-expanded="false"
aria-haspopup="true"
aria-owns="react-select-5-listbox"
autocapitalize="none"
autocomplete="off"
autocorrect="off"
class=""
style="display: inline-block;"
>
<input
aria-autocomplete="list"
autocapitalize="none"
autocomplete="off"
autocorrect="off"
id="react-select-5-input"
spellcheck="false"
style="box-sizing: content-box; width: 2px; border: 0px; opacity: 1; outline: 0; padding: 0px;"
tabindex="0"
type="text"
value=""
/>
<div
style="position: absolute; top: 0px; left: 0px; visibility: hidden; height: 0px; overflow: scroll; white-space: pre; font-family: -webkit-small-control; letter-spacing: normal; text-transform: none;"
/>
</div>
id="react-select-5-input"
role="combobox"
spellcheck="false"
style="opacity: 1; width: 100%; grid-area: 1 / 2; min-width: 2px; border: 0px; margin: 0px; outline: 0; padding: 0px;"
tabindex="0"
type="text"
value=""
/>
</div>
</div>
<div
@ -507,7 +549,7 @@ exports[`src/components/shareBoard/shareBoard return shareBoard and click Regene
class=" css-byrije-loadingIndicator"
>
<span
class="css-1yvy2vo-LoadingDot"
class="css-1xtdfmb-LoadingDot"
/>
<span
class="css-zoievk-LoadingDot"
@ -677,6 +719,15 @@ exports[`src/components/shareBoard/shareBoard return shareBoard, and click switc
class="CompassIcon icon-close CloseIcon"
/>
</button>
<div
class="cardToolbar"
>
<span
class="text-heading5"
>
Share Board
</span>
</div>
</div>
<div
class="share-input__container"
@ -688,8 +739,12 @@ exports[`src/components/shareBoard/shareBoard return shareBoard, and click switc
class="CompassIcon icon-magnify MagnifyIcon"
/>
<div
class="userSearchInput css-2b097c-container"
class="userSearchInput css-b62m3t-container"
>
<span
class="css-1f43avz-a11yText-A11yText"
id="react-select-6-live-region"
/>
<span
aria-atomic="false"
aria-live="polite"
@ -700,36 +755,37 @@ exports[`src/components/shareBoard/shareBoard return shareBoard, and click switc
class=" css-1wmrr75-Control"
>
<div
class=" css-kpfmlq-ValueContainer"
class=" css-30zlo3-ValueContainer"
>
<div
class=" css-1wa3eu0-placeholder"
class=" css-14el2xx-placeholder"
id="react-select-6-placeholder"
>
Select...
</div>
<div
class="css-1shkodo-Input"
class=" css-ox1y69-Input"
data-value=""
>
<div
<input
aria-autocomplete="list"
aria-controls="react-select-6-listbox"
aria-describedby="react-select-6-placeholder"
aria-expanded="false"
aria-haspopup="true"
aria-owns="react-select-6-listbox"
autocapitalize="none"
autocomplete="off"
autocorrect="off"
class=""
style="display: inline-block;"
>
<input
aria-autocomplete="list"
autocapitalize="none"
autocomplete="off"
autocorrect="off"
id="react-select-6-input"
spellcheck="false"
style="box-sizing: content-box; width: 2px; border: 0px; opacity: 1; outline: 0; padding: 0px;"
tabindex="0"
type="text"
value=""
/>
<div
style="position: absolute; top: 0px; left: 0px; visibility: hidden; height: 0px; overflow: scroll; white-space: pre; font-family: -webkit-small-control; letter-spacing: normal; text-transform: none;"
/>
</div>
id="react-select-6-input"
role="combobox"
spellcheck="false"
style="opacity: 1; width: 100%; grid-area: 1 / 2; min-width: 2px; border: 0px; margin: 0px; outline: 0; padding: 0px;"
tabindex="0"
type="text"
value=""
/>
</div>
</div>
<div
@ -740,7 +796,7 @@ exports[`src/components/shareBoard/shareBoard return shareBoard, and click switc
class=" css-byrije-loadingIndicator"
>
<span
class="css-1yvy2vo-LoadingDot"
class="css-1xtdfmb-LoadingDot"
/>
<span
class="css-zoievk-LoadingDot"
@ -910,6 +966,15 @@ exports[`src/components/shareBoard/shareBoard return shareBoardComponent and cli
class="CompassIcon icon-close CloseIcon"
/>
</button>
<div
class="cardToolbar"
>
<span
class="text-heading5"
>
Share Board
</span>
</div>
</div>
<div
class="share-input__container"
@ -921,8 +986,12 @@ exports[`src/components/shareBoard/shareBoard return shareBoardComponent and cli
class="CompassIcon icon-magnify MagnifyIcon"
/>
<div
class="userSearchInput css-2b097c-container"
class="userSearchInput css-b62m3t-container"
>
<span
class="css-1f43avz-a11yText-A11yText"
id="react-select-7-live-region"
/>
<span
aria-atomic="false"
aria-live="polite"
@ -933,36 +1002,37 @@ exports[`src/components/shareBoard/shareBoard return shareBoardComponent and cli
class=" css-1wmrr75-Control"
>
<div
class=" css-kpfmlq-ValueContainer"
class=" css-30zlo3-ValueContainer"
>
<div
class=" css-1wa3eu0-placeholder"
class=" css-14el2xx-placeholder"
id="react-select-7-placeholder"
>
Select...
</div>
<div
class="css-1shkodo-Input"
class=" css-ox1y69-Input"
data-value=""
>
<div
<input
aria-autocomplete="list"
aria-controls="react-select-7-listbox"
aria-describedby="react-select-7-placeholder"
aria-expanded="false"
aria-haspopup="true"
aria-owns="react-select-7-listbox"
autocapitalize="none"
autocomplete="off"
autocorrect="off"
class=""
style="display: inline-block;"
>
<input
aria-autocomplete="list"
autocapitalize="none"
autocomplete="off"
autocorrect="off"
id="react-select-7-input"
spellcheck="false"
style="box-sizing: content-box; width: 2px; border: 0px; opacity: 1; outline: 0; padding: 0px;"
tabindex="0"
type="text"
value=""
/>
<div
style="position: absolute; top: 0px; left: 0px; visibility: hidden; height: 0px; overflow: scroll; white-space: pre; font-family: -webkit-small-control; letter-spacing: normal; text-transform: none;"
/>
</div>
id="react-select-7-input"
role="combobox"
spellcheck="false"
style="opacity: 1; width: 100%; grid-area: 1 / 2; min-width: 2px; border: 0px; margin: 0px; outline: 0; padding: 0px;"
tabindex="0"
type="text"
value=""
/>
</div>
</div>
<div
@ -973,7 +1043,7 @@ exports[`src/components/shareBoard/shareBoard return shareBoardComponent and cli
class=" css-byrije-loadingIndicator"
>
<span
class="css-1yvy2vo-LoadingDot"
class="css-1xtdfmb-LoadingDot"
/>
<span
class="css-zoievk-LoadingDot"
@ -1143,6 +1213,15 @@ exports[`src/components/shareBoard/shareBoard should match snapshot 1`] = `
class="CompassIcon icon-close CloseIcon"
/>
</button>
<div
class="cardToolbar"
>
<span
class="text-heading5"
>
Share Board
</span>
</div>
</div>
<div
class="share-input__container"
@ -1154,8 +1233,12 @@ exports[`src/components/shareBoard/shareBoard should match snapshot 1`] = `
class="CompassIcon icon-magnify MagnifyIcon"
/>
<div
class="userSearchInput css-2b097c-container"
class="userSearchInput css-b62m3t-container"
>
<span
class="css-1f43avz-a11yText-A11yText"
id="react-select-2-live-region"
/>
<span
aria-atomic="false"
aria-live="polite"
@ -1166,36 +1249,37 @@ exports[`src/components/shareBoard/shareBoard should match snapshot 1`] = `
class=" css-1wmrr75-Control"
>
<div
class=" css-kpfmlq-ValueContainer"
class=" css-30zlo3-ValueContainer"
>
<div
class=" css-1wa3eu0-placeholder"
class=" css-14el2xx-placeholder"
id="react-select-2-placeholder"
>
Select...
</div>
<div
class="css-1shkodo-Input"
class=" css-ox1y69-Input"
data-value=""
>
<div
<input
aria-autocomplete="list"
aria-controls="react-select-2-listbox"
aria-describedby="react-select-2-placeholder"
aria-expanded="false"
aria-haspopup="true"
aria-owns="react-select-2-listbox"
autocapitalize="none"
autocomplete="off"
autocorrect="off"
class=""
style="display: inline-block;"
>
<input
aria-autocomplete="list"
autocapitalize="none"
autocomplete="off"
autocorrect="off"
id="react-select-2-input"
spellcheck="false"
style="box-sizing: content-box; width: 2px; border: 0px; opacity: 1; outline: 0; padding: 0px;"
tabindex="0"
type="text"
value=""
/>
<div
style="position: absolute; top: 0px; left: 0px; visibility: hidden; height: 0px; overflow: scroll; white-space: pre; font-family: -webkit-small-control; letter-spacing: normal; text-transform: none;"
/>
</div>
id="react-select-2-input"
role="combobox"
spellcheck="false"
style="opacity: 1; width: 100%; grid-area: 1 / 2; min-width: 2px; border: 0px; margin: 0px; outline: 0; padding: 0px;"
tabindex="0"
type="text"
value=""
/>
</div>
</div>
<div
@ -1206,7 +1290,7 @@ exports[`src/components/shareBoard/shareBoard should match snapshot 1`] = `
class=" css-byrije-loadingIndicator"
>
<span
class="css-1yvy2vo-LoadingDot"
class="css-1xtdfmb-LoadingDot"
/>
<span
class="css-zoievk-LoadingDot"
@ -1353,6 +1437,15 @@ exports[`src/components/shareBoard/shareBoard should match snapshot with sharing
class="CompassIcon icon-close CloseIcon"
/>
</button>
<div
class="cardToolbar"
>
<span
class="text-heading5"
>
Share Board
</span>
</div>
</div>
<div
class="share-input__container"
@ -1364,8 +1457,12 @@ exports[`src/components/shareBoard/shareBoard should match snapshot with sharing
class="CompassIcon icon-magnify MagnifyIcon"
/>
<div
class="userSearchInput css-2b097c-container"
class="userSearchInput css-b62m3t-container"
>
<span
class="css-1f43avz-a11yText-A11yText"
id="react-select-3-live-region"
/>
<span
aria-atomic="false"
aria-live="polite"
@ -1376,36 +1473,37 @@ exports[`src/components/shareBoard/shareBoard should match snapshot with sharing
class=" css-1wmrr75-Control"
>
<div
class=" css-kpfmlq-ValueContainer"
class=" css-30zlo3-ValueContainer"
>
<div
class=" css-1wa3eu0-placeholder"
class=" css-14el2xx-placeholder"
id="react-select-3-placeholder"
>
Select...
</div>
<div
class="css-1shkodo-Input"
class=" css-ox1y69-Input"
data-value=""
>
<div
<input
aria-autocomplete="list"
aria-controls="react-select-3-listbox"
aria-describedby="react-select-3-placeholder"
aria-expanded="false"
aria-haspopup="true"
aria-owns="react-select-3-listbox"
autocapitalize="none"
autocomplete="off"
autocorrect="off"
class=""
style="display: inline-block;"
>
<input
aria-autocomplete="list"
autocapitalize="none"
autocomplete="off"
autocorrect="off"
id="react-select-3-input"
spellcheck="false"
style="box-sizing: content-box; width: 2px; border: 0px; opacity: 1; outline: 0; padding: 0px;"
tabindex="0"
type="text"
value=""
/>
<div
style="position: absolute; top: 0px; left: 0px; visibility: hidden; height: 0px; overflow: scroll; white-space: pre; font-family: -webkit-small-control; letter-spacing: normal; text-transform: none;"
/>
</div>
id="react-select-3-input"
role="combobox"
spellcheck="false"
style="opacity: 1; width: 100%; grid-area: 1 / 2; min-width: 2px; border: 0px; margin: 0px; outline: 0; padding: 0px;"
tabindex="0"
type="text"
value=""
/>
</div>
</div>
<div
@ -1416,7 +1514,7 @@ exports[`src/components/shareBoard/shareBoard should match snapshot with sharing
class=" css-byrije-loadingIndicator"
>
<span
class="css-1yvy2vo-LoadingDot"
class="css-1xtdfmb-LoadingDot"
/>
<span
class="css-zoievk-LoadingDot"
@ -1563,6 +1661,15 @@ exports[`src/components/shareBoard/shareBoard should match snapshot with sharing
class="CompassIcon icon-close CloseIcon"
/>
</button>
<div
class="cardToolbar"
>
<span
class="text-heading5"
>
Share Board
</span>
</div>
</div>
<div
class="share-input__container"
@ -1574,8 +1681,12 @@ exports[`src/components/shareBoard/shareBoard should match snapshot with sharing
class="CompassIcon icon-magnify MagnifyIcon"
/>
<div
class="userSearchInput css-2b097c-container"
class="userSearchInput css-b62m3t-container"
>
<span
class="css-1f43avz-a11yText-A11yText"
id="react-select-9-live-region"
/>
<span
aria-atomic="false"
aria-live="polite"
@ -1586,36 +1697,37 @@ exports[`src/components/shareBoard/shareBoard should match snapshot with sharing
class=" css-1wmrr75-Control"
>
<div
class=" css-kpfmlq-ValueContainer"
class=" css-30zlo3-ValueContainer"
>
<div
class=" css-1wa3eu0-placeholder"
class=" css-14el2xx-placeholder"
id="react-select-9-placeholder"
>
Select...
</div>
<div
class="css-1shkodo-Input"
class=" css-ox1y69-Input"
data-value=""
>
<div
<input
aria-autocomplete="list"
aria-controls="react-select-9-listbox"
aria-describedby="react-select-9-placeholder"
aria-expanded="false"
aria-haspopup="true"
aria-owns="react-select-9-listbox"
autocapitalize="none"
autocomplete="off"
autocorrect="off"
class=""
style="display: inline-block;"
>
<input
aria-autocomplete="list"
autocapitalize="none"
autocomplete="off"
autocorrect="off"
id="react-select-9-input"
spellcheck="false"
style="box-sizing: content-box; width: 2px; border: 0px; opacity: 1; outline: 0; padding: 0px;"
tabindex="0"
type="text"
value=""
/>
<div
style="position: absolute; top: 0px; left: 0px; visibility: hidden; height: 0px; overflow: scroll; white-space: pre; font-family: -webkit-small-control; letter-spacing: normal; text-transform: none;"
/>
</div>
id="react-select-9-input"
role="combobox"
spellcheck="false"
style="opacity: 1; width: 100%; grid-area: 1 / 2; min-width: 2px; border: 0px; margin: 0px; outline: 0; padding: 0px;"
tabindex="0"
type="text"
value=""
/>
</div>
</div>
<div
@ -1626,7 +1738,7 @@ exports[`src/components/shareBoard/shareBoard should match snapshot with sharing
class=" css-byrije-loadingIndicator"
>
<span
class="css-1yvy2vo-LoadingDot"
class="css-1xtdfmb-LoadingDot"
/>
<span
class="css-zoievk-LoadingDot"
@ -1773,6 +1885,15 @@ exports[`src/components/shareBoard/shareBoard should match snapshot with sharing
class="CompassIcon icon-close CloseIcon"
/>
</button>
<div
class="cardToolbar"
>
<span
class="text-heading5"
>
Share Board
</span>
</div>
</div>
<div
class="share-input__container"
@ -1784,8 +1905,12 @@ exports[`src/components/shareBoard/shareBoard should match snapshot with sharing
class="CompassIcon icon-magnify MagnifyIcon"
/>
<div
class="userSearchInput css-2b097c-container"
class="userSearchInput css-b62m3t-container"
>
<span
class="css-1f43avz-a11yText-A11yText"
id="react-select-8-live-region"
/>
<span
aria-atomic="false"
aria-live="polite"
@ -1796,36 +1921,37 @@ exports[`src/components/shareBoard/shareBoard should match snapshot with sharing
class=" css-1wmrr75-Control"
>
<div
class=" css-kpfmlq-ValueContainer"
class=" css-30zlo3-ValueContainer"
>
<div
class=" css-1wa3eu0-placeholder"
class=" css-14el2xx-placeholder"
id="react-select-8-placeholder"
>
Select...
</div>
<div
class="css-1shkodo-Input"
class=" css-ox1y69-Input"
data-value=""
>
<div
<input
aria-autocomplete="list"
aria-controls="react-select-8-listbox"
aria-describedby="react-select-8-placeholder"
aria-expanded="false"
aria-haspopup="true"
aria-owns="react-select-8-listbox"
autocapitalize="none"
autocomplete="off"
autocorrect="off"
class=""
style="display: inline-block;"
>
<input
aria-autocomplete="list"
autocapitalize="none"
autocomplete="off"
autocorrect="off"
id="react-select-8-input"
spellcheck="false"
style="box-sizing: content-box; width: 2px; border: 0px; opacity: 1; outline: 0; padding: 0px;"
tabindex="0"
type="text"
value=""
/>
<div
style="position: absolute; top: 0px; left: 0px; visibility: hidden; height: 0px; overflow: scroll; white-space: pre; font-family: -webkit-small-control; letter-spacing: normal; text-transform: none;"
/>
</div>
id="react-select-8-input"
role="combobox"
spellcheck="false"
style="opacity: 1; width: 100%; grid-area: 1 / 2; min-width: 2px; border: 0px; margin: 0px; outline: 0; padding: 0px;"
tabindex="0"
type="text"
value=""
/>
</div>
</div>
<div
@ -1836,7 +1962,7 @@ exports[`src/components/shareBoard/shareBoard should match snapshot with sharing
class=" css-byrije-loadingIndicator"
>
<span
class="css-1yvy2vo-LoadingDot"
class="css-1xtdfmb-LoadingDot"
/>
<span
class="css-zoievk-LoadingDot"

View File

@ -239,10 +239,20 @@ export default function ShareBoardDialog(props: Props): JSX.Element {
))
}
const toolbar = (
<span className='text-heading5'>
<FormattedMessage
id={'ShareBoard.Title'}
defaultMessage={'Share Board'}
/>
</span>
)
return (
<Dialog
onClose={props.onClose}
className='ShareBoardDialog'
toolbar={toolbar}
>
<BoardPermissionGate permissions={[Permission.ManageBoardRoles]}>
<div className='share-input__container'>
@ -253,7 +263,7 @@ export default function ShareBoardDialog(props: Props): JSX.Element {
value={selectedUser}
className={'userSearchInput'}
cacheOptions={true}
loadOptions={(inputValue) => client.searchTeamUsers(inputValue)}
loadOptions={(inputValue: string) => client.searchTeamUsers(inputValue)}
components={{DropdownIndicator: () => null, IndicatorSeparator: () => null}}
defaultOptions={true}
getOptionValue={(u) => u.id}

View File

@ -160,3 +160,12 @@
left: calc(240px - 50px);
}
}
.team-sidebar + .product-wrapper {
.SidebarBoardItem {
.Menu.noselect.left {
right: calc(100% - 480px - 64px + 50px);
left: calc(64px + 240px - 50px);
}
}
}

View File

@ -257,6 +257,7 @@ exports[`components/table/TableHeaderMenu should match snapshot, title column 1`
/>
</div>
</div>
<div />
</div>
<div
class="menu-spacer hideOnWidescreen"

View File

@ -31,6 +31,7 @@ exports[`components/viewHeader/newCardButton return NewCardButton 1`] = `
<div
class="menu-options"
>
<div />
<div>
<div
aria-label="Empty card"
@ -154,6 +155,7 @@ exports[`components/viewHeader/newCardButton return NewCardButton and addCard 1`
<div
class="menu-options"
>
<div />
<div>
<div
aria-label="Empty card"
@ -277,6 +279,7 @@ exports[`components/viewHeader/newCardButton return NewCardButton and addCardTem
<div
class="menu-options"
>
<div />
<div>
<div
aria-label="Empty card"

View File

@ -403,6 +403,7 @@ exports[`components/viewHeader/viewHeaderGroupByMenu return groupBy menu 1`] = `
<div
class="menu-options"
>
<div />
<div>
<div
aria-label="Status"

View File

@ -24,6 +24,7 @@ exports[`components/viewHeader/viewHeaderPropertiesMenu return properties menu 1
<div
class="menu-options"
>
<div />
<div>
<div
aria-label="Status"

View File

@ -253,23 +253,29 @@ const ViewMenu = (props: Props) => {
/>))}
<BoardPermissionGate permissions={[Permission.ManageBoardProperties]}>
<Menu.Separator/>
{!props.readonly &&
</BoardPermissionGate>
{!props.readonly &&
<BoardPermissionGate permissions={[Permission.ManageBoardProperties]}>
<Menu.Text
id='__duplicateView'
name={duplicateViewText}
icon={<DuplicateIcon/>}
onClick={handleDuplicateView}
/>
}
{!props.readonly && views.length > 1 &&
</BoardPermissionGate>
}
{!props.readonly && views.length > 1 &&
<BoardPermissionGate permissions={[Permission.ManageBoardProperties]}>
<Menu.Text
id='__deleteView'
name={deleteViewText}
icon={<DeleteIcon/>}
onClick={handleDeleteView}
/>
}
{!props.readonly &&
</BoardPermissionGate>
}
{!props.readonly &&
<BoardPermissionGate permissions={[Permission.ManageBoardProperties]}>
<Menu.SubMenu
id='__addView'
name={addViewText}
@ -300,8 +306,8 @@ const ViewMenu = (props: Props) => {
onClick={handleAddViewCalendar}
/>
</Menu.SubMenu>
}
</BoardPermissionGate>
</BoardPermissionGate>
}
</Menu>
)
}

View File

@ -435,6 +435,21 @@ class OctoClient {
return this.getJson<BoardMember>(response, {} as BoardMember)
}
async joinBoard(boardId: string): Promise<BoardMember|undefined> {
Utils.log(`joinBoard: board ${boardId}`)
const response = await fetch(this.getBaseURL() + `/api/v1/boards/${boardId}/join`, {
method: 'POST',
headers: this.headers()
})
if (response.status !== 200) {
return undefined
}
return this.getJson<BoardMember>(response, {} as BoardMember)
}
async updateBoardMember(member: BoardMember): Promise<Response> {
Utils.log(`udpateBoardMember: user ${member.userId} and board ${member.boardId}`)

View File

@ -84,7 +84,7 @@ const BoardPage = (props: Props): JSX.Element => {
// and fetch its data
const result: any = await dispatch(loadBoardData(boardId))
if (result.payload.blocks.length === 0 && userId) {
const member = await octoClient.createBoardMember({userId, boardId})
const member = await octoClient.joinBoard(boardId)
if (!member) {
UserSettings.setLastBoardID(boardTeamId, null)
UserSettings.setLastViewId(boardId, null)

View File

@ -48,8 +48,11 @@ const WelcomePage = () => {
history.replace(queryString.get('r')!)
return
}
history.replace(`/team/${currentTeam?.id}`)
if (currentTeam) {
history.replace(`/team/${currentTeam?.id}`)
} else {
history.replace('/')
}
}
const skipTour = async () => {

View File

@ -4,7 +4,6 @@ import React from 'react'
import {
Redirect,
Route,
useRouteMatch,
} from 'react-router-dom'
import {Utils} from './utils'
@ -27,14 +26,10 @@ type RouteProps = {
function FBRoute(props: RouteProps) {
const loggedIn = useAppSelector<boolean|null>(getLoggedIn)
const match = useRouteMatch<any>()
const me = useAppSelector<IUser|null>(getMe)
const clientConfig = useAppSelector<ClientConfig>(getClientConfig)
let originalPath
if (props.getOriginalPath) {
originalPath = props.getOriginalPath(match)
}
let redirect: React.ReactNode = null
const showWelcomePage = !clientConfig.featureFlags.disableTour &&
Utils.isFocalboardPlugin() &&
@ -44,37 +39,38 @@ function FBRoute(props: RouteProps) {
!me?.props[UserPropPrefix + UserSettingKey.WelcomePageViewed]
if (showWelcomePage) {
if (originalPath) {
return <Redirect to={`/welcome?r=${originalPath}`}/>
}
return <Redirect to='/welcome'/>
}
if (loggedIn === false && props.loginRequired) {
if (originalPath) {
let redirectUrl = '/' + Utils.buildURL(originalPath)
if (redirectUrl.indexOf('//') === 0) {
redirectUrl = redirectUrl.slice(1)
redirect = ({match}: any) => {
if (props.getOriginalPath) {
return <Redirect to={`/welcome?r=${props.getOriginalPath!(match)}`}/>
}
const loginUrl = `/error?id=not-logged-in&r=${encodeURIComponent(redirectUrl)}`
return <Redirect to={loginUrl}/>
return <Redirect to='/welcome'/>
}
return <Redirect to='/error?id=not-logged-in'/>
}
if (loggedIn === true || !props.loginRequired) {
return (
<Route
path={props.path}
render={props.render}
component={props.component}
exact={props.exact}
>
{props.children}
</Route>
)
if (redirect === null && loggedIn === false && props.loginRequired) {
redirect = ({match}: any) => {
if (props.getOriginalPath) {
let redirectUrl = '/' + Utils.buildURL(props.getOriginalPath!(match))
if (redirectUrl.indexOf('//') === 0) {
redirectUrl = redirectUrl.slice(1)
}
const loginUrl = `/error?id=not-logged-in&r=${encodeURIComponent(redirectUrl)}`
return <Redirect to={loginUrl}/>
}
return <Redirect to='/error?id=not-logged-in'/>
}
}
return null
return (
<Route
path={props.path}
render={props.render}
component={props.component}
exact={props.exact}
>
{redirect || props.children}
</Route>
)
}
export default React.memo(FBRoute)

View File

@ -7,6 +7,7 @@ import {Card} from '../blocks/card'
import {IUser} from '../user'
import {Board} from '../blocks/board'
import {BoardView} from '../blocks/boardView'
import {CommentBlock} from '../blocks/commentBlock'
import {Utils} from '../utils'
import {Constants} from '../constants'
import {CardFilter} from '../cardFilter'
@ -14,6 +15,7 @@ import {CardFilter} from '../cardFilter'
import {loadBoardData, initialReadOnlyLoad} from './initialLoad'
import {getCurrentBoard} from './boards'
import {getBoardUsers} from './users'
import {getLastCommentByCard} from './comments'
import {getCurrentView} from './views'
import {getSearchText} from './searchText'
@ -157,7 +159,7 @@ function manualOrder(activeView: BoardView, cardA: Card, cardB: Card) {
return indexA - indexB
}
function sortCards(cards: Card[], board: Board, activeView: BoardView, usersById: {[key: string]: IUser}): Card[] {
function sortCards(cards: Card[], lastCommentByCard: {[key: string]: CommentBlock}, board: Board, activeView: BoardView, usersById: {[key: string]: IUser}): Card[] {
if (!activeView) {
return cards
}
@ -217,7 +219,9 @@ function sortCards(cards: Card[], board: Board, activeView: BoardView, usersById
} else if (template.type === 'createdTime') {
result = a.createAt - b.createAt
} else if (template.type === 'updatedTime') {
result = a.updateAt - b.updateAt
const aUpdateAt = Math.max(a.updateAt, lastCommentByCard[a.id]?.updateAt || 0)
const bUpdateAt = Math.max(b.updateAt, lastCommentByCard[b.id]?.updateAt || 0)
result = aUpdateAt - bUpdateAt
} else {
// Text-based sort
@ -292,11 +296,12 @@ function searchFilterCards(cards: Card[], board: Board, searchTextRaw: string):
export const getCurrentViewCardsSortedFilteredAndGrouped = createSelector(
getCurrentBoardCards,
getLastCommentByCard,
getCurrentBoard,
getCurrentView,
getSearchText,
getBoardUsers,
(cards, board, view, searchText, users) => {
(cards, lastCommentByCard, board, view, searchText, users) => {
if (!view || !board || !users || !cards) {
return []
}
@ -308,7 +313,7 @@ export const getCurrentViewCardsSortedFilteredAndGrouped = createSelector(
if (searchText) {
result = searchFilterCards(result, board, searchText)
}
result = sortCards(result, board, view, users)
result = sortCards(result, lastCommentByCard, board, view, users)
return result
},
)

View File

@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {createSlice, PayloadAction} from '@reduxjs/toolkit'
import {createSlice, PayloadAction, createSelector} from '@reduxjs/toolkit'
import {CommentBlock} from '../blocks/commentBlock'
@ -92,3 +92,17 @@ export function getLastCardComment(cardId: string): (state: RootState) => Commen
return comments?.[comments?.length - 1]
}
}
export const getLastCommentByCard = createSelector(
(state: RootState) => state.comments?.commentsByCard || null,
(commentsByCard: {[key: string]: CommentBlock[]}|null): {[key: string]: CommentBlock} => {
const lastCommentByCard: {[key: string]: CommentBlock} = {}
Object.keys(commentsByCard || {}).forEach((cardId) => {
if (commentsByCard && commentsByCard[cardId]) {
const comments = commentsByCard[cardId]
lastCommentByCard[cardId] = comments?.[comments?.length - 1]
}
})
return lastCommentByCard
},
)

View File

@ -12,7 +12,8 @@ import {RootState} from './index'
export const initialLoad = createAsyncThunk(
'initialLoad',
async () => {
const [team, teams, boards, boardsMemberships, boardTemplates] = await Promise.all([
const [me, team, teams, boards, boardsMemberships, boardTemplates] = await Promise.all([
client.getMe(),
client.getTeam(),
client.getTeams(),
client.getBoards(),
@ -20,6 +21,11 @@ export const initialLoad = createAsyncThunk(
client.getTeamTemplates(),
])
// if no me, normally user not logged in
if (!me) {
throw new Error(ErrorId.NotLoggedIn)
}
// if no team, either bad id, or user doesn't have access
if (!team) {
throw new Error(ErrorId.TeamUndefined)

View File

@ -6,7 +6,7 @@ import SeparatorOption from './separatorOption'
import SwitchOption from './switchOption'
import TextOption from './textOption'
import ColorOption from './colorOption'
import SubMenuOption from './subMenuOption'
import SubMenuOption, {HoveringContext} from './subMenuOption'
import LabelOption from './labelOption'
import './menu.scss'
@ -27,7 +27,7 @@ export default class Menu extends React.PureComponent<Props> {
static Label = LabelOption
public state = {
hoveringIdx: -1,
hovering: null,
}
public render(): JSX.Element {
@ -36,16 +36,14 @@ export default class Menu extends React.PureComponent<Props> {
<div className={'Menu noselect ' + (position || 'bottom')}>
<div className='menu-contents'>
<div className='menu-options'>
{React.Children.map(children, (child, i) => {
return addChildMenuItem({
child,
onMouseEnter: () =>
this.setState({
hoveringIdx: i,
}),
isHovering: () => i === this.state.hoveringIdx,
})
})}
{React.Children.map(children, (child) => (
<div
onMouseEnter={() => this.setState({hovering: child})}
>
<HoveringContext.Provider value={child == this.state.hovering}>
{child}
</HoveringContext.Provider>
</div>))}
</div>
<div className='menu-spacer hideOnWidescreen'/>
@ -67,28 +65,3 @@ export default class Menu extends React.PureComponent<Props> {
// No need to do anything, as click bubbled up to MenuWrapper, which closes
}
}
function addChildMenuItem(props: {child: React.ReactNode, onMouseEnter: () => void, isHovering: () => boolean}): JSX.Element | null {
const {child, onMouseEnter, isHovering} = props
if (child !== null) {
if (React.isValidElement(child)) {
const castedChild = child as React.ReactElement
return (
<div
onMouseEnter={onMouseEnter}
>
{castedChild.type === SubMenuOption ? (
<castedChild.type
{...castedChild.props}
isHovering={isHovering}
/>
) : (
<castedChild.type {...castedChild.props}/>
)}
</div>
)
}
}
return (null)
}

View File

@ -1,6 +1,6 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useEffect, useState} from 'react'
import React, {useEffect, useState, useContext} from 'react'
import SubmenuTriangleIcon from '../icons/submenuTriangle'
@ -8,25 +8,27 @@ import Menu from '.'
import './subMenuOption.scss'
export const HoveringContext = React.createContext(false)
type SubMenuOptionProps = {
id: string
name: string
position?: 'bottom' | 'top' | 'left' | 'left-bottom'
icon?: React.ReactNode
children: React.ReactNode
isHovering?: boolean
}
function SubMenuOption(props: SubMenuOptionProps): JSX.Element {
const [isOpen, setIsOpen] = useState(false)
const isHovering = useContext(HoveringContext)
const openLeftClass = props.position === 'left' || props.position === 'left-bottom' ? ' open-left' : ''
useEffect(() => {
if (props.isHovering !== undefined) {
setIsOpen(props.isHovering)
if (isHovering !== undefined) {
setIsOpen(isHovering)
}
}, [props.isHovering])
}, [isHovering])
return (
<div

View File

@ -2,7 +2,8 @@
// See LICENSE.txt for license information.
import React from 'react'
import {useIntl} from 'react-intl'
import {ActionMeta, FormatOptionLabelMeta, ValueType} from 'react-select'
import {ActionMeta, OnChangeValue} from 'react-select'
import {FormatOptionLabelMeta} from 'react-select/base'
import CreatableSelect from 'react-select/creatable'
import {CSSObject} from '@emotion/serialize'
@ -37,7 +38,7 @@ type Props = {
type LabelProps = {
option: IPropertyOption
meta: FormatOptionLabelMeta<IPropertyOption, true | false>
meta: FormatOptionLabelMeta<IPropertyOption>
onChangeColor: (option: IPropertyOption, color: string) => void
onDeleteOption: (option: IPropertyOption) => void
onDeleteValue?: (value: IPropertyOption) => void
@ -163,7 +164,7 @@ function ValueSelector(props: Props): JSX.Element {
isMulti={props.isMulti}
isClearable={true}
styles={valueSelectorStyle}
formatOptionLabel={(option: IPropertyOption, meta: FormatOptionLabelMeta<IPropertyOption, true | false>) => (
formatOptionLabel={(option: IPropertyOption, meta: FormatOptionLabelMeta<IPropertyOption>) => (
<ValueSelectorLabel
option={option}
meta={meta}
@ -178,7 +179,7 @@ function ValueSelector(props: Props): JSX.Element {
options={props.options}
getOptionLabel={(o: IPropertyOption) => o.value}
getOptionValue={(o: IPropertyOption) => o.id}
onChange={(value: ValueType<IPropertyOption, true | false>, action: ActionMeta<IPropertyOption>): void => {
onChange={(value: OnChangeValue<IPropertyOption, true | false>, action: ActionMeta<IPropertyOption>): void => {
if (action.action === 'select-option') {
if (Array.isArray(value)) {
props.onChange((value as IPropertyOption[]).map((option) => option.id))

View File

@ -18,10 +18,10 @@ Popular hosted options include:
## Install Focalboard
Download the Ubuntu archive package from the appropriate [release in GitHub](https://github.com/mattermost/focalboard/releases). E.g. this is the link for v0.9.2 (which may no longer be the latest one):
Download the Ubuntu archive package from the appropriate [release in GitHub](https://github.com/mattermost/focalboard/releases). The example below uses the link for **v0.15.0**, but you're encouraged to use the latest version in the release list:
```
wget https://github.com/mattermost/focalboard/releases/download/v0.9.2/focalboard-server-linux-amd64.tar.gz
wget https://github.com/mattermost/focalboard/releases/download/v0.15.0/focalboard-server-linux-amd64.tar.gz
tar -xvzf focalboard-server-linux-amd64.tar.gz
sudo mv focalboard /opt
```