mirror of
https://github.com/mattermost/focalboard.git
synced 2025-04-07 21:18:42 +02:00
Merge branch 'main' into GH2520
This commit is contained in:
commit
343c9a99a5
2
.gitattributes
vendored
Normal file
2
.gitattributes
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
website/** linguist-documentation
|
||||
server/swagger/** linguist-generated
|
40
.github/workflows/ci.yml
vendored
40
.github/workflows/ci.yml
vendored
@ -22,12 +22,12 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v2
|
||||
uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: 1.16
|
||||
go-version: 1.18.1
|
||||
|
||||
- name: "Test server: ${{matrix['db']}}"
|
||||
run: make server-test-${{matrix['db']}}
|
||||
@ -37,21 +37,20 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: npm ci
|
||||
run: cd webapp; npm ci
|
||||
|
||||
- name: ESLint
|
||||
run: cd webapp; npm run check
|
||||
run: |
|
||||
cd webapp && npm ci && cd -
|
||||
cd mattermost-plugin/webapp && npm ci
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v2
|
||||
uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: 1.16
|
||||
go-version: 1.18.1
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v2
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 16.1.0
|
||||
|
||||
@ -67,11 +66,8 @@ jobs:
|
||||
name: focalboard-server-linux-amd64.tar.gz
|
||||
path: ${{ github.workspace }}/dist/focalboard-server-linux-amd64.tar.gz
|
||||
|
||||
- name: "Test webapp: Jest"
|
||||
run: cd webapp; npm run test
|
||||
|
||||
- name: "Test webapp: Cypress"
|
||||
run: "cd webapp; npm run cypress:ci"
|
||||
- name: Lint & test webapp
|
||||
run: make webapp-ci
|
||||
|
||||
ci-windows-server:
|
||||
runs-on: windows-2022
|
||||
@ -83,12 +79,12 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v2
|
||||
uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: 1.16
|
||||
go-version: 1.18.1
|
||||
|
||||
- name: "Test server (minimum): ${{matrix['db']}}"
|
||||
run: make server-test-mini-${{matrix['db']}}
|
||||
@ -103,12 +99,12 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v2
|
||||
uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: 1.16
|
||||
go-version: 1.18.1
|
||||
|
||||
- name: "Test server (minimum): ${{matrix['db']}}"
|
||||
run: make server-test-mini-${{matrix['db']}}
|
||||
|
6
.github/workflows/codeql-analysis.yml
vendored
6
.github/workflows/codeql-analysis.yml
vendored
@ -40,14 +40,14 @@ jobs:
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v1
|
||||
uses: github/codeql-action/init@v2
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v1
|
||||
uses: github/codeql-action/autobuild@v2
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v1
|
||||
uses: github/codeql-action/analyze@v2
|
||||
|
42
.github/workflows/dev-release.yml
vendored
42
.github/workflows/dev-release.yml
vendored
@ -14,7 +14,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Replace token 1 server
|
||||
run: sed -i -e "s,placeholder_rudder_dataplane_url,${{ secrets.RUDDER_DATAPLANE_URL }},g" ${{ github.workspace }}/server/services/telemetry/telemetry.go
|
||||
@ -32,12 +32,12 @@ jobs:
|
||||
run: cd webapp; npm ci --no-optional
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v2
|
||||
uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: 1.16
|
||||
go-version: 1.18.1
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v2
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 16.1.0
|
||||
|
||||
@ -56,13 +56,13 @@ jobs:
|
||||
BUILD_NUMBER: ${{ github.run_id }}
|
||||
|
||||
- name: Upload server package
|
||||
uses: actions/upload-artifact@v1
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: focalboard-server-linux-amd64.tar.gz
|
||||
path: ${{ github.workspace }}/dist/focalboard-server-linux-amd64.tar.gz
|
||||
|
||||
- name: Upload app package
|
||||
uses: actions/upload-artifact@v1
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: focalboard-linux.tar.gz
|
||||
path: ${{ github.workspace }}/linux/dist/focalboard-linux.tar.gz
|
||||
@ -73,7 +73,7 @@ jobs:
|
||||
steps:
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Replace token 1 server
|
||||
run: sed -i -e "s,placeholder_rudder_dataplane_url,${{ secrets.RUDDER_DATAPLANE_URL }},g" ${{ github.workspace }}/server/services/telemetry/telemetry.go
|
||||
@ -91,9 +91,9 @@ jobs:
|
||||
run: cd webapp; npm ci --no-optional
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v2
|
||||
uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: 1.16
|
||||
go-version: 1.18.1
|
||||
|
||||
- name: List Xcode versions
|
||||
run: ls -n /Applications/ | grep Xcode*
|
||||
@ -105,7 +105,7 @@ jobs:
|
||||
BUILD_NUMBER: ${{ github.run_id }}
|
||||
|
||||
- name: Upload macOS package
|
||||
uses: actions/upload-artifact@v1
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: focalboard-mac.zip
|
||||
path: ${{ github.workspace }}/mac/dist/focalboard-mac.zip
|
||||
@ -115,7 +115,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Replace token 1 server
|
||||
run: sed -i -e "s,placeholder_rudder_dataplane_url,${{ secrets.RUDDER_DATAPLANE_URL }},g" ${{ github.workspace }}/server/services/telemetry/telemetry.go
|
||||
@ -130,15 +130,15 @@ jobs:
|
||||
run: sed -i -e "s,placeholder_rudder_key,${{ secrets.RUDDER_DEV_KEY }},g" ${{ github.workspace }}/mattermost-plugin/webapp/src/index.tsx
|
||||
|
||||
- name: Add msbuild to PATH
|
||||
uses: microsoft/setup-msbuild@v1.0.2
|
||||
uses: microsoft/setup-msbuild@v1.1
|
||||
|
||||
- name: npm ci
|
||||
run: cd webapp; npm ci --no-optional
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v2
|
||||
uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: 1.16
|
||||
go-version: 1.18.1
|
||||
|
||||
- name: Setup NuGet
|
||||
uses: nuget/setup-nuget@v1
|
||||
@ -154,13 +154,13 @@ jobs:
|
||||
BUILD_NUMBER: ${{ github.run_id }}
|
||||
|
||||
- name: Upload app msix package
|
||||
uses: actions/upload-artifact@v1
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: focalboard.msix
|
||||
path: ${{ github.workspace }}/win-wpf/focalboard.msix
|
||||
|
||||
- name: Upload app zip package
|
||||
uses: actions/upload-artifact@v1
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: focalboard-win.zip
|
||||
path: ${{ github.workspace }}/win-wpf/dist/focalboard-win.zip
|
||||
@ -170,7 +170,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Replace token 1 server
|
||||
run: sed -i -e "s,placeholder_rudder_dataplane_url,${{ secrets.RUDDER_DATAPLANE_URL }},g" ${{ github.workspace }}/server/services/telemetry/telemetry.go
|
||||
@ -188,12 +188,12 @@ jobs:
|
||||
run: cd webapp; npm ci --no-optional
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v2
|
||||
uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: 1.16
|
||||
go-version: 1.18.1
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v2
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 16.1.0
|
||||
|
||||
@ -212,7 +212,7 @@ jobs:
|
||||
run: cd mattermost-plugin/dist; mv focalboard-*.tar.gz mattermost-plugin-focalboard.tar.gz
|
||||
|
||||
- name: Upload plugin artifact
|
||||
uses: actions/upload-artifact@v1
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: mattermost-plugin-focalboard.tar.gz
|
||||
path: ${{ github.workspace }}/mattermost-plugin/dist/mattermost-plugin-focalboard.tar.gz
|
||||
|
23
.github/workflows/lint-plugin.yml
vendored
23
.github/workflows/lint-plugin.yml
vendored
@ -1,23 +0,0 @@
|
||||
name: golangci-lint
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, release-** ]
|
||||
pull_request:
|
||||
branches: [ main, release-** ]
|
||||
|
||||
jobs:
|
||||
golangci:
|
||||
name: plugin
|
||||
runs-on: ubuntu-18.04
|
||||
steps:
|
||||
- uses: actions/setup-go@v2
|
||||
with:
|
||||
go-version: 1.16
|
||||
- uses: actions/checkout@v2
|
||||
- name: plugin-lint
|
||||
uses: golangci/golangci-lint-action@v2
|
||||
with:
|
||||
version: v1.41
|
||||
working-directory: mattermost-plugin
|
||||
skip-go-installation: true
|
20
.github/workflows/lint-server.yml
vendored
20
.github/workflows/lint-server.yml
vendored
@ -5,20 +5,18 @@ on:
|
||||
branches: [ main, release-** ]
|
||||
pull_request:
|
||||
branches: [ main, release-** ]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
golangci:
|
||||
name: server
|
||||
name: plugin
|
||||
runs-on: ubuntu-18.04
|
||||
steps:
|
||||
- uses: actions/setup-go@v2
|
||||
- uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: 1.16
|
||||
- uses: actions/checkout@v2
|
||||
- run: make templates-archive
|
||||
- name: server-lint
|
||||
uses: golangci/golangci-lint-action@v2
|
||||
with:
|
||||
version: v1.41
|
||||
working-directory: server
|
||||
skip-go-installation: true
|
||||
go-version: 1.18.1
|
||||
- uses: actions/checkout@v3
|
||||
- name: set up golangci-lint
|
||||
run: curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v1.46.2
|
||||
- name: lint
|
||||
run: make server-lint
|
||||
|
42
.github/workflows/prod-release.yml
vendored
42
.github/workflows/prod-release.yml
vendored
@ -9,7 +9,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Replace token 1 server
|
||||
run: sed -i -e "s,placeholder_rudder_dataplane_url,${{ secrets.RUDDER_DATAPLANE_URL }},g" ${{ github.workspace }}/server/services/telemetry/telemetry.go
|
||||
@ -27,12 +27,12 @@ jobs:
|
||||
run: cd webapp; npm ci --no-optional
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v2
|
||||
uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: 1.16
|
||||
go-version: 1.18.1
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v2
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 16.1.0
|
||||
|
||||
@ -51,13 +51,13 @@ jobs:
|
||||
BUILD_NUMBER: ${{ github.run_id }}
|
||||
|
||||
- name: Upload server package
|
||||
uses: actions/upload-artifact@v1
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: focalboard-server-linux-amd64.tar.gz
|
||||
path: ${{ github.workspace }}/dist/focalboard-server-linux-amd64.tar.gz
|
||||
|
||||
- name: Upload app package
|
||||
uses: actions/upload-artifact@v1
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: focalboard-linux.tar.gz
|
||||
path: ${{ github.workspace }}/linux/dist/focalboard-linux.tar.gz
|
||||
@ -68,7 +68,7 @@ jobs:
|
||||
steps:
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Replace token 1 server
|
||||
run: sed -i -e "s,placeholder_rudder_dataplane_url,${{ secrets.RUDDER_DATAPLANE_URL }},g" ${{ github.workspace }}/server/services/telemetry/telemetry.go
|
||||
@ -86,9 +86,9 @@ jobs:
|
||||
run: cd webapp; npm ci --no-optional
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v2
|
||||
uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: 1.16
|
||||
go-version: 1.18.1
|
||||
|
||||
- name: List Xcode versions
|
||||
run: ls -n /Applications/ | grep Xcode*
|
||||
@ -100,7 +100,7 @@ jobs:
|
||||
BUILD_NUMBER: ${{ github.run_id }}
|
||||
|
||||
- name: Upload macOS package
|
||||
uses: actions/upload-artifact@v1
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: focalboard-mac.zip
|
||||
path: ${{ github.workspace }}/mac/dist/focalboard-mac.zip
|
||||
@ -110,7 +110,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Replace token 1 server
|
||||
run: sed -i -e "s,placeholder_rudder_dataplane_url,${{ secrets.RUDDER_DATAPLANE_URL }},g" ${{ github.workspace }}/server/services/telemetry/telemetry.go
|
||||
@ -125,15 +125,15 @@ jobs:
|
||||
run: sed -i -e "s,placeholder_rudder_key,${{ secrets.RUDDER_PROD_KEY }},g" ${{ github.workspace }}/mattermost-plugin/webapp/src/index.tsx
|
||||
|
||||
- name: Add msbuild to PATH
|
||||
uses: microsoft/setup-msbuild@v1.0.2
|
||||
uses: microsoft/setup-msbuild@v1.1
|
||||
|
||||
- name: npm ci
|
||||
run: cd webapp; npm ci --no-optional
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v2
|
||||
uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: 1.16
|
||||
go-version: 1.18.1
|
||||
|
||||
- name: Setup NuGet
|
||||
uses: nuget/setup-nuget@v1
|
||||
@ -149,13 +149,13 @@ jobs:
|
||||
BUILD_NUMBER: ${{ github.run_id }}
|
||||
|
||||
- name: Upload app msix package
|
||||
uses: actions/upload-artifact@v1
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: focalboard.msix
|
||||
path: ${{ github.workspace }}/win-wpf/focalboard.msix
|
||||
|
||||
- name: Upload app zip package
|
||||
uses: actions/upload-artifact@v1
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: focalboard-win.zip
|
||||
path: ${{ github.workspace }}/win-wpf/dist/focalboard-win.zip
|
||||
@ -165,7 +165,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Replace token 1 server
|
||||
run: sed -i -e "s,placeholder_rudder_dataplane_url,${{ secrets.RUDDER_DATAPLANE_URL }},g" ${{ github.workspace }}/server/services/telemetry/telemetry.go
|
||||
@ -183,12 +183,12 @@ jobs:
|
||||
run: cd webapp; npm ci --no-optional
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v2
|
||||
uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: 1.16
|
||||
go-version: 1.18.1
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v2
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 16.1.0
|
||||
|
||||
@ -207,7 +207,7 @@ jobs:
|
||||
run: cd mattermost-plugin/dist; mv focalboard-*.tar.gz mattermost-plugin-focalboard.tar.gz
|
||||
|
||||
- name: Upload plugin artifact
|
||||
uses: actions/upload-artifact@v1
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: mattermost-plugin-focalboard.tar.gz
|
||||
path: ${{ github.workspace }}/mattermost-plugin/dist/mattermost-plugin-focalboard.tar.gz
|
||||
|
6
.gitignore
vendored
6
.gitignore
vendored
@ -18,6 +18,10 @@ pids
|
||||
.vscode
|
||||
*.code-workspace
|
||||
|
||||
# golang
|
||||
go.work
|
||||
go.work.sum
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
@ -67,3 +71,5 @@ mattermost-plugin/dist
|
||||
.idea
|
||||
docker/certs
|
||||
docker/data
|
||||
server/**/*.coverage
|
||||
mattermost-plugin/**/*.coverage
|
||||
|
@ -4,7 +4,7 @@ stages:
|
||||
|
||||
variables:
|
||||
BUILD: "yes"
|
||||
IMAGE_BUILDER: $CI_REGISTRY/mattermost/ci/images/builder:go-1.16.5-node-16.3.0
|
||||
IMAGE_BUILDER: $CI_REGISTRY/mattermost/ci/images/builder:go-1.18.1-node-16.15.0-1
|
||||
|
||||
include:
|
||||
- project: mattermost/ci/focalboard
|
||||
|
1
.gitpod.yml
Normal file
1
.gitpod.yml
Normal file
@ -0,0 +1 @@
|
||||
mainConfiguration: https://github.com/mattermost/mattermost-gitpod-config
|
@ -16,7 +16,7 @@ RUN npm install --no-optional
|
||||
RUN npm run pack
|
||||
|
||||
# build backend and package
|
||||
FROM golang:1.16.5@sha256:3ba07778b0a48cef0820fe630220089b74ac9bd06a92ac1cf7b2f1abceffcdaa AS backend
|
||||
FROM golang:1.18.3@sha256:b203dc573d81da7b3176264bfa447bd7c10c9347689be40540381838d75eebef AS backend
|
||||
|
||||
COPY . .
|
||||
COPY --from=frontend /webapp/pack webapp/pack
|
||||
@ -26,7 +26,7 @@ RUN make server-linux
|
||||
RUN make server-linux-package-docker
|
||||
|
||||
# just hold the packages to output later
|
||||
FROM alpine:3.12@sha256:d9459083f962de6bd980ae6a05be2a4cf670df6a1d898157bceb420342bec280 AS dist
|
||||
FROM alpine:3.12@sha256:c75ac27b49326926b803b9ed43bf088bc220d22556de1bc5f72d742c91398f69 AS dist
|
||||
|
||||
WORKDIR /dist
|
||||
|
||||
|
60
Makefile
60
Makefile
@ -18,6 +18,12 @@ LDFLAGS += -X "github.com/mattermost/focalboard/server/model.BuildNumber=$(BUILD
|
||||
LDFLAGS += -X "github.com/mattermost/focalboard/server/model.BuildDate=$(BUILD_DATE)"
|
||||
LDFLAGS += -X "github.com/mattermost/focalboard/server/model.BuildHash=$(BUILD_HASH)"
|
||||
|
||||
RACE = -race
|
||||
|
||||
ifeq ($(OS),Windows_NT)
|
||||
RACE := ''
|
||||
endif
|
||||
|
||||
# MAC cpu architecture
|
||||
ifeq ($(shell uname -m),arm64)
|
||||
MAC_GO_ARCH := arm64
|
||||
@ -29,13 +35,14 @@ all: webapp server ## Build server and webapp.
|
||||
|
||||
prebuild: ## Run prebuild actions (install dependencies etc.).
|
||||
cd webapp; npm install
|
||||
cd mattermost-plugin/webapp; npm install
|
||||
|
||||
ci: server-test
|
||||
cd webapp; npm run check
|
||||
cd webapp; npm run test
|
||||
cd webapp; npm run cypress:ci
|
||||
ci: webapp-ci server-test ## Simulate CI, locally.
|
||||
|
||||
templates-archive: ## Build templates archive file
|
||||
setup-go-work: ## Sets up a go.work file
|
||||
go run ./mattermost-plugin/build/gowork/main.go
|
||||
|
||||
templates-archive: setup-go-work ## Build templates archive file
|
||||
cd server/assets/build-template-archive; go run -tags '$(BUILD_TAGS)' main.go --dir="../templates-boardarchive" --out="../templates.boardarchive"
|
||||
|
||||
server: templates-archive ## Build server for local environment.
|
||||
@ -45,7 +52,12 @@ server: templates-archive ## Build server for local environment.
|
||||
server-mac: templates-archive ## Build server for Mac.
|
||||
mkdir -p bin/mac
|
||||
$(eval LDFLAGS += -X "github.com/mattermost/focalboard/server/model.Edition=mac")
|
||||
ifeq ($(FB_PROD),)
|
||||
cd server; env GOOS=darwin GOARCH=$(MAC_GO_ARCH) go build -ldflags '$(LDFLAGS)' -tags '$(BUILD_TAGS)' -o ../bin/mac/focalboard-server ./main
|
||||
else
|
||||
# Always build x86 for production, to work on both Apple Silicon and legacy Macs
|
||||
cd server; env GOOS=darwin GOARCH=amd64 CGO_ENABLED=1 go build -ldflags '$(LDFLAGS)' -tags '$(BUILD_TAGS)' -o ../bin/mac/focalboard-server ./main
|
||||
endif
|
||||
|
||||
server-linux: templates-archive ## Build server for Linux.
|
||||
mkdir -p bin/linux
|
||||
@ -115,41 +127,55 @@ watch-server-test: modd-precheck ## Run server tests watching for changes
|
||||
|
||||
server-test: server-test-sqlite server-test-mysql server-test-postgres ## Run server tests
|
||||
|
||||
server-test-sqlite: export FB_UNIT_TESTING=1
|
||||
server-test-sqlite: export FOCALBOARD_UNIT_TESTING=1
|
||||
|
||||
server-test-sqlite: templates-archive ## Run server tests using sqlite
|
||||
cd server; go test -tags '$(BUILD_TAGS)' -race -v -count=1 -timeout=30m ./...
|
||||
cd server; go test -tags '$(BUILD_TAGS)' -race -v -coverpkg=./... -coverprofile=server-sqlite-profile.coverage -count=1 -timeout=30m ./...
|
||||
cd server; go tool cover -func server-sqlite-profile.coverage
|
||||
|
||||
server-test-mini-sqlite: export FB_UNIT_TESTING=1
|
||||
server-test-mini-sqlite: export FOCALBOARD_UNIT_TESTING=1
|
||||
|
||||
server-test-mini-sqlite: templates-archive ## Run server tests using sqlite
|
||||
cd server/integrationtests; go test -tags '$(BUILD_TAGS)' -race -v -count=1 -timeout=30m ./...
|
||||
cd server/integrationtests; go test -tags '$(BUILD_TAGS)' $(RACE) -v -count=1 -timeout=30m ./...
|
||||
|
||||
server-test-mysql: export FB_UNIT_TESTING=1
|
||||
server-test-mysql: export FB_STORE_TEST_DB_TYPE=mysql
|
||||
server-test-mysql: export FB_STORE_TEST_DOCKER_PORT=44445
|
||||
server-test-mysql: export FOCALBOARD_UNIT_TESTING=1
|
||||
server-test-mysql: export FOCALBOARD_STORE_TEST_DB_TYPE=mysql
|
||||
server-test-mysql: export FOCALBOARD_STORE_TEST_DOCKER_PORT=44445
|
||||
|
||||
server-test-mysql: templates-archive ## Run server tests using mysql
|
||||
@echo Starting docker container for mysql
|
||||
docker-compose -f ./docker-testing/docker-compose-mysql.yml down -v --remove-orphans
|
||||
docker-compose -f ./docker-testing/docker-compose-mysql.yml run start_dependencies
|
||||
cd server; go test -tags '$(BUILD_TAGS)' -race -v -count=1 -timeout=30m ./...
|
||||
cd server; go test -tags '$(BUILD_TAGS)' -race -v -coverpkg=./... -coverprofile=server-mysql-profile.coverage -count=1 -timeout=30m ./...
|
||||
cd server; go tool cover -func server-mysql-profile.coverage
|
||||
cd mattermost-plugin/server; go test -tags '$(BUILD_TAGS)' -race -v -coverpkg=./... -coverprofile=plugin-mysql-profile.coverage -count=1 -timeout=30m ./...
|
||||
cd mattermost-plugin/server; go tool cover -func plugin-mysql-profile.coverage
|
||||
docker-compose -f ./docker-testing/docker-compose-mysql.yml down -v --remove-orphans
|
||||
|
||||
server-test-postgres: export FB_UNIT_TESTING=1
|
||||
server-test-postgres: export FB_STORE_TEST_DB_TYPE=postgres
|
||||
server-test-postgres: export FB_STORE_TEST_DOCKER_PORT=44446
|
||||
server-test-postgres: export FOCALBOARD_UNIT_TESTING=1
|
||||
server-test-postgres: export FOCALBOARD_STORE_TEST_DB_TYPE=postgres
|
||||
server-test-postgres: export FOCALBOARD_STORE_TEST_DOCKER_PORT=44446
|
||||
|
||||
server-test-postgres: templates-archive ## Run server tests using postgres
|
||||
@echo Starting docker container for postgres
|
||||
docker-compose -f ./docker-testing/docker-compose-postgres.yml down -v --remove-orphans
|
||||
docker-compose -f ./docker-testing/docker-compose-postgres.yml run start_dependencies
|
||||
cd server; go test -tags '$(BUILD_TAGS)' -race -v -count=1 -timeout=30m ./...
|
||||
cd server; go test -tags '$(BUILD_TAGS)' -race -v -coverpkg=./... -coverprofile=server-postgres-profile.coverage -count=1 -timeout=30m ./...
|
||||
cd server; go tool cover -func server-postgres-profile.coverage
|
||||
cd mattermost-plugin/server; go test -tags '$(BUILD_TAGS)' -race -v -coverpkg=./... -coverprofile=plugin-postgres-profile.coverage -count=1 -timeout=30m ./...
|
||||
cd mattermost-plugin/server; go tool cover -func plugin-postgres-profile.coverage
|
||||
docker-compose -f ./docker-testing/docker-compose-postgres.yml down -v --remove-orphans
|
||||
|
||||
webapp: ## Build webapp.
|
||||
cd webapp; npm run pack
|
||||
|
||||
webapp-ci: ## Webapp CI: linting & testing.
|
||||
cd webapp; npm run check
|
||||
cd mattermost-plugin/webapp; npm run lint
|
||||
cd webapp; npm run test
|
||||
cd mattermost-plugin/webapp; npm run test
|
||||
cd webapp; npm run cypress:ci
|
||||
|
||||
webapp-test: ## jest tests for webapp
|
||||
cd webapp; npm run test
|
||||
|
||||
|
39
README.md
39
README.md
@ -16,28 +16,28 @@ 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 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 **[free cloud server](https://mattermost.com/sign-up/?utm_source=focalboard&utm_campaign=focalboard)** for your team to plan and collaborate.
|
||||
|
||||
* **[Mattermost Boards](https://www.focalboard.com/download/mattermost/)**: A self-hosted or cloud server for your team to plan and collaborate.
|
||||
* **[Personal Desktop](https://www.focalboard.com/download/personal-edition/desktop/)**: A standalone, single-user [Mac](https://apps.apple.com/app/apple-store/id1556908618?pt=2114704&ct=website&mt=8), [Windows](https://www.microsoft.com/store/apps/9NLN2T0SX9VF?cid=website), or [Linux](https://www.focalboard.com/download/personal-edition/desktop/#linux-desktop) desktop app for your own todos and personal projects.
|
||||
|
||||
Focalboard can also be installed as a standalone **[Personal Server](https://www.focalboard.com/download/personal-edition/ubuntu/)** for development and personal use.
|
||||
|
||||
## Try Focalboard
|
||||
|
||||
### Mattermost Boards - [now available as a free cloud server](https://mattermost.com/sign-up/?utm_source=focalboard&utm_campaign=focalboard)
|
||||
|
||||
**Mattermost Boards** 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/sign-up/?utm_source=focalboard&utm_campaign=focalboard). After logging into Mattermost, select the menu in the top left corner and select **Boards**.
|
||||
|
||||
***Mattermost Boards** is installed and enabled by default in Mattermost v6.0 and later.*
|
||||
|
||||
See the [plugin setup guide](https://www.focalboard.com/download/mattermost/) for more details.
|
||||
|
||||
### Personal Desktop (Windows, Mac or Linux Desktop)
|
||||
|
||||
* **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** 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**.
|
||||
|
||||
***Mattermost Boards** is installed and enabled by default in Mattermost v6.0 and later.*
|
||||
|
||||
See the [plugin setup guide](https://www.focalboard.com/download/mattermost/) for more details.
|
||||
|
||||
### Personal Server
|
||||
|
||||
**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/).
|
||||
@ -74,18 +74,27 @@ You can build standalone apps that package the server to run locally against SQL
|
||||
* **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 prebuild`
|
||||
* The above prebuild step needs to be run only when you make changes to or want to install your npm dependencies, etc.
|
||||
* Once the prebuild is completed, you can keep repeating the below steps to build the app & see the changes.
|
||||
* 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`
|
||||
* `open mac/dist/Focalboard.app`
|
||||
* Run `make prebuild`
|
||||
* The above prebuild step needs to be run only when you make changes to or want to install your npm dependencies, etc.
|
||||
* Once the prebuild is completed, you can keep repeating the below steps to build the app & see the changes.
|
||||
* Run `make mac-app`
|
||||
* Run `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`
|
||||
* Run `sudo apt-get install libgtk-3-dev`
|
||||
* Run `sudo apt-get install libwebkit2gtk-4.0-dev`
|
||||
* Run `make prebuild`
|
||||
* The above prebuild step needs to be run only when you make changes to or want to install your npm dependencies, etc.
|
||||
* Once the prebuild is completed, you can keep repeating the below steps to build the app & see the changes.
|
||||
* Run `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
|
||||
* **Docker**:
|
||||
|
@ -8,7 +8,7 @@ RUN npm install --no-optional && \
|
||||
npm run pack
|
||||
|
||||
### Go build
|
||||
FROM golang:1.16.5@sha256:3ba07778b0a48cef0820fe630220089b74ac9bd06a92ac1cf7b2f1abceffcdaa as gobuild
|
||||
FROM golang:1.18.3@sha256:b203dc573d81da7b3176264bfa447bd7c10c9347689be40540381838d75eebef AS gobuild
|
||||
|
||||
WORKDIR /go/src/focalboard
|
||||
ADD . /go/src/focalboard
|
||||
|
14876
experiments/webext/package-lock.json
generated
14876
experiments/webext/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
104
linux/go.mod
104
linux/go.mod
@ -1,12 +1,110 @@
|
||||
module github.com/mattermost/focalboard/linux
|
||||
|
||||
go 1.16
|
||||
go 1.18
|
||||
|
||||
replace github.com/mattermost/focalboard/server => ../server
|
||||
|
||||
require (
|
||||
github.com/google/uuid v1.3.0
|
||||
github.com/mattermost/focalboard/server v0.0.0-20220325164658-33557093b00d
|
||||
github.com/mattermost/mattermost-server/v6 v6.5.0
|
||||
github.com/mattermost/focalboard/server v0.0.0-00010101000000-000000000000
|
||||
github.com/mattermost/mattermost-server/v6 v6.0.0-20220711175838-7ee7523729e6
|
||||
github.com/webview/webview v0.0.0-20220314230258-a2b7746141c3
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/Masterminds/squirrel v1.5.2 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/blang/semver v3.5.1+incompatible // indirect
|
||||
github.com/cespare/xxhash/v2 v2.1.2 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dustin/go-humanize v1.0.0 // indirect
|
||||
github.com/dyatlov/go-opengraph v0.0.0-20210112100619-dae8665a5b09 // indirect
|
||||
github.com/fatih/color v1.13.0 // indirect
|
||||
github.com/francoispqt/gojay v1.2.13 // indirect
|
||||
github.com/fsnotify/fsnotify v1.5.4 // indirect
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.4 // indirect
|
||||
github.com/go-sql-driver/mysql v1.6.0 // indirect
|
||||
github.com/golang/protobuf v1.5.2 // indirect
|
||||
github.com/gorilla/mux v1.8.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.0 // indirect
|
||||
github.com/graph-gophers/graphql-go v1.4.0 // indirect
|
||||
github.com/hashicorp/hcl v1.0.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
|
||||
github.com/klauspost/compress v1.15.6 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.0.13 // indirect
|
||||
github.com/krolaw/zipstream v0.0.0-20180621105154-0a2661891f94 // indirect
|
||||
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect
|
||||
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect
|
||||
github.com/lib/pq v1.10.6 // indirect
|
||||
github.com/magiconair/properties v1.8.6 // indirect
|
||||
github.com/mattermost/go-i18n v1.11.1-0.20211013152124-5c415071e404 // indirect
|
||||
github.com/mattermost/ldap v0.0.0-20201202150706-ee0e6284187d // indirect
|
||||
github.com/mattermost/logr/v2 v2.0.15 // indirect
|
||||
github.com/mattermost/mattermost-plugin-api v0.0.28-0.20220623051512-0afd85e854d4 // indirect
|
||||
github.com/mattermost/morph v0.0.0-20220401091636-39f834798da8 // indirect
|
||||
github.com/mattn/go-colorable v0.1.12 // indirect
|
||||
github.com/mattn/go-isatty v0.0.14 // indirect
|
||||
github.com/mattn/go-sqlite3 v2.0.3+incompatible // indirect
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369 // indirect
|
||||
github.com/minio/md5-simd v1.1.2 // indirect
|
||||
github.com/minio/minio-go/v7 v7.0.28 // indirect
|
||||
github.com/minio/sha256-simd v1.0.0 // indirect
|
||||
github.com/mitchellh/go-homedir v1.1.0 // indirect
|
||||
github.com/mitchellh/mapstructure v1.4.3 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/oklog/run v1.1.0 // indirect
|
||||
github.com/pborman/uuid v1.2.1 // indirect
|
||||
github.com/pelletier/go-toml v1.9.5 // indirect
|
||||
github.com/philhofer/fwd v1.1.1 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/prometheus/client_golang v1.12.1 // indirect
|
||||
github.com/prometheus/client_model v0.2.0 // indirect
|
||||
github.com/prometheus/common v0.33.0 // indirect
|
||||
github.com/prometheus/procfs v0.7.3 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 // indirect
|
||||
github.com/rs/xid v1.4.0 // indirect
|
||||
github.com/rudderlabs/analytics-go v3.3.2+incompatible // indirect
|
||||
github.com/segmentio/backo-go v0.0.0-20200129164019-23eae7c10bd3 // indirect
|
||||
github.com/sirupsen/logrus v1.8.1 // indirect
|
||||
github.com/spf13/afero v1.8.2 // indirect
|
||||
github.com/spf13/cast v1.4.1 // indirect
|
||||
github.com/spf13/jwalterweatherman v1.1.0 // indirect
|
||||
github.com/spf13/pflag v1.0.5 // indirect
|
||||
github.com/spf13/viper v1.10.1 // indirect
|
||||
github.com/stretchr/testify v1.7.2 // indirect
|
||||
github.com/subosito/gotenv v1.2.0 // indirect
|
||||
github.com/tidwall/gjson v1.14.1 // indirect
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.0 // indirect
|
||||
github.com/tinylib/msgp v1.1.6 // indirect
|
||||
github.com/vmihailenco/msgpack/v5 v5.3.5 // indirect
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
|
||||
github.com/wiggin77/merror v1.0.3 // indirect
|
||||
github.com/wiggin77/srslog v1.0.1 // indirect
|
||||
github.com/xtgo/uuid v0.0.0-20140804021211-a0b114877d4c // indirect
|
||||
github.com/yuin/goldmark v1.4.12 // indirect
|
||||
golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e // indirect
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 // indirect
|
||||
golang.org/x/net v0.0.0-20220614195744-fb05da6f9022 // indirect
|
||||
golang.org/x/sys v0.0.0-20220614162138-6c1b26c55098 // indirect
|
||||
golang.org/x/text v0.3.7 // indirect
|
||||
golang.org/x/tools v0.1.11 // indirect
|
||||
google.golang.org/protobuf v1.28.0 // indirect
|
||||
gopkg.in/ini.v1 v1.66.6 // indirect
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.0.0 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
lukechampine.com/uint128 v1.2.0 // indirect
|
||||
modernc.org/cc/v3 v3.35.24 // indirect
|
||||
modernc.org/ccgo/v3 v3.15.17 // indirect
|
||||
modernc.org/libc v1.14.12 // indirect
|
||||
modernc.org/mathutil v1.4.1 // indirect
|
||||
modernc.org/memory v1.0.7 // indirect
|
||||
modernc.org/opt v0.1.1 // indirect
|
||||
modernc.org/sqlite v1.15.3 // indirect
|
||||
modernc.org/strutil v1.1.1 // indirect
|
||||
modernc.org/token v1.0.0 // indirect
|
||||
)
|
||||
|
538
linux/go.sum
538
linux/go.sum
File diff suppressed because it is too large
Load Diff
@ -61,7 +61,8 @@ func runServer(port int) (*server.Server, error) {
|
||||
AuthMode: "native",
|
||||
}
|
||||
|
||||
db, err := server.NewStore(config, logger)
|
||||
singleUser := len(sessionToken) > 0
|
||||
db, err := server.NewStore(config, singleUser, logger)
|
||||
if err != nil {
|
||||
fmt.Println("ERROR INITIALIZING THE SERVER STORE", err)
|
||||
return nil, err
|
||||
|
@ -160,7 +160,7 @@
|
||||
80D6DEB3252E13CB00AEED9E /* Sources */,
|
||||
80D6DEB4252E13CB00AEED9E /* Frameworks */,
|
||||
80D6DEB5252E13CB00AEED9E /* Resources */,
|
||||
80D6DF1D25324A8100AEED9E /* ShellScript */,
|
||||
80D6DF1D25324A8100AEED9E /* Run Script */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
@ -278,7 +278,7 @@
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXShellScriptBuildPhase section */
|
||||
80D6DF1D25324A8100AEED9E /* ShellScript */ = {
|
||||
80D6DF1D25324A8100AEED9E /* Run Script */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
@ -287,6 +287,7 @@
|
||||
);
|
||||
inputPaths = (
|
||||
);
|
||||
name = "Run Script";
|
||||
outputFileListPaths = (
|
||||
);
|
||||
outputPaths = (
|
||||
@ -480,7 +481,7 @@
|
||||
CODE_SIGN_IDENTITY = "-";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 24;
|
||||
CURRENT_PROJECT_VERSION = 33;
|
||||
DEVELOPMENT_TEAM = HFP57A3MYB;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
INFOPLIST_FILE = Focalboard/Info.plist;
|
||||
@ -488,6 +489,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 7.3;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.mattermost.focalboard;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
@ -504,7 +506,7 @@
|
||||
CODE_SIGN_IDENTITY = "-";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 24;
|
||||
CURRENT_PROJECT_VERSION = 33;
|
||||
DEVELOPMENT_TEAM = HFP57A3MYB;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
INFOPLIST_FILE = Focalboard/Info.plist;
|
||||
@ -512,6 +514,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 7.3;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.mattermost.focalboard;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
|
@ -66,6 +66,10 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
vc.showWindow(self)
|
||||
}
|
||||
|
||||
@IBAction func getCloudServer(_: AnyObject) {
|
||||
Globals.openGetCloudServerUrl()
|
||||
}
|
||||
|
||||
private func webFolder() -> URL {
|
||||
let url = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
|
||||
return url.appendingPathComponent("Focalboard").appendingPathComponent("server")
|
||||
|
@ -1,9 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.Cocoa.Storyboard.XIB" version="3.0" toolsVersion="19529" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" initialViewController="B8D-0N-5wS">
|
||||
<document type="com.apple.InterfaceBuilder3.Cocoa.Storyboard.XIB" version="3.0" toolsVersion="20037" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" initialViewController="B8D-0N-5wS">
|
||||
<dependencies>
|
||||
<deployment identifier="macosx"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="19529"/>
|
||||
<plugIn identifier="com.apple.WebKit2IBPlugin" version="19529"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="20037"/>
|
||||
<plugIn identifier="com.apple.WebKit2IBPlugin" version="20037"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
</dependencies>
|
||||
<scenes>
|
||||
@ -29,6 +29,12 @@
|
||||
<action selector="showWhatsNew:" target="Ady-hI-5gd" id="dlB-6q-7bK"/>
|
||||
</connections>
|
||||
</menuItem>
|
||||
<menuItem title="Get Mattermost Boards server" id="8YW-NP-PAh">
|
||||
<modifierMask key="keyEquivalentModifierMask"/>
|
||||
<connections>
|
||||
<action selector="getCloudServer:" target="Ady-hI-5gd" id="OvY-fG-Tqt"/>
|
||||
</connections>
|
||||
</menuItem>
|
||||
<menuItem isSeparatorItem="YES" id="VOq-y0-SEH"/>
|
||||
<menuItem title="Preferences…" keyEquivalent="," id="BOF-NM-1cW"/>
|
||||
<menuItem isSeparatorItem="YES" id="wFC-TO-SCJ"/>
|
||||
@ -942,17 +948,17 @@ Thanks so much,
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
</scroller>
|
||||
</scrollView>
|
||||
<button verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="eyp-ee-dxf">
|
||||
<rect key="frame" x="7" y="7" width="289" height="32"/>
|
||||
<button verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="WZS-cI-X3m">
|
||||
<rect key="frame" x="13" y="7" width="314" height="32"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="width" constant="275" id="Zzi-QI-UrX"/>
|
||||
<constraint firstAttribute="width" relation="greaterThanOrEqual" constant="300" id="tZN-oK-pDS"/>
|
||||
</constraints>
|
||||
<buttonCell key="cell" type="push" title="Rate Focalboard" bezelStyle="rounded" alignment="center" borderStyle="border" imageScaling="proportionallyDown" inset="2" id="7Kt-FU-UP3">
|
||||
<buttonCell key="cell" type="push" title="Setup free Mattermost Boards server" bezelStyle="rounded" alignment="center" borderStyle="border" imageScaling="proportionallyDown" inset="2" id="XJY-Vn-Z2s">
|
||||
<behavior key="behavior" pushIn="YES" lightByBackground="YES" lightByGray="YES"/>
|
||||
<font key="font" metaFont="system"/>
|
||||
</buttonCell>
|
||||
<connections>
|
||||
<action selector="rateButtonClicked:" target="aYX-zT-OJj" id="QqZ-am-cnJ"/>
|
||||
<action selector="cloudButtonClicked:" target="Fag-zV-Usm" id="ju3-LN-Zq9"/>
|
||||
</connections>
|
||||
</button>
|
||||
<button verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="dYB-Yp-UHf">
|
||||
@ -968,13 +974,28 @@ Thanks so much,
|
||||
<action selector="closeButtonClicked:" target="aYX-zT-OJj" id="run-tB-6Tb"/>
|
||||
</connections>
|
||||
</button>
|
||||
<button verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="eyp-ee-dxf">
|
||||
<rect key="frame" x="325" y="7" width="164" height="32"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="width" constant="150" id="Zzi-QI-UrX"/>
|
||||
</constraints>
|
||||
<buttonCell key="cell" type="push" title="Rate Focalboard" bezelStyle="rounded" alignment="center" borderStyle="border" imageScaling="proportionallyDown" inset="2" id="7Kt-FU-UP3">
|
||||
<behavior key="behavior" pushIn="YES" lightByBackground="YES" lightByGray="YES"/>
|
||||
<font key="font" metaFont="system"/>
|
||||
</buttonCell>
|
||||
<connections>
|
||||
<action selector="rateButtonClicked:" target="aYX-zT-OJj" id="QqZ-am-cnJ"/>
|
||||
</connections>
|
||||
</button>
|
||||
</subviews>
|
||||
<constraints>
|
||||
<constraint firstItem="WZS-cI-X3m" firstAttribute="top" secondItem="baY-7A-lBA" secondAttribute="bottom" constant="20" symbolic="YES" id="29y-HQ-Ik9"/>
|
||||
<constraint firstItem="WZS-cI-X3m" firstAttribute="leading" secondItem="fy7-95-Eie" secondAttribute="leading" constant="20" symbolic="YES" id="EMs-BU-zTE"/>
|
||||
<constraint firstAttribute="bottom" secondItem="eyp-ee-dxf" secondAttribute="bottom" constant="14" id="Kvc-VM-O1W"/>
|
||||
<constraint firstAttribute="trailing" secondItem="dYB-Yp-UHf" secondAttribute="trailing" constant="32" id="SQ6-Hs-a1n"/>
|
||||
<constraint firstItem="eyp-ee-dxf" firstAttribute="leading" secondItem="fy7-95-Eie" secondAttribute="leading" constant="14" id="TTF-mb-cZY"/>
|
||||
<constraint firstItem="eyp-ee-dxf" firstAttribute="top" secondItem="baY-7A-lBA" secondAttribute="bottom" constant="20" symbolic="YES" id="UIA-ji-xOe"/>
|
||||
<constraint firstAttribute="trailing" secondItem="baY-7A-lBA" secondAttribute="trailing" constant="20" id="cRi-Tx-jtT"/>
|
||||
<constraint firstItem="eyp-ee-dxf" firstAttribute="leading" secondItem="WZS-cI-X3m" secondAttribute="trailing" constant="12" symbolic="YES" id="eKo-Qk-lxl"/>
|
||||
<constraint firstAttribute="bottom" secondItem="dYB-Yp-UHf" secondAttribute="bottom" constant="14" id="grP-Cj-Euz"/>
|
||||
<constraint firstItem="baY-7A-lBA" firstAttribute="leading" secondItem="fy7-95-Eie" secondAttribute="leading" constant="20" id="mxh-wg-iXP"/>
|
||||
<constraint firstItem="baY-7A-lBA" firstAttribute="top" secondItem="fy7-95-Eie" secondAttribute="top" constant="20" id="oAR-aZ-gNJ"/>
|
||||
@ -982,6 +1003,7 @@ Thanks so much,
|
||||
</constraints>
|
||||
</view>
|
||||
<connections>
|
||||
<outlet property="cloudButton" destination="WZS-cI-X3m" id="4ry-4a-zGW"/>
|
||||
<outlet property="rateButton" destination="eyp-ee-dxf" id="cvh-x3-TPU"/>
|
||||
<outlet property="textView" destination="f3s-Q5-6IG" id="nbt-zY-bAo"/>
|
||||
</connections>
|
||||
|
@ -2,13 +2,19 @@
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import Foundation
|
||||
import Cocoa
|
||||
|
||||
class Globals {
|
||||
static let ProductVersion = 01600
|
||||
static let WhatsNewVersion = 01100
|
||||
static let ProductVersion = 70000
|
||||
static let WhatsNewVersion = 70000
|
||||
|
||||
static var currentWhatsNewVersion: Int {
|
||||
get { return UserDefaults.standard.integer(forKey: "whatsNewVersion") }
|
||||
set { UserDefaults.standard.setValue(newValue, forKey: "whatsNewVersion") }
|
||||
}
|
||||
|
||||
static func openGetCloudServerUrl() {
|
||||
let url = URL(string: "https://mattermost.com/sign-up/?utm_source=focalboard&utm_campaign=focalboardapp")!
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
}
|
||||
|
@ -17,7 +17,7 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.16.0</string>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
|
@ -7,6 +7,7 @@ class WhatsNewViewController:
|
||||
NSViewController {
|
||||
@IBOutlet var textView: NSTextView!
|
||||
@IBOutlet var rateButton: NSButton!
|
||||
@IBOutlet var cloudButton: NSButton!
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
@ -28,6 +29,10 @@ class WhatsNewViewController:
|
||||
view.window?.close()
|
||||
}
|
||||
|
||||
@IBAction func cloudButtonClicked(_ sender: Any) {
|
||||
Globals.openGetCloudServerUrl()
|
||||
}
|
||||
|
||||
@IBAction func closeButtonClicked(_ sender: Any) {
|
||||
view.window?.close()
|
||||
}
|
||||
|
@ -1,6 +1,12 @@
|
||||
Welcome to Focalboard v0.11.0!
|
||||
Welcome to Focalboard v7.2!
|
||||
|
||||
Thank you contributors! There are a number of improvements in this release, and one major new feature:
|
||||
Mattermost Boards is now availalbe as a Cloud service. Set up your free server via the button below to collaborate with your team.
|
||||
|
||||
Mattermost Boards combines all the features of Focalboard with real-time collaboration, calls, and playbooks.
|
||||
|
||||
You can export boards from Focalboard and import them into Mattermost Boards, and pick up where you left off.
|
||||
|
||||
Thank you contributors! Recent improvements include:
|
||||
|
||||
* Calendar view. Thanks @sbishel!
|
||||
|
||||
|
@ -67,6 +67,7 @@ linters:
|
||||
- unconvert
|
||||
- unused
|
||||
- whitespace
|
||||
- gocyclo
|
||||
|
||||
issues:
|
||||
exclude-rules:
|
||||
|
@ -60,6 +60,9 @@ all: check-style test dist
|
||||
apply:
|
||||
./build/bin/manifest apply
|
||||
|
||||
setup-go-work: ## Sets up a go.work file
|
||||
cd ..; go run ./mattermost-plugin/build/gowork/main.go
|
||||
|
||||
## Runs eslint and golangci-lint
|
||||
.PHONY: check-style
|
||||
check-style: webapp/node_modules
|
||||
@ -80,7 +83,7 @@ ifneq ($(HAS_SERVER),)
|
||||
golangci-lint run ./...
|
||||
endif
|
||||
|
||||
templates-archive: ## Build templates archive file
|
||||
templates-archive: setup-go-work ## Build templates archive file
|
||||
cd ../server/assets/build-template-archive; go run -tags '$(BUILD_TAGS)' main.go --dir="../templates-boardarchive" --out="../templates.boardarchive"
|
||||
|
||||
## Builds the server, if it exists, for all supported architectures.
|
||||
@ -214,7 +217,7 @@ detach: setup-attach
|
||||
|
||||
## Runs any lints and unit tests defined for the server and webapp, if they exist.
|
||||
.PHONY: test
|
||||
test: export FB_UNIT_TESTING=1
|
||||
test: export FOCALBOARD_UNIT_TESTING=1
|
||||
test: webapp/node_modules
|
||||
ifneq ($(HAS_SERVER),)
|
||||
$(GO) test -v $(GO_TEST_FLAGS) ./server/...
|
||||
|
@ -1,11 +1,63 @@
|
||||
module github.com/mattermost/mattermost-plugin-starter-template/build
|
||||
|
||||
go 1.12
|
||||
go 1.18
|
||||
|
||||
require (
|
||||
github.com/go-git/go-git/v5 v5.1.0
|
||||
github.com/mattermost/mattermost-server/v6 v6.0.0-20210817091833-04b27ce93c02
|
||||
github.com/mattermost/mattermost-server/v6 v6.0.0-20220705131644-b99bd0d04915
|
||||
github.com/pkg/errors v0.9.1
|
||||
github.com/stretchr/testify v1.7.0
|
||||
github.com/stretchr/testify v1.7.2
|
||||
sigs.k8s.io/yaml v1.2.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/blang/semver v3.5.1+incompatible // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dustin/go-humanize v1.0.0 // indirect
|
||||
github.com/dyatlov/go-opengraph v0.0.0-20210112100619-dae8665a5b09 // indirect
|
||||
github.com/emirpasic/gods v1.12.0 // indirect
|
||||
github.com/francoispqt/gojay v1.2.13 // indirect
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.4 // indirect
|
||||
github.com/go-git/gcfg v1.5.0 // indirect
|
||||
github.com/go-git/go-billy/v5 v5.0.0 // indirect
|
||||
github.com/google/uuid v1.3.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.0 // indirect
|
||||
github.com/graph-gophers/graphql-go v1.4.0 // indirect
|
||||
github.com/imdario/mergo v0.3.12 // indirect
|
||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/kevinburke/ssh_config v0.0.0-20190725054713-01f96b0aa0cd // indirect
|
||||
github.com/klauspost/compress v1.15.6 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.0.13 // indirect
|
||||
github.com/mattermost/go-i18n v1.11.1-0.20211013152124-5c415071e404 // indirect
|
||||
github.com/mattermost/ldap v0.0.0-20201202150706-ee0e6284187d // indirect
|
||||
github.com/mattermost/logr/v2 v2.0.15 // indirect
|
||||
github.com/minio/md5-simd v1.1.2 // indirect
|
||||
github.com/minio/minio-go/v7 v7.0.28 // indirect
|
||||
github.com/minio/sha256-simd v1.0.0 // indirect
|
||||
github.com/mitchellh/go-homedir v1.1.0 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pborman/uuid v1.2.1 // indirect
|
||||
github.com/pelletier/go-toml v1.9.5 // indirect
|
||||
github.com/philhofer/fwd v1.1.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/rs/xid v1.4.0 // indirect
|
||||
github.com/sergi/go-diff v1.1.0 // indirect
|
||||
github.com/sirupsen/logrus v1.8.1 // indirect
|
||||
github.com/tinylib/msgp v1.1.6 // indirect
|
||||
github.com/vmihailenco/msgpack/v5 v5.3.5 // indirect
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
|
||||
github.com/wiggin77/merror v1.0.3 // indirect
|
||||
github.com/wiggin77/srslog v1.0.1 // indirect
|
||||
github.com/xanzy/ssh-agent v0.2.1 // indirect
|
||||
golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e // indirect
|
||||
golang.org/x/net v0.0.0-20220614195744-fb05da6f9022 // indirect
|
||||
golang.org/x/sys v0.0.0-20220614162138-6c1b26c55098 // indirect
|
||||
golang.org/x/text v0.3.7 // indirect
|
||||
gopkg.in/ini.v1 v1.66.6 // indirect
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.0.0 // indirect
|
||||
gopkg.in/warnings.v0 v0.1.2 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
File diff suppressed because it is too large
Load Diff
93
mattermost-plugin/build/gowork/main.go
Normal file
93
mattermost-plugin/build/gowork/main.go
Normal file
@ -0,0 +1,93 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
filename = "go.work"
|
||||
)
|
||||
|
||||
func main() {
|
||||
force := false
|
||||
if len(os.Args) == 2 && strings.ToLower(os.Args[1]) == "-f" {
|
||||
force = true
|
||||
}
|
||||
|
||||
if _, err := os.Stat(filename); err == nil && !force {
|
||||
// go.work already exists and force flag not specified
|
||||
fmt.Fprintln(os.Stdout, "go.work already exists and -f (force) not specified; nothing to do.")
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
f, err := os.Create(filename)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error creating %s: %s", filename, err.Error())
|
||||
os.Exit(-1)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
isCI := isCI()
|
||||
content := makeGoWork(isCI)
|
||||
|
||||
_, err = f.WriteString(content)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error writing %s: %s", filename, err.Error())
|
||||
os.Exit(-1)
|
||||
}
|
||||
|
||||
fmt.Fprintln(os.Stdout, "go.work written successfully.")
|
||||
}
|
||||
|
||||
func makeGoWork(ci bool) string {
|
||||
var b strings.Builder
|
||||
|
||||
b.WriteString("go 1.18\n\n")
|
||||
b.WriteString("use ./mattermost-plugin\n")
|
||||
b.WriteString("use ./server\n")
|
||||
|
||||
if ci {
|
||||
b.WriteString("use ./linux\n")
|
||||
} else {
|
||||
b.WriteString("use ../mattermost-server\n")
|
||||
b.WriteString("use ../enterprise\n")
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func isCI() bool {
|
||||
vars := map[string]bool{
|
||||
// var name: must_be_true (false means being defined is enough)
|
||||
"CIRCLECI": true,
|
||||
"GITHUB_ACTIONS": true,
|
||||
"GITLAB_CI": false,
|
||||
"TRAVIS": true,
|
||||
}
|
||||
|
||||
for name, mustBeTrue := range vars {
|
||||
if isEnvVarTrue(name, mustBeTrue) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isEnvVarTrue(name string, mustBeTrue bool) bool {
|
||||
val, ok := os.LookupEnv(name)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
if !mustBeTrue {
|
||||
return true
|
||||
}
|
||||
|
||||
switch strings.ToLower(val) {
|
||||
case "t", "1", "true", "y", "yes":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
@ -1,14 +1,202 @@
|
||||
module github.com/mattermost/focalboard/mattermost-plugin
|
||||
|
||||
go 1.16
|
||||
|
||||
replace github.com/mattermost/focalboard/server => ../server
|
||||
replace github.com/mattermost/focalboard/server/server => ../server/server
|
||||
go 1.18
|
||||
|
||||
require (
|
||||
github.com/golang/mock v1.6.0
|
||||
github.com/mattermost/focalboard/server v0.0.0-20220325164658-33557093b00d
|
||||
github.com/mattermost/mattermost-plugin-api v0.0.27
|
||||
github.com/mattermost/mattermost-server/v6 v6.5.0
|
||||
github.com/stretchr/testify v1.7.1
|
||||
github.com/mattermost/mattermost-plugin-api v0.0.28-0.20220623051512-0afd85e854d4
|
||||
github.com/stretchr/testify v1.7.2
|
||||
)
|
||||
|
||||
require (
|
||||
code.sajari.com/docconv v1.2.0 // indirect
|
||||
github.com/JalfResi/justext v0.0.0-20170829062021-c0282dea7198 // indirect
|
||||
github.com/Masterminds/semver/v3 v3.1.1 // indirect
|
||||
github.com/Masterminds/squirrel v1.5.2 // indirect
|
||||
github.com/PuerkitoBio/goquery v1.8.0 // indirect
|
||||
github.com/RoaringBitmap/roaring v1.2.1 // indirect
|
||||
github.com/advancedlogic/GoOse v0.0.0-20210820140952-9d5822d4a625 // indirect
|
||||
github.com/andybalholm/brotli v1.0.4 // indirect
|
||||
github.com/andybalholm/cascadia v1.3.1 // indirect
|
||||
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de // indirect
|
||||
github.com/avct/uasurfer v0.0.0-20191028135549-26b5daa857f1 // indirect
|
||||
github.com/aws/aws-sdk-go v1.44.34 // indirect
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/bits-and-blooms/bitset v1.2.2 // indirect
|
||||
github.com/blang/semver v3.5.1+incompatible // indirect
|
||||
github.com/blang/semver/v4 v4.0.0 // indirect
|
||||
github.com/blevesearch/bleve/v2 v2.3.2 // indirect
|
||||
github.com/blevesearch/bleve_index_api v1.0.2 // indirect
|
||||
github.com/blevesearch/go-porterstemmer v1.0.3 // indirect
|
||||
github.com/blevesearch/gtreap v0.1.1 // indirect
|
||||
github.com/blevesearch/mmap-go v1.0.4 // indirect
|
||||
github.com/blevesearch/scorch_segment_api/v2 v2.1.0 // indirect
|
||||
github.com/blevesearch/segment v0.9.0 // indirect
|
||||
github.com/blevesearch/snowballstem v0.9.0 // indirect
|
||||
github.com/blevesearch/upsidedown_store_api v1.0.1 // indirect
|
||||
github.com/blevesearch/vellum v1.0.8 // indirect
|
||||
github.com/blevesearch/zapx/v11 v11.3.4 // indirect
|
||||
github.com/blevesearch/zapx/v12 v12.3.4 // indirect
|
||||
github.com/blevesearch/zapx/v13 v13.3.4 // indirect
|
||||
github.com/blevesearch/zapx/v14 v14.3.4 // indirect
|
||||
github.com/blevesearch/zapx/v15 v15.3.4 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.1.2 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dgrijalva/jwt-go v3.2.0+incompatible // indirect
|
||||
github.com/dgryski/dgoogauth v0.0.0-20190221195224-5a805980a5f3 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/disintegration/imaging v1.6.2 // indirect
|
||||
github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5 // indirect
|
||||
github.com/dustin/go-humanize v1.0.0 // indirect
|
||||
github.com/dyatlov/go-opengraph v0.0.0-20210112100619-dae8665a5b09 // indirect
|
||||
github.com/fatih/color v1.13.0 // indirect
|
||||
github.com/fatih/set v0.2.1 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.3 // indirect
|
||||
github.com/francoispqt/gojay v1.2.13 // indirect
|
||||
github.com/fsnotify/fsnotify v1.5.4 // indirect
|
||||
github.com/getsentry/sentry-go v0.13.0 // indirect
|
||||
github.com/gigawattio/window v0.0.0-20180317192513-0f5467e35573 // indirect
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.4 // indirect
|
||||
github.com/go-redis/redis/v8 v8.11.5 // indirect
|
||||
github.com/go-resty/resty/v2 v2.7.0 // indirect
|
||||
github.com/go-sql-driver/mysql v1.6.0 // indirect
|
||||
github.com/golang-migrate/migrate/v4 v4.15.2 // indirect
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
|
||||
github.com/golang/protobuf v1.5.2 // indirect
|
||||
github.com/golang/snappy v0.0.4 // indirect
|
||||
github.com/google/uuid v1.3.0 // indirect
|
||||
github.com/gopherjs/gopherjs v1.17.2 // indirect
|
||||
github.com/gorilla/css v1.0.0 // indirect
|
||||
github.com/gorilla/handlers v1.5.1 // indirect
|
||||
github.com/gorilla/mux v1.8.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.0 // indirect
|
||||
github.com/graph-gophers/graphql-go v1.4.0 // indirect
|
||||
github.com/h2non/go-is-svg v0.0.0-20160927212452-35e8c4b0612c // indirect
|
||||
github.com/hashicorp/go-hclog v1.2.1 // indirect
|
||||
github.com/hashicorp/go-plugin v1.4.4 // indirect
|
||||
github.com/hashicorp/golang-lru v0.5.4 // indirect
|
||||
github.com/hashicorp/hcl v1.0.0 // indirect
|
||||
github.com/hashicorp/yamux v0.0.0-20211028200310-0bc27b27de87 // indirect
|
||||
github.com/jaytaylor/html2text v0.0.0-20211105163654-bc68cce691ba // indirect
|
||||
github.com/jmespath/go-jmespath v0.4.0 // indirect
|
||||
github.com/jmoiron/sqlx v1.3.5 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
|
||||
github.com/klauspost/compress v1.15.6 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.0.13 // indirect
|
||||
github.com/klauspost/pgzip v1.2.5 // indirect
|
||||
github.com/krolaw/zipstream v0.0.0-20180621105154-0a2661891f94 // indirect
|
||||
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect
|
||||
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect
|
||||
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 // indirect
|
||||
github.com/levigross/exp-html v0.0.0-20120902181939-8df60c69a8f5 // indirect
|
||||
github.com/lib/pq v1.10.6 // indirect
|
||||
github.com/magiconair/properties v1.8.6 // indirect
|
||||
github.com/mattermost/go-i18n v1.11.1-0.20211013152124-5c415071e404 // indirect
|
||||
github.com/mattermost/ldap v0.0.0-20201202150706-ee0e6284187d // indirect
|
||||
github.com/mattermost/logr/v2 v2.0.15 // indirect
|
||||
github.com/mattermost/mattermost-server/v6 v6.0.0-20220711175838-7ee7523729e6 // indirect
|
||||
github.com/mattermost/morph v0.0.0-20220401091636-39f834798da8 // indirect
|
||||
github.com/mattermost/rsc v0.0.0-20160330161541-bbaefb05eaa0 // indirect
|
||||
github.com/mattermost/squirrel v0.2.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.12 // indirect
|
||||
github.com/mattn/go-isatty v0.0.14 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.13 // indirect
|
||||
github.com/mattn/go-sqlite3 v2.0.3+incompatible // indirect
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369 // indirect
|
||||
github.com/mholt/archiver/v3 v3.5.1 // indirect
|
||||
github.com/microcosm-cc/bluemonday v1.0.18 // indirect
|
||||
github.com/minio/md5-simd v1.1.2 // indirect
|
||||
github.com/minio/minio-go/v7 v7.0.28 // indirect
|
||||
github.com/minio/sha256-simd v1.0.0 // indirect
|
||||
github.com/mitchellh/go-homedir v1.1.0 // indirect
|
||||
github.com/mitchellh/go-testing-interface v1.14.1 // indirect
|
||||
github.com/mitchellh/mapstructure v1.4.3 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/mschoch/smat v0.2.0 // indirect
|
||||
github.com/nwaples/rardecode v1.1.3 // indirect
|
||||
github.com/oklog/run v1.1.0 // indirect
|
||||
github.com/olekukonko/tablewriter v0.0.5 // indirect
|
||||
github.com/oov/psd v0.0.0-20220121172623-5db5eafcecbb // indirect
|
||||
github.com/opentracing/opentracing-go v1.2.0 // indirect
|
||||
github.com/otiai10/gosseract/v2 v2.3.1 // indirect
|
||||
github.com/pborman/uuid v1.2.1 // indirect
|
||||
github.com/pelletier/go-toml v1.9.5 // indirect
|
||||
github.com/philhofer/fwd v1.1.1 // indirect
|
||||
github.com/pierrec/lz4/v4 v4.1.14 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/prometheus/client_golang v1.12.1 // indirect
|
||||
github.com/prometheus/client_model v0.2.0 // indirect
|
||||
github.com/prometheus/common v0.33.0 // indirect
|
||||
github.com/prometheus/procfs v0.7.3 // indirect
|
||||
github.com/reflog/dateconstraints v0.2.1 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 // indirect
|
||||
github.com/richardlehane/mscfb v1.0.4 // indirect
|
||||
github.com/richardlehane/msoleps v1.0.3 // indirect
|
||||
github.com/rivo/uniseg v0.2.0 // indirect
|
||||
github.com/rs/cors v1.8.2 // indirect
|
||||
github.com/rs/xid v1.4.0 // indirect
|
||||
github.com/rudderlabs/analytics-go v3.3.2+incompatible // indirect
|
||||
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd // indirect
|
||||
github.com/segmentio/backo-go v0.0.0-20200129164019-23eae7c10bd3 // indirect
|
||||
github.com/sergi/go-diff v1.2.0 // indirect
|
||||
github.com/sirupsen/logrus v1.8.1 // indirect
|
||||
github.com/spf13/afero v1.8.2 // indirect
|
||||
github.com/spf13/cast v1.4.1 // indirect
|
||||
github.com/spf13/jwalterweatherman v1.1.0 // indirect
|
||||
github.com/spf13/pflag v1.0.5 // indirect
|
||||
github.com/spf13/viper v1.10.1 // indirect
|
||||
github.com/splitio/go-client/v6 v6.1.0 // indirect
|
||||
github.com/splitio/go-split-commons/v3 v3.1.0 // indirect
|
||||
github.com/splitio/go-toolkit/v4 v4.2.0 // indirect
|
||||
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect
|
||||
github.com/stretchr/objx v0.4.0 // indirect
|
||||
github.com/subosito/gotenv v1.2.0 // indirect
|
||||
github.com/throttled/throttled v2.2.5+incompatible // indirect
|
||||
github.com/tidwall/gjson v1.14.1 // indirect
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.0 // indirect
|
||||
github.com/tinylib/msgp v1.1.6 // indirect
|
||||
github.com/uber/jaeger-client-go v2.30.0+incompatible // indirect
|
||||
github.com/uber/jaeger-lib v2.4.1+incompatible // indirect
|
||||
github.com/ulikunitz/xz v0.5.10 // indirect
|
||||
github.com/vmihailenco/msgpack/v5 v5.3.5 // indirect
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
|
||||
github.com/wiggin77/merror v1.0.3 // indirect
|
||||
github.com/wiggin77/srslog v1.0.1 // indirect
|
||||
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect
|
||||
github.com/xtgo/uuid v0.0.0-20140804021211-a0b114877d4c // indirect
|
||||
github.com/yuin/goldmark v1.4.12 // indirect
|
||||
go.etcd.io/bbolt v1.3.6 // indirect
|
||||
go.uber.org/atomic v1.9.0 // indirect
|
||||
golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e // indirect
|
||||
golang.org/x/image v0.0.0-20220601225756-64ec528b34cd // indirect
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 // indirect
|
||||
golang.org/x/net v0.0.0-20220614195744-fb05da6f9022 // indirect
|
||||
golang.org/x/sync v0.0.0-20220601150217-0de741cfad7f // indirect
|
||||
golang.org/x/sys v0.0.0-20220614162138-6c1b26c55098 // indirect
|
||||
golang.org/x/text v0.3.7 // indirect
|
||||
golang.org/x/tools v0.1.11 // indirect
|
||||
google.golang.org/genproto v0.0.0-20220614165028-45ed7f3ff16e // indirect
|
||||
google.golang.org/grpc v1.47.0 // indirect
|
||||
google.golang.org/protobuf v1.28.0 // indirect
|
||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
|
||||
gopkg.in/ini.v1 v1.66.6 // indirect
|
||||
gopkg.in/mail.v2 v2.3.1 // indirect
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.0.0 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
lukechampine.com/uint128 v1.2.0 // indirect
|
||||
modernc.org/cc/v3 v3.35.24 // indirect
|
||||
modernc.org/ccgo/v3 v3.15.17 // indirect
|
||||
modernc.org/libc v1.14.12 // indirect
|
||||
modernc.org/mathutil v1.4.1 // indirect
|
||||
modernc.org/memory v1.0.7 // indirect
|
||||
modernc.org/opt v0.1.1 // indirect
|
||||
modernc.org/sqlite v1.15.3 // indirect
|
||||
modernc.org/strutil v1.1.1 // indirect
|
||||
modernc.org/token v1.0.0 // indirect
|
||||
)
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -6,8 +6,8 @@
|
||||
"support_url": "https://github.com/mattermost/focalboard/issues",
|
||||
"release_notes_url": "https://github.com/mattermost/focalboard/releases",
|
||||
"icon_path": "assets/starter-template-icon.svg",
|
||||
"version": "0.16.0",
|
||||
"min_server_version": "6.0.0",
|
||||
"version": "7.3.0",
|
||||
"min_server_version": "7.0.0",
|
||||
"server": {
|
||||
"executables": {
|
||||
"linux-amd64": "server/dist/plugin-linux-amd64",
|
||||
|
209
mattermost-plugin/product/api_adapter.go
Normal file
209
mattermost-plugin/product/api_adapter.go
Normal file
@ -0,0 +1,209 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package product
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/app/request"
|
||||
mm_model "github.com/mattermost/mattermost-server/v6/model"
|
||||
"github.com/mattermost/mattermost-server/v6/shared/mlog"
|
||||
|
||||
"github.com/mattermost/focalboard/server/model"
|
||||
)
|
||||
|
||||
// normalizeAppError returns a truly nil error if appErr is nil
|
||||
// See https://golang.org/doc/faq#nil_error for more details.
|
||||
func normalizeAppErr(appErr *mm_model.AppError) error {
|
||||
if appErr == nil {
|
||||
return nil
|
||||
}
|
||||
return appErr
|
||||
}
|
||||
|
||||
// serviceAPIAdapter is an adapter that flattens the APIs provided by suite services so they can
|
||||
// be used as per the Plugin API.
|
||||
// Note: when supporting a plugin build is no longer needed this adapter may be removed as the Boards app
|
||||
// can be modified to use the services in modular fashion.
|
||||
type serviceAPIAdapter struct {
|
||||
api *boardsProduct
|
||||
ctx *request.Context
|
||||
}
|
||||
|
||||
func newServiceAPIAdapter(api *boardsProduct) *serviceAPIAdapter {
|
||||
return &serviceAPIAdapter{
|
||||
api: api,
|
||||
ctx: &request.Context{},
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Channels service.
|
||||
//
|
||||
|
||||
func (a *serviceAPIAdapter) GetDirectChannel(userID1, userID2 string) (*mm_model.Channel, error) {
|
||||
channel, appErr := a.api.channelService.GetDirectChannel(userID1, userID2)
|
||||
return channel, normalizeAppErr(appErr)
|
||||
}
|
||||
|
||||
func (a *serviceAPIAdapter) GetChannelByID(channelID string) (*mm_model.Channel, error) {
|
||||
channel, appErr := a.api.channelService.GetChannelByID(channelID)
|
||||
return channel, normalizeAppErr(appErr)
|
||||
}
|
||||
|
||||
func (a *serviceAPIAdapter) GetChannelMember(channelID string, userID string) (*mm_model.ChannelMember, error) {
|
||||
member, appErr := a.api.channelService.GetChannelMember(channelID, userID)
|
||||
return member, normalizeAppErr(appErr)
|
||||
}
|
||||
|
||||
func (a *serviceAPIAdapter) GetChannelsForTeamForUser(teamID string, userID string, includeDeleted bool) (mm_model.ChannelList, error) {
|
||||
opts := &mm_model.ChannelSearchOpts{
|
||||
IncludeDeleted: includeDeleted,
|
||||
}
|
||||
channels, appErr := a.api.channelService.GetChannelsForTeamForUser(teamID, userID, opts)
|
||||
return channels, normalizeAppErr(appErr)
|
||||
}
|
||||
|
||||
//
|
||||
// Post service.
|
||||
//
|
||||
|
||||
func (a *serviceAPIAdapter) CreatePost(post *mm_model.Post) (*mm_model.Post, error) {
|
||||
post, appErr := a.api.postService.CreatePost(a.ctx, post)
|
||||
return post, normalizeAppErr(appErr)
|
||||
}
|
||||
|
||||
//
|
||||
// User service.
|
||||
//
|
||||
|
||||
func (a *serviceAPIAdapter) GetUserByID(userID string) (*mm_model.User, error) {
|
||||
user, appErr := a.api.userService.GetUser(userID)
|
||||
return user, normalizeAppErr(appErr)
|
||||
}
|
||||
|
||||
func (a *serviceAPIAdapter) GetUserByUsername(name string) (*mm_model.User, error) {
|
||||
user, appErr := a.api.userService.GetUserByUsername(name)
|
||||
return user, normalizeAppErr(appErr)
|
||||
}
|
||||
|
||||
func (a *serviceAPIAdapter) GetUserByEmail(email string) (*mm_model.User, error) {
|
||||
user, appErr := a.api.userService.GetUserByEmail(email)
|
||||
return user, normalizeAppErr(appErr)
|
||||
}
|
||||
|
||||
func (a *serviceAPIAdapter) UpdateUser(user *mm_model.User) (*mm_model.User, error) {
|
||||
user, appErr := a.api.userService.UpdateUser(user, true)
|
||||
return user, normalizeAppErr(appErr)
|
||||
}
|
||||
|
||||
func (a *serviceAPIAdapter) GetUsersFromProfiles(options *mm_model.UserGetOptions) ([]*mm_model.User, error) {
|
||||
user, appErr := a.api.userService.GetUsersFromProfiles(options)
|
||||
return user, normalizeAppErr(appErr)
|
||||
}
|
||||
|
||||
//
|
||||
// Team service.
|
||||
//
|
||||
|
||||
func (a *serviceAPIAdapter) GetTeamMember(teamID string, userID string) (*mm_model.TeamMember, error) {
|
||||
member, appErr := a.api.teamService.GetMember(teamID, userID)
|
||||
return member, normalizeAppErr(appErr)
|
||||
}
|
||||
|
||||
func (a *serviceAPIAdapter) CreateMember(teamID string, userID string) (*mm_model.TeamMember, error) {
|
||||
member, appErr := a.api.teamService.CreateMember(a.ctx, teamID, userID)
|
||||
return member, normalizeAppErr(appErr)
|
||||
}
|
||||
|
||||
//
|
||||
// Permissions service.
|
||||
//
|
||||
|
||||
func (a *serviceAPIAdapter) HasPermissionToTeam(userID, teamID string, permission *mm_model.Permission) bool {
|
||||
return a.api.permissionsService.HasPermissionToTeam(userID, teamID, permission)
|
||||
}
|
||||
|
||||
func (a *serviceAPIAdapter) HasPermissionToChannel(askingUserID string, channelID string, permission *mm_model.Permission) bool {
|
||||
return a.api.permissionsService.HasPermissionToChannel(askingUserID, channelID, permission)
|
||||
}
|
||||
|
||||
//
|
||||
// Bot service.
|
||||
//
|
||||
func (a *serviceAPIAdapter) EnsureBot(bot *mm_model.Bot) (string, error) {
|
||||
return a.api.botService.EnsureBot(a.ctx, boardsProductID, bot)
|
||||
}
|
||||
|
||||
//
|
||||
// License service.
|
||||
//
|
||||
func (a *serviceAPIAdapter) GetLicense() *mm_model.License {
|
||||
return a.api.licenseService.GetLicense()
|
||||
}
|
||||
|
||||
//
|
||||
// FileInfoStore service.
|
||||
//
|
||||
func (a *serviceAPIAdapter) GetFileInfo(fileID string) (*mm_model.FileInfo, error) {
|
||||
fi, appErr := a.api.fileInfoStoreService.GetFileInfo(fileID)
|
||||
return fi, normalizeAppErr(appErr)
|
||||
}
|
||||
|
||||
//
|
||||
// Cluster store.
|
||||
//
|
||||
func (a *serviceAPIAdapter) PublishWebSocketEvent(event string, payload map[string]interface{}, broadcast *mm_model.WebsocketBroadcast) {
|
||||
a.api.clusterService.PublishWebSocketEvent(boardsProductID, event, payload, broadcast)
|
||||
}
|
||||
|
||||
func (a *serviceAPIAdapter) PublishPluginClusterEvent(ev mm_model.PluginClusterEvent, opts mm_model.PluginClusterEventSendOptions) error {
|
||||
return a.api.clusterService.PublishPluginClusterEvent(boardsProductID, ev, opts)
|
||||
}
|
||||
|
||||
//
|
||||
// Cloud service.
|
||||
//
|
||||
func (a *serviceAPIAdapter) GetCloudLimits() (*mm_model.ProductLimits, error) {
|
||||
return a.api.cloudService.GetCloudLimits()
|
||||
}
|
||||
|
||||
//
|
||||
// Config service.
|
||||
//
|
||||
func (a *serviceAPIAdapter) GetConfig() *mm_model.Config {
|
||||
return a.api.configService.Config()
|
||||
}
|
||||
|
||||
//
|
||||
// Logger service.
|
||||
//
|
||||
func (a *serviceAPIAdapter) GetLogger() mlog.LoggerIFace {
|
||||
return a.api.logger
|
||||
}
|
||||
|
||||
//
|
||||
// KVStore service.
|
||||
//
|
||||
func (a *serviceAPIAdapter) KVSetWithOptions(key string, value []byte, options mm_model.PluginKVSetOptions) (bool, error) {
|
||||
b, appErr := a.api.kvStoreService.SetPluginKeyWithOptions(boardsProductID, key, value, options)
|
||||
return b, normalizeAppErr(appErr)
|
||||
}
|
||||
|
||||
//
|
||||
// Store service.
|
||||
//
|
||||
func (a *serviceAPIAdapter) GetMasterDB() (*sql.DB, error) {
|
||||
return a.api.storeService.GetMasterDB(), nil
|
||||
}
|
||||
|
||||
//
|
||||
// System service.
|
||||
//
|
||||
func (a *serviceAPIAdapter) GetDiagnosticID() string {
|
||||
return a.api.systemService.GetDiagnosticId()
|
||||
}
|
||||
|
||||
// Ensure the adapter implements ServicesAPI.
|
||||
var _ model.ServicesAPI = &serviceAPIAdapter{}
|
287
mattermost-plugin/product/boards_product.go
Normal file
287
mattermost-plugin/product/boards_product.go
Normal file
@ -0,0 +1,287 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package product
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/mattermost/focalboard/mattermost-plugin/server/boards"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/app"
|
||||
mm_model "github.com/mattermost/mattermost-server/v6/model"
|
||||
"github.com/mattermost/mattermost-server/v6/plugin"
|
||||
"github.com/mattermost/mattermost-server/v6/product"
|
||||
"github.com/mattermost/mattermost-server/v6/shared/mlog"
|
||||
)
|
||||
|
||||
const (
|
||||
boardsProductName = "boards"
|
||||
boardsProductID = "com.mattermost.boards"
|
||||
)
|
||||
|
||||
var errServiceTypeAssert = errors.New("type assertion failed")
|
||||
|
||||
func init() {
|
||||
app.RegisterProduct("boards", app.ProductManifest{
|
||||
Initializer: newBoardsProduct,
|
||||
Dependencies: map[app.ServiceKey]struct{}{
|
||||
app.TeamKey: {},
|
||||
app.ChannelKey: {},
|
||||
app.UserKey: {},
|
||||
app.PostKey: {},
|
||||
app.BotKey: {},
|
||||
app.ClusterKey: {},
|
||||
app.ConfigKey: {},
|
||||
app.LogKey: {},
|
||||
app.LicenseKey: {},
|
||||
app.FilestoreKey: {},
|
||||
app.FileInfoStoreKey: {},
|
||||
app.RouterKey: {},
|
||||
app.CloudKey: {},
|
||||
app.KVStoreKey: {},
|
||||
app.StoreKey: {},
|
||||
app.SystemKey: {},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
type boardsProduct struct {
|
||||
teamService product.TeamService
|
||||
channelService product.ChannelService
|
||||
userService product.UserService
|
||||
postService product.PostService
|
||||
permissionsService product.PermissionService
|
||||
botService product.BotService
|
||||
clusterService product.ClusterService
|
||||
configService product.ConfigService
|
||||
logger mlog.LoggerIFace
|
||||
licenseService product.LicenseService
|
||||
filestoreService product.FilestoreService
|
||||
fileInfoStoreService product.FileInfoStoreService
|
||||
routerService product.RouterService
|
||||
cloudService product.CloudService
|
||||
kvStoreService product.KVStoreService
|
||||
storeService product.StoreService
|
||||
systemService product.SystemService
|
||||
|
||||
boardsApp *boards.BoardsApp
|
||||
}
|
||||
|
||||
//nolint:gocyclo
|
||||
func newBoardsProduct(_ *app.Server, services map[app.ServiceKey]interface{}) (app.Product, error) {
|
||||
boards := &boardsProduct{}
|
||||
|
||||
for key, service := range services {
|
||||
switch key {
|
||||
case app.TeamKey:
|
||||
teamService, ok := service.(product.TeamService)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid service key '%s': %w", key, errServiceTypeAssert)
|
||||
}
|
||||
boards.teamService = teamService
|
||||
case app.ChannelKey:
|
||||
channelService, ok := service.(product.ChannelService)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid service key '%s': %w", key, errServiceTypeAssert)
|
||||
}
|
||||
boards.channelService = channelService
|
||||
case app.UserKey:
|
||||
userService, ok := service.(product.UserService)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid service key '%s': %w", key, errServiceTypeAssert)
|
||||
}
|
||||
boards.userService = userService
|
||||
case app.PostKey:
|
||||
postService, ok := service.(product.PostService)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid service key '%s': %w", key, errServiceTypeAssert)
|
||||
}
|
||||
boards.postService = postService
|
||||
case app.PermissionsKey:
|
||||
permissionsService, ok := service.(product.PermissionService)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid service key '%s': %w", key, errServiceTypeAssert)
|
||||
}
|
||||
boards.permissionsService = permissionsService
|
||||
case app.BotKey:
|
||||
botService, ok := service.(product.BotService)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid service key '%s': %w", key, errServiceTypeAssert)
|
||||
}
|
||||
boards.botService = botService
|
||||
case app.ClusterKey:
|
||||
clusterService, ok := service.(product.ClusterService)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid service key '%s': %w", key, errServiceTypeAssert)
|
||||
}
|
||||
boards.clusterService = clusterService
|
||||
case app.ConfigKey:
|
||||
configService, ok := service.(product.ConfigService)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid service key '%s': %w", key, errServiceTypeAssert)
|
||||
}
|
||||
boards.configService = configService
|
||||
case app.LogKey:
|
||||
logger, ok := service.(mlog.LoggerIFace)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid service key '%s': %w", key, errServiceTypeAssert)
|
||||
}
|
||||
boards.logger = logger.With(mlog.String("product", boardsProductName))
|
||||
case app.LicenseKey:
|
||||
licenseService, ok := service.(product.LicenseService)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid service key '%s': %w", key, errServiceTypeAssert)
|
||||
}
|
||||
boards.licenseService = licenseService
|
||||
case app.FilestoreKey:
|
||||
filestoreService, ok := service.(product.FilestoreService)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid service key '%s': %w", key, errServiceTypeAssert)
|
||||
}
|
||||
boards.filestoreService = filestoreService
|
||||
case app.FileInfoStoreKey:
|
||||
fileInfoStoreService, ok := service.(product.FileInfoStoreService)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid service key '%s': %w", key, errServiceTypeAssert)
|
||||
}
|
||||
boards.fileInfoStoreService = fileInfoStoreService
|
||||
case app.RouterKey:
|
||||
routerService, ok := service.(product.RouterService)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid service key '%s': %w", key, errServiceTypeAssert)
|
||||
}
|
||||
boards.routerService = routerService
|
||||
case app.CloudKey:
|
||||
cloudService, ok := service.(product.CloudService)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid service key '%s': %w", key, errServiceTypeAssert)
|
||||
}
|
||||
boards.cloudService = cloudService
|
||||
case app.KVStoreKey:
|
||||
kvStoreService, ok := service.(product.KVStoreService)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid service key '%s': %w", key, errServiceTypeAssert)
|
||||
}
|
||||
boards.kvStoreService = kvStoreService
|
||||
case app.StoreKey:
|
||||
storeService, ok := service.(product.StoreService)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid service key '%s': %w", key, errServiceTypeAssert)
|
||||
}
|
||||
boards.storeService = storeService
|
||||
case app.SystemKey:
|
||||
systemService, ok := service.(product.SystemService)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid service key '%s': %w", key, errServiceTypeAssert)
|
||||
}
|
||||
boards.systemService = systemService
|
||||
case app.HooksKey: // not needed
|
||||
}
|
||||
}
|
||||
return boards, nil
|
||||
}
|
||||
|
||||
func (bp *boardsProduct) Start() error {
|
||||
if !bp.configService.Config().FeatureFlags.BoardsProduct {
|
||||
bp.logger.Info("Boards product disabled via feature flag")
|
||||
return nil
|
||||
}
|
||||
|
||||
bp.logger.Info("Starting boards service")
|
||||
|
||||
adapter := newServiceAPIAdapter(bp)
|
||||
boardsApp, err := boards.NewBoardsApp(adapter)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create Boards service: %w", err)
|
||||
}
|
||||
|
||||
bp.boardsApp = boardsApp
|
||||
if err := bp.boardsApp.Start(); err != nil {
|
||||
return fmt.Errorf("failed to start Boards service: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (bp *boardsProduct) Stop() error {
|
||||
bp.logger.Info("Stopping boards service")
|
||||
|
||||
if bp.boardsApp == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := bp.boardsApp.Stop(); err != nil {
|
||||
return fmt.Errorf("error while stopping Boards service: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
//
|
||||
// These callbacks are called by the suite automatically
|
||||
//
|
||||
|
||||
func (bp *boardsProduct) OnConfigurationChange() error {
|
||||
if bp.boardsApp == nil {
|
||||
return nil
|
||||
}
|
||||
return bp.boardsApp.OnConfigurationChange()
|
||||
}
|
||||
|
||||
func (bp *boardsProduct) OnWebSocketConnect(webConnID, userID string) {
|
||||
if bp.boardsApp == nil {
|
||||
return
|
||||
}
|
||||
bp.boardsApp.OnWebSocketConnect(webConnID, userID)
|
||||
}
|
||||
|
||||
func (bp *boardsProduct) OnWebSocketDisconnect(webConnID, userID string) {
|
||||
if bp.boardsApp == nil {
|
||||
return
|
||||
}
|
||||
bp.boardsApp.OnWebSocketDisconnect(webConnID, userID)
|
||||
}
|
||||
|
||||
func (bp *boardsProduct) WebSocketMessageHasBeenPosted(webConnID, userID string, req *mm_model.WebSocketRequest) {
|
||||
if bp.boardsApp == nil {
|
||||
return
|
||||
}
|
||||
bp.boardsApp.WebSocketMessageHasBeenPosted(webConnID, userID, req)
|
||||
}
|
||||
|
||||
func (bp *boardsProduct) OnPluginClusterEvent(ctx *plugin.Context, ev mm_model.PluginClusterEvent) {
|
||||
if bp.boardsApp == nil {
|
||||
return
|
||||
}
|
||||
bp.boardsApp.OnPluginClusterEvent(ctx, ev)
|
||||
}
|
||||
|
||||
func (bp *boardsProduct) MessageWillBePosted(ctx *plugin.Context, post *mm_model.Post) (*mm_model.Post, string) {
|
||||
if bp.boardsApp == nil {
|
||||
return post, ""
|
||||
}
|
||||
return bp.boardsApp.MessageWillBePosted(ctx, post)
|
||||
}
|
||||
|
||||
func (bp *boardsProduct) MessageWillBeUpdated(ctx *plugin.Context, newPost, oldPost *mm_model.Post) (*mm_model.Post, string) {
|
||||
if bp.boardsApp == nil {
|
||||
return newPost, ""
|
||||
}
|
||||
return bp.boardsApp.MessageWillBeUpdated(ctx, newPost, oldPost)
|
||||
}
|
||||
|
||||
func (bp *boardsProduct) OnCloudLimitsUpdated(limits *mm_model.ProductLimits) {
|
||||
if bp.boardsApp == nil {
|
||||
return
|
||||
}
|
||||
bp.boardsApp.OnCloudLimitsUpdated(limits)
|
||||
}
|
||||
|
||||
func (bp *boardsProduct) RunDataRetention(nowTime, batchSize int64) (int64, error) {
|
||||
if bp.boardsApp == nil {
|
||||
return 0, nil
|
||||
}
|
||||
return bp.boardsApp.RunDataRetention(nowTime, batchSize)
|
||||
}
|
10
mattermost-plugin/product/imports/boards_imports.go
Normal file
10
mattermost-plugin/product/imports/boards_imports.go
Normal file
@ -0,0 +1,10 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package imports
|
||||
|
||||
import (
|
||||
// Needed to ensure the init() method in the FocalBoard product is run.
|
||||
// This file is copied to the mmserver imports package via makefile.
|
||||
_ "github.com/mattermost/focalboard/mattermost-plugin/product"
|
||||
)
|
BIN
mattermost-plugin/public/boards-screenshots.png
Normal file
BIN
mattermost-plugin/public/boards-screenshots.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 196 KiB |
212
mattermost-plugin/server/api_adapter.go
Normal file
212
mattermost-plugin/server/api_adapter.go
Normal file
@ -0,0 +1,212 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/mattermost/focalboard/server/model"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/plugin"
|
||||
|
||||
mm_model "github.com/mattermost/mattermost-server/v6/model"
|
||||
"github.com/mattermost/mattermost-server/v6/shared/mlog"
|
||||
)
|
||||
|
||||
type storeService interface {
|
||||
GetMasterDB() (*sql.DB, error)
|
||||
}
|
||||
|
||||
// normalizeAppError returns a truly nil error if appErr is nil
|
||||
// See https://golang.org/doc/faq#nil_error for more details.
|
||||
func normalizeAppErr(appErr *mm_model.AppError) error {
|
||||
if appErr == nil {
|
||||
return nil
|
||||
}
|
||||
return appErr
|
||||
}
|
||||
|
||||
// pluginAPIAdapter is an adapter that ensures all Plugin API methods have the same signature as the
|
||||
// services API.
|
||||
// Note: this will be removed when plugin builds are no longer needed.
|
||||
type pluginAPIAdapter struct {
|
||||
api plugin.API
|
||||
storeService storeService
|
||||
logger mlog.LoggerIFace
|
||||
}
|
||||
|
||||
func newServiceAPIAdapter(api plugin.API, storeService storeService, logger mlog.LoggerIFace) *pluginAPIAdapter {
|
||||
return &pluginAPIAdapter{
|
||||
api: api,
|
||||
storeService: storeService,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Channels service.
|
||||
//
|
||||
|
||||
func (a *pluginAPIAdapter) GetDirectChannel(userID1, userID2 string) (*mm_model.Channel, error) {
|
||||
channel, appErr := a.api.GetDirectChannel(userID1, userID2)
|
||||
return channel, normalizeAppErr(appErr)
|
||||
}
|
||||
|
||||
func (a *pluginAPIAdapter) GetChannelByID(channelID string) (*mm_model.Channel, error) {
|
||||
channel, appErr := a.api.GetChannel(channelID)
|
||||
return channel, normalizeAppErr(appErr)
|
||||
}
|
||||
|
||||
func (a *pluginAPIAdapter) GetChannelMember(channelID string, userID string) (*mm_model.ChannelMember, error) {
|
||||
member, appErr := a.api.GetChannelMember(channelID, userID)
|
||||
return member, normalizeAppErr(appErr)
|
||||
}
|
||||
|
||||
func (a *pluginAPIAdapter) GetChannelsForTeamForUser(teamID string, userID string, includeDeleted bool) (mm_model.ChannelList, error) {
|
||||
channels, appErr := a.api.GetChannelsForTeamForUser(teamID, userID, includeDeleted)
|
||||
return channels, normalizeAppErr(appErr)
|
||||
}
|
||||
|
||||
//
|
||||
// Post service.
|
||||
//
|
||||
|
||||
func (a *pluginAPIAdapter) CreatePost(post *mm_model.Post) (*mm_model.Post, error) {
|
||||
post, appErr := a.api.CreatePost(post)
|
||||
return post, normalizeAppErr(appErr)
|
||||
}
|
||||
|
||||
//
|
||||
// User service.
|
||||
//
|
||||
|
||||
func (a *pluginAPIAdapter) GetUserByID(userID string) (*mm_model.User, error) {
|
||||
user, appErr := a.api.GetUser(userID)
|
||||
return user, normalizeAppErr(appErr)
|
||||
}
|
||||
|
||||
func (a *pluginAPIAdapter) GetUserByUsername(name string) (*mm_model.User, error) {
|
||||
user, appErr := a.api.GetUserByUsername(name)
|
||||
return user, normalizeAppErr(appErr)
|
||||
}
|
||||
|
||||
func (a *pluginAPIAdapter) GetUserByEmail(email string) (*mm_model.User, error) {
|
||||
user, appErr := a.api.GetUserByEmail(email)
|
||||
return user, normalizeAppErr(appErr)
|
||||
}
|
||||
|
||||
func (a *pluginAPIAdapter) UpdateUser(user *mm_model.User) (*mm_model.User, error) {
|
||||
user, appErr := a.api.UpdateUser(user)
|
||||
return user, normalizeAppErr(appErr)
|
||||
}
|
||||
|
||||
func (a *pluginAPIAdapter) GetUsersFromProfiles(options *mm_model.UserGetOptions) ([]*mm_model.User, error) {
|
||||
users, appErr := a.api.GetUsers(options)
|
||||
return users, normalizeAppErr(appErr)
|
||||
}
|
||||
|
||||
//
|
||||
// Team service.
|
||||
//
|
||||
|
||||
func (a *pluginAPIAdapter) GetTeamMember(teamID string, userID string) (*mm_model.TeamMember, error) {
|
||||
member, appErr := a.api.GetTeamMember(teamID, userID)
|
||||
return member, normalizeAppErr(appErr)
|
||||
}
|
||||
|
||||
func (a *pluginAPIAdapter) CreateMember(teamID string, userID string) (*mm_model.TeamMember, error) {
|
||||
member, appErr := a.api.CreateTeamMember(teamID, userID)
|
||||
return member, normalizeAppErr(appErr)
|
||||
}
|
||||
|
||||
//
|
||||
// Permissions service.
|
||||
//
|
||||
|
||||
func (a *pluginAPIAdapter) HasPermissionToTeam(userID, teamID string, permission *mm_model.Permission) bool {
|
||||
return a.api.HasPermissionToTeam(userID, teamID, permission)
|
||||
}
|
||||
|
||||
func (a *pluginAPIAdapter) HasPermissionToChannel(askingUserID string, channelID string, permission *mm_model.Permission) bool {
|
||||
return a.api.HasPermissionToChannel(askingUserID, channelID, permission)
|
||||
}
|
||||
|
||||
//
|
||||
// Bot service.
|
||||
//
|
||||
func (a *pluginAPIAdapter) EnsureBot(bot *mm_model.Bot) (string, error) {
|
||||
return a.api.EnsureBotUser(bot)
|
||||
}
|
||||
|
||||
//
|
||||
// License service.
|
||||
//
|
||||
func (a *pluginAPIAdapter) GetLicense() *mm_model.License {
|
||||
return a.api.GetLicense()
|
||||
}
|
||||
|
||||
//
|
||||
// FileInfoStore service.
|
||||
//
|
||||
func (a *pluginAPIAdapter) GetFileInfo(fileID string) (*mm_model.FileInfo, error) {
|
||||
fi, appErr := a.api.GetFileInfo(fileID)
|
||||
return fi, normalizeAppErr(appErr)
|
||||
}
|
||||
|
||||
//
|
||||
// Cluster store.
|
||||
//
|
||||
func (a *pluginAPIAdapter) PublishWebSocketEvent(event string, payload map[string]interface{}, broadcast *mm_model.WebsocketBroadcast) {
|
||||
a.api.PublishWebSocketEvent(event, payload, broadcast)
|
||||
}
|
||||
|
||||
func (a *pluginAPIAdapter) PublishPluginClusterEvent(ev mm_model.PluginClusterEvent, opts mm_model.PluginClusterEventSendOptions) error {
|
||||
return a.api.PublishPluginClusterEvent(ev, opts)
|
||||
}
|
||||
|
||||
//
|
||||
// Cloud service.
|
||||
//
|
||||
func (a *pluginAPIAdapter) GetCloudLimits() (*mm_model.ProductLimits, error) {
|
||||
return a.api.GetCloudLimits()
|
||||
}
|
||||
|
||||
//
|
||||
// Config service.
|
||||
//
|
||||
func (a *pluginAPIAdapter) GetConfig() *mm_model.Config {
|
||||
return a.api.GetUnsanitizedConfig()
|
||||
}
|
||||
|
||||
//
|
||||
// Logger service.
|
||||
//
|
||||
func (a *pluginAPIAdapter) GetLogger() mlog.LoggerIFace {
|
||||
return a.logger
|
||||
}
|
||||
|
||||
//
|
||||
// KVStore service.
|
||||
//
|
||||
func (a *pluginAPIAdapter) KVSetWithOptions(key string, value []byte, options mm_model.PluginKVSetOptions) (bool, error) {
|
||||
b, appErr := a.api.KVSetWithOptions(key, value, options)
|
||||
return b, normalizeAppErr(appErr)
|
||||
}
|
||||
|
||||
//
|
||||
// Store service.
|
||||
//
|
||||
func (a *pluginAPIAdapter) GetMasterDB() (*sql.DB, error) {
|
||||
return a.storeService.GetMasterDB()
|
||||
}
|
||||
|
||||
//
|
||||
// System service.
|
||||
//
|
||||
func (a *pluginAPIAdapter) GetDiagnosticID() string {
|
||||
return a.api.GetDiagnosticId()
|
||||
}
|
||||
|
||||
// Ensure the adapter implements ServicesAPI.
|
||||
var _ model.ServicesAPI = &pluginAPIAdapter{}
|
218
mattermost-plugin/server/boards/boardsapp.go
Normal file
218
mattermost-plugin/server/boards/boardsapp.go
Normal file
@ -0,0 +1,218 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package boards
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
"github.com/mattermost/focalboard/server/auth"
|
||||
"github.com/mattermost/focalboard/server/model"
|
||||
"github.com/mattermost/focalboard/server/server"
|
||||
"github.com/mattermost/focalboard/server/services/notify"
|
||||
"github.com/mattermost/focalboard/server/services/permissions/mmpermissions"
|
||||
"github.com/mattermost/focalboard/server/services/store"
|
||||
"github.com/mattermost/focalboard/server/services/store/mattermostauthlayer"
|
||||
"github.com/mattermost/focalboard/server/services/store/sqlstore"
|
||||
"github.com/mattermost/focalboard/server/utils"
|
||||
"github.com/mattermost/focalboard/server/ws"
|
||||
|
||||
mm_model "github.com/mattermost/mattermost-server/v6/model"
|
||||
"github.com/mattermost/mattermost-server/v6/plugin"
|
||||
"github.com/mattermost/mattermost-server/v6/shared/mlog"
|
||||
|
||||
"github.com/mattermost/mattermost-plugin-api/cluster"
|
||||
)
|
||||
|
||||
const (
|
||||
boardsFeatureFlagName = "BoardsFeatureFlags"
|
||||
PluginName = "focalboard"
|
||||
SharedBoardsName = "enablepublicsharedboards"
|
||||
|
||||
notifyFreqCardSecondsKey = "notify_freq_card_seconds"
|
||||
notifyFreqBoardSecondsKey = "notify_freq_board_seconds"
|
||||
)
|
||||
|
||||
type BoardsEmbed struct {
|
||||
OriginalPath string `json:"originalPath"`
|
||||
TeamID string `json:"teamID"`
|
||||
ViewID string `json:"viewID"`
|
||||
BoardID string `json:"boardID"`
|
||||
CardID string `json:"cardID"`
|
||||
ReadToken string `json:"readToken,omitempty"`
|
||||
}
|
||||
|
||||
type BoardsApp struct {
|
||||
// configurationLock synchronizes access to the configuration.
|
||||
configurationLock sync.RWMutex
|
||||
|
||||
// configuration is the active plugin configuration. Consult getConfiguration and
|
||||
// setConfiguration for usage.
|
||||
configuration *configuration
|
||||
|
||||
server *server.Server
|
||||
wsPluginAdapter ws.PluginAdapterInterface
|
||||
|
||||
servicesAPI model.ServicesAPI
|
||||
logger mlog.LoggerIFace
|
||||
}
|
||||
|
||||
func NewBoardsApp(api model.ServicesAPI) (*BoardsApp, error) {
|
||||
mmconfig := api.GetConfig()
|
||||
logger := api.GetLogger()
|
||||
|
||||
baseURL := ""
|
||||
if mmconfig.ServiceSettings.SiteURL != nil {
|
||||
baseURL = *mmconfig.ServiceSettings.SiteURL
|
||||
}
|
||||
serverID := api.GetDiagnosticID()
|
||||
cfg := createBoardsConfig(*mmconfig, baseURL, serverID)
|
||||
sqlDB, err := api.GetMasterDB()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot access database while initializing Boards: %w", err)
|
||||
}
|
||||
|
||||
storeParams := sqlstore.Params{
|
||||
DBType: cfg.DBType,
|
||||
ConnectionString: cfg.DBConfigString,
|
||||
TablePrefix: cfg.DBTablePrefix,
|
||||
Logger: logger,
|
||||
DB: sqlDB,
|
||||
IsPlugin: true,
|
||||
NewMutexFn: func(name string) (*cluster.Mutex, error) {
|
||||
return cluster.NewMutex(&mutexAPIAdapter{api: api}, name)
|
||||
},
|
||||
ServicesAPI: api,
|
||||
}
|
||||
|
||||
var db store.Store
|
||||
db, err = sqlstore.New(storeParams)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error initializing the DB: %w", err)
|
||||
}
|
||||
if cfg.AuthMode == server.MattermostAuthMod {
|
||||
layeredStore, err2 := mattermostauthlayer.New(cfg.DBType, sqlDB, db, logger, api, storeParams.TablePrefix)
|
||||
if err2 != nil {
|
||||
return nil, fmt.Errorf("error initializing the DB: %w", err2)
|
||||
}
|
||||
db = layeredStore
|
||||
}
|
||||
|
||||
permissionsService := mmpermissions.New(db, api, logger)
|
||||
|
||||
wsPluginAdapter := ws.NewPluginAdapter(api, auth.New(cfg, db, permissionsService), db, logger)
|
||||
|
||||
backendParams := notifyBackendParams{
|
||||
cfg: cfg,
|
||||
servicesAPI: api,
|
||||
appAPI: &appAPI{store: db},
|
||||
permissions: permissionsService,
|
||||
serverRoot: baseURL + "/boards",
|
||||
logger: logger,
|
||||
}
|
||||
|
||||
var notifyBackends []notify.Backend
|
||||
|
||||
mentionsBackend, err := createMentionsNotifyBackend(backendParams)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error creating mention notifications backend: %w", err)
|
||||
}
|
||||
notifyBackends = append(notifyBackends, mentionsBackend)
|
||||
|
||||
subscriptionsBackend, err2 := createSubscriptionsNotifyBackend(backendParams)
|
||||
if err2 != nil {
|
||||
return nil, fmt.Errorf("error creating subscription notifications backend: %w", err2)
|
||||
}
|
||||
notifyBackends = append(notifyBackends, subscriptionsBackend)
|
||||
mentionsBackend.AddListener(subscriptionsBackend)
|
||||
|
||||
params := server.Params{
|
||||
Cfg: cfg,
|
||||
SingleUserToken: "",
|
||||
DBStore: db,
|
||||
Logger: logger,
|
||||
ServerID: serverID,
|
||||
WSAdapter: wsPluginAdapter,
|
||||
NotifyBackends: notifyBackends,
|
||||
PermissionsService: permissionsService,
|
||||
IsPlugin: true,
|
||||
}
|
||||
|
||||
server, err := server.New(params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error initializing the server: %w", err)
|
||||
}
|
||||
|
||||
backendParams.appAPI.init(db, server.App())
|
||||
|
||||
if utils.IsCloudLicense(api.GetLicense()) {
|
||||
limits, err := api.GetCloudLimits()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error fetching cloud limits when starting Boards: %w", err)
|
||||
}
|
||||
|
||||
if err := server.App().SetCloudLimits(limits); err != nil {
|
||||
return nil, fmt.Errorf("error setting cloud limits when starting Boards: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return &BoardsApp{
|
||||
server: server,
|
||||
wsPluginAdapter: wsPluginAdapter,
|
||||
servicesAPI: api,
|
||||
logger: logger,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (b *BoardsApp) Start() error {
|
||||
if err := b.server.Start(); err != nil {
|
||||
return fmt.Errorf("error starting Boards server: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *BoardsApp) Stop() error {
|
||||
return b.server.Shutdown()
|
||||
}
|
||||
|
||||
//
|
||||
// These callbacks are called automatically by the suite server.
|
||||
//
|
||||
|
||||
func (b *BoardsApp) MessageWillBePosted(_ *plugin.Context, post *mm_model.Post) (*mm_model.Post, string) {
|
||||
return postWithBoardsEmbed(post), ""
|
||||
}
|
||||
|
||||
func (b *BoardsApp) MessageWillBeUpdated(_ *plugin.Context, newPost, _ *mm_model.Post) (*mm_model.Post, string) {
|
||||
return postWithBoardsEmbed(newPost), ""
|
||||
}
|
||||
|
||||
func (b *BoardsApp) OnWebSocketConnect(webConnID, userID string) {
|
||||
b.wsPluginAdapter.OnWebSocketConnect(webConnID, userID)
|
||||
}
|
||||
|
||||
func (b *BoardsApp) OnWebSocketDisconnect(webConnID, userID string) {
|
||||
b.wsPluginAdapter.OnWebSocketDisconnect(webConnID, userID)
|
||||
}
|
||||
|
||||
func (b *BoardsApp) WebSocketMessageHasBeenPosted(webConnID, userID string, req *mm_model.WebSocketRequest) {
|
||||
b.wsPluginAdapter.WebSocketMessageHasBeenPosted(webConnID, userID, req)
|
||||
}
|
||||
|
||||
func (b *BoardsApp) OnPluginClusterEvent(_ *plugin.Context, ev mm_model.PluginClusterEvent) {
|
||||
b.wsPluginAdapter.HandleClusterEvent(ev)
|
||||
}
|
||||
|
||||
func (b *BoardsApp) OnCloudLimitsUpdated(limits *mm_model.ProductLimits) {
|
||||
if err := b.server.App().SetCloudLimits(limits); err != nil {
|
||||
b.logger.Error("Error setting the cloud limits for Boards", mlog.Err(err))
|
||||
}
|
||||
}
|
||||
|
||||
// ServeHTTP demonstrates a plugin that handles HTTP requests by greeting the world.
|
||||
func (b *BoardsApp) ServeHTTP(_ *plugin.Context, w http.ResponseWriter, r *http.Request) {
|
||||
router := b.server.GetRootRouter()
|
||||
router.ServeHTTP(w, r)
|
||||
}
|
121
mattermost-plugin/server/boards/boardsapp_test.go
Normal file
121
mattermost-plugin/server/boards/boardsapp_test.go
Normal file
@ -0,0 +1,121 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package boards
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/model"
|
||||
"github.com/mattermost/mattermost-server/v6/shared/mlog"
|
||||
)
|
||||
|
||||
func TestSetConfiguration(t *testing.T) {
|
||||
boolTrue := true
|
||||
stringRef := ""
|
||||
|
||||
baseFeatureFlags := &model.FeatureFlags{}
|
||||
basePluginSettings := &model.PluginSettings{
|
||||
Directory: &stringRef,
|
||||
}
|
||||
driverName := "testDriver"
|
||||
dataSource := "testDirectory"
|
||||
baseSQLSettings := &model.SqlSettings{
|
||||
DriverName: &driverName,
|
||||
DataSource: &dataSource,
|
||||
}
|
||||
|
||||
directory := "testDirectory"
|
||||
baseFileSettings := &model.FileSettings{
|
||||
DriverName: &driverName,
|
||||
Directory: &directory,
|
||||
MaxFileSize: model.NewInt64(1024 * 1024),
|
||||
}
|
||||
|
||||
days := 365
|
||||
baseDataRetentionSettings := &model.DataRetentionSettings{
|
||||
BoardsRetentionDays: &days,
|
||||
}
|
||||
usernameRef := "username"
|
||||
baseTeamSettings := &model.TeamSettings{
|
||||
TeammateNameDisplay: &usernameRef,
|
||||
}
|
||||
|
||||
baseConfig := &model.Config{
|
||||
FeatureFlags: baseFeatureFlags,
|
||||
PluginSettings: *basePluginSettings,
|
||||
SqlSettings: *baseSQLSettings,
|
||||
FileSettings: *baseFileSettings,
|
||||
DataRetentionSettings: *baseDataRetentionSettings,
|
||||
TeamSettings: *baseTeamSettings,
|
||||
}
|
||||
|
||||
t.Run("test enable telemetry", func(t *testing.T) {
|
||||
logSettings := &model.LogSettings{
|
||||
EnableDiagnostics: &boolTrue,
|
||||
}
|
||||
mmConfig := baseConfig
|
||||
mmConfig.LogSettings = *logSettings
|
||||
|
||||
config := createBoardsConfig(*mmConfig, "", "testId")
|
||||
assert.Equal(t, true, config.Telemetry)
|
||||
assert.Equal(t, "testId", config.TelemetryID)
|
||||
})
|
||||
|
||||
t.Run("test enable shared boards", func(t *testing.T) {
|
||||
mmConfig := baseConfig
|
||||
mmConfig.PluginSettings.Plugins = make(map[string]map[string]interface{})
|
||||
mmConfig.PluginSettings.Plugins[PluginName] = make(map[string]interface{})
|
||||
mmConfig.PluginSettings.Plugins[PluginName][SharedBoardsName] = true
|
||||
config := createBoardsConfig(*mmConfig, "", "")
|
||||
assert.Equal(t, true, config.EnablePublicSharedBoards)
|
||||
})
|
||||
|
||||
t.Run("test boards feature flags", func(t *testing.T) {
|
||||
featureFlags := &model.FeatureFlags{
|
||||
TestFeature: "test",
|
||||
TestBoolFeature: boolTrue,
|
||||
BoardsFeatureFlags: "hello_world-myTest",
|
||||
}
|
||||
|
||||
mmConfig := baseConfig
|
||||
mmConfig.FeatureFlags = featureFlags
|
||||
|
||||
config := createBoardsConfig(*mmConfig, "", "")
|
||||
assert.Equal(t, "true", config.FeatureFlags["TestBoolFeature"])
|
||||
assert.Equal(t, "test", config.FeatureFlags["TestFeature"])
|
||||
|
||||
assert.Equal(t, "true", config.FeatureFlags["hello_world"])
|
||||
assert.Equal(t, "true", config.FeatureFlags["myTest"])
|
||||
})
|
||||
}
|
||||
|
||||
func TestServeHTTP(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
b := &BoardsApp{
|
||||
server: th.Server,
|
||||
logger: mlog.CreateConsoleTestLogger(true, mlog.LvlError),
|
||||
}
|
||||
|
||||
assert := assert.New(t)
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(http.MethodGet, "/hello", nil)
|
||||
|
||||
b.ServeHTTP(nil, w, r)
|
||||
|
||||
result := w.Result()
|
||||
assert.NotNil(result)
|
||||
defer result.Body.Close()
|
||||
bodyBytes, err := ioutil.ReadAll(result.Body)
|
||||
assert.Nil(err)
|
||||
bodyString := string(bodyBytes)
|
||||
|
||||
assert.Equal("Hello", bodyString)
|
||||
}
|
136
mattermost-plugin/server/boards/boardsapp_util.go
Normal file
136
mattermost-plugin/server/boards/boardsapp_util.go
Normal file
@ -0,0 +1,136 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package boards
|
||||
|
||||
import (
|
||||
"math"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/mattermost/focalboard/server/services/config"
|
||||
|
||||
mm_model "github.com/mattermost/mattermost-server/v6/model"
|
||||
)
|
||||
|
||||
func createBoardsConfig(mmconfig mm_model.Config, baseURL string, serverID string) *config.Configuration {
|
||||
filesS3Config := config.AmazonS3Config{}
|
||||
if mmconfig.FileSettings.AmazonS3AccessKeyId != nil {
|
||||
filesS3Config.AccessKeyID = *mmconfig.FileSettings.AmazonS3AccessKeyId
|
||||
}
|
||||
if mmconfig.FileSettings.AmazonS3SecretAccessKey != nil {
|
||||
filesS3Config.SecretAccessKey = *mmconfig.FileSettings.AmazonS3SecretAccessKey
|
||||
}
|
||||
if mmconfig.FileSettings.AmazonS3Bucket != nil {
|
||||
filesS3Config.Bucket = *mmconfig.FileSettings.AmazonS3Bucket
|
||||
}
|
||||
if mmconfig.FileSettings.AmazonS3PathPrefix != nil {
|
||||
filesS3Config.PathPrefix = *mmconfig.FileSettings.AmazonS3PathPrefix
|
||||
}
|
||||
if mmconfig.FileSettings.AmazonS3Region != nil {
|
||||
filesS3Config.Region = *mmconfig.FileSettings.AmazonS3Region
|
||||
}
|
||||
if mmconfig.FileSettings.AmazonS3Endpoint != nil {
|
||||
filesS3Config.Endpoint = *mmconfig.FileSettings.AmazonS3Endpoint
|
||||
}
|
||||
if mmconfig.FileSettings.AmazonS3SSL != nil {
|
||||
filesS3Config.SSL = *mmconfig.FileSettings.AmazonS3SSL
|
||||
}
|
||||
if mmconfig.FileSettings.AmazonS3SignV2 != nil {
|
||||
filesS3Config.SignV2 = *mmconfig.FileSettings.AmazonS3SignV2
|
||||
}
|
||||
if mmconfig.FileSettings.AmazonS3SSE != nil {
|
||||
filesS3Config.SSE = *mmconfig.FileSettings.AmazonS3SSE
|
||||
}
|
||||
if mmconfig.FileSettings.AmazonS3Trace != nil {
|
||||
filesS3Config.Trace = *mmconfig.FileSettings.AmazonS3Trace
|
||||
}
|
||||
|
||||
enableTelemetry := false
|
||||
if mmconfig.LogSettings.EnableDiagnostics != nil {
|
||||
enableTelemetry = *mmconfig.LogSettings.EnableDiagnostics
|
||||
}
|
||||
|
||||
enablePublicSharedBoards := false
|
||||
if mmconfig.PluginSettings.Plugins[PluginName][SharedBoardsName] == true {
|
||||
enablePublicSharedBoards = true
|
||||
}
|
||||
|
||||
enableBoardsDeletion := false
|
||||
if mmconfig.DataRetentionSettings.EnableBoardsDeletion != nil {
|
||||
enableBoardsDeletion = true
|
||||
}
|
||||
|
||||
featureFlags := parseFeatureFlags(mmconfig.FeatureFlags.ToMap())
|
||||
|
||||
return &config.Configuration{
|
||||
ServerRoot: baseURL + "/plugins/focalboard",
|
||||
Port: -1,
|
||||
DBType: *mmconfig.SqlSettings.DriverName,
|
||||
DBConfigString: *mmconfig.SqlSettings.DataSource,
|
||||
DBTablePrefix: "focalboard_",
|
||||
UseSSL: false,
|
||||
SecureCookie: true,
|
||||
WebPath: path.Join(*mmconfig.PluginSettings.Directory, "focalboard", "pack"),
|
||||
FilesDriver: *mmconfig.FileSettings.DriverName,
|
||||
FilesPath: *mmconfig.FileSettings.Directory,
|
||||
FilesS3Config: filesS3Config,
|
||||
MaxFileSize: *mmconfig.FileSettings.MaxFileSize,
|
||||
Telemetry: enableTelemetry,
|
||||
TelemetryID: serverID,
|
||||
WebhookUpdate: []string{},
|
||||
SessionExpireTime: 2592000,
|
||||
SessionRefreshTime: 18000,
|
||||
LocalOnly: false,
|
||||
EnableLocalMode: false,
|
||||
LocalModeSocketLocation: "",
|
||||
AuthMode: "mattermost",
|
||||
EnablePublicSharedBoards: enablePublicSharedBoards,
|
||||
FeatureFlags: featureFlags,
|
||||
NotifyFreqCardSeconds: getPluginSettingInt(mmconfig, notifyFreqCardSecondsKey, 120),
|
||||
NotifyFreqBoardSeconds: getPluginSettingInt(mmconfig, notifyFreqBoardSecondsKey, 86400),
|
||||
EnableDataRetention: enableBoardsDeletion,
|
||||
DataRetentionDays: *mmconfig.DataRetentionSettings.BoardsRetentionDays,
|
||||
TeammateNameDisplay: *mmconfig.TeamSettings.TeammateNameDisplay,
|
||||
}
|
||||
}
|
||||
|
||||
func getPluginSetting(mmConfig mm_model.Config, key string) (interface{}, bool) {
|
||||
plugin, ok := mmConfig.PluginSettings.Plugins[PluginName]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
val, ok := plugin[key]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
return val, true
|
||||
}
|
||||
|
||||
func getPluginSettingInt(mmConfig mm_model.Config, key string, def int) int {
|
||||
val, ok := getPluginSetting(mmConfig, key)
|
||||
if !ok {
|
||||
return def
|
||||
}
|
||||
valFloat, ok := val.(float64)
|
||||
if !ok {
|
||||
return def
|
||||
}
|
||||
return int(math.Round(valFloat))
|
||||
}
|
||||
|
||||
func parseFeatureFlags(configFeatureFlags map[string]string) map[string]string {
|
||||
featureFlags := make(map[string]string)
|
||||
for key, value := range configFeatureFlags {
|
||||
// Break out FeatureFlags and pass remaining
|
||||
if key == boardsFeatureFlagName {
|
||||
for _, flag := range strings.Split(value, "-") {
|
||||
featureFlags[flag] = "true"
|
||||
}
|
||||
} else {
|
||||
featureFlags[key] = value
|
||||
}
|
||||
}
|
||||
return featureFlags
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
package main
|
||||
package boards
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
@ -29,15 +29,15 @@ func (c *configuration) Clone() *configuration {
|
||||
// getConfiguration retrieves the active configuration under lock, making it safe to use
|
||||
// concurrently. The active configuration may change underneath the client of this method, but
|
||||
// the struct returned by this API call is considered immutable.
|
||||
func (p *Plugin) getConfiguration() *configuration {
|
||||
p.configurationLock.RLock()
|
||||
defer p.configurationLock.RUnlock()
|
||||
func (b *BoardsApp) getConfiguration() *configuration {
|
||||
b.configurationLock.RLock()
|
||||
defer b.configurationLock.RUnlock()
|
||||
|
||||
if p.configuration == nil {
|
||||
if b.configuration == nil {
|
||||
return &configuration{}
|
||||
}
|
||||
|
||||
return p.configuration
|
||||
return b.configuration
|
||||
}
|
||||
|
||||
// setConfiguration replaces the active configuration under lock.
|
||||
@ -49,11 +49,11 @@ func (p *Plugin) getConfiguration() *configuration {
|
||||
// This method panics if setConfiguration is called with the existing configuration. This almost
|
||||
// certainly means that the configuration was modified without being cloned and may result in
|
||||
// an unsafe access.
|
||||
func (p *Plugin) setConfiguration(configuration *configuration) {
|
||||
p.configurationLock.Lock()
|
||||
defer p.configurationLock.Unlock()
|
||||
func (b *BoardsApp) setConfiguration(configuration *configuration) {
|
||||
b.configurationLock.Lock()
|
||||
defer b.configurationLock.Unlock()
|
||||
|
||||
if configuration != nil && p.configuration == configuration {
|
||||
if configuration != nil && b.configuration == configuration {
|
||||
// Ignore assignment if the configuration struct is empty. Go will optimize the
|
||||
// allocation for same to point at the same memory address, breaking the check
|
||||
// above.
|
||||
@ -64,36 +64,41 @@ func (p *Plugin) setConfiguration(configuration *configuration) {
|
||||
panic("setConfiguration called with the existing configuration")
|
||||
}
|
||||
|
||||
p.configuration = configuration
|
||||
b.configuration = configuration
|
||||
}
|
||||
|
||||
// OnConfigurationChange is invoked when configuration changes may have been made.
|
||||
func (p *Plugin) OnConfigurationChange() error { //nolint
|
||||
func (b *BoardsApp) OnConfigurationChange() error {
|
||||
// Have we been setup by OnActivate?
|
||||
if p.wsPluginAdapter == nil {
|
||||
if b.server == nil {
|
||||
return nil
|
||||
}
|
||||
mmconfig := p.API.GetConfig()
|
||||
mmconfig := b.servicesAPI.GetConfig()
|
||||
|
||||
// handle plugin configuration settings
|
||||
enableShareBoards := false
|
||||
if mmconfig.PluginSettings.Plugins[pluginName][sharedBoardsName] == true {
|
||||
if mmconfig.PluginSettings.Plugins[PluginName][SharedBoardsName] == true {
|
||||
enableShareBoards = true
|
||||
}
|
||||
configuration := &configuration{
|
||||
EnablePublicSharedBoards: enableShareBoards,
|
||||
}
|
||||
p.setConfiguration(configuration)
|
||||
p.server.Config().EnablePublicSharedBoards = enableShareBoards
|
||||
b.setConfiguration(configuration)
|
||||
b.server.Config().EnablePublicSharedBoards = enableShareBoards
|
||||
|
||||
// handle feature flags
|
||||
p.server.Config().FeatureFlags = parseFeatureFlags(mmconfig.FeatureFlags.ToMap())
|
||||
b.server.Config().FeatureFlags = parseFeatureFlags(mmconfig.FeatureFlags.ToMap())
|
||||
|
||||
// handle Data Retention settings
|
||||
p.server.Config().EnableDataRetention = *mmconfig.DataRetentionSettings.EnableBoardsDeletion
|
||||
p.server.Config().DataRetentionDays = *mmconfig.DataRetentionSettings.BoardsRetentionDays
|
||||
enableBoardsDeletion := false
|
||||
if mmconfig.DataRetentionSettings.EnableBoardsDeletion != nil {
|
||||
enableBoardsDeletion = true
|
||||
}
|
||||
b.server.Config().EnableDataRetention = enableBoardsDeletion
|
||||
b.server.Config().DataRetentionDays = *mmconfig.DataRetentionSettings.BoardsRetentionDays
|
||||
b.server.Config().TeammateNameDisplay = *mmconfig.TeamSettings.TeammateNameDisplay
|
||||
|
||||
p.server.UpdateAppConfig()
|
||||
p.wsPluginAdapter.BroadcastConfigChange(*p.server.App().GetClientConfig())
|
||||
b.server.UpdateAppConfig()
|
||||
b.wsPluginAdapter.BroadcastConfigChange(*b.server.App().GetClientConfig())
|
||||
return nil
|
||||
}
|
117
mattermost-plugin/server/boards/configuration_test.go
Normal file
117
mattermost-plugin/server/boards/configuration_test.go
Normal file
@ -0,0 +1,117 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package boards
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"github.com/mattermost/focalboard/server/integrationtests"
|
||||
"github.com/mattermost/focalboard/server/model"
|
||||
"github.com/mattermost/focalboard/server/server"
|
||||
"github.com/mattermost/focalboard/server/ws"
|
||||
|
||||
mockservicesapi "github.com/mattermost/focalboard/server/model/mocks"
|
||||
|
||||
serverModel "github.com/mattermost/mattermost-server/v6/model"
|
||||
"github.com/mattermost/mattermost-server/v6/shared/mlog"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type TestHelper struct {
|
||||
Server *server.Server
|
||||
}
|
||||
|
||||
func SetupTestHelper(t *testing.T) (*TestHelper, func()) {
|
||||
th := &TestHelper{}
|
||||
th.Server = newTestServer()
|
||||
|
||||
err := th.Server.Start()
|
||||
require.NoError(t, err, "Server start should not error")
|
||||
|
||||
tearDown := func() {
|
||||
err := th.Server.Shutdown()
|
||||
require.NoError(t, err, "Server shutdown should not error")
|
||||
}
|
||||
return th, tearDown
|
||||
}
|
||||
|
||||
func newTestServer() *server.Server {
|
||||
return integrationtests.NewTestServerPluginMode()
|
||||
}
|
||||
func TestConfigurationNullConfiguration(t *testing.T) {
|
||||
boardsApp := &BoardsApp{}
|
||||
assert.NotNil(t, boardsApp.getConfiguration())
|
||||
}
|
||||
|
||||
func TestOnConfigurationChange(t *testing.T) {
|
||||
stringRef := ""
|
||||
|
||||
basePlugins := make(map[string]map[string]interface{})
|
||||
basePlugins[PluginName] = make(map[string]interface{})
|
||||
basePlugins[PluginName][SharedBoardsName] = true
|
||||
|
||||
baseFeatureFlags := &serverModel.FeatureFlags{
|
||||
BoardsFeatureFlags: "Feature1-Feature2",
|
||||
}
|
||||
basePluginSettings := &serverModel.PluginSettings{
|
||||
Directory: &stringRef,
|
||||
Plugins: basePlugins,
|
||||
}
|
||||
intRef := 365
|
||||
baseDataRetentionSettings := &serverModel.DataRetentionSettings{
|
||||
BoardsRetentionDays: &intRef,
|
||||
}
|
||||
usernameRef := "username"
|
||||
baseTeamSettings := &serverModel.TeamSettings{
|
||||
TeammateNameDisplay: &usernameRef,
|
||||
}
|
||||
|
||||
baseConfig := &serverModel.Config{
|
||||
FeatureFlags: baseFeatureFlags,
|
||||
PluginSettings: *basePluginSettings,
|
||||
DataRetentionSettings: *baseDataRetentionSettings,
|
||||
TeamSettings: *baseTeamSettings,
|
||||
}
|
||||
|
||||
t.Run("Test Load Plugin Success", func(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
ctrl := gomock.NewController(t)
|
||||
api := mockservicesapi.NewMockServicesAPI(ctrl)
|
||||
api.EXPECT().GetConfig().Return(baseConfig)
|
||||
|
||||
b := &BoardsApp{
|
||||
server: th.Server,
|
||||
wsPluginAdapter: &FakePluginAdapter{},
|
||||
servicesAPI: api,
|
||||
logger: mlog.CreateConsoleTestLogger(true, mlog.LvlError),
|
||||
}
|
||||
|
||||
err := b.OnConfigurationChange()
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 1, count)
|
||||
|
||||
// make sure both App and Server got updated
|
||||
assert.True(t, b.server.Config().EnablePublicSharedBoards)
|
||||
assert.True(t, b.server.App().GetClientConfig().EnablePublicSharedBoards)
|
||||
|
||||
assert.Equal(t, "true", b.server.Config().FeatureFlags["Feature1"])
|
||||
assert.Equal(t, "true", b.server.Config().FeatureFlags["Feature2"])
|
||||
assert.Equal(t, "", b.server.Config().FeatureFlags["Feature3"])
|
||||
})
|
||||
}
|
||||
|
||||
var count = 0
|
||||
|
||||
type FakePluginAdapter struct {
|
||||
ws.PluginAdapter
|
||||
}
|
||||
|
||||
func (c *FakePluginAdapter) BroadcastConfigChange(clientConfig model.ClientConfig) {
|
||||
count++
|
||||
}
|
31
mattermost-plugin/server/boards/data_retention.go
Normal file
31
mattermost-plugin/server/boards/data_retention.go
Normal file
@ -0,0 +1,31 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
package boards
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
var ErrInsufficientLicense = errors.New("appropriate license required")
|
||||
|
||||
func (b *BoardsApp) RunDataRetention(nowTime, batchSize int64) (int64, error) {
|
||||
b.logger.Debug("Boards RunDataRetention")
|
||||
license := b.server.Store().GetLicense()
|
||||
if license == nil || !(*license.Features.DataRetention) {
|
||||
return 0, ErrInsufficientLicense
|
||||
}
|
||||
|
||||
if b.server.Config().EnableDataRetention {
|
||||
boardsRetentionDays := b.server.Config().DataRetentionDays
|
||||
endTimeBoards := convertDaysToCutoff(boardsRetentionDays, time.Unix(nowTime/1000, 0))
|
||||
return b.server.Store().RunDataRetention(endTimeBoards, batchSize)
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func convertDaysToCutoff(days int, now time.Time) int64 {
|
||||
upToStartOfDay := now.AddDate(0, 0, -days)
|
||||
cutoffDate := time.Date(upToStartOfDay.Year(), upToStartOfDay.Month(), upToStartOfDay.Day(), 0, 0, 0, 0, time.Local)
|
||||
return cutoffDate.UnixNano() / int64(time.Millisecond)
|
||||
}
|
142
mattermost-plugin/server/boards/data_retention_test.go
Normal file
142
mattermost-plugin/server/boards/data_retention_test.go
Normal file
@ -0,0 +1,142 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package boards
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"github.com/mattermost/focalboard/server/server"
|
||||
"github.com/mattermost/focalboard/server/services/config"
|
||||
"github.com/mattermost/focalboard/server/services/permissions/localpermissions"
|
||||
"github.com/mattermost/focalboard/server/services/store/mockstore"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/model"
|
||||
"github.com/mattermost/mattermost-server/v6/shared/mlog"
|
||||
)
|
||||
|
||||
type TestHelperMockStore struct {
|
||||
Server *server.Server
|
||||
Store *mockstore.MockStore
|
||||
}
|
||||
|
||||
func SetupTestHelperMockStore(t *testing.T) (*TestHelperMockStore, func()) {
|
||||
th := &TestHelperMockStore{}
|
||||
|
||||
origUnitTesting := os.Getenv("FOCALBOARD_UNIT_TESTING")
|
||||
os.Setenv("FOCALBOARD_UNIT_TESTING", "1")
|
||||
|
||||
ctrl := gomock.NewController(t)
|
||||
mockStore := mockstore.NewMockStore(ctrl)
|
||||
|
||||
tearDown := func() {
|
||||
defer ctrl.Finish()
|
||||
os.Setenv("FOCALBOARD_UNIT_TESTING", origUnitTesting)
|
||||
}
|
||||
|
||||
th.Server = newTestServerMock(mockStore)
|
||||
th.Store = mockStore
|
||||
|
||||
return th, tearDown
|
||||
}
|
||||
|
||||
func newTestServerMock(mockStore *mockstore.MockStore) *server.Server {
|
||||
config := &config.Configuration{
|
||||
EnableDataRetention: false,
|
||||
DataRetentionDays: 10,
|
||||
FilesDriver: "local",
|
||||
FilesPath: "./files",
|
||||
WebPath: "/",
|
||||
}
|
||||
|
||||
logger := mlog.CreateConsoleTestLogger(true, mlog.LvlDebug)
|
||||
|
||||
mockStore.EXPECT().GetTeam(gomock.Any()).Return(nil, nil).AnyTimes()
|
||||
mockStore.EXPECT().UpsertTeamSignupToken(gomock.Any()).AnyTimes()
|
||||
mockStore.EXPECT().GetSystemSettings().AnyTimes()
|
||||
mockStore.EXPECT().SetSystemSetting(gomock.Any(), gomock.Any()).AnyTimes()
|
||||
|
||||
permissionsService := localpermissions.New(mockStore, logger)
|
||||
|
||||
srv, err := server.New(server.Params{
|
||||
Cfg: config,
|
||||
DBStore: mockStore,
|
||||
Logger: logger,
|
||||
PermissionsService: permissionsService,
|
||||
})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return srv
|
||||
}
|
||||
|
||||
func TestRunDataRetention(t *testing.T) {
|
||||
th, tearDown := SetupTestHelperMockStore(t)
|
||||
defer tearDown()
|
||||
|
||||
b := &BoardsApp{
|
||||
server: th.Server,
|
||||
logger: mlog.CreateConsoleTestLogger(true, mlog.LvlError),
|
||||
}
|
||||
|
||||
now := time.Now().UnixNano()
|
||||
|
||||
t.Run("test null license", func(t *testing.T) {
|
||||
th.Store.EXPECT().GetLicense().Return(nil)
|
||||
_, err := b.RunDataRetention(now, 10)
|
||||
assert.NotNil(t, err)
|
||||
assert.Equal(t, ErrInsufficientLicense, err)
|
||||
})
|
||||
|
||||
t.Run("test invalid license", func(t *testing.T) {
|
||||
falseValue := false
|
||||
|
||||
th.Store.EXPECT().GetLicense().Return(
|
||||
&model.License{
|
||||
Features: &model.Features{
|
||||
DataRetention: &falseValue,
|
||||
},
|
||||
},
|
||||
)
|
||||
_, err := b.RunDataRetention(now, 10)
|
||||
assert.NotNil(t, err)
|
||||
assert.Equal(t, ErrInsufficientLicense, err)
|
||||
})
|
||||
|
||||
t.Run("test valid license, invalid config", func(t *testing.T) {
|
||||
trueValue := true
|
||||
th.Store.EXPECT().GetLicense().Return(
|
||||
&model.License{
|
||||
Features: &model.Features{
|
||||
DataRetention: &trueValue,
|
||||
},
|
||||
})
|
||||
|
||||
count, err := b.RunDataRetention(now, 10)
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, int64(0), count)
|
||||
})
|
||||
|
||||
t.Run("test valid license, valid config", func(t *testing.T) {
|
||||
trueValue := true
|
||||
th.Store.EXPECT().GetLicense().Return(
|
||||
&model.License{
|
||||
Features: &model.Features{
|
||||
DataRetention: &trueValue,
|
||||
},
|
||||
})
|
||||
|
||||
th.Store.EXPECT().RunDataRetention(gomock.Any(), int64(10)).Return(int64(100), nil)
|
||||
b.server.Config().EnableDataRetention = true
|
||||
|
||||
count, err := b.RunDataRetention(now, 10)
|
||||
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, int64(100), count)
|
||||
})
|
||||
}
|
34
mattermost-plugin/server/boards/mutex_adapter.go
Normal file
34
mattermost-plugin/server/boards/mutex_adapter.go
Normal file
@ -0,0 +1,34 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package boards
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
mm_model "github.com/mattermost/mattermost-server/v6/model"
|
||||
"github.com/mattermost/mattermost-server/v6/shared/mlog"
|
||||
|
||||
"github.com/mattermost/focalboard/server/model"
|
||||
)
|
||||
|
||||
type mutexAPIAdapter struct {
|
||||
api model.ServicesAPI
|
||||
}
|
||||
|
||||
func (m *mutexAPIAdapter) KVSetWithOptions(key string, value []byte, options mm_model.PluginKVSetOptions) (bool, *mm_model.AppError) {
|
||||
b, err := m.api.KVSetWithOptions(key, value, options)
|
||||
|
||||
var appErr *mm_model.AppError
|
||||
if err != nil {
|
||||
if !errors.As(err, &appErr) {
|
||||
appErr = mm_model.NewAppError("KVSetWithOptions", "", nil, "", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
return b, appErr
|
||||
}
|
||||
|
||||
func (m *mutexAPIAdapter) LogError(msg string, keyValuePairs ...interface{}) {
|
||||
m.api.GetLogger().Error(msg, mlog.Array("kvpairs", keyValuePairs))
|
||||
}
|
147
mattermost-plugin/server/boards/notifications.go
Normal file
147
mattermost-plugin/server/boards/notifications.go
Normal file
@ -0,0 +1,147 @@
|
||||
package boards
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/mattermost/focalboard/server/model"
|
||||
"github.com/mattermost/focalboard/server/services/config"
|
||||
"github.com/mattermost/focalboard/server/services/notify/notifymentions"
|
||||
"github.com/mattermost/focalboard/server/services/notify/notifysubscriptions"
|
||||
"github.com/mattermost/focalboard/server/services/notify/plugindelivery"
|
||||
"github.com/mattermost/focalboard/server/services/permissions"
|
||||
"github.com/mattermost/focalboard/server/services/store"
|
||||
|
||||
mm_model "github.com/mattermost/mattermost-server/v6/model"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/shared/mlog"
|
||||
)
|
||||
|
||||
const (
|
||||
botUsername = "boards"
|
||||
botDisplayname = "Boards"
|
||||
botDescription = "Created by Boards plugin."
|
||||
)
|
||||
|
||||
type notifyBackendParams struct {
|
||||
cfg *config.Configuration
|
||||
servicesAPI model.ServicesAPI
|
||||
permissions permissions.PermissionsService
|
||||
appAPI *appAPI
|
||||
serverRoot string
|
||||
logger mlog.LoggerIFace
|
||||
}
|
||||
|
||||
func createMentionsNotifyBackend(params notifyBackendParams) (*notifymentions.Backend, error) {
|
||||
delivery, err := createDelivery(params.servicesAPI, params.serverRoot)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
backendParams := notifymentions.BackendParams{
|
||||
AppAPI: params.appAPI,
|
||||
Permissions: params.permissions,
|
||||
Delivery: delivery,
|
||||
Logger: params.logger,
|
||||
}
|
||||
|
||||
backend := notifymentions.New(backendParams)
|
||||
|
||||
return backend, nil
|
||||
}
|
||||
|
||||
func createSubscriptionsNotifyBackend(params notifyBackendParams) (*notifysubscriptions.Backend, error) {
|
||||
delivery, err := createDelivery(params.servicesAPI, params.serverRoot)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
backendParams := notifysubscriptions.BackendParams{
|
||||
ServerRoot: params.serverRoot,
|
||||
AppAPI: params.appAPI,
|
||||
Permissions: params.permissions,
|
||||
Delivery: delivery,
|
||||
Logger: params.logger,
|
||||
NotifyFreqCardSeconds: params.cfg.NotifyFreqCardSeconds,
|
||||
NotifyFreqBoardSeconds: params.cfg.NotifyFreqBoardSeconds,
|
||||
}
|
||||
backend := notifysubscriptions.New(backendParams)
|
||||
|
||||
return backend, nil
|
||||
}
|
||||
|
||||
func createDelivery(servicesAPI model.ServicesAPI, serverRoot string) (*plugindelivery.PluginDelivery, error) {
|
||||
bot := &mm_model.Bot{
|
||||
Username: botUsername,
|
||||
DisplayName: botDisplayname,
|
||||
Description: botDescription,
|
||||
}
|
||||
botID, err := servicesAPI.EnsureBot(bot)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to ensure %s bot: %w", botDisplayname, err)
|
||||
}
|
||||
|
||||
return plugindelivery.New(botID, serverRoot, servicesAPI), nil
|
||||
}
|
||||
|
||||
type appIface interface {
|
||||
CreateSubscription(sub *model.Subscription) (*model.Subscription, error)
|
||||
AddMemberToBoard(member *model.BoardMember) (*model.BoardMember, error)
|
||||
}
|
||||
|
||||
// appAPI provides app and store APIs for notification services. Where appropriate calls are made to the
|
||||
// app layer to leverage the additional websocket notification logic present there, and other times the
|
||||
// store APIs are called directly.
|
||||
type appAPI struct {
|
||||
store store.Store
|
||||
app appIface
|
||||
}
|
||||
|
||||
func (a *appAPI) init(store store.Store, app appIface) {
|
||||
a.store = store
|
||||
a.app = app
|
||||
}
|
||||
|
||||
func (a *appAPI) GetBlockHistory(blockID string, opts model.QueryBlockHistoryOptions) ([]model.Block, error) {
|
||||
return a.store.GetBlockHistory(blockID, opts)
|
||||
}
|
||||
|
||||
func (a *appAPI) GetSubTree2(boardID, blockID string, opts model.QuerySubtreeOptions) ([]model.Block, error) {
|
||||
return a.store.GetSubTree2(boardID, blockID, opts)
|
||||
}
|
||||
|
||||
func (a *appAPI) GetBoardAndCardByID(blockID string) (board *model.Board, card *model.Block, err error) {
|
||||
return a.store.GetBoardAndCardByID(blockID)
|
||||
}
|
||||
|
||||
func (a *appAPI) GetUserByID(userID string) (*model.User, error) {
|
||||
return a.store.GetUserByID(userID)
|
||||
}
|
||||
|
||||
func (a *appAPI) CreateSubscription(sub *model.Subscription) (*model.Subscription, error) {
|
||||
return a.app.CreateSubscription(sub)
|
||||
}
|
||||
|
||||
func (a *appAPI) GetSubscribersForBlock(blockID string) ([]*model.Subscriber, error) {
|
||||
return a.store.GetSubscribersForBlock(blockID)
|
||||
}
|
||||
|
||||
func (a *appAPI) UpdateSubscribersNotifiedAt(blockID string, notifyAt int64) error {
|
||||
return a.store.UpdateSubscribersNotifiedAt(blockID, notifyAt)
|
||||
}
|
||||
|
||||
func (a *appAPI) UpsertNotificationHint(hint *model.NotificationHint, notificationFreq time.Duration) (*model.NotificationHint, error) {
|
||||
return a.store.UpsertNotificationHint(hint, notificationFreq)
|
||||
}
|
||||
|
||||
func (a *appAPI) GetNextNotificationHint(remove bool) (*model.NotificationHint, error) {
|
||||
return a.store.GetNextNotificationHint(remove)
|
||||
}
|
||||
|
||||
func (a *appAPI) GetMemberForBoard(boardID, userID string) (*model.BoardMember, error) {
|
||||
return a.store.GetMemberForBoard(boardID, userID)
|
||||
}
|
||||
|
||||
func (a *appAPI) AddMemberToBoard(member *model.BoardMember) (*model.BoardMember, error) {
|
||||
return a.app.AddMemberToBoard(member)
|
||||
}
|
163
mattermost-plugin/server/boards/post.go
Normal file
163
mattermost-plugin/server/boards/post.go
Normal file
@ -0,0 +1,163 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package boards
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
mm_model "github.com/mattermost/mattermost-server/v6/model"
|
||||
"github.com/mattermost/mattermost-server/v6/shared/markdown"
|
||||
)
|
||||
|
||||
func postWithBoardsEmbed(post *mm_model.Post) *mm_model.Post {
|
||||
if _, ok := post.GetProps()["boards"]; ok {
|
||||
post.AddProp("boards", nil)
|
||||
}
|
||||
|
||||
firstLink, newPostMessage := getFirstLinkAndShortenAllBoardsLink(post.Message)
|
||||
post.Message = newPostMessage
|
||||
|
||||
if firstLink == "" {
|
||||
return post
|
||||
}
|
||||
|
||||
u, err := url.Parse(firstLink)
|
||||
|
||||
if err != nil {
|
||||
return post
|
||||
}
|
||||
|
||||
// Trim away the first / because otherwise after we split the string, the first element in the array is a empty element
|
||||
urlPath := u.Path
|
||||
urlPath = strings.TrimPrefix(urlPath, "/")
|
||||
urlPath = strings.TrimSuffix(urlPath, "/")
|
||||
pathSplit := strings.Split(strings.ToLower(urlPath), "/")
|
||||
queryParams := u.Query()
|
||||
|
||||
if len(pathSplit) == 0 {
|
||||
return post
|
||||
}
|
||||
|
||||
teamID, boardID, viewID, cardID := returnBoardsParams(pathSplit)
|
||||
|
||||
if teamID != "" && boardID != "" && viewID != "" && cardID != "" {
|
||||
b, _ := json.Marshal(BoardsEmbed{
|
||||
TeamID: teamID,
|
||||
BoardID: boardID,
|
||||
ViewID: viewID,
|
||||
CardID: cardID,
|
||||
ReadToken: queryParams.Get("r"),
|
||||
OriginalPath: u.RequestURI(),
|
||||
})
|
||||
|
||||
BoardsPostEmbed := &mm_model.PostEmbed{
|
||||
Type: mm_model.PostEmbedBoards,
|
||||
Data: string(b),
|
||||
}
|
||||
|
||||
if post.Metadata == nil {
|
||||
post.Metadata = &mm_model.PostMetadata{}
|
||||
}
|
||||
|
||||
post.Metadata.Embeds = []*mm_model.PostEmbed{BoardsPostEmbed}
|
||||
post.AddProp("boards", string(b))
|
||||
}
|
||||
|
||||
return post
|
||||
}
|
||||
|
||||
func getFirstLinkAndShortenAllBoardsLink(postMessage string) (firstLink, newPostMessage string) {
|
||||
newPostMessage = postMessage
|
||||
seenLinks := make(map[string]bool)
|
||||
markdown.Inspect(postMessage, func(blockOrInline interface{}) bool {
|
||||
if autoLink, ok := blockOrInline.(*markdown.Autolink); ok {
|
||||
link := autoLink.Destination()
|
||||
|
||||
if firstLink == "" {
|
||||
firstLink = link
|
||||
}
|
||||
|
||||
if seen := seenLinks[link]; !seen && isBoardsLink(link) {
|
||||
// TODO: Make sure that <Jump To Card> is Internationalized and translated to the Users Language preference
|
||||
markdownFormattedLink := fmt.Sprintf("[%s](%s)", "<Jump To Card>", link)
|
||||
newPostMessage = strings.ReplaceAll(newPostMessage, link, markdownFormattedLink)
|
||||
seenLinks[link] = true
|
||||
}
|
||||
}
|
||||
if inlineLink, ok := blockOrInline.(*markdown.InlineLink); ok {
|
||||
if link := inlineLink.Destination(); firstLink == "" {
|
||||
firstLink = link
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
return firstLink, newPostMessage
|
||||
}
|
||||
|
||||
func returnBoardsParams(pathArray []string) (teamID, boardID, viewID, cardID string) {
|
||||
// The reason we are doing this search for the first instance of boards or plugins is to take into account URL subpaths
|
||||
index := -1
|
||||
for i := 0; i < len(pathArray); i++ {
|
||||
if pathArray[i] == "boards" || pathArray[i] == "plugins" {
|
||||
index = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if index == -1 {
|
||||
return teamID, boardID, viewID, cardID
|
||||
}
|
||||
|
||||
// If at index, the parameter in the path is boards,
|
||||
// then we've copied this directly as logged in user of that board
|
||||
|
||||
// If at index, the parameter in the path is plugins,
|
||||
// then we've copied this from a shared board
|
||||
|
||||
// For card links copied on a non-shared board, the path looks like {...Mattermost Url}.../boards/team/teamID/boardID/viewID/cardID
|
||||
|
||||
// For card links copied on a shared board, the path looks like
|
||||
// {...Mattermost Url}.../plugins/focalboard/team/teamID/shared/boardID/viewID/cardID?r=read_token
|
||||
|
||||
// This is a non-shared board card link
|
||||
if len(pathArray)-index == 6 && pathArray[index] == "boards" && pathArray[index+1] == "team" {
|
||||
teamID = pathArray[index+2]
|
||||
boardID = pathArray[index+3]
|
||||
viewID = pathArray[index+4]
|
||||
cardID = pathArray[index+5]
|
||||
} else if len(pathArray)-index == 8 && pathArray[index] == "plugins" &&
|
||||
pathArray[index+1] == "focalboard" &&
|
||||
pathArray[index+2] == "team" &&
|
||||
pathArray[index+4] == "shared" { // This is a shared board card link
|
||||
teamID = pathArray[index+3]
|
||||
boardID = pathArray[index+5]
|
||||
viewID = pathArray[index+6]
|
||||
cardID = pathArray[index+7]
|
||||
}
|
||||
return teamID, boardID, viewID, cardID
|
||||
}
|
||||
|
||||
func isBoardsLink(link string) bool {
|
||||
u, err := url.Parse(link)
|
||||
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
urlPath := u.Path
|
||||
urlPath = strings.TrimPrefix(urlPath, "/")
|
||||
urlPath = strings.TrimSuffix(urlPath, "/")
|
||||
pathSplit := strings.Split(strings.ToLower(urlPath), "/")
|
||||
|
||||
if len(pathSplit) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
teamID, boardID, viewID, cardID := returnBoardsParams(pathSplit)
|
||||
return teamID != "" && boardID != "" && viewID != "" && cardID != ""
|
||||
}
|
@ -1,82 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mattermost/focalboard/server/model"
|
||||
"github.com/mattermost/focalboard/server/ws"
|
||||
serverModel "github.com/mattermost/mattermost-server/v6/model"
|
||||
"github.com/mattermost/mattermost-server/v6/plugin/plugintest"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestConfigurationNullConfiguration(t *testing.T) {
|
||||
plugin := &Plugin{}
|
||||
assert.NotNil(t, plugin.getConfiguration())
|
||||
}
|
||||
|
||||
func TestOnConfigurationChange(t *testing.T) {
|
||||
stringRef := ""
|
||||
|
||||
basePlugins := make(map[string]map[string]interface{})
|
||||
basePlugins[pluginName] = make(map[string]interface{})
|
||||
basePlugins[pluginName][sharedBoardsName] = true
|
||||
|
||||
baseFeatureFlags := &serverModel.FeatureFlags{
|
||||
BoardsFeatureFlags: "Feature1-Feature2",
|
||||
}
|
||||
basePluginSettings := &serverModel.PluginSettings{
|
||||
Directory: &stringRef,
|
||||
Plugins: basePlugins,
|
||||
}
|
||||
falseRef := false
|
||||
intRef := 365
|
||||
baseDataRetentionSettings := &serverModel.DataRetentionSettings{
|
||||
EnableBoardsDeletion: &falseRef,
|
||||
BoardsRetentionDays: &intRef,
|
||||
}
|
||||
|
||||
baseConfig := &serverModel.Config{
|
||||
FeatureFlags: baseFeatureFlags,
|
||||
PluginSettings: *basePluginSettings,
|
||||
DataRetentionSettings: *baseDataRetentionSettings,
|
||||
}
|
||||
|
||||
t.Run("Test Load Plugin Success", func(t *testing.T) {
|
||||
th := SetupTestHelper(t)
|
||||
api := &plugintest.API{}
|
||||
api.On("GetUnsanitizedConfig").Return(baseConfig)
|
||||
api.On("GetConfig").Return(baseConfig)
|
||||
|
||||
p := Plugin{}
|
||||
p.SetAPI(api)
|
||||
p.server = th.Server
|
||||
p.wsPluginAdapter = &FakePluginAdapter{}
|
||||
|
||||
err := p.OnConfigurationChange()
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 1, count)
|
||||
|
||||
// make sure both App and Server got updated
|
||||
assert.True(t, p.server.Config().EnablePublicSharedBoards)
|
||||
assert.True(t, p.server.App().GetClientConfig().EnablePublicSharedBoards)
|
||||
|
||||
assert.Equal(t, "true", p.server.Config().FeatureFlags["Feature1"])
|
||||
assert.Equal(t, "true", p.server.Config().FeatureFlags["Feature2"])
|
||||
assert.Equal(t, "", p.server.Config().FeatureFlags["Feature3"])
|
||||
})
|
||||
}
|
||||
|
||||
var count = 0
|
||||
|
||||
type FakePluginAdapter struct {
|
||||
ws.PluginAdapter
|
||||
}
|
||||
|
||||
func (c *FakePluginAdapter) BroadcastConfigChange(clientConfig model.ClientConfig) {
|
||||
count++
|
||||
}
|
@ -1,68 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"github.com/mattermost/focalboard/server/server"
|
||||
"github.com/mattermost/focalboard/server/services/config"
|
||||
"github.com/mattermost/focalboard/server/services/permissions/localpermissions"
|
||||
"github.com/mattermost/focalboard/server/services/store/mockstore"
|
||||
"github.com/mattermost/mattermost-server/v6/shared/mlog"
|
||||
)
|
||||
|
||||
type TestHelper struct {
|
||||
Server *server.Server
|
||||
Store *mockstore.MockStore
|
||||
}
|
||||
|
||||
func SetupTestHelper(t *testing.T) *TestHelper {
|
||||
th := &TestHelper{}
|
||||
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
mockStore := mockstore.NewMockStore(ctrl)
|
||||
|
||||
th.Server = newTestServer(t, mockStore)
|
||||
th.Store = mockStore
|
||||
|
||||
return th
|
||||
}
|
||||
|
||||
func newTestServer(t *testing.T, mockStore *mockstore.MockStore) *server.Server {
|
||||
config := &config.Configuration{
|
||||
EnableDataRetention: false,
|
||||
DataRetentionDays: 10,
|
||||
FilesDriver: "local",
|
||||
FilesPath: "./files",
|
||||
WebPath: "/",
|
||||
}
|
||||
|
||||
logger, err := mlog.NewLogger()
|
||||
if err = logger.Configure("", config.LoggingCfgJSON, nil); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
mockStore.EXPECT().GetTeam(gomock.Any()).Return(nil, nil).AnyTimes()
|
||||
mockStore.EXPECT().UpsertTeamSignupToken(gomock.Any()).AnyTimes()
|
||||
mockStore.EXPECT().GetSystemSettings().AnyTimes()
|
||||
mockStore.EXPECT().SetSystemSetting(gomock.Any(), gomock.Any()).AnyTimes()
|
||||
|
||||
permissionsService := localpermissions.New(mockStore, logger)
|
||||
|
||||
srv, err := server.New(server.Params{
|
||||
Cfg: config,
|
||||
DBStore: mockStore,
|
||||
Logger: logger,
|
||||
PermissionsService: permissionsService,
|
||||
SkipTemplateInit: true,
|
||||
})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return srv
|
||||
}
|
4
mattermost-plugin/server/manifest.go
generated
4
mattermost-plugin/server/manifest.go
generated
@ -20,8 +20,8 @@ const manifestStr = `
|
||||
"support_url": "https://github.com/mattermost/focalboard/issues",
|
||||
"release_notes_url": "https://github.com/mattermost/focalboard/releases",
|
||||
"icon_path": "assets/starter-template-icon.svg",
|
||||
"version": "0.16.0",
|
||||
"min_server_version": "6.0.0",
|
||||
"version": "7.3.0",
|
||||
"min_server_version": "7.0.0",
|
||||
"server": {
|
||||
"executables": {
|
||||
"darwin-amd64": "server/dist/plugin-darwin-amd64",
|
||||
|
@ -1,129 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/mattermost/focalboard/server/services/config"
|
||||
"github.com/mattermost/focalboard/server/services/notify/notifymentions"
|
||||
"github.com/mattermost/focalboard/server/services/notify/notifysubscriptions"
|
||||
"github.com/mattermost/focalboard/server/services/notify/plugindelivery"
|
||||
"github.com/mattermost/focalboard/server/services/permissions"
|
||||
"github.com/mattermost/focalboard/server/services/store"
|
||||
"github.com/mattermost/focalboard/server/ws"
|
||||
|
||||
pluginapi "github.com/mattermost/mattermost-plugin-api"
|
||||
apierrors "github.com/mattermost/mattermost-plugin-api/errors"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/model"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/shared/mlog"
|
||||
)
|
||||
|
||||
const (
|
||||
botUsername = "boards"
|
||||
botDisplayname = "Boards"
|
||||
botDescription = "Created by Boards plugin."
|
||||
)
|
||||
|
||||
type notifyBackendParams struct {
|
||||
cfg *config.Configuration
|
||||
client *pluginapi.Client
|
||||
permissions permissions.PermissionsService
|
||||
store store.Store
|
||||
wsAdapter ws.Adapter
|
||||
serverRoot string
|
||||
logger *mlog.Logger
|
||||
}
|
||||
|
||||
func createMentionsNotifyBackend(params notifyBackendParams) (*notifymentions.Backend, error) {
|
||||
delivery, err := createDelivery(params.client, params.serverRoot)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
backendParams := notifymentions.BackendParams{
|
||||
Store: params.store,
|
||||
Permissions: params.permissions,
|
||||
Delivery: delivery,
|
||||
WSAdapter: params.wsAdapter,
|
||||
Logger: params.logger,
|
||||
}
|
||||
|
||||
backend := notifymentions.New(backendParams)
|
||||
|
||||
return backend, nil
|
||||
}
|
||||
|
||||
func createSubscriptionsNotifyBackend(params notifyBackendParams) (*notifysubscriptions.Backend, error) {
|
||||
delivery, err := createDelivery(params.client, params.serverRoot)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
backendParams := notifysubscriptions.BackendParams{
|
||||
ServerRoot: params.serverRoot,
|
||||
Store: params.store,
|
||||
Permissions: params.permissions,
|
||||
Delivery: delivery,
|
||||
WSAdapter: params.wsAdapter,
|
||||
Logger: params.logger,
|
||||
NotifyFreqCardSeconds: params.cfg.NotifyFreqCardSeconds,
|
||||
NotifyFreqBoardSeconds: params.cfg.NotifyFreqBoardSeconds,
|
||||
}
|
||||
backend := notifysubscriptions.New(backendParams)
|
||||
|
||||
return backend, nil
|
||||
}
|
||||
|
||||
func createDelivery(client *pluginapi.Client, serverRoot string) (*plugindelivery.PluginDelivery, error) {
|
||||
bot := &model.Bot{
|
||||
Username: botUsername,
|
||||
DisplayName: botDisplayname,
|
||||
Description: botDescription,
|
||||
}
|
||||
botID, err := client.Bot.EnsureBot(bot)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to ensure %s bot: %w", botDisplayname, err)
|
||||
}
|
||||
|
||||
pluginAPI := &pluginAPIAdapter{client: client}
|
||||
|
||||
return plugindelivery.New(botID, serverRoot, pluginAPI), nil
|
||||
}
|
||||
|
||||
type pluginAPIAdapter struct {
|
||||
client *pluginapi.Client
|
||||
}
|
||||
|
||||
func (da *pluginAPIAdapter) GetDirectChannel(userID1, userID2 string) (*model.Channel, error) {
|
||||
return da.client.Channel.GetDirect(userID1, userID2)
|
||||
}
|
||||
|
||||
func (da *pluginAPIAdapter) CreatePost(post *model.Post) error {
|
||||
return da.client.Post.CreatePost(post)
|
||||
}
|
||||
|
||||
func (da *pluginAPIAdapter) GetUserByID(userID string) (*model.User, error) {
|
||||
return da.client.User.Get(userID)
|
||||
}
|
||||
|
||||
func (da *pluginAPIAdapter) GetUserByUsername(name string) (*model.User, error) {
|
||||
return da.client.User.GetByUsername(name)
|
||||
}
|
||||
|
||||
func (da *pluginAPIAdapter) GetTeamMember(teamID string, userID string) (*model.TeamMember, error) {
|
||||
return da.client.Team.GetMember(teamID, userID)
|
||||
}
|
||||
|
||||
func (da *pluginAPIAdapter) GetChannelByID(channelID string) (*model.Channel, error) {
|
||||
return da.client.Channel.Get(channelID)
|
||||
}
|
||||
|
||||
func (da *pluginAPIAdapter) GetChannelMember(channelID string, userID string) (*model.ChannelMember, error) {
|
||||
return da.client.Channel.GetMember(channelID, userID)
|
||||
}
|
||||
|
||||
func (da *pluginAPIAdapter) IsErrNotFound(err error) bool {
|
||||
return errors.Is(err, apierrors.ErrNotFound)
|
||||
}
|
@ -1,79 +1,38 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/mattermost/focalboard/server/auth"
|
||||
"github.com/mattermost/focalboard/server/server"
|
||||
"github.com/mattermost/focalboard/server/services/config"
|
||||
"github.com/mattermost/focalboard/server/services/notify"
|
||||
"github.com/mattermost/focalboard/server/services/permissions/mmpermissions"
|
||||
"github.com/mattermost/focalboard/server/services/store"
|
||||
"github.com/mattermost/focalboard/server/services/store/mattermostauthlayer"
|
||||
"github.com/mattermost/focalboard/server/services/store/sqlstore"
|
||||
"github.com/mattermost/focalboard/server/ws"
|
||||
"github.com/mattermost/focalboard/mattermost-plugin/server/boards"
|
||||
|
||||
pluginapi "github.com/mattermost/mattermost-plugin-api"
|
||||
"github.com/mattermost/mattermost-plugin-api/cluster"
|
||||
|
||||
mmModel "github.com/mattermost/mattermost-server/v6/model"
|
||||
mm_model "github.com/mattermost/mattermost-server/v6/model"
|
||||
"github.com/mattermost/mattermost-server/v6/plugin"
|
||||
"github.com/mattermost/mattermost-server/v6/shared/markdown"
|
||||
"github.com/mattermost/mattermost-server/v6/shared/mlog"
|
||||
)
|
||||
|
||||
const (
|
||||
boardsFeatureFlagName = "BoardsFeatureFlags"
|
||||
pluginName = "focalboard"
|
||||
sharedBoardsName = "enablepublicsharedboards"
|
||||
|
||||
notifyFreqCardSecondsKey = "notify_freq_card_seconds"
|
||||
notifyFreqBoardSecondsKey = "notify_freq_board_seconds"
|
||||
)
|
||||
|
||||
var ErrInsufficientLicense = errors.New("appropriate license required")
|
||||
|
||||
type BoardsEmbed struct {
|
||||
OriginalPath string `json:"originalPath"`
|
||||
TeamID string `json:"teamID"`
|
||||
ViewID string `json:"viewID"`
|
||||
BoardID string `json:"boardID"`
|
||||
CardID string `json:"cardID"`
|
||||
ReadToken string `json:"readToken,omitempty"`
|
||||
}
|
||||
var ErrPluginNotAllowed = errors.New("boards plugin not allowed while Boards product enabled")
|
||||
|
||||
// Plugin implements the interface expected by the Mattermost server to communicate between the server and plugin processes.
|
||||
type Plugin struct {
|
||||
plugin.MattermostPlugin
|
||||
|
||||
// configurationLock synchronizes access to the configuration.
|
||||
configurationLock sync.RWMutex
|
||||
|
||||
// configuration is the active plugin configuration. Consult getConfiguration and
|
||||
// setConfiguration for usage.
|
||||
configuration *configuration
|
||||
|
||||
server *server.Server
|
||||
wsPluginAdapter ws.PluginAdapterInterface
|
||||
boardsApp *boards.BoardsApp
|
||||
}
|
||||
|
||||
func (p *Plugin) OnActivate() error {
|
||||
mmconfig := p.API.GetUnsanitizedConfig()
|
||||
if p.API.GetConfig().FeatureFlags.BoardsProduct {
|
||||
p.API.LogError(ErrPluginNotAllowed.Error())
|
||||
return ErrPluginNotAllowed
|
||||
}
|
||||
|
||||
client := pluginapi.NewClient(p.API, p.Driver)
|
||||
sqlDB, err := client.Store.GetMasterDB()
|
||||
if err != nil {
|
||||
return fmt.Errorf("error initializing the DB: %w", err)
|
||||
}
|
||||
|
||||
logger, _ := mlog.NewLogger()
|
||||
pluginTargetFactory := newPluginTargetFactory(&client.Log)
|
||||
@ -81,234 +40,71 @@ func (p *Plugin) OnActivate() error {
|
||||
TargetFactory: pluginTargetFactory.createTarget,
|
||||
}
|
||||
cfgJSON := defaultLoggingConfig()
|
||||
err = logger.Configure("", cfgJSON, factories)
|
||||
err := logger.Configure("", cfgJSON, factories)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
baseURL := ""
|
||||
if mmconfig.ServiceSettings.SiteURL != nil {
|
||||
baseURL = *mmconfig.ServiceSettings.SiteURL
|
||||
}
|
||||
serverID := client.System.GetDiagnosticID()
|
||||
cfg := p.createBoardsConfig(*mmconfig, baseURL, serverID)
|
||||
adapter := newServiceAPIAdapter(p.API, client.Store, logger)
|
||||
|
||||
storeParams := sqlstore.Params{
|
||||
DBType: cfg.DBType,
|
||||
ConnectionString: cfg.DBConfigString,
|
||||
TablePrefix: cfg.DBTablePrefix,
|
||||
Logger: logger,
|
||||
DB: sqlDB,
|
||||
IsPlugin: true,
|
||||
NewMutexFn: func(name string) (*cluster.Mutex, error) {
|
||||
return cluster.NewMutex(p.API, name)
|
||||
},
|
||||
PluginAPI: &p.API,
|
||||
}
|
||||
|
||||
var db store.Store
|
||||
db, err = sqlstore.New(storeParams)
|
||||
boardsApp, err := boards.NewBoardsApp(adapter)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error initializing the DB: %w", err)
|
||||
}
|
||||
if cfg.AuthMode == server.MattermostAuthMod {
|
||||
layeredStore, err2 := mattermostauthlayer.New(cfg.DBType, sqlDB, db, logger, p.API)
|
||||
if err2 != nil {
|
||||
return fmt.Errorf("error initializing the DB: %w", err2)
|
||||
}
|
||||
db = layeredStore
|
||||
return fmt.Errorf("cannot activate plugin: %w", err)
|
||||
}
|
||||
|
||||
permissionsService := mmpermissions.New(db, p.API)
|
||||
|
||||
p.wsPluginAdapter = ws.NewPluginAdapter(p.API, auth.New(cfg, db, permissionsService), db, logger)
|
||||
|
||||
backendParams := notifyBackendParams{
|
||||
cfg: cfg,
|
||||
client: client,
|
||||
store: db,
|
||||
permissions: permissionsService,
|
||||
wsAdapter: p.wsPluginAdapter,
|
||||
serverRoot: baseURL + "/boards",
|
||||
logger: logger,
|
||||
}
|
||||
|
||||
var notifyBackends []notify.Backend
|
||||
|
||||
mentionsBackend, err := createMentionsNotifyBackend(backendParams)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error creating mention notifications backend: %w", err)
|
||||
}
|
||||
notifyBackends = append(notifyBackends, mentionsBackend)
|
||||
|
||||
subscriptionsBackend, err2 := createSubscriptionsNotifyBackend(backendParams)
|
||||
if err2 != nil {
|
||||
return fmt.Errorf("error creating subscription notifications backend: %w", err2)
|
||||
}
|
||||
notifyBackends = append(notifyBackends, subscriptionsBackend)
|
||||
mentionsBackend.AddListener(subscriptionsBackend)
|
||||
|
||||
params := server.Params{
|
||||
Cfg: cfg,
|
||||
SingleUserToken: "",
|
||||
DBStore: db,
|
||||
Logger: logger,
|
||||
ServerID: serverID,
|
||||
WSAdapter: p.wsPluginAdapter,
|
||||
NotifyBackends: notifyBackends,
|
||||
PermissionsService: permissionsService,
|
||||
}
|
||||
|
||||
server, err := server.New(params)
|
||||
if err != nil {
|
||||
fmt.Println("ERROR INITIALIZING THE SERVER", err)
|
||||
return err
|
||||
}
|
||||
|
||||
p.server = server
|
||||
return server.Start()
|
||||
p.boardsApp = boardsApp
|
||||
return p.boardsApp.Start()
|
||||
}
|
||||
|
||||
func (p *Plugin) createBoardsConfig(mmconfig mmModel.Config, baseURL string, serverID string) *config.Configuration {
|
||||
filesS3Config := config.AmazonS3Config{}
|
||||
if mmconfig.FileSettings.AmazonS3AccessKeyId != nil {
|
||||
filesS3Config.AccessKeyID = *mmconfig.FileSettings.AmazonS3AccessKeyId
|
||||
}
|
||||
if mmconfig.FileSettings.AmazonS3SecretAccessKey != nil {
|
||||
filesS3Config.SecretAccessKey = *mmconfig.FileSettings.AmazonS3SecretAccessKey
|
||||
}
|
||||
if mmconfig.FileSettings.AmazonS3Bucket != nil {
|
||||
filesS3Config.Bucket = *mmconfig.FileSettings.AmazonS3Bucket
|
||||
}
|
||||
if mmconfig.FileSettings.AmazonS3PathPrefix != nil {
|
||||
filesS3Config.PathPrefix = *mmconfig.FileSettings.AmazonS3PathPrefix
|
||||
}
|
||||
if mmconfig.FileSettings.AmazonS3Region != nil {
|
||||
filesS3Config.Region = *mmconfig.FileSettings.AmazonS3Region
|
||||
}
|
||||
if mmconfig.FileSettings.AmazonS3Endpoint != nil {
|
||||
filesS3Config.Endpoint = *mmconfig.FileSettings.AmazonS3Endpoint
|
||||
}
|
||||
if mmconfig.FileSettings.AmazonS3SSL != nil {
|
||||
filesS3Config.SSL = *mmconfig.FileSettings.AmazonS3SSL
|
||||
}
|
||||
if mmconfig.FileSettings.AmazonS3SignV2 != nil {
|
||||
filesS3Config.SignV2 = *mmconfig.FileSettings.AmazonS3SignV2
|
||||
}
|
||||
if mmconfig.FileSettings.AmazonS3SSE != nil {
|
||||
filesS3Config.SSE = *mmconfig.FileSettings.AmazonS3SSE
|
||||
}
|
||||
if mmconfig.FileSettings.AmazonS3Trace != nil {
|
||||
filesS3Config.Trace = *mmconfig.FileSettings.AmazonS3Trace
|
||||
// OnConfigurationChange is invoked when configuration changes may have been made.
|
||||
func (p *Plugin) OnConfigurationChange() error {
|
||||
// Have we been setup by OnActivate?
|
||||
if p.boardsApp == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
enableTelemetry := false
|
||||
if mmconfig.LogSettings.EnableDiagnostics != nil {
|
||||
enableTelemetry = *mmconfig.LogSettings.EnableDiagnostics
|
||||
}
|
||||
|
||||
enablePublicSharedBoards := false
|
||||
if mmconfig.PluginSettings.Plugins[pluginName][sharedBoardsName] == true {
|
||||
enablePublicSharedBoards = true
|
||||
}
|
||||
|
||||
featureFlags := parseFeatureFlags(mmconfig.FeatureFlags.ToMap())
|
||||
|
||||
return &config.Configuration{
|
||||
ServerRoot: baseURL + "/plugins/focalboard",
|
||||
Port: -1,
|
||||
DBType: *mmconfig.SqlSettings.DriverName,
|
||||
DBConfigString: *mmconfig.SqlSettings.DataSource,
|
||||
DBTablePrefix: "focalboard_",
|
||||
UseSSL: false,
|
||||
SecureCookie: true,
|
||||
WebPath: path.Join(*mmconfig.PluginSettings.Directory, "focalboard", "pack"),
|
||||
FilesDriver: *mmconfig.FileSettings.DriverName,
|
||||
FilesPath: *mmconfig.FileSettings.Directory,
|
||||
FilesS3Config: filesS3Config,
|
||||
MaxFileSize: *mmconfig.FileSettings.MaxFileSize,
|
||||
Telemetry: enableTelemetry,
|
||||
TelemetryID: serverID,
|
||||
WebhookUpdate: []string{},
|
||||
SessionExpireTime: 2592000,
|
||||
SessionRefreshTime: 18000,
|
||||
LocalOnly: false,
|
||||
EnableLocalMode: false,
|
||||
LocalModeSocketLocation: "",
|
||||
AuthMode: "mattermost",
|
||||
EnablePublicSharedBoards: enablePublicSharedBoards,
|
||||
FeatureFlags: featureFlags,
|
||||
NotifyFreqCardSeconds: getPluginSettingInt(mmconfig, notifyFreqCardSecondsKey, 120),
|
||||
NotifyFreqBoardSeconds: getPluginSettingInt(mmconfig, notifyFreqBoardSecondsKey, 86400),
|
||||
EnableDataRetention: *mmconfig.DataRetentionSettings.EnableBoardsDeletion,
|
||||
DataRetentionDays: *mmconfig.DataRetentionSettings.BoardsRetentionDays,
|
||||
}
|
||||
}
|
||||
|
||||
func getPluginSetting(mmConfig mmModel.Config, key string) (interface{}, bool) {
|
||||
plugin, ok := mmConfig.PluginSettings.Plugins[pluginName]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
val, ok := plugin[key]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
return val, true
|
||||
}
|
||||
|
||||
func getPluginSettingInt(mmConfig mmModel.Config, key string, def int) int {
|
||||
val, ok := getPluginSetting(mmConfig, key)
|
||||
if !ok {
|
||||
return def
|
||||
}
|
||||
valFloat, ok := val.(float64)
|
||||
if !ok {
|
||||
return def
|
||||
}
|
||||
return int(math.Round(valFloat))
|
||||
}
|
||||
|
||||
func parseFeatureFlags(configFeatureFlags map[string]string) map[string]string {
|
||||
featureFlags := make(map[string]string)
|
||||
for key, value := range configFeatureFlags {
|
||||
// Break out FeatureFlags and pass remaining
|
||||
if key == boardsFeatureFlagName {
|
||||
for _, flag := range strings.Split(value, "-") {
|
||||
featureFlags[flag] = "true"
|
||||
}
|
||||
} else {
|
||||
featureFlags[key] = value
|
||||
}
|
||||
}
|
||||
return featureFlags
|
||||
return p.boardsApp.OnConfigurationChange()
|
||||
}
|
||||
|
||||
func (p *Plugin) OnWebSocketConnect(webConnID, userID string) {
|
||||
p.wsPluginAdapter.OnWebSocketConnect(webConnID, userID)
|
||||
p.boardsApp.OnWebSocketConnect(webConnID, userID)
|
||||
}
|
||||
|
||||
func (p *Plugin) OnWebSocketDisconnect(webConnID, userID string) {
|
||||
p.wsPluginAdapter.OnWebSocketDisconnect(webConnID, userID)
|
||||
p.boardsApp.OnWebSocketDisconnect(webConnID, userID)
|
||||
}
|
||||
|
||||
func (p *Plugin) WebSocketMessageHasBeenPosted(webConnID, userID string, req *mmModel.WebSocketRequest) {
|
||||
p.wsPluginAdapter.WebSocketMessageHasBeenPosted(webConnID, userID, req)
|
||||
func (p *Plugin) WebSocketMessageHasBeenPosted(webConnID, userID string, req *mm_model.WebSocketRequest) {
|
||||
p.boardsApp.WebSocketMessageHasBeenPosted(webConnID, userID, req)
|
||||
}
|
||||
|
||||
func (p *Plugin) OnDeactivate() error {
|
||||
return p.server.Shutdown()
|
||||
return p.boardsApp.Stop()
|
||||
}
|
||||
|
||||
func (p *Plugin) OnPluginClusterEvent(_ *plugin.Context, ev mmModel.PluginClusterEvent) {
|
||||
p.wsPluginAdapter.HandleClusterEvent(ev)
|
||||
func (p *Plugin) OnPluginClusterEvent(ctx *plugin.Context, ev mm_model.PluginClusterEvent) {
|
||||
p.boardsApp.OnPluginClusterEvent(ctx, ev)
|
||||
}
|
||||
|
||||
func (p *Plugin) MessageWillBePosted(ctx *plugin.Context, post *mm_model.Post) (*mm_model.Post, string) {
|
||||
return p.boardsApp.MessageWillBePosted(ctx, post)
|
||||
}
|
||||
|
||||
func (p *Plugin) MessageWillBeUpdated(ctx *plugin.Context, newPost, oldPost *mm_model.Post) (*mm_model.Post, string) {
|
||||
return p.boardsApp.MessageWillBeUpdated(ctx, newPost, oldPost)
|
||||
}
|
||||
|
||||
func (p *Plugin) OnCloudLimitsUpdated(limits *mm_model.ProductLimits) {
|
||||
p.boardsApp.OnCloudLimitsUpdated(limits)
|
||||
}
|
||||
|
||||
func (p *Plugin) RunDataRetention(nowTime, batchSize int64) (int64, error) {
|
||||
return p.boardsApp.RunDataRetention(nowTime, batchSize)
|
||||
}
|
||||
|
||||
// ServeHTTP demonstrates a plugin that handles HTTP requests by greeting the world.
|
||||
func (p *Plugin) ServeHTTP(_ *plugin.Context, w http.ResponseWriter, r *http.Request) {
|
||||
router := p.server.GetRootRouter()
|
||||
router.ServeHTTP(w, r)
|
||||
func (p *Plugin) ServeHTTP(ctx *plugin.Context, w http.ResponseWriter, r *http.Request) {
|
||||
p.boardsApp.ServeHTTP(ctx, w, r)
|
||||
}
|
||||
|
||||
func defaultLoggingConfig() string {
|
||||
@ -351,181 +147,3 @@ func defaultLoggingConfig() string {
|
||||
}
|
||||
}`
|
||||
}
|
||||
|
||||
func (p *Plugin) MessageWillBePosted(_ *plugin.Context, post *mmModel.Post) (*mmModel.Post, string) {
|
||||
return postWithBoardsEmbed(post), ""
|
||||
}
|
||||
|
||||
func (p *Plugin) MessageWillBeUpdated(_ *plugin.Context, newPost, _ *mmModel.Post) (*mmModel.Post, string) {
|
||||
return postWithBoardsEmbed(newPost), ""
|
||||
}
|
||||
|
||||
func postWithBoardsEmbed(post *mmModel.Post) *mmModel.Post {
|
||||
if _, ok := post.GetProps()["boards"]; ok {
|
||||
post.AddProp("boards", nil)
|
||||
}
|
||||
|
||||
firstLink, newPostMessage := getFirstLinkAndShortenAllBoardsLink(post.Message)
|
||||
post.Message = newPostMessage
|
||||
|
||||
if firstLink == "" {
|
||||
return post
|
||||
}
|
||||
|
||||
u, err := url.Parse(firstLink)
|
||||
|
||||
if err != nil {
|
||||
return post
|
||||
}
|
||||
|
||||
// Trim away the first / because otherwise after we split the string, the first element in the array is a empty element
|
||||
urlPath := u.Path
|
||||
urlPath = strings.TrimPrefix(urlPath, "/")
|
||||
urlPath = strings.TrimSuffix(urlPath, "/")
|
||||
pathSplit := strings.Split(strings.ToLower(urlPath), "/")
|
||||
queryParams := u.Query()
|
||||
|
||||
if len(pathSplit) == 0 {
|
||||
return post
|
||||
}
|
||||
|
||||
teamID, boardID, viewID, cardID := returnBoardsParams(pathSplit)
|
||||
|
||||
if teamID != "" && boardID != "" && viewID != "" && cardID != "" {
|
||||
b, _ := json.Marshal(BoardsEmbed{
|
||||
TeamID: teamID,
|
||||
BoardID: boardID,
|
||||
ViewID: viewID,
|
||||
CardID: cardID,
|
||||
ReadToken: queryParams.Get("r"),
|
||||
OriginalPath: u.RequestURI(),
|
||||
})
|
||||
|
||||
BoardsPostEmbed := &mmModel.PostEmbed{
|
||||
Type: mmModel.PostEmbedBoards,
|
||||
Data: string(b),
|
||||
}
|
||||
|
||||
if post.Metadata == nil {
|
||||
post.Metadata = &mmModel.PostMetadata{}
|
||||
}
|
||||
|
||||
post.Metadata.Embeds = []*mmModel.PostEmbed{BoardsPostEmbed}
|
||||
post.AddProp("boards", string(b))
|
||||
}
|
||||
|
||||
return post
|
||||
}
|
||||
|
||||
func getFirstLinkAndShortenAllBoardsLink(postMessage string) (firstLink, newPostMessage string) {
|
||||
newPostMessage = postMessage
|
||||
seenLinks := make(map[string]bool)
|
||||
markdown.Inspect(postMessage, func(blockOrInline interface{}) bool {
|
||||
if autoLink, ok := blockOrInline.(*markdown.Autolink); ok {
|
||||
link := autoLink.Destination()
|
||||
|
||||
if firstLink == "" {
|
||||
firstLink = link
|
||||
}
|
||||
|
||||
if seen := seenLinks[link]; !seen && isBoardsLink(link) {
|
||||
// TODO: Make sure that <Jump To Card> is Internationalized and translated to the Users Language preference
|
||||
markdownFormattedLink := fmt.Sprintf("[%s](%s)", "<Jump To Card>", link)
|
||||
newPostMessage = strings.ReplaceAll(newPostMessage, link, markdownFormattedLink)
|
||||
seenLinks[link] = true
|
||||
}
|
||||
}
|
||||
if inlineLink, ok := blockOrInline.(*markdown.InlineLink); ok {
|
||||
if link := inlineLink.Destination(); firstLink == "" {
|
||||
firstLink = link
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
return firstLink, newPostMessage
|
||||
}
|
||||
|
||||
func returnBoardsParams(pathArray []string) (teamID, boardID, viewID, cardID string) {
|
||||
// The reason we are doing this search for the first instance of boards or plugins is to take into account URL subpaths
|
||||
index := -1
|
||||
for i := 0; i < len(pathArray); i++ {
|
||||
if pathArray[i] == "boards" || pathArray[i] == "plugins" {
|
||||
index = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if index == -1 {
|
||||
return teamID, boardID, viewID, cardID
|
||||
}
|
||||
|
||||
// If at index, the parameter in the path is boards,
|
||||
// then we've copied this directly as logged in user of that board
|
||||
|
||||
// If at index, the parameter in the path is plugins,
|
||||
// then we've copied this from a shared board
|
||||
|
||||
// For card links copied on a non-shared board, the path looks like {...Mattermost Url}.../boards/team/teamID/boardID/viewID/cardID
|
||||
|
||||
// For card links copied on a shared board, the path looks like
|
||||
// {...Mattermost Url}.../plugins/focalboard/team/teamID/shared/boardID/viewID/cardID?r=read_token
|
||||
|
||||
// This is a non-shared board card link
|
||||
if len(pathArray)-index == 6 && pathArray[index] == "boards" && pathArray[index+1] == "team" {
|
||||
teamID = pathArray[index+2]
|
||||
boardID = pathArray[index+3]
|
||||
viewID = pathArray[index+4]
|
||||
cardID = pathArray[index+5]
|
||||
} else if len(pathArray)-index == 8 && pathArray[index] == "plugins" &&
|
||||
pathArray[index+1] == "focalboard" &&
|
||||
pathArray[index+2] == "team" &&
|
||||
pathArray[index+4] == "shared" { // This is a shared board card link
|
||||
teamID = pathArray[index+3]
|
||||
boardID = pathArray[index+5]
|
||||
viewID = pathArray[index+6]
|
||||
cardID = pathArray[index+7]
|
||||
}
|
||||
return teamID, boardID, viewID, cardID
|
||||
}
|
||||
|
||||
func isBoardsLink(link string) bool {
|
||||
u, err := url.Parse(link)
|
||||
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
urlPath := u.Path
|
||||
urlPath = strings.TrimPrefix(urlPath, "/")
|
||||
urlPath = strings.TrimSuffix(urlPath, "/")
|
||||
pathSplit := strings.Split(strings.ToLower(urlPath), "/")
|
||||
|
||||
if len(pathSplit) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
teamID, boardID, viewID, cardID := returnBoardsParams(pathSplit)
|
||||
return teamID != "" && boardID != "" && viewID != "" && cardID != ""
|
||||
}
|
||||
|
||||
func (p *Plugin) RunDataRetention(nowTime, batchSize int64) (int64, error) {
|
||||
p.server.Logger().Debug("Boards RunDataRetention")
|
||||
license := p.server.Store().GetLicense()
|
||||
if license == nil || !(*license.Features.DataRetention) {
|
||||
return 0, ErrInsufficientLicense
|
||||
}
|
||||
|
||||
if p.server.Config().EnableDataRetention {
|
||||
boardsRetentionDays := p.server.Config().DataRetentionDays
|
||||
endTimeBoards := convertDaysToCutoff(boardsRetentionDays, time.Unix(nowTime/1000, 0))
|
||||
return p.server.Store().RunDataRetention(endTimeBoards, batchSize)
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func convertDaysToCutoff(days int, now time.Time) int64 {
|
||||
upToStartOfDay := now.AddDate(0, 0, -days)
|
||||
cutoffDate := time.Date(upToStartOfDay.Year(), upToStartOfDay.Month(), upToStartOfDay.Day(), 0, 0, 0, 0, time.Local)
|
||||
return cutoffDate.UnixNano() / int64(time.Millisecond)
|
||||
}
|
||||
|
@ -1,193 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"github.com/mattermost/mattermost-server/v6/model"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func testHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// A very simple health check.
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
// In the future we could report back on the status of our DB, or our cache
|
||||
// (e.g. Redis) by performing a simple PING, and include them in the response.
|
||||
io.WriteString(w, "Hello, world!")
|
||||
}
|
||||
|
||||
func TestServeHTTP(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
th := SetupTestHelper(t)
|
||||
plugin := Plugin{}
|
||||
|
||||
testHandler := http.HandlerFunc(testHandler)
|
||||
th.Server.GetRootRouter().Handle("/", testHandler)
|
||||
|
||||
plugin.server = th.Server
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
|
||||
// plugin.server.
|
||||
plugin.ServeHTTP(nil, w, r)
|
||||
|
||||
result := w.Result()
|
||||
assert.NotNil(result)
|
||||
defer result.Body.Close()
|
||||
bodyBytes, err := ioutil.ReadAll(result.Body)
|
||||
assert.Nil(err)
|
||||
bodyString := string(bodyBytes)
|
||||
|
||||
assert.Equal("Hello, world!", bodyString)
|
||||
}
|
||||
|
||||
func TestSetConfiguration(t *testing.T) {
|
||||
plugin := Plugin{}
|
||||
boolTrue := true
|
||||
boolFalse := false
|
||||
stringRef := ""
|
||||
|
||||
baseFeatureFlags := &model.FeatureFlags{}
|
||||
basePluginSettings := &model.PluginSettings{
|
||||
Directory: &stringRef,
|
||||
}
|
||||
driverName := "testDriver"
|
||||
dataSource := "testDirectory"
|
||||
baseSQLSettings := &model.SqlSettings{
|
||||
DriverName: &driverName,
|
||||
DataSource: &dataSource,
|
||||
}
|
||||
|
||||
directory := "testDirectory"
|
||||
maxFileSize := int64(1048576000)
|
||||
baseFileSettings := &model.FileSettings{
|
||||
DriverName: &driverName,
|
||||
Directory: &directory,
|
||||
MaxFileSize: &maxFileSize,
|
||||
}
|
||||
|
||||
days := 365
|
||||
baseDataRetentionSettings := &model.DataRetentionSettings{
|
||||
EnableBoardsDeletion: &boolFalse,
|
||||
BoardsRetentionDays: &days,
|
||||
}
|
||||
|
||||
baseConfig := &model.Config{
|
||||
FeatureFlags: baseFeatureFlags,
|
||||
PluginSettings: *basePluginSettings,
|
||||
SqlSettings: *baseSQLSettings,
|
||||
FileSettings: *baseFileSettings,
|
||||
DataRetentionSettings: *baseDataRetentionSettings,
|
||||
}
|
||||
|
||||
t.Run("test enable telemetry", func(t *testing.T) {
|
||||
logSettings := &model.LogSettings{
|
||||
EnableDiagnostics: &boolTrue,
|
||||
}
|
||||
mmConfig := baseConfig
|
||||
mmConfig.LogSettings = *logSettings
|
||||
|
||||
config := plugin.createBoardsConfig(*mmConfig, "", "testId")
|
||||
assert.Equal(t, true, config.Telemetry)
|
||||
assert.Equal(t, "testId", config.TelemetryID)
|
||||
})
|
||||
|
||||
t.Run("test enable shared boards", func(t *testing.T) {
|
||||
mmConfig := baseConfig
|
||||
mmConfig.PluginSettings.Plugins = make(map[string]map[string]interface{})
|
||||
mmConfig.PluginSettings.Plugins[pluginName] = make(map[string]interface{})
|
||||
mmConfig.PluginSettings.Plugins[pluginName][sharedBoardsName] = true
|
||||
config := plugin.createBoardsConfig(*mmConfig, "", "")
|
||||
assert.Equal(t, true, config.EnablePublicSharedBoards)
|
||||
})
|
||||
|
||||
t.Run("test boards feature flags", func(t *testing.T) {
|
||||
featureFlags := &model.FeatureFlags{
|
||||
TestFeature: "test",
|
||||
TestBoolFeature: boolTrue,
|
||||
BoardsFeatureFlags: "hello_world-myTest",
|
||||
}
|
||||
|
||||
mmConfig := baseConfig
|
||||
mmConfig.FeatureFlags = featureFlags
|
||||
|
||||
config := plugin.createBoardsConfig(*mmConfig, "", "")
|
||||
assert.Equal(t, "true", config.FeatureFlags["TestBoolFeature"])
|
||||
assert.Equal(t, "test", config.FeatureFlags["TestFeature"])
|
||||
|
||||
assert.Equal(t, "true", config.FeatureFlags["hello_world"])
|
||||
assert.Equal(t, "true", config.FeatureFlags["myTest"])
|
||||
})
|
||||
}
|
||||
|
||||
func TestRunDataRetention(t *testing.T) {
|
||||
th := SetupTestHelper(t)
|
||||
plugin := Plugin{}
|
||||
plugin.server = th.Server
|
||||
|
||||
now := time.Now().UnixNano()
|
||||
|
||||
t.Run("test null license", func(t *testing.T) {
|
||||
th.Store.EXPECT().GetLicense().Return(nil)
|
||||
_, err := plugin.RunDataRetention(now, 10)
|
||||
assert.NotNil(t, err)
|
||||
assert.Equal(t, ErrInsufficientLicense, err)
|
||||
})
|
||||
|
||||
t.Run("test invalid license", func(t *testing.T) {
|
||||
falseValue := false
|
||||
|
||||
th.Store.EXPECT().GetLicense().Return(
|
||||
&model.License{
|
||||
Features: &model.Features{
|
||||
DataRetention: &falseValue,
|
||||
},
|
||||
},
|
||||
)
|
||||
_, err := plugin.RunDataRetention(now, 10)
|
||||
assert.NotNil(t, err)
|
||||
assert.Equal(t, ErrInsufficientLicense, err)
|
||||
})
|
||||
|
||||
t.Run("test valid license, invalid config", func(t *testing.T) {
|
||||
trueValue := true
|
||||
th.Store.EXPECT().GetLicense().Return(
|
||||
&model.License{
|
||||
Features: &model.Features{
|
||||
DataRetention: &trueValue,
|
||||
},
|
||||
})
|
||||
|
||||
count, err := plugin.RunDataRetention(now, 10)
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, int64(0), count)
|
||||
})
|
||||
|
||||
t.Run("test valid license, valid config", func(t *testing.T) {
|
||||
trueValue := true
|
||||
th.Store.EXPECT().GetLicense().Return(
|
||||
&model.License{
|
||||
Features: &model.Features{
|
||||
DataRetention: &trueValue,
|
||||
},
|
||||
})
|
||||
|
||||
th.Store.EXPECT().RunDataRetention(gomock.Any(), int64(10)).Return(int64(100), nil)
|
||||
plugin.server.Config().EnableDataRetention = true
|
||||
|
||||
count, err := plugin.RunDataRetention(now, 10)
|
||||
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, int64(100), count)
|
||||
})
|
||||
}
|
@ -1,12 +1,12 @@
|
||||
{
|
||||
"extends": [
|
||||
"plugin:mattermost/react",
|
||||
"plugin:react/recommended",
|
||||
"plugin:cypress/recommended",
|
||||
"plugin:jquery/deprecated"
|
||||
],
|
||||
"plugins": [
|
||||
"react",
|
||||
"babel",
|
||||
"mattermost",
|
||||
"import",
|
||||
"cypress",
|
||||
"jquery",
|
||||
|
19687
mattermost-plugin/webapp/package-lock.json
generated
19687
mattermost-plugin/webapp/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -26,8 +26,11 @@
|
||||
"@babel/preset-typescript": "7.16.7",
|
||||
"@babel/runtime": "7.17.8",
|
||||
"@formatjs/ts-transformer": "3.9.2",
|
||||
"@testing-library/react": "11.2.7",
|
||||
"@testing-library/user-event": "14.2.1",
|
||||
"@types/enzyme": "3.10.11",
|
||||
"@types/jest": "27.4.1",
|
||||
"@types/lodash": "4.14.182",
|
||||
"@types/node": "17.0.23",
|
||||
"@types/react": "17.0.42",
|
||||
"@types/react-dom": "17.0.14",
|
||||
@ -35,6 +38,7 @@
|
||||
"@types/react-redux": "7.1.23",
|
||||
"@types/react-router-dom": "5.3.3",
|
||||
"@types/react-transition-group": "4.4.4",
|
||||
"@types/redux-mock-store": "1.0.3",
|
||||
"@typescript-eslint/eslint-plugin": "5.16.0",
|
||||
"@typescript-eslint/parser": "5.16.0",
|
||||
"babel-eslint": "10.1.0",
|
||||
@ -49,7 +53,7 @@
|
||||
"eslint-plugin-header": "3.1.1",
|
||||
"eslint-plugin-import": "2.25.4",
|
||||
"eslint-plugin-jquery": "1.5.1",
|
||||
"eslint-plugin-mattermost": "github:mattermost/eslint-plugin-mattermost#070ce792d105482ffb2b27cfc0b7e78b3d20acee",
|
||||
"eslint-plugin-mattermost": "github:mattermost/eslint-plugin-mattermost#46ad99355644a719bf32082f472048f526605181",
|
||||
"eslint-plugin-no-only-tests": "2.6.0",
|
||||
"eslint-plugin-react": "7.29.4",
|
||||
"eslint-plugin-react-hooks": "4.3.0",
|
||||
@ -62,9 +66,12 @@
|
||||
"imagemin-pngquant": "^9.0.2",
|
||||
"imagemin-svgo": "^10.0.1",
|
||||
"imagemin-webp": "7.0.0",
|
||||
"isomorphic-fetch": "3.0.0",
|
||||
"jest": "27.5.1",
|
||||
"jest-canvas-mock": "2.3.1",
|
||||
"jest-junit": "13.0.0",
|
||||
"jest-mock": "27.5.1",
|
||||
"redux-mock-store": "1.5.4",
|
||||
"sass": "1.49.9",
|
||||
"sass-loader": "12.6.0",
|
||||
"style-loader": "3.3.1",
|
||||
@ -78,14 +85,15 @@
|
||||
"glob-parent": "6.0.2",
|
||||
"marked": ">=4.0.12",
|
||||
"mattermost-redux": "5.33.1",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "17.0.2",
|
||||
"react-intl": "^5.24.7",
|
||||
"react-redux": "^7.2.8",
|
||||
"react-router-dom": "5.2.0",
|
||||
"trim-newlines": "4.0.2"
|
||||
},
|
||||
"jest": {
|
||||
"snapshotSerializers": [
|
||||
"<rootDir>/node_modules/enzyme-to-json/serializer"
|
||||
],
|
||||
"testEnvironment": "jsdom",
|
||||
"testPathIgnorePatterns": [
|
||||
"/node_modules/",
|
||||
"/non_npm_dependencies/"
|
||||
@ -100,9 +108,12 @@
|
||||
],
|
||||
"moduleNameMapper": {
|
||||
"^.+\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "identity-obj-proxy",
|
||||
"^.+\\.(css|less|scss)$": "identity-obj-proxy",
|
||||
"^.+\\.(scss|css)$": "<rootDir>/tests/style_mock.json",
|
||||
"^.*i18n.*\\.(json)$": "<rootDir>/tests/i18n_mock.json",
|
||||
"^bundle-loader\\?lazy\\!(.*)$": "$1"
|
||||
"^bundle-loader\\?lazy\\!(.*)$": "$1",
|
||||
"^react$": "<rootDir>/../../webapp/node_modules/react",
|
||||
"^react-redux$": "<rootDir>/../../webapp/node_modules/react-redux",
|
||||
"^react-intl$": "<rootDir>/../../webapp/node_modules/react-intl"
|
||||
},
|
||||
"moduleDirectories": [
|
||||
"",
|
||||
|
@ -0,0 +1,383 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`components/boardSelector renders with no results 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="focalboard-body"
|
||||
>
|
||||
<div
|
||||
class="Dialog dialog-back BoardSelector"
|
||||
>
|
||||
<div
|
||||
class="backdrop"
|
||||
/>
|
||||
<div
|
||||
class="wrapper"
|
||||
>
|
||||
<div
|
||||
class="dialog"
|
||||
role="dialog"
|
||||
>
|
||||
<div
|
||||
class="toolbar"
|
||||
>
|
||||
<button
|
||||
aria-label="Close dialog"
|
||||
class="IconButton size--medium"
|
||||
title="Close dialog"
|
||||
type="button"
|
||||
>
|
||||
<i
|
||||
class="CompassIcon icon-close CloseIcon"
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
class="toolbar--right"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="BoardSelectorBody"
|
||||
>
|
||||
<div
|
||||
class="head"
|
||||
>
|
||||
<div
|
||||
class="heading"
|
||||
>
|
||||
<h3
|
||||
class="text-heading4"
|
||||
>
|
||||
Link boards
|
||||
</h3>
|
||||
<button
|
||||
class="Button emphasis--secondary"
|
||||
type="button"
|
||||
>
|
||||
<span>
|
||||
Create a board
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="queryWrapper"
|
||||
>
|
||||
<i
|
||||
class="CompassIcon icon-magnify MagnifyIcon"
|
||||
/>
|
||||
<input
|
||||
class="searchQuery"
|
||||
maxlength="100"
|
||||
placeholder="Search for boards"
|
||||
type="text"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="searchResults"
|
||||
>
|
||||
<div
|
||||
class="noResults"
|
||||
>
|
||||
<div
|
||||
class="iconWrapper"
|
||||
>
|
||||
<i
|
||||
class="CompassIcon icon-magnify MagnifyIcon"
|
||||
/>
|
||||
</div>
|
||||
<h4
|
||||
class="text-heading4"
|
||||
>
|
||||
No results for "test"
|
||||
</h4>
|
||||
<span>
|
||||
Check the spelling or try another search.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`components/boardSelector renders with some results 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="focalboard-body"
|
||||
>
|
||||
<div
|
||||
class="Dialog dialog-back BoardSelector"
|
||||
>
|
||||
<div
|
||||
class="backdrop"
|
||||
/>
|
||||
<div
|
||||
class="wrapper"
|
||||
>
|
||||
<div
|
||||
class="dialog"
|
||||
role="dialog"
|
||||
>
|
||||
<div
|
||||
class="toolbar"
|
||||
>
|
||||
<button
|
||||
aria-label="Close dialog"
|
||||
class="IconButton size--medium"
|
||||
title="Close dialog"
|
||||
type="button"
|
||||
>
|
||||
<i
|
||||
class="CompassIcon icon-close CloseIcon"
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
class="toolbar--right"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="BoardSelectorBody"
|
||||
>
|
||||
<div
|
||||
class="head"
|
||||
>
|
||||
<div
|
||||
class="heading"
|
||||
>
|
||||
<h3
|
||||
class="text-heading4"
|
||||
>
|
||||
Link boards
|
||||
</h3>
|
||||
<button
|
||||
class="Button emphasis--secondary"
|
||||
type="button"
|
||||
>
|
||||
<span>
|
||||
Create a board
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="queryWrapper"
|
||||
>
|
||||
<i
|
||||
class="CompassIcon icon-magnify MagnifyIcon"
|
||||
/>
|
||||
<input
|
||||
class="searchQuery"
|
||||
maxlength="100"
|
||||
placeholder="Search for boards"
|
||||
type="text"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="searchResults"
|
||||
>
|
||||
<div
|
||||
class="BoardSelectorItem"
|
||||
>
|
||||
<span
|
||||
class="icon"
|
||||
/>
|
||||
<div
|
||||
class="resultLine"
|
||||
>
|
||||
<div
|
||||
class="resultTitle"
|
||||
>
|
||||
Untitled board
|
||||
</div>
|
||||
<div
|
||||
class="resultDescription"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="linkUnlinkButton"
|
||||
>
|
||||
<button
|
||||
class="Button emphasis--primary"
|
||||
type="button"
|
||||
>
|
||||
<span>
|
||||
Link
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="BoardSelectorItem"
|
||||
>
|
||||
<span
|
||||
class="icon"
|
||||
/>
|
||||
<div
|
||||
class="resultLine"
|
||||
>
|
||||
<div
|
||||
class="resultTitle"
|
||||
>
|
||||
Untitled board
|
||||
</div>
|
||||
<div
|
||||
class="resultDescription"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="linkUnlinkButton"
|
||||
>
|
||||
<button
|
||||
class="Button emphasis--primary"
|
||||
type="button"
|
||||
>
|
||||
<span>
|
||||
Link
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="BoardSelectorItem"
|
||||
>
|
||||
<span
|
||||
class="icon"
|
||||
/>
|
||||
<div
|
||||
class="resultLine"
|
||||
>
|
||||
<div
|
||||
class="resultTitle"
|
||||
>
|
||||
Untitled board
|
||||
</div>
|
||||
<div
|
||||
class="resultDescription"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="linkUnlinkButton"
|
||||
>
|
||||
<button
|
||||
class="Button emphasis--primary"
|
||||
type="button"
|
||||
>
|
||||
<span>
|
||||
Link
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`components/boardSelector renders without start searching 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="focalboard-body"
|
||||
>
|
||||
<div
|
||||
class="Dialog dialog-back BoardSelector"
|
||||
>
|
||||
<div
|
||||
class="backdrop"
|
||||
/>
|
||||
<div
|
||||
class="wrapper"
|
||||
>
|
||||
<div
|
||||
class="dialog"
|
||||
role="dialog"
|
||||
>
|
||||
<div
|
||||
class="toolbar"
|
||||
>
|
||||
<button
|
||||
aria-label="Close dialog"
|
||||
class="IconButton size--medium"
|
||||
title="Close dialog"
|
||||
type="button"
|
||||
>
|
||||
<i
|
||||
class="CompassIcon icon-close CloseIcon"
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
class="toolbar--right"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="BoardSelectorBody"
|
||||
>
|
||||
<div
|
||||
class="head"
|
||||
>
|
||||
<div
|
||||
class="heading"
|
||||
>
|
||||
<h3
|
||||
class="text-heading4"
|
||||
>
|
||||
Link boards
|
||||
</h3>
|
||||
<button
|
||||
class="Button emphasis--secondary"
|
||||
type="button"
|
||||
>
|
||||
<span>
|
||||
Create a board
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="queryWrapper"
|
||||
>
|
||||
<i
|
||||
class="CompassIcon icon-magnify MagnifyIcon"
|
||||
/>
|
||||
<input
|
||||
class="searchQuery"
|
||||
maxlength="100"
|
||||
placeholder="Search for boards"
|
||||
type="text"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="searchResults"
|
||||
>
|
||||
|
||||
|
||||
<div
|
||||
class="noResults introScreen"
|
||||
>
|
||||
<div
|
||||
class="iconWrapper"
|
||||
>
|
||||
<i
|
||||
class="CompassIcon icon-magnify MagnifyIcon"
|
||||
/>
|
||||
</div>
|
||||
<h4
|
||||
class="text-heading4"
|
||||
>
|
||||
Search for boards
|
||||
</h4>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
@ -0,0 +1,109 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`components/boardSelectorItem renders board without title 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="BoardSelectorItem"
|
||||
>
|
||||
<span
|
||||
class="icon"
|
||||
/>
|
||||
<div
|
||||
class="resultLine"
|
||||
>
|
||||
<div
|
||||
class="resultTitle"
|
||||
>
|
||||
Untitled board
|
||||
</div>
|
||||
<div
|
||||
class="resultDescription"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="linkUnlinkButton"
|
||||
>
|
||||
<button
|
||||
class="Button emphasis--secondary"
|
||||
type="button"
|
||||
>
|
||||
<span>
|
||||
Unlink
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`components/boardSelectorItem renders linked board 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="BoardSelectorItem"
|
||||
>
|
||||
<span
|
||||
class="icon"
|
||||
/>
|
||||
<div
|
||||
class="resultLine"
|
||||
>
|
||||
<div
|
||||
class="resultTitle"
|
||||
>
|
||||
Test title
|
||||
</div>
|
||||
<div
|
||||
class="resultDescription"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="linkUnlinkButton"
|
||||
>
|
||||
<button
|
||||
class="Button emphasis--secondary"
|
||||
type="button"
|
||||
>
|
||||
<span>
|
||||
Unlink
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`components/boardSelectorItem renders not linked board 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="BoardSelectorItem"
|
||||
>
|
||||
<span
|
||||
class="icon"
|
||||
/>
|
||||
<div
|
||||
class="resultLine"
|
||||
>
|
||||
<div
|
||||
class="resultTitle"
|
||||
>
|
||||
Test title
|
||||
</div>
|
||||
<div
|
||||
class="resultDescription"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="linkUnlinkButton"
|
||||
>
|
||||
<button
|
||||
class="Button emphasis--primary"
|
||||
type="button"
|
||||
>
|
||||
<span>
|
||||
Link
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
@ -0,0 +1,146 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`components/rhsChannelBoardItem render board 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="RHSChannelBoardItem"
|
||||
>
|
||||
<div
|
||||
class="board-info"
|
||||
>
|
||||
|
||||
<span
|
||||
class="title"
|
||||
>
|
||||
Test board
|
||||
</span>
|
||||
<div
|
||||
aria-label="menuwrapper"
|
||||
class="MenuWrapper"
|
||||
role="button"
|
||||
>
|
||||
<button
|
||||
class="IconButton"
|
||||
type="button"
|
||||
>
|
||||
<i
|
||||
class="CompassIcon icon-dots-horizontal OptionsIcon"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="description"
|
||||
/>
|
||||
<div
|
||||
class="date"
|
||||
>
|
||||
Last update at: July 08, 8:10 PM
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`components/rhsChannelBoardItem render board with menu open 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="RHSChannelBoardItem"
|
||||
>
|
||||
<div
|
||||
class="board-info"
|
||||
>
|
||||
|
||||
<span
|
||||
class="title"
|
||||
>
|
||||
Test board
|
||||
</span>
|
||||
<div
|
||||
aria-label="menuwrapper"
|
||||
class="MenuWrapper"
|
||||
role="button"
|
||||
>
|
||||
<button
|
||||
class="IconButton"
|
||||
type="button"
|
||||
>
|
||||
<i
|
||||
class="CompassIcon icon-dots-horizontal OptionsIcon"
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
class="Menu noselect left fixed"
|
||||
>
|
||||
<div
|
||||
class="menu-contents"
|
||||
>
|
||||
<div
|
||||
class="menu-options"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
aria-label="Unlink board"
|
||||
class="MenuOption TextOption menu-option"
|
||||
role="button"
|
||||
>
|
||||
<div
|
||||
class="d-flex"
|
||||
>
|
||||
<i
|
||||
class="CompassIcon icon-trash-can-outline DeleteIcon trash-can-outline"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="menu-name"
|
||||
>
|
||||
Unlink board
|
||||
</div>
|
||||
<div
|
||||
class="noicon"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="menu-spacer hideOnWidescreen"
|
||||
/>
|
||||
<div
|
||||
class="menu-options hideOnWidescreen"
|
||||
>
|
||||
<div
|
||||
aria-label="Cancel"
|
||||
class="MenuOption TextOption menu-option menu-cancel"
|
||||
role="button"
|
||||
>
|
||||
<div
|
||||
class="d-flex"
|
||||
>
|
||||
<div
|
||||
class="noicon"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="menu-name"
|
||||
>
|
||||
Cancel
|
||||
</div>
|
||||
<div
|
||||
class="noicon"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="description"
|
||||
/>
|
||||
<div
|
||||
class="date"
|
||||
>
|
||||
Last update at: July 08, 8:10 PM
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
@ -0,0 +1,146 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`components/rhsChannelBoards renders the RHS for channel boards 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="focalboard-body"
|
||||
>
|
||||
<div
|
||||
class="RHSChannelBoards"
|
||||
>
|
||||
<div
|
||||
class="rhs-boards-header"
|
||||
>
|
||||
<span
|
||||
class="linked-boards"
|
||||
>
|
||||
Linked boards
|
||||
</span>
|
||||
<button
|
||||
class="Button emphasis--primary"
|
||||
type="button"
|
||||
>
|
||||
<i
|
||||
class="CompassIcon icon-plus AddIcon"
|
||||
/>
|
||||
<span>
|
||||
Add
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="rhs-boards-list"
|
||||
>
|
||||
<div
|
||||
class="RHSChannelBoardItem"
|
||||
>
|
||||
<div
|
||||
class="board-info"
|
||||
>
|
||||
|
||||
<span
|
||||
class="title"
|
||||
>
|
||||
Untitled board
|
||||
</span>
|
||||
<div
|
||||
aria-label="menuwrapper"
|
||||
class="MenuWrapper"
|
||||
role="button"
|
||||
>
|
||||
<button
|
||||
class="IconButton"
|
||||
type="button"
|
||||
>
|
||||
<i
|
||||
class="CompassIcon icon-dots-horizontal OptionsIcon"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="description"
|
||||
/>
|
||||
<div
|
||||
class="date"
|
||||
>
|
||||
Last update at: July 08, 8:10 PM
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="RHSChannelBoardItem"
|
||||
>
|
||||
<div
|
||||
class="board-info"
|
||||
>
|
||||
|
||||
<span
|
||||
class="title"
|
||||
>
|
||||
Untitled board
|
||||
</span>
|
||||
<div
|
||||
aria-label="menuwrapper"
|
||||
class="MenuWrapper"
|
||||
role="button"
|
||||
>
|
||||
<button
|
||||
class="IconButton"
|
||||
type="button"
|
||||
>
|
||||
<i
|
||||
class="CompassIcon icon-dots-horizontal OptionsIcon"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="description"
|
||||
/>
|
||||
<div
|
||||
class="date"
|
||||
>
|
||||
Last update at: July 08, 8:10 PM
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`components/rhsChannelBoards renders with empty list of boards 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="focalboard-body"
|
||||
>
|
||||
<div
|
||||
class="RHSChannelBoards empty"
|
||||
>
|
||||
<h2>
|
||||
No boards are linked to Channel Name yet
|
||||
</h2>
|
||||
<div
|
||||
class="empty-paragraph"
|
||||
>
|
||||
Boards is a project management tool that helps define, organize, track and manage work across teams, using a familiar kanban board view.
|
||||
</div>
|
||||
<div
|
||||
class="boards-screenshots"
|
||||
>
|
||||
<img
|
||||
src="undefined/public/boards-screenshots.png"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
class="Button emphasis--primary size--medium"
|
||||
type="button"
|
||||
>
|
||||
<span>
|
||||
Link boards to Channel Name
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
@ -0,0 +1,20 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`components/rhsChannelBoardsHeader renders the header 1`] = `
|
||||
<div>
|
||||
<div>
|
||||
<img
|
||||
class="boards-rhs-header-logo"
|
||||
src="undefined/public/app-bar-icon.png"
|
||||
/>
|
||||
<span>
|
||||
Boards
|
||||
</span>
|
||||
<span
|
||||
class="style--none sidebar--right__title__subtitle"
|
||||
>
|
||||
Channel Name
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
166
mattermost-plugin/webapp/src/components/boardSelector.scss
Normal file
166
mattermost-plugin/webapp/src/components/boardSelector.scss
Normal file
@ -0,0 +1,166 @@
|
||||
.BoardSelector {
|
||||
color: rgba(var(--center-channel-color-rgb));
|
||||
|
||||
.dialog {
|
||||
.toolbar {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
}
|
||||
|
||||
.heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-right: 35px;
|
||||
margin-top: 5px;
|
||||
|
||||
.text-heading4 {
|
||||
flex-grow: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
.dialog {
|
||||
position: relative;
|
||||
width: 600px;
|
||||
height: 450px;
|
||||
|
||||
.toolbar {
|
||||
flex-direction: row-reverse;
|
||||
padding: 0;
|
||||
position: absolute;
|
||||
right: 18px;
|
||||
top: 18px;
|
||||
}
|
||||
}
|
||||
.confirmation-dialog-box {
|
||||
.dialog {
|
||||
position: fixed;
|
||||
width: 500px;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.BoardSelectorBody {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
|
||||
.head {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.head,
|
||||
.searchResults {
|
||||
padding: 0 32px 32px;
|
||||
}
|
||||
|
||||
.searchResults {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 0;
|
||||
margin-bottom: 18px;
|
||||
border-top: solid 1px rgba(var(--center-channel-color-rgb), 0.16);
|
||||
|
||||
.searchResult {
|
||||
height: 40px;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
padding: 0 24px;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
|
||||
&.freesize {
|
||||
height: unset;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: rgba(var(--center-channel-color-rgb), 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
.iconWrapper {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
background: rgba(var(--center-channel-color-rgb), 0.08);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
/*! align-content: center; */
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.CompassIcon.icon-magnify.MagnifyIcon {
|
||||
font-size: 72px !important;
|
||||
color: var(--button-bg);
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.noResults {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 500px;
|
||||
margin: 0 auto;
|
||||
overflow: hidden;
|
||||
word-wrap: anywhere;
|
||||
margin-top: 30px;
|
||||
|
||||
.text-heading4 {
|
||||
line-height: 120%;
|
||||
}
|
||||
|
||||
&.introScreen {
|
||||
margin-top: 48px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.text-heading1 {
|
||||
font-size: 12px !important;
|
||||
}
|
||||
|
||||
h5 {
|
||||
font-size: 12px;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.queryWrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
margin-top: 24px;
|
||||
|
||||
.MagnifyIcon {
|
||||
position: absolute;
|
||||
left: 13px;
|
||||
font-size: 18px;
|
||||
top: 14px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
opacity: 0.48;
|
||||
}
|
||||
|
||||
.searchQuery {
|
||||
height: 48px;
|
||||
font-size: 14px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid rgba(var(--center-channel-color-rgb), 0.16);
|
||||
background: var(--center-channel-bg);
|
||||
color: var(--center-channel-color);
|
||||
padding: 0 40px;
|
||||
flex: 1;
|
||||
transition: border 0.15s ease-in;
|
||||
|
||||
&:focus {
|
||||
border-color: var(--button-bg);
|
||||
box-shadow: inset 0 0 0 1px var(--button-bg);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,95 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react'
|
||||
import {Provider as ReduxProvider} from 'react-redux'
|
||||
import {render, screen, act} from '@testing-library/react'
|
||||
import {mocked} from 'jest-mock'
|
||||
|
||||
import userEvent from '@testing-library/user-event'
|
||||
|
||||
import octoClient from '../../../../webapp/src/octoClient'
|
||||
import {mockStateStore} from '../../../../webapp/src/testUtils'
|
||||
import {createBoard} from '../../../../webapp/src/blocks/board'
|
||||
import {wrapIntl} from '../../../../webapp/src/testUtils'
|
||||
|
||||
import BoardSelector from './boardSelector'
|
||||
|
||||
jest.mock('../../../../webapp/src/octoClient')
|
||||
const mockedOctoClient = mocked(octoClient, true)
|
||||
|
||||
const wait = (ms: number) => {
|
||||
return new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, ms)
|
||||
})
|
||||
}
|
||||
|
||||
describe('components/boardSelector', () => {
|
||||
const team = {
|
||||
id: 'team-id',
|
||||
name: 'team',
|
||||
display_name: 'Team name',
|
||||
}
|
||||
const state = {
|
||||
teams: {
|
||||
allTeams: [team],
|
||||
current: team,
|
||||
currentId: team.id,
|
||||
},
|
||||
language: {
|
||||
value: 'en',
|
||||
},
|
||||
boards: {
|
||||
linkToChannel: 'channel-id',
|
||||
},
|
||||
}
|
||||
|
||||
it('renders without start searching', async () => {
|
||||
const store = mockStateStore([], state)
|
||||
const {container} = render(wrapIntl(
|
||||
<ReduxProvider store={store}>
|
||||
<BoardSelector/>
|
||||
</ReduxProvider>
|
||||
))
|
||||
expect(container).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it('renders with no results', async () => {
|
||||
mockedOctoClient.search.mockResolvedValueOnce([])
|
||||
|
||||
const store = mockStateStore([], state)
|
||||
const {container} = render(wrapIntl(
|
||||
<ReduxProvider store={store}>
|
||||
<BoardSelector/>
|
||||
</ReduxProvider>
|
||||
))
|
||||
|
||||
await act(async () => {
|
||||
const inputElement = screen.getByPlaceholderText('Search for boards')
|
||||
await userEvent.type(inputElement, 'test')
|
||||
await wait(300)
|
||||
})
|
||||
|
||||
expect(container).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it('renders with some results', async () => {
|
||||
mockedOctoClient.search.mockResolvedValueOnce([createBoard(), createBoard(), createBoard()])
|
||||
|
||||
const store = mockStateStore([], state)
|
||||
const {container} = render(wrapIntl(
|
||||
<ReduxProvider store={store}>
|
||||
<BoardSelector/>
|
||||
</ReduxProvider>
|
||||
))
|
||||
|
||||
await act(async () => {
|
||||
const inputElement = screen.getByPlaceholderText('Search for boards')
|
||||
await userEvent.type(inputElement, 'test')
|
||||
await wait(300)
|
||||
})
|
||||
|
||||
expect(container).toMatchSnapshot()
|
||||
})
|
||||
})
|
||||
|
225
mattermost-plugin/webapp/src/components/boardSelector.tsx
Normal file
225
mattermost-plugin/webapp/src/components/boardSelector.tsx
Normal file
@ -0,0 +1,225 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
import React, {useState, useMemo, useCallback} from 'react'
|
||||
import {IntlProvider, useIntl, FormattedMessage} from 'react-intl'
|
||||
import debounce from 'lodash/debounce'
|
||||
|
||||
import {getMessages} from '../../../../webapp/src/i18n'
|
||||
import {getLanguage} from '../../../../webapp/src/store/language'
|
||||
|
||||
import {useWebsockets} from '../../../../webapp/src/hooks/websockets'
|
||||
|
||||
import octoClient from '../../../../webapp/src/octoClient'
|
||||
import mutator from '../../../../webapp/src/mutator'
|
||||
import {getCurrentTeamId, getAllTeams, Team} from '../../../../webapp/src/store/teams'
|
||||
import {createBoard, BoardsAndBlocks, Board} from '../../../../webapp/src/blocks/board'
|
||||
import {createBoardView} from '../../../../webapp/src/blocks/boardView'
|
||||
import {useAppSelector, useAppDispatch} from '../../../../webapp/src/store/hooks'
|
||||
import {EmptySearch, EmptyResults} from '../../../../webapp/src/components/searchDialog/searchDialog'
|
||||
import ConfirmationDialog from '../../../../webapp/src/components/confirmationDialogBox'
|
||||
import Dialog from '../../../../webapp/src/components/dialog'
|
||||
import SearchIcon from '../../../../webapp/src/widgets/icons/search'
|
||||
import Button from '../../../../webapp/src/widgets/buttons/button'
|
||||
import {getCurrentLinkToChannel, setLinkToChannel} from '../../../../webapp/src/store/boards'
|
||||
import TelemetryClient, {TelemetryCategory, TelemetryActions} from '../../../../webapp/src/telemetry/telemetryClient'
|
||||
import {WSClient} from '../../../../webapp/src/wsclient'
|
||||
|
||||
import BoardSelectorItem from './boardSelectorItem'
|
||||
|
||||
import './boardSelector.scss'
|
||||
|
||||
const BoardSelector = () => {
|
||||
const teamsById:Record<string, Team> = {}
|
||||
useAppSelector(getAllTeams).forEach((t) => {
|
||||
teamsById[t.id] = t
|
||||
})
|
||||
const intl = useIntl()
|
||||
const teamId = useAppSelector(getCurrentTeamId)
|
||||
const currentChannel = useAppSelector(getCurrentLinkToChannel)
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
const [results, setResults] = useState<Array<Board>>([])
|
||||
const [isSearching, setIsSearching] = useState<boolean>(false)
|
||||
const [searchQuery, setSearchQuery] = useState<string>('')
|
||||
const [showLinkBoardConfirmation, setShowLinkBoardConfirmation] = useState<Board|null>(null)
|
||||
|
||||
const searchHandler = useCallback(async (query: string): Promise<void> => {
|
||||
setSearchQuery(query)
|
||||
|
||||
if (query.trim().length === 0 || !teamId) {
|
||||
return
|
||||
}
|
||||
const items = await octoClient.search(teamId, query)
|
||||
|
||||
setResults(items)
|
||||
setIsSearching(false)
|
||||
}, [teamId])
|
||||
|
||||
const debouncedSearchHandler = useMemo(() => debounce(searchHandler, 200), [searchHandler])
|
||||
|
||||
const emptyResult = results.length === 0 && !isSearching && searchQuery
|
||||
|
||||
useWebsockets(teamId, (wsClient: WSClient) => {
|
||||
const onChangeBoardHandler = (_: WSClient, boards: Board[]): void => {
|
||||
const newResults = [...results]
|
||||
let updated = false
|
||||
results.forEach((board, idx) => {
|
||||
for (const newBoard of boards) {
|
||||
if (newBoard.id == board.id) {
|
||||
newResults[idx] = newBoard
|
||||
updated = true
|
||||
}
|
||||
}
|
||||
})
|
||||
if (updated) {
|
||||
setResults(newResults)
|
||||
}
|
||||
}
|
||||
|
||||
wsClient.addOnChange(onChangeBoardHandler, 'board')
|
||||
|
||||
return () => {
|
||||
wsClient.removeOnChange(onChangeBoardHandler, 'board')
|
||||
}
|
||||
}, [results])
|
||||
|
||||
|
||||
if (!teamId) {
|
||||
return null
|
||||
}
|
||||
if (!currentChannel) {
|
||||
return null
|
||||
}
|
||||
|
||||
const linkBoard = async (board: Board, confirmed?: boolean): Promise<void> => {
|
||||
if (!confirmed) {
|
||||
setShowLinkBoardConfirmation(board)
|
||||
return
|
||||
}
|
||||
const newBoard = createBoard({...board, channelId: currentChannel})
|
||||
await mutator.updateBoard(newBoard, board, 'linked channel')
|
||||
setShowLinkBoardConfirmation(null)
|
||||
}
|
||||
|
||||
const unlinkBoard = async (board: Board): Promise<void> => {
|
||||
const newBoard = createBoard({...board, channelId: ''})
|
||||
await mutator.updateBoard(newBoard, board, 'unlinked channel')
|
||||
}
|
||||
|
||||
const newLinkedBoard = async (): Promise<void> => {
|
||||
const board = {...createBoard(), teamId, channelId: currentChannel}
|
||||
|
||||
const view = createBoardView()
|
||||
view.fields.viewType = 'board'
|
||||
view.parentId = board.id
|
||||
view.boardId = board.id
|
||||
view.title = intl.formatMessage({id: 'View.NewBoardTitle', defaultMessage: 'Board view'})
|
||||
|
||||
await mutator.createBoardsAndBlocks(
|
||||
{boards: [board], blocks: [view]},
|
||||
'add linked board',
|
||||
async (bab: BoardsAndBlocks): Promise<void> => {
|
||||
const windowAny: any = window
|
||||
const newBoard = bab.boards[0]
|
||||
// TODO: Maybe create a new event for create linked board
|
||||
TelemetryClient.trackEvent(TelemetryCategory, TelemetryActions.CreateBoard, {board: newBoard?.id})
|
||||
windowAny.WebappUtils.browserHistory.push(`/boards/team/${teamId}/${newBoard.id}`)
|
||||
dispatch(setLinkToChannel(''))
|
||||
},
|
||||
async () => {return},
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='focalboard-body'>
|
||||
<Dialog
|
||||
className='BoardSelector'
|
||||
onClose={() => {
|
||||
dispatch(setLinkToChannel(''))
|
||||
setResults([])
|
||||
setIsSearching(false)
|
||||
setSearchQuery('')
|
||||
setShowLinkBoardConfirmation(null)
|
||||
}}
|
||||
>
|
||||
{showLinkBoardConfirmation &&
|
||||
<ConfirmationDialog
|
||||
dialogBox={{
|
||||
heading: intl.formatMessage({id: 'boardSelector.confirm-link-board', defaultMessage: 'Link board to channel'}),
|
||||
subText: intl.formatMessage({
|
||||
id: 'boardSelector.confirm-link-board-subtext',
|
||||
defaultMessage: 'Linking the "{boardName}" board to this channel would give all members of this channel "Editor" access to the board. Are you sure you want to link it?'
|
||||
}, {boardName: showLinkBoardConfirmation.title}),
|
||||
confirmButtonText: intl.formatMessage({id: 'boardSelector.confirm-link-board-button', defaultMessage: 'Yes, link board'}),
|
||||
onConfirm: () => linkBoard(showLinkBoardConfirmation, true),
|
||||
onClose: () => setShowLinkBoardConfirmation(null),
|
||||
}}
|
||||
/>}
|
||||
<div className='BoardSelectorBody'>
|
||||
<div className='head'>
|
||||
<div className='heading'>
|
||||
<h3 className='text-heading4'>
|
||||
<FormattedMessage
|
||||
id='boardSelector.title'
|
||||
defaultMessage='Link boards'
|
||||
/>
|
||||
</h3>
|
||||
<Button
|
||||
onClick={() => newLinkedBoard()}
|
||||
emphasis='secondary'
|
||||
>
|
||||
<FormattedMessage
|
||||
id='boardSelector.create-a-board'
|
||||
defaultMessage='Create a board'
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
<div className='queryWrapper'>
|
||||
<SearchIcon/>
|
||||
<input
|
||||
className='searchQuery'
|
||||
placeholder={intl.formatMessage({id: 'boardSelector.search-for-boards', defaultMessage:'Search for boards'})}
|
||||
type='text'
|
||||
onChange={(e) => debouncedSearchHandler(e.target.value)}
|
||||
autoFocus={true}
|
||||
maxLength={100}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className='searchResults'>
|
||||
{/*When there are results to show*/}
|
||||
{searchQuery && results.length > 0 &&
|
||||
results.map((result) => (<BoardSelectorItem
|
||||
key={result.id}
|
||||
item={result}
|
||||
linkBoard={linkBoard}
|
||||
unlinkBoard={unlinkBoard}
|
||||
currentChannel={currentChannel}
|
||||
/>))}
|
||||
|
||||
{/*when user searched for something and there were no results*/}
|
||||
{emptyResult && <EmptyResults query={searchQuery}/>}
|
||||
|
||||
{/*default state, when user didn't search for anything. This is the initial screen*/}
|
||||
{!emptyResult && !searchQuery && <EmptySearch/>}
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const IntlBoardSelector = () => {
|
||||
const language = useAppSelector<string>(getLanguage)
|
||||
|
||||
return (
|
||||
<IntlProvider
|
||||
locale={language.split(/[_]/)[0]}
|
||||
messages={getMessages(language)}
|
||||
>
|
||||
<BoardSelector/>
|
||||
</IntlProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default IntlBoardSelector
|
@ -0,0 +1,38 @@
|
||||
.BoardSelectorItem {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
flex-direction: row;
|
||||
padding: 10px 0;
|
||||
margin: 0 35px;
|
||||
|
||||
.icon {
|
||||
align-items: flex-start;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.resultLine {
|
||||
flex-grow: 1;
|
||||
width: 80%;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.resultTitle {
|
||||
overflow: hidden;
|
||||
max-width: 60%;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.resultDescription {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.linkUnlinkButton {
|
||||
display: flex;
|
||||
align-self: center;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
@ -0,0 +1,102 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react'
|
||||
import {render, screen} from '@testing-library/react'
|
||||
|
||||
import userEvent from '@testing-library/user-event'
|
||||
|
||||
import {createBoard} from '../../../../webapp/src/blocks/board'
|
||||
import {wrapIntl} from '../../../../webapp/src/testUtils'
|
||||
|
||||
import BoardSelectorItem from './boardSelectorItem'
|
||||
|
||||
describe('components/boardSelectorItem', () => {
|
||||
it('renders board without title', async () => {
|
||||
const board = createBoard()
|
||||
board.title = ""
|
||||
|
||||
const {container} = render(wrapIntl(
|
||||
<BoardSelectorItem
|
||||
item={board}
|
||||
currentChannel={board.channelId || ''}
|
||||
linkBoard={jest.fn()}
|
||||
unlinkBoard={jest.fn()}
|
||||
/>,
|
||||
))
|
||||
expect(container).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it('renders linked board', async () => {
|
||||
const board = createBoard()
|
||||
board.title = "Test title"
|
||||
|
||||
const {container} = render(wrapIntl(
|
||||
<BoardSelectorItem
|
||||
item={board}
|
||||
currentChannel={board.channelId || ''}
|
||||
linkBoard={jest.fn()}
|
||||
unlinkBoard={jest.fn()}
|
||||
/>,
|
||||
))
|
||||
expect(container).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it('renders not linked board', async () => {
|
||||
const board = createBoard()
|
||||
board.title = "Test title"
|
||||
|
||||
const {container} = render(wrapIntl(
|
||||
<BoardSelectorItem
|
||||
item={board}
|
||||
currentChannel={'other-channel'}
|
||||
linkBoard={jest.fn()}
|
||||
unlinkBoard={jest.fn()}
|
||||
/>,
|
||||
))
|
||||
expect(container).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it('call handler on link', async () => {
|
||||
const board = createBoard()
|
||||
|
||||
const linkBoard = jest.fn()
|
||||
const unlinkBoard = jest.fn()
|
||||
|
||||
render(wrapIntl(
|
||||
<BoardSelectorItem
|
||||
item={board}
|
||||
currentChannel={'other-channel'}
|
||||
linkBoard={linkBoard}
|
||||
unlinkBoard={unlinkBoard}
|
||||
/>,
|
||||
))
|
||||
|
||||
const buttonElement = screen.getByRole('button')
|
||||
await userEvent.click(buttonElement)
|
||||
expect(linkBoard).toBeCalledWith(board)
|
||||
expect(unlinkBoard).not.toBeCalled()
|
||||
})
|
||||
|
||||
it('call handler on unlink', async () => {
|
||||
const board = createBoard()
|
||||
|
||||
const linkBoard = jest.fn()
|
||||
const unlinkBoard = jest.fn()
|
||||
|
||||
render(wrapIntl(
|
||||
<BoardSelectorItem
|
||||
item={board}
|
||||
currentChannel={board.channelId || ''}
|
||||
linkBoard={linkBoard}
|
||||
unlinkBoard={unlinkBoard}
|
||||
/>,
|
||||
))
|
||||
|
||||
const buttonElement = screen.getByRole('button')
|
||||
await userEvent.click(buttonElement)
|
||||
expect(unlinkBoard).toBeCalledWith(board)
|
||||
expect(linkBoard).not.toBeCalled()
|
||||
})
|
||||
})
|
||||
|
@ -0,0 +1,56 @@
|
||||
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
import React from 'react'
|
||||
import {useIntl, FormattedMessage} from 'react-intl'
|
||||
|
||||
import {Board} from '../../../../webapp/src/blocks/board'
|
||||
import Button from '../../../../webapp/src/widgets/buttons/button'
|
||||
|
||||
import './boardSelectorItem.scss'
|
||||
|
||||
type Props = {
|
||||
item: Board
|
||||
currentChannel: string
|
||||
linkBoard: (board: Board) => void
|
||||
unlinkBoard: (board: Board) => void
|
||||
}
|
||||
|
||||
const BoardSelectorItem = (props: Props) => {
|
||||
const {item, currentChannel} = props
|
||||
const intl = useIntl()
|
||||
const untitledBoardTitle = intl.formatMessage({id: 'ViewTitle.untitled-board', defaultMessage: 'Untitled board'})
|
||||
const resultTitle = item.title || untitledBoardTitle
|
||||
return (
|
||||
<div className='BoardSelectorItem'>
|
||||
<span className='icon'>{item.icon}</span>
|
||||
<div className='resultLine'>
|
||||
<div className='resultTitle'>{resultTitle}</div>
|
||||
<div className='resultDescription'>{item.description}</div>
|
||||
</div>
|
||||
<div className='linkUnlinkButton'>
|
||||
{item.channelId === currentChannel &&
|
||||
<Button
|
||||
onClick={() => props.unlinkBoard(item)}
|
||||
emphasis='secondary'
|
||||
>
|
||||
<FormattedMessage
|
||||
id='boardSelector.unlink'
|
||||
defaultMessage='Unlink'
|
||||
/>
|
||||
</Button>}
|
||||
{item.channelId !== currentChannel &&
|
||||
<Button
|
||||
onClick={() => props.linkBoard(item)}
|
||||
emphasis='primary'
|
||||
>
|
||||
<FormattedMessage
|
||||
id='boardSelector.link'
|
||||
defaultMessage='Link'
|
||||
/>
|
||||
</Button>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default BoardSelectorItem
|
@ -0,0 +1,98 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`components/boardsUnfurl/BoardsUnfurl renders normally 1`] = `
|
||||
<div>
|
||||
<a
|
||||
class="FocalboardUnfurl"
|
||||
href="http://localhost:8065/test"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<div
|
||||
class="header"
|
||||
>
|
||||
<span
|
||||
class="icon"
|
||||
/>
|
||||
<div
|
||||
class="information"
|
||||
>
|
||||
<span
|
||||
class="card_title"
|
||||
>
|
||||
test card
|
||||
</span>
|
||||
<span
|
||||
class="board_title"
|
||||
>
|
||||
test board
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="body"
|
||||
>
|
||||
<div />
|
||||
</div>
|
||||
<div
|
||||
class="footer"
|
||||
>
|
||||
<div
|
||||
class="avatar"
|
||||
>
|
||||
|
||||
</div>
|
||||
<div
|
||||
class="timestamp_properties"
|
||||
>
|
||||
<div
|
||||
class="properties"
|
||||
/>
|
||||
<span
|
||||
class="post-preview__time"
|
||||
>
|
||||
Updated January 01, 1970, 12:00 AM
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`components/boardsUnfurl/BoardsUnfurl renders when limited 1`] = `
|
||||
<div>
|
||||
<a
|
||||
class="FocalboardUnfurl"
|
||||
href="http://localhost:8065/test"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<div
|
||||
class="header"
|
||||
>
|
||||
<span
|
||||
class="icon"
|
||||
/>
|
||||
<div
|
||||
class="information"
|
||||
>
|
||||
<span
|
||||
class="card_title"
|
||||
>
|
||||
test card
|
||||
</span>
|
||||
<span
|
||||
class="board_title"
|
||||
>
|
||||
test board
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
class="limited"
|
||||
>
|
||||
Additional details are hidden due to the card being archived
|
||||
</p>
|
||||
</a>
|
||||
</div>
|
||||
`;
|
@ -71,6 +71,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
.limited {
|
||||
font-size: 14px;
|
||||
color: rgba(var(--center-channel-color-rgb), 0.6);
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
@ -0,0 +1,120 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react'
|
||||
import {act, render} from '@testing-library/react'
|
||||
|
||||
import configureStore from 'redux-mock-store'
|
||||
|
||||
import {Provider as ReduxProvider} from 'react-redux'
|
||||
|
||||
import {mocked} from 'jest-mock'
|
||||
|
||||
import {Utils} from '../../../../../webapp/src/utils'
|
||||
import {createCard} from '../../../../../webapp/src/blocks/card'
|
||||
import {createBoard} from '../../../../../webapp/src/blocks/board'
|
||||
import octoClient from '../../../../../webapp/src/octoClient'
|
||||
import {wrapIntl} from '../../../../../webapp/src/testUtils'
|
||||
|
||||
import BoardsUnfurl from './boardsUnfurl'
|
||||
|
||||
jest.mock('../../../../../webapp/src/octoClient')
|
||||
jest.mock('../../../../../webapp/src/utils')
|
||||
const mockedOctoClient = mocked(octoClient, true)
|
||||
const mockedUtils = mocked(Utils, true)
|
||||
mockedUtils.createGuid = jest.requireActual('../../../../../webapp/src/utils').Utils.createGuid
|
||||
mockedUtils.blockTypeToIDType = jest.requireActual('../../../../../webapp/src/utils').Utils.blockTypeToIDType
|
||||
mockedUtils.displayDateTime = jest.requireActual('../../../../../webapp/src/utils').Utils.displayDateTime
|
||||
|
||||
describe('components/boardsUnfurl/BoardsUnfurl', () => {
|
||||
const team = {
|
||||
id: 'team-id',
|
||||
name: 'team',
|
||||
display_name: 'Team name',
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// This is done to the websocket not to try to connect directly
|
||||
mockedUtils.isFocalboardPlugin.mockReturnValue(true)
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renders normally', async () => {
|
||||
const mockStore = configureStore([])
|
||||
const store = mockStore({
|
||||
language: {
|
||||
value: 'en',
|
||||
},
|
||||
teams: {
|
||||
allTeams: [team],
|
||||
current: team,
|
||||
},
|
||||
})
|
||||
|
||||
const cards = [{...createCard(), title: 'test card', updateAt: 12345}]
|
||||
const board = {...createBoard(), title: 'test board'}
|
||||
|
||||
mockedOctoClient.getBlocksWithBlockID.mockResolvedValueOnce(cards)
|
||||
mockedOctoClient.getBoard.mockResolvedValueOnce(board)
|
||||
|
||||
const component = (
|
||||
<ReduxProvider store={store}>
|
||||
{wrapIntl(
|
||||
<BoardsUnfurl
|
||||
embed={{data: JSON.stringify({workspaceID: "foo", cardID: cards[0].id, boardID: board.id, readToken: "abc", originalPath: "/test"})}}
|
||||
/>,
|
||||
)}
|
||||
</ReduxProvider>
|
||||
)
|
||||
|
||||
let container: Element | DocumentFragment | null = null
|
||||
|
||||
await act(async () => {
|
||||
const result = render(component)
|
||||
container = result.container
|
||||
})
|
||||
expect(mockedOctoClient.getBoard).toBeCalledWith(board.id)
|
||||
expect(mockedOctoClient.getBlocksWithBlockID).toBeCalledWith(cards[0].id, board.id, "abc")
|
||||
|
||||
expect(container).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it('renders when limited', async () => {
|
||||
const mockStore = configureStore([])
|
||||
const store = mockStore({
|
||||
language: {
|
||||
value: 'en',
|
||||
},
|
||||
teams: {
|
||||
allTeams: [team],
|
||||
current: team,
|
||||
},
|
||||
})
|
||||
|
||||
const cards = [{...createCard(), title: 'test card', limited: true, updateAt: 12345}]
|
||||
const board = {...createBoard(), title: 'test board'}
|
||||
|
||||
mockedOctoClient.getBlocksWithBlockID.mockResolvedValueOnce(cards)
|
||||
mockedOctoClient.getBoard.mockResolvedValueOnce(board)
|
||||
|
||||
const component = (
|
||||
<ReduxProvider store={store}>
|
||||
{wrapIntl(
|
||||
<BoardsUnfurl
|
||||
embed={{data: JSON.stringify({workspaceID: "foo", cardID: cards[0].id, boardID: board.id, readToken: "abc", originalPath: "/test"})}}
|
||||
/>,
|
||||
)}
|
||||
</ReduxProvider>
|
||||
)
|
||||
|
||||
let container: Element | DocumentFragment | null = null
|
||||
|
||||
await act(async () => {
|
||||
const result = render(component)
|
||||
container = result.container
|
||||
})
|
||||
|
||||
expect(container).toMatchSnapshot()
|
||||
})
|
||||
})
|
||||
|
@ -1,22 +1,30 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
import React, {useState, useEffect} from 'react'
|
||||
import {IntlProvider, FormattedMessage} from 'react-intl'
|
||||
import {connect} from 'react-redux'
|
||||
import {IntlProvider, FormattedMessage, useIntl} from 'react-intl'
|
||||
|
||||
import {GlobalState} from 'mattermost-redux/types/store'
|
||||
import {getCurrentUserLocale} from 'mattermost-redux/selectors/entities/i18n'
|
||||
import WithWebSockets from '../../../../../webapp/src/components/withWebSockets'
|
||||
import {useWebsockets} from '../../../../../webapp/src/hooks/websockets'
|
||||
|
||||
import {getLanguage} from '../../../../../webapp/src/store/language'
|
||||
import {useAppSelector} from '../../../../../webapp/src/store/hooks'
|
||||
import {getCurrentTeamId} from '../../../../../webapp/src/store/teams'
|
||||
|
||||
import {WSClient, MMWebSocketClient} from '../../../../../webapp/src/wsclient'
|
||||
import manifest from '../../manifest'
|
||||
|
||||
import {getMessages} from './../../../../../webapp/src/i18n'
|
||||
import {Utils} from './../../../../../webapp/src/utils'
|
||||
import {Block} from './../../../../../webapp/src/blocks/block'
|
||||
import {Card} from './../../../../../webapp/src/blocks/card'
|
||||
import {Board} from './../../../../../webapp/src/blocks/board'
|
||||
import {ContentBlock} from './../../../../../webapp/src/blocks/contentBlock'
|
||||
|
||||
import octoClient from './../../../../../webapp/src/octoClient'
|
||||
|
||||
const Avatar = (window as any).Components.Avatar
|
||||
const Timestamp = (window as any).Components.Timestamp
|
||||
const imageURLForUser = (window as any).Components.imageURLForUser
|
||||
const noop = () => ''
|
||||
const Avatar = (window as any).Components?.Avatar || noop
|
||||
const imageURLForUser = (window as any).Components?.imageURLForUser || noop
|
||||
|
||||
import './boardsUnfurl.scss'
|
||||
import '../../../../../webapp/src/styles/labels.scss'
|
||||
@ -25,15 +33,7 @@ type Props = {
|
||||
embed: {
|
||||
data: string,
|
||||
},
|
||||
locale: string,
|
||||
}
|
||||
|
||||
function mapStateToProps(state: GlobalState) {
|
||||
const locale = getCurrentUserLocale(state)
|
||||
|
||||
return {
|
||||
locale,
|
||||
}
|
||||
webSocketClient?: MMWebSocketClient,
|
||||
}
|
||||
|
||||
class FocalboardEmbeddedData {
|
||||
@ -53,13 +53,16 @@ class FocalboardEmbeddedData {
|
||||
}
|
||||
}
|
||||
|
||||
const BoardsUnfurl = (props: Props): JSX.Element => {
|
||||
export const BoardsUnfurl = (props: Props): JSX.Element => {
|
||||
if (!props.embed || !props.embed.data) {
|
||||
return <></>
|
||||
}
|
||||
|
||||
const {embed, locale} = props
|
||||
const intl = useIntl()
|
||||
|
||||
const {embed, webSocketClient} = props
|
||||
const focalboardInformation: FocalboardEmbeddedData = new FocalboardEmbeddedData(embed.data)
|
||||
const currentTeamId = useAppSelector(getCurrentTeamId)
|
||||
const {teamID, cardID, boardID, readToken, originalPath} = focalboardInformation
|
||||
const baseURL = window.location.origin
|
||||
|
||||
@ -110,6 +113,26 @@ const BoardsUnfurl = (props: Props): JSX.Element => {
|
||||
fetchData()
|
||||
}, [originalPath])
|
||||
|
||||
useWebsockets(currentTeamId, (wsClient: WSClient) => {
|
||||
const onChangeHandler = (_: WSClient, blocks: Block[]): void => {
|
||||
const cardBlock: Block|undefined = blocks.find(b => b.id === cardID)
|
||||
if (cardBlock && !cardBlock.deleteAt) {
|
||||
setCard(cardBlock as Card)
|
||||
}
|
||||
|
||||
const contentBlock: Block|undefined = blocks.find(b => b.id === content?.id)
|
||||
if (contentBlock && !contentBlock.deleteAt) {
|
||||
setContent(contentBlock)
|
||||
}
|
||||
}
|
||||
|
||||
wsClient.addOnChange(onChangeHandler, 'block')
|
||||
|
||||
return () => {
|
||||
wsClient.removeOnChange(onChangeHandler, 'block')
|
||||
}
|
||||
}, [cardID, content?.id])
|
||||
|
||||
let remainder = 0
|
||||
let html = ''
|
||||
const propertiesToDisplay: Array<Record<string, string>> = []
|
||||
@ -159,10 +182,7 @@ const BoardsUnfurl = (props: Props): JSX.Element => {
|
||||
}
|
||||
|
||||
return (
|
||||
<IntlProvider
|
||||
messages={getMessages(locale)}
|
||||
locale={locale}
|
||||
>
|
||||
<WithWebSockets manifest={manifest} webSocketClient={webSocketClient}>
|
||||
{!loading && (!card || !board) && <></>}
|
||||
{!loading && card && board &&
|
||||
<a
|
||||
@ -182,7 +202,7 @@ const BoardsUnfurl = (props: Props): JSX.Element => {
|
||||
</div>
|
||||
|
||||
{/* Body of the Card*/}
|
||||
{html !== '' &&
|
||||
{!card.limited && html !== '' &&
|
||||
<div className='body'>
|
||||
<div
|
||||
dangerouslySetInnerHTML={{__html: html}}
|
||||
@ -190,69 +210,79 @@ const BoardsUnfurl = (props: Props): JSX.Element => {
|
||||
</div>
|
||||
}
|
||||
|
||||
{/* Footer of the Card*/}
|
||||
<div className='footer'>
|
||||
<div className='avatar'>
|
||||
<Avatar
|
||||
size={'md'}
|
||||
url={imageURLForUser(card.createdBy)}
|
||||
className={'avatar-post-preview'}
|
||||
{card.limited &&
|
||||
<p className='limited'>
|
||||
<FormattedMessage
|
||||
id='BoardsUnfurl.Limited'
|
||||
defaultMessage={'Additional details are hidden due to the card being archived'}
|
||||
/>
|
||||
</div>
|
||||
<div className='timestamp_properties'>
|
||||
<div className='properties'>
|
||||
{propertiesToDisplay.map((property) => (
|
||||
<div
|
||||
key={property.optionValue}
|
||||
className={`property ${property.optionValueColour}`}
|
||||
title={`${property.optionName}`}
|
||||
style={{maxWidth: `${(1 / propertiesToDisplay.length) * 100}%`}}
|
||||
>
|
||||
{property.optionValue}
|
||||
</div>
|
||||
))}
|
||||
{remainder > 0 &&
|
||||
<span className='remainder'>
|
||||
<FormattedMessage
|
||||
id='BoardsUnfurl.Remainder'
|
||||
defaultMessage='+{remainder} more'
|
||||
values={{
|
||||
remainder,
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<span className='post-preview__time'>
|
||||
<FormattedMessage
|
||||
id='BoardsUnfurl.Updated'
|
||||
defaultMessage='Updated {time}'
|
||||
values={{
|
||||
time: (
|
||||
<Timestamp
|
||||
value={card.updateAt}
|
||||
units={[
|
||||
'now',
|
||||
'minute',
|
||||
'hour',
|
||||
'day',
|
||||
]}
|
||||
useTime={false}
|
||||
day={'numeric'}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
</p>}
|
||||
|
||||
{/* Footer of the Card*/}
|
||||
{!card.limited &&
|
||||
<div className='footer'>
|
||||
<div className='avatar'>
|
||||
<Avatar
|
||||
size={'md'}
|
||||
url={imageURLForUser(card.createdBy)}
|
||||
className={'avatar-post-preview'}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='timestamp_properties'>
|
||||
<div className='properties'>
|
||||
{propertiesToDisplay.map((property) => (
|
||||
<div
|
||||
key={property.optionValue}
|
||||
className={`property ${property.optionValueColour}`}
|
||||
title={`${property.optionName}`}
|
||||
style={{maxWidth: `${(1 / propertiesToDisplay.length) * 100}%`}}
|
||||
>
|
||||
{property.optionValue}
|
||||
</div>
|
||||
))}
|
||||
{remainder > 0 &&
|
||||
<span className='remainder'>
|
||||
<FormattedMessage
|
||||
id='BoardsUnfurl.Remainder'
|
||||
defaultMessage='+{remainder} more'
|
||||
values={{
|
||||
remainder,
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<span className='post-preview__time'>
|
||||
<FormattedMessage
|
||||
id='BoardsUnfurl.Updated'
|
||||
defaultMessage='Updated {time}'
|
||||
values={{
|
||||
time: Utils.displayDateTime(new Date(card.updateAt), intl)
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</div>}
|
||||
</a>
|
||||
}
|
||||
{loading &&
|
||||
<div style={{height: '302px'}}/>
|
||||
}
|
||||
</WithWebSockets>
|
||||
)
|
||||
}
|
||||
|
||||
const IntlBoardsUnfurl = (props: Props) => {
|
||||
const language = useAppSelector<string>(getLanguage)
|
||||
|
||||
return (
|
||||
<IntlProvider
|
||||
locale={language.split(/[_]/)[0]}
|
||||
messages={getMessages(language)}
|
||||
>
|
||||
<BoardsUnfurl {...props}/>
|
||||
</IntlProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps)(BoardsUnfurl)
|
||||
export default IntlBoardsUnfurl
|
||||
|
@ -0,0 +1,51 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react'
|
||||
import {Post} from 'mattermost-redux/types/posts'
|
||||
|
||||
const PostTypeCloudUpgradeNudge = (props: {post: Post}): JSX.Element => {
|
||||
const ctaHandler = (e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
const windowAny = (window as any)
|
||||
windowAny?.openPricingModal()()
|
||||
}
|
||||
|
||||
// custom post type doesn't support styling via CSS stylesheet.
|
||||
// Only styled components or react styles work there.
|
||||
const ctaContainerStyle = {
|
||||
padding: '12px',
|
||||
borderRadius: '0 4px 4px 0',
|
||||
border: '1px solid rgba(63, 67, 80, 0.16)',
|
||||
borderLeft: '6px solid var(--link-color)',
|
||||
width: 'max-content',
|
||||
margin: '10px 0',
|
||||
}
|
||||
|
||||
const ctaBtnStyle = {
|
||||
background: 'var(--link-color)',
|
||||
color: 'var(--center-channel-bg)',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
padding: '8px 20px',
|
||||
fontWeight: 600,
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='PostTypeCloudUpgradeNudge'>
|
||||
<span>{props.post.message}</span>
|
||||
<div
|
||||
style={ctaContainerStyle}
|
||||
>
|
||||
<button
|
||||
onClick={ctaHandler}
|
||||
style={ctaBtnStyle}
|
||||
>
|
||||
{'View upgrade options'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PostTypeCloudUpgradeNudge
|
@ -0,0 +1,36 @@
|
||||
.RHSChannelBoardItem {
|
||||
padding: 15px;
|
||||
text-align: left;
|
||||
border: 1px solid #cccccc;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
color: rgb(var(--center-channel-color-rgb));
|
||||
|
||||
.date {
|
||||
color: #cccccc;
|
||||
}
|
||||
|
||||
.board-info {
|
||||
display: flex;
|
||||
font-size: 16px;
|
||||
|
||||
.icon {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 600;
|
||||
flex-grow: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
.description {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
@ -0,0 +1,65 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react'
|
||||
import {Provider as ReduxProvider} from 'react-redux'
|
||||
import {render, screen} from '@testing-library/react'
|
||||
|
||||
import userEvent from '@testing-library/user-event'
|
||||
|
||||
import {createBoard} from '../../../../webapp/src/blocks/board'
|
||||
import {mockStateStore, wrapIntl} from '../../../../webapp/src/testUtils'
|
||||
|
||||
import RHSChannelBoardItem from './rhsChannelBoardItem'
|
||||
|
||||
describe('components/rhsChannelBoardItem', () => {
|
||||
it('render board', async () => {
|
||||
const state = {
|
||||
teams: {
|
||||
current: {
|
||||
id: 'team-id',
|
||||
name: 'team',
|
||||
display_name: 'Team name',
|
||||
},
|
||||
},
|
||||
}
|
||||
const board = createBoard()
|
||||
board.updateAt = 1657311058157
|
||||
board.title = 'Test board'
|
||||
|
||||
const store = mockStateStore([], state)
|
||||
const {container} = render(wrapIntl(
|
||||
<ReduxProvider store={store}>
|
||||
<RHSChannelBoardItem board={board} />
|
||||
</ReduxProvider>
|
||||
))
|
||||
expect(container).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it('render board with menu open', async () => {
|
||||
const state = {
|
||||
teams: {
|
||||
current: {
|
||||
id: 'team-id',
|
||||
name: 'team',
|
||||
display_name: 'Team name',
|
||||
},
|
||||
},
|
||||
}
|
||||
const board = createBoard()
|
||||
board.updateAt = 1657311058157
|
||||
board.title = 'Test board'
|
||||
|
||||
const store = mockStateStore([], state)
|
||||
const {container} = render(wrapIntl(
|
||||
<ReduxProvider store={store}>
|
||||
<RHSChannelBoardItem board={board} />
|
||||
</ReduxProvider>
|
||||
))
|
||||
|
||||
const buttonElement = screen.getByRole('button', {name: 'menuwrapper'})
|
||||
await userEvent.click(buttonElement)
|
||||
|
||||
expect(container).toMatchSnapshot()
|
||||
})
|
||||
})
|
@ -0,0 +1,83 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
import React from 'react'
|
||||
import {FormattedMessage, useIntl} from 'react-intl'
|
||||
|
||||
import mutator from '../../../../webapp/src/mutator'
|
||||
import {Utils} from '../../../../webapp/src/utils'
|
||||
import {getCurrentTeam} from '../../../../webapp/src/store/teams'
|
||||
import {createBoard, Board} from '../../../../webapp/src/blocks/board'
|
||||
import {useAppSelector} from '../../../../webapp/src/store/hooks'
|
||||
import IconButton from '../../../../webapp/src/widgets/buttons/iconButton'
|
||||
import OptionsIcon from '../../../../webapp/src/widgets/icons/options'
|
||||
import DeleteIcon from '../../../../webapp/src/widgets/icons/delete'
|
||||
import Menu from '../../../../webapp/src/widgets/menu'
|
||||
import MenuWrapper from '../../../../webapp/src/widgets/menuWrapper'
|
||||
|
||||
import './rhsChannelBoardItem.scss'
|
||||
|
||||
type Props = {
|
||||
board: Board
|
||||
}
|
||||
|
||||
const RHSChannelBoardItem = (props: Props) => {
|
||||
const intl = useIntl()
|
||||
const board = props.board
|
||||
|
||||
const team = useAppSelector(getCurrentTeam)
|
||||
if (!team) {
|
||||
return null
|
||||
}
|
||||
|
||||
const handleBoardClicked = (boardID: string) => {
|
||||
const windowAny: any = window
|
||||
windowAny.WebappUtils.browserHistory.push(`/boards/team/${team.id}/${boardID}`)
|
||||
}
|
||||
|
||||
const onUnlinkBoard = async (board: Board) => {
|
||||
const newBoard = createBoard(board)
|
||||
newBoard.channelId = ''
|
||||
mutator.updateBoard(newBoard, board, 'unlinked channel')
|
||||
}
|
||||
|
||||
const untitledBoardTitle = intl.formatMessage({id: 'ViewTitle.untitled-board', defaultMessage: 'Untitled board'})
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={() => handleBoardClicked(board.id)}
|
||||
className='RHSChannelBoardItem'
|
||||
>
|
||||
<div className='board-info'>
|
||||
{board.icon && <span className='icon'>{board.icon}</span>}
|
||||
<span className='title'>{board.title || untitledBoardTitle}</span>
|
||||
<MenuWrapper stopPropagationOnToggle={true}>
|
||||
<IconButton icon={<OptionsIcon/>}/>
|
||||
<Menu
|
||||
fixed={true}
|
||||
position='left'
|
||||
>
|
||||
<Menu.Text
|
||||
key={`unlinkBoard-${board.id}`}
|
||||
id='unlinkBoard'
|
||||
name={intl.formatMessage({id: 'rhs-boards.unlink-board', defaultMessage: 'Unlink board'})}
|
||||
icon={<DeleteIcon/>}
|
||||
onClick={() => {
|
||||
onUnlinkBoard(board)
|
||||
}}
|
||||
/>
|
||||
</Menu>
|
||||
</MenuWrapper>
|
||||
</div>
|
||||
<div className='description'>{board.description}</div>
|
||||
<div className='date'>
|
||||
<FormattedMessage
|
||||
id='rhs-boards.last-update-at'
|
||||
defaultMessage='Last update at: {datetime}'
|
||||
values={{datetime: Utils.displayDateTime(new Date(board.updateAt), intl as any)}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default RHSChannelBoardItem
|
@ -0,0 +1,63 @@
|
||||
.RHSChannelBoards {
|
||||
padding: 20px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
|
||||
&.empty {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
padding: 60px;
|
||||
}
|
||||
|
||||
.rhs-boards-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
>h2 {
|
||||
text-align: center;
|
||||
word-wrap: anywhere;
|
||||
}
|
||||
|
||||
.empty-paragraph {
|
||||
text-align: justify;
|
||||
text-align-last: center;
|
||||
}
|
||||
|
||||
.boards-screenshots {
|
||||
margin: 24px 0;
|
||||
}
|
||||
|
||||
.linked-boards {
|
||||
flex-grow: 1;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.rhs-boards-list {
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.Button {
|
||||
width: auto;
|
||||
align-self: center;
|
||||
max-width: 100%;
|
||||
|
||||
span {
|
||||
max-width: 100%;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,90 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react'
|
||||
import {Provider as ReduxProvider} from 'react-redux'
|
||||
import {render} from '@testing-library/react'
|
||||
import {mocked} from 'jest-mock'
|
||||
import thunk from 'redux-thunk'
|
||||
|
||||
import octoClient from '../../../../webapp/src/octoClient'
|
||||
import {createBoard} from '../../../../webapp/src/blocks/board'
|
||||
import {mockStateStore, wrapIntl} from '../../../../webapp/src/testUtils'
|
||||
|
||||
import RHSChannelBoards from './rhsChannelBoards'
|
||||
|
||||
jest.mock('../../../../webapp/src/octoClient')
|
||||
const mockedOctoClient = mocked(octoClient, true)
|
||||
|
||||
describe('components/rhsChannelBoards', () => {
|
||||
const board1 = createBoard()
|
||||
board1.updateAt = 1657311058157
|
||||
const board2 = createBoard()
|
||||
const board3 = createBoard()
|
||||
board3.updateAt = 1657311058157
|
||||
|
||||
board1.channelId = 'channel-id'
|
||||
board3.channelId = 'channel-id'
|
||||
|
||||
const team = {
|
||||
id: 'team-id',
|
||||
name: 'team',
|
||||
display_name: 'Team name',
|
||||
}
|
||||
const state = {
|
||||
teams: {
|
||||
allTeams: [team],
|
||||
current: team,
|
||||
currentId: team.id,
|
||||
},
|
||||
language: {
|
||||
value: 'en',
|
||||
},
|
||||
boards: {
|
||||
boards: {
|
||||
[board1.id]: board1,
|
||||
[board2.id]: board2,
|
||||
[board3.id]: board3,
|
||||
},
|
||||
myBoardMemberships: {
|
||||
[board1.id]: {boardId: board1.id, userId: 'user-id'},
|
||||
[board2.id]: {boardId: board2.id, userId: 'user-id'},
|
||||
[board3.id]: {boardId: board3.id, userId: 'user-id'},
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
current: {
|
||||
id: 'channel-id',
|
||||
name: 'channel',
|
||||
display_name: 'Channel Name',
|
||||
type: 'O',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockedOctoClient.getBoards.mockResolvedValue([board1, board2, board3])
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renders the RHS for channel boards', async () => {
|
||||
const store = mockStateStore([thunk], state)
|
||||
const {container} = render(wrapIntl(
|
||||
<ReduxProvider store={store}>
|
||||
<RHSChannelBoards/>
|
||||
</ReduxProvider>
|
||||
))
|
||||
expect(container).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it('renders with empty list of boards', async () => {
|
||||
const localState = {...state, boards: {...state.boards, boards: {}}}
|
||||
const store = mockStateStore([thunk], localState)
|
||||
const {container} = render(wrapIntl(
|
||||
<ReduxProvider store={store}>
|
||||
<RHSChannelBoards/>
|
||||
</ReduxProvider>
|
||||
))
|
||||
expect(container).toMatchSnapshot()
|
||||
})
|
||||
})
|
158
mattermost-plugin/webapp/src/components/rhsChannelBoards.tsx
Normal file
158
mattermost-plugin/webapp/src/components/rhsChannelBoards.tsx
Normal file
@ -0,0 +1,158 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
import React, {useEffect} from 'react'
|
||||
import {FormattedMessage, IntlProvider, useIntl} from 'react-intl'
|
||||
|
||||
import {getMessages} from '../../../../webapp/src/i18n'
|
||||
import {getLanguage} from '../../../../webapp/src/store/language'
|
||||
|
||||
import {useWebsockets} from '../../../../webapp/src/hooks/websockets'
|
||||
|
||||
import {Board, BoardMember} from '../../../../webapp/src/blocks/board'
|
||||
import {getCurrentTeamId} from '../../../../webapp/src/store/teams'
|
||||
import {loadBoards} from '../../../../webapp/src/store/initialLoad'
|
||||
import {getCurrentChannel} from '../../../../webapp/src/store/channels'
|
||||
import {getMySortedBoards, setLinkToChannel, updateBoards, updateMembers} from '../../../../webapp/src/store/boards'
|
||||
import {useAppSelector, useAppDispatch} from '../../../../webapp/src/store/hooks'
|
||||
import AddIcon from '../../../../webapp/src/widgets/icons/add'
|
||||
import Button from '../../../../webapp/src/widgets/buttons/button'
|
||||
|
||||
import {WSClient} from '../../../../webapp/src/wsclient'
|
||||
|
||||
import RHSChannelBoardItem from './rhsChannelBoardItem'
|
||||
|
||||
import './rhsChannelBoards.scss'
|
||||
|
||||
const boardsScreenshots = (window as any).baseURL + '/public/boards-screenshots.png'
|
||||
|
||||
const RHSChannelBoards = () => {
|
||||
const boards = useAppSelector(getMySortedBoards)
|
||||
const teamId = useAppSelector(getCurrentTeamId)
|
||||
const currentChannel = useAppSelector(getCurrentChannel)
|
||||
const dispatch = useAppDispatch()
|
||||
const intl = useIntl()
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(loadBoards())
|
||||
}, [])
|
||||
|
||||
useWebsockets(teamId || '', (wsClient: WSClient) => {
|
||||
const onChangeBoardHandler = (_: WSClient, boards: Board[]): void => {
|
||||
dispatch(updateBoards(boards))
|
||||
}
|
||||
const onChangeMemberHandler = (_: WSClient, members: BoardMember[]): void => {
|
||||
dispatch(updateMembers(members))
|
||||
}
|
||||
|
||||
wsClient.addOnChange(onChangeBoardHandler, 'board')
|
||||
wsClient.addOnChange(onChangeMemberHandler, 'boardMembers')
|
||||
|
||||
return () => {
|
||||
wsClient.removeOnChange(onChangeBoardHandler, 'board')
|
||||
wsClient.removeOnChange(onChangeMemberHandler, 'boardMembers')
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (!boards) {
|
||||
return null
|
||||
}
|
||||
if (!teamId) {
|
||||
return null
|
||||
}
|
||||
if (!currentChannel) {
|
||||
return null
|
||||
}
|
||||
const channelBoards = boards.filter((b) => b.channelId === currentChannel.id)
|
||||
|
||||
let channelName = currentChannel.display_name
|
||||
let headerChannelName = currentChannel.display_name
|
||||
|
||||
if (currentChannel.type === 'D') {
|
||||
channelName = intl.formatMessage({id: 'rhs-boards.dm', defaultMessage: 'DM'})
|
||||
headerChannelName = intl.formatMessage({id: 'rhs-boards.header.dm', defaultMessage: 'this Direct Message'})
|
||||
} else if (currentChannel.type === 'G') {
|
||||
channelName = intl.formatMessage({id: 'rhs-boards.gm', defaultMessage: 'GM'})
|
||||
headerChannelName = intl.formatMessage({id: 'rhs-boards.header.gm', defaultMessage: 'this Group Message'})
|
||||
}
|
||||
|
||||
if (channelBoards.length === 0) {
|
||||
return (
|
||||
<div className='focalboard-body'>
|
||||
<div className='RHSChannelBoards empty'>
|
||||
<h2>
|
||||
<FormattedMessage
|
||||
id='rhs-boards.no-boards-linked-to-channel'
|
||||
defaultMessage='No boards are linked to {channelName} yet'
|
||||
values={{channelName: headerChannelName}}
|
||||
/>
|
||||
</h2>
|
||||
<div className='empty-paragraph'>
|
||||
<FormattedMessage
|
||||
id='rhs-boards.no-boards-linked-to-channel-description'
|
||||
defaultMessage='Boards is a project management tool that helps define, organize, track and manage work across teams, using a familiar kanban board view.'
|
||||
/>
|
||||
</div>
|
||||
<div className='boards-screenshots'><img src={boardsScreenshots}/></div>
|
||||
<Button
|
||||
onClick={() => dispatch(setLinkToChannel(currentChannel.id))}
|
||||
emphasis='primary'
|
||||
size='medium'
|
||||
>
|
||||
<FormattedMessage
|
||||
id='rhs-boards.link-boards-to-channel'
|
||||
defaultMessage='Link boards to {channelName}'
|
||||
values={{channelName: channelName}}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='focalboard-body'>
|
||||
<div className='RHSChannelBoards'>
|
||||
<div className='rhs-boards-header'>
|
||||
<span className='linked-boards'>
|
||||
<FormattedMessage
|
||||
id='rhs-boards.linked-boards'
|
||||
defaultMessage='Linked boards'
|
||||
/>
|
||||
</span>
|
||||
<Button
|
||||
onClick={() => dispatch(setLinkToChannel(currentChannel.id))}
|
||||
icon={<AddIcon/>}
|
||||
emphasis='primary'
|
||||
>
|
||||
<FormattedMessage
|
||||
id='rhs-boards.add'
|
||||
defaultMessage='Add'
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
<div className='rhs-boards-list'>
|
||||
{channelBoards.map((b) => (
|
||||
<RHSChannelBoardItem
|
||||
key={b.id}
|
||||
board={b}
|
||||
/>))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const IntlRHSChannelBoards = () => {
|
||||
const language = useAppSelector<string>(getLanguage)
|
||||
|
||||
return (
|
||||
<IntlProvider
|
||||
locale={language.split(/[_]/)[0]}
|
||||
messages={getMessages(language)}
|
||||
>
|
||||
<RHSChannelBoards/>
|
||||
</IntlProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default IntlRHSChannelBoards
|
@ -0,0 +1,35 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react'
|
||||
import {Provider as ReduxProvider} from 'react-redux'
|
||||
import {render} from '@testing-library/react'
|
||||
|
||||
import {mockStateStore, wrapIntl} from '../../../../webapp/src/testUtils'
|
||||
|
||||
import RHSChannelBoardsHeader from './rhsChannelBoardsHeader'
|
||||
|
||||
describe('components/rhsChannelBoardsHeader', () => {
|
||||
it('renders the header', async () => {
|
||||
const state = {
|
||||
language: {
|
||||
value: 'en',
|
||||
},
|
||||
channels: {
|
||||
current: {
|
||||
id: 'channel-id',
|
||||
name: 'channel',
|
||||
display_name: 'Channel Name',
|
||||
type: 'O',
|
||||
},
|
||||
},
|
||||
}
|
||||
const store = mockStateStore([], state)
|
||||
const {container} = render(wrapIntl(
|
||||
<ReduxProvider store={store}>
|
||||
<RHSChannelBoardsHeader/>
|
||||
</ReduxProvider>
|
||||
))
|
||||
expect(container).toMatchSnapshot()
|
||||
})
|
||||
})
|
@ -0,0 +1,42 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
import React from 'react'
|
||||
import {FormattedMessage, IntlProvider} from 'react-intl'
|
||||
|
||||
import {getMessages} from '../../../../webapp/src/i18n'
|
||||
import {getLanguage} from '../../../../webapp/src/store/language'
|
||||
import {getCurrentChannel} from '../../../../webapp/src/store/channels'
|
||||
import {useAppSelector} from '../../../../webapp/src/store/hooks'
|
||||
|
||||
const RHSChannelBoardsHeader = () => {
|
||||
const appBarIconURL = (window as any).baseURL + '/public/app-bar-icon.png'
|
||||
const currentChannel = useAppSelector(getCurrentChannel)
|
||||
const language = useAppSelector<string>(getLanguage)
|
||||
|
||||
if (!currentChannel) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<IntlProvider
|
||||
locale={language.split(/[_]/)[0]}
|
||||
messages={getMessages(language)}
|
||||
>
|
||||
<div>
|
||||
<img
|
||||
className='boards-rhs-header-logo'
|
||||
src={appBarIconURL}
|
||||
/>
|
||||
<span>
|
||||
<FormattedMessage
|
||||
id='rhs-channel-boards-header.title'
|
||||
defaultMessage='Boards'
|
||||
/>
|
||||
</span>
|
||||
<span className='style--none sidebar--right__title__subtitle'>{currentChannel.display_name}</span>
|
||||
</div>
|
||||
</IntlProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default RHSChannelBoardsHeader
|
@ -12,6 +12,8 @@ import {GlobalState} from 'mattermost-redux/types/store'
|
||||
import {selectTeam} from 'mattermost-redux/actions/teams'
|
||||
|
||||
import {SuiteWindow} from '../../../webapp/src/types/index'
|
||||
import {UserSettings} from '../../../webapp/src/userSettings'
|
||||
|
||||
|
||||
const windowAny = (window as SuiteWindow)
|
||||
windowAny.baseURL = '/plugins/focalboard'
|
||||
@ -21,6 +23,9 @@ windowAny.isFocalboardPlugin = true
|
||||
import App from '../../../webapp/src/app'
|
||||
import store from '../../../webapp/src/store'
|
||||
import {setTeam} from '../../../webapp/src/store/teams'
|
||||
import WithWebSockets from '../../../webapp/src/components/withWebSockets'
|
||||
import {setChannel} from '../../../webapp/src/store/channels'
|
||||
import {initialLoad} from '../../../webapp/src/store/initialLoad'
|
||||
import {Utils} from '../../../webapp/src/utils'
|
||||
import GlobalHeader from '../../../webapp/src/components/globalHeader/globalHeader'
|
||||
import FocalboardIcon from '../../../webapp/src/widgets/icons/logo'
|
||||
@ -34,12 +39,18 @@ import '../../../webapp/src/styles/labels.scss'
|
||||
import octoClient from '../../../webapp/src/octoClient'
|
||||
|
||||
import BoardsUnfurl from './components/boardsUnfurl/boardsUnfurl'
|
||||
import RHSChannelBoards from './components/rhsChannelBoards'
|
||||
import RHSChannelBoardsHeader from './components/rhsChannelBoardsHeader'
|
||||
import BoardSelector from './components/boardSelector'
|
||||
import wsClient, {
|
||||
MMWebSocketClient,
|
||||
ACTION_UPDATE_BLOCK,
|
||||
ACTION_UPDATE_CLIENT_CONFIG,
|
||||
ACTION_UPDATE_SUBSCRIPTION,
|
||||
ACTION_UPDATE_CATEGORY, ACTION_UPDATE_BOARD_CATEGORY, ACTION_UPDATE_BOARD,
|
||||
ACTION_UPDATE_CARD_LIMIT_TIMESTAMP,
|
||||
ACTION_UPDATE_CATEGORY,
|
||||
ACTION_UPDATE_BOARD_CATEGORY,
|
||||
ACTION_UPDATE_BOARD,
|
||||
} from './../../../webapp/src/wsclient'
|
||||
|
||||
import manifest from './manifest'
|
||||
@ -49,6 +60,7 @@ import ErrorBoundary from './error_boundary'
|
||||
import {PluginRegistry} from './types/mattermost-webapp'
|
||||
|
||||
import './plugin.scss'
|
||||
import CloudUpgradeNudge from "./components/cloudUpgradeNudge/cloudUpgradeNudge"
|
||||
|
||||
function getSubpath(siteURL: string): string {
|
||||
const url = new URL(siteURL)
|
||||
@ -87,12 +99,12 @@ function customHistory() {
|
||||
}
|
||||
|
||||
const pathName = event.data.message?.pathName
|
||||
if (!pathName || !pathName.startsWith(windowAny.frontendBaseURL)) {
|
||||
if (!pathName || !pathName.startsWith('/boards')) {
|
||||
return
|
||||
}
|
||||
|
||||
Utils.log(`Navigating Boards to ${pathName}`)
|
||||
history.replace(pathName.replace(windowAny.frontendBaseURL, ''))
|
||||
history.replace(pathName.replace('/boards', ''))
|
||||
})
|
||||
}
|
||||
return {
|
||||
@ -118,8 +130,6 @@ function customHistory() {
|
||||
let browserHistory: History<unknown>
|
||||
|
||||
const MainApp = (props: Props) => {
|
||||
wsClient.initPlugin(manifest.id, manifest.version, props.webSocketClient)
|
||||
|
||||
useEffect(() => {
|
||||
document.body.classList.add('focalboard-body')
|
||||
document.body.classList.add('app__body')
|
||||
@ -140,10 +150,12 @@ const MainApp = (props: Props) => {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<ReduxProvider store={store}>
|
||||
<div id='focalboard-app'>
|
||||
<App history={browserHistory}/>
|
||||
</div>
|
||||
<div id='focalboard-root-portal'/>
|
||||
<WithWebSockets manifest={manifest} webSocketClient={props.webSocketClient}>
|
||||
<div id='focalboard-app'>
|
||||
<App history={browserHistory}/>
|
||||
</div>
|
||||
<div id='focalboard-root-portal'/>
|
||||
</WithWebSockets>
|
||||
</ReduxProvider>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
@ -159,6 +171,8 @@ const HeaderComponent = () => {
|
||||
|
||||
export default class Plugin {
|
||||
channelHeaderButtonId?: string
|
||||
rhsId?: string
|
||||
boardSelectorId?: string
|
||||
registry?: PluginRegistry
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-empty-function
|
||||
@ -171,16 +185,54 @@ export default class Plugin {
|
||||
|
||||
this.registry = registry
|
||||
|
||||
UserSettings.nameFormat = mmStore.getState().entities.preferences?.myPreferences['display_settings--name_format']?.value || null
|
||||
let theme = mmStore.getState().entities.preferences.myPreferences.theme
|
||||
setMattermostTheme(theme)
|
||||
|
||||
// register websocket handlers
|
||||
this.registry?.registerWebSocketEventHandler(`custom_${manifest.id}_${ACTION_UPDATE_BOARD}`, (e: any) => wsClient.updateHandler(e.data))
|
||||
this.registry?.registerWebSocketEventHandler(`custom_${manifest.id}_${ACTION_UPDATE_CATEGORY}`, (e: any) => wsClient.updateHandler(e.data))
|
||||
this.registry?.registerWebSocketEventHandler(`custom_${manifest.id}_${ACTION_UPDATE_BOARD_CATEGORY}`, (e: any) => wsClient.updateHandler(e.data))
|
||||
this.registry?.registerWebSocketEventHandler(`custom_${manifest.id}_${ACTION_UPDATE_CLIENT_CONFIG}`, (e: any) => wsClient.updateClientConfigHandler(e.data))
|
||||
this.registry?.registerWebSocketEventHandler(`custom_${manifest.id}_${ACTION_UPDATE_CARD_LIMIT_TIMESTAMP}`, (e: any) => wsClient.updateCardLimitTimestampHandler(e.data))
|
||||
this.registry?.registerWebSocketEventHandler(`custom_${manifest.id}_${ACTION_UPDATE_SUBSCRIPTION}`, (e: any) => wsClient.updateSubscriptionHandler(e.data))
|
||||
this.registry?.registerWebSocketEventHandler('plugin_statuses_changed', (e: any) => wsClient.pluginStatusesChangedHandler(e.data))
|
||||
this.registry?.registerPostTypeComponent('custom_cloud_upgrade_nudge', CloudUpgradeNudge)
|
||||
this.registry?.registerWebSocketEventHandler('preferences_changed', (e: any) => {
|
||||
let preferences
|
||||
try {
|
||||
preferences = JSON.parse(e.data.preferences)
|
||||
} catch {
|
||||
preferences = []
|
||||
}
|
||||
if (preferences) {
|
||||
for (const preference of preferences) {
|
||||
if (preference.category === 'theme' && theme !== preference.value) {
|
||||
setMattermostTheme(JSON.parse(preference.value))
|
||||
theme = preference.value
|
||||
}
|
||||
if(preference.category === 'display_settings' && preference.name === 'name_format'){
|
||||
UserSettings.nameFormat = preference.value
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
let lastViewedChannel = mmStore.getState().entities.channels.currentChannelId
|
||||
let prevTeamID: string
|
||||
|
||||
const currentChannel = mmStore.getState().entities.channels.currentChannelId
|
||||
const currentChannelObj = mmStore.getState().entities.channels.channels[currentChannel]
|
||||
store.dispatch(setChannel(currentChannelObj))
|
||||
|
||||
mmStore.subscribe(() => {
|
||||
const currentUserId = mmStore.getState().entities.users.currentUserId
|
||||
const currentChannel = mmStore.getState().entities.channels.currentChannelId
|
||||
if (lastViewedChannel !== currentChannel && currentChannel) {
|
||||
localStorage.setItem('focalboardLastViewedChannel:' + currentUserId, currentChannel)
|
||||
lastViewedChannel = currentChannel
|
||||
const currentChannelObj = mmStore.getState().entities.channels.channels[lastViewedChannel]
|
||||
store.dispatch(setChannel(currentChannelObj))
|
||||
}
|
||||
|
||||
// Watch for change in active team.
|
||||
@ -188,24 +240,37 @@ export default class Plugin {
|
||||
const currentTeamID = mmStore.getState().entities.teams.currentTeamId
|
||||
if (currentTeamID && currentTeamID !== prevTeamID) {
|
||||
if (prevTeamID && window.location.pathname.startsWith(windowAny.frontendBaseURL || '')) {
|
||||
console.log("REDIRECTING HERE")
|
||||
browserHistory.push(`/team/${currentTeamID}`)
|
||||
wsClient.subscribeToTeam(currentTeamID)
|
||||
}
|
||||
prevTeamID = currentTeamID
|
||||
store.dispatch(setTeam(currentTeamID))
|
||||
octoClient.teamId = currentTeamID
|
||||
store.dispatch(initialLoad())
|
||||
}
|
||||
})
|
||||
|
||||
if (this.registry.registerProduct) {
|
||||
windowAny.frontendBaseURL = subpath + '/boards'
|
||||
const goToFocalboard = () => {
|
||||
const currentTeam = mmStore.getState().entities.teams.currentTeamId
|
||||
TelemetryClient.trackEvent(TelemetryCategory, TelemetryActions.ClickChannelHeader, {teamID: currentTeam})
|
||||
window.open(`${windowAny.frontendBaseURL}/team/${currentTeam}`, '_blank', 'noopener')
|
||||
}
|
||||
|
||||
this.channelHeaderButtonId = registry.registerChannelHeaderButtonAction(<FocalboardIcon/>, goToFocalboard, 'Boards', 'Boards')
|
||||
const {rhsId, toggleRHSPlugin} = this.registry.registerRightHandSidebarComponent(
|
||||
(props: {webSocketClient: MMWebSocketClient}) => (
|
||||
<ReduxProvider store={store}>
|
||||
<WithWebSockets manifest={manifest} webSocketClient={props.webSocketClient}>
|
||||
<RHSChannelBoards/>
|
||||
</WithWebSockets>
|
||||
</ReduxProvider>
|
||||
),
|
||||
<ErrorBoundary>
|
||||
<ReduxProvider store={store}>
|
||||
<RHSChannelBoardsHeader/>
|
||||
</ReduxProvider>
|
||||
</ErrorBoundary>
|
||||
,
|
||||
)
|
||||
this.rhsId = rhsId
|
||||
|
||||
this.channelHeaderButtonId = registry.registerChannelHeaderButtonAction(<FocalboardIcon/>, () => mmStore.dispatch(toggleRHSPlugin), 'Boards', 'Boards')
|
||||
|
||||
this.registry.registerProduct(
|
||||
'/boards',
|
||||
'product-boards',
|
||||
@ -229,12 +294,31 @@ export default class Plugin {
|
||||
|
||||
if (this.registry.registerAppBarComponent) {
|
||||
const appBarIconURL = windowAny.baseURL + '/public/app-bar-icon.png'
|
||||
this.registry.registerAppBarComponent(appBarIconURL, goToFocalboard, 'Open Boards')
|
||||
this.registry.registerAppBarComponent(appBarIconURL, () => mmStore.dispatch(toggleRHSPlugin), 'Boards')
|
||||
}
|
||||
|
||||
this.registry.registerPostWillRenderEmbedComponent((embed) => embed.type === 'boards', BoardsUnfurl, false)
|
||||
this.registry.registerPostWillRenderEmbedComponent(
|
||||
(embed) => embed.type === 'boards',
|
||||
(props: {embed: {data: string}, webSocketClient: MMWebSocketClient}) => (
|
||||
<ReduxProvider store={store}>
|
||||
<BoardsUnfurl
|
||||
embed={props.embed}
|
||||
webSocketClient={props.webSocketClient}
|
||||
/>
|
||||
</ReduxProvider>
|
||||
),
|
||||
false
|
||||
)
|
||||
}
|
||||
|
||||
this.boardSelectorId = this.registry.registerRootComponent((props: {webSocketClient: MMWebSocketClient}) => (
|
||||
<ReduxProvider store={store}>
|
||||
<WithWebSockets manifest={manifest} webSocketClient={props.webSocketClient}>
|
||||
<BoardSelector/>
|
||||
</WithWebSockets>
|
||||
</ReduxProvider>
|
||||
))
|
||||
|
||||
const config = await octoClient.getClientConfig()
|
||||
if (config?.telemetry) {
|
||||
let rudderKey = TELEMETRY_RUDDER_KEY
|
||||
@ -270,29 +354,6 @@ export default class Plugin {
|
||||
}
|
||||
}
|
||||
|
||||
// register websocket handlers
|
||||
this.registry?.registerWebSocketEventHandler(`custom_${manifest.id}_${ACTION_UPDATE_BOARD}`, (e: any) => wsClient.updateHandler(e.data))
|
||||
this.registry?.registerWebSocketEventHandler(`custom_${manifest.id}_${ACTION_UPDATE_CATEGORY}`, (e: any) => wsClient.updateHandler(e.data))
|
||||
this.registry?.registerWebSocketEventHandler(`custom_${manifest.id}_${ACTION_UPDATE_BOARD_CATEGORY}`, (e: any) => wsClient.updateHandler(e.data))
|
||||
this.registry?.registerWebSocketEventHandler(`custom_${manifest.id}_${ACTION_UPDATE_CLIENT_CONFIG}`, (e: any) => wsClient.updateClientConfigHandler(e.data))
|
||||
this.registry?.registerWebSocketEventHandler(`custom_${manifest.id}_${ACTION_UPDATE_SUBSCRIPTION}`, (e: any) => wsClient.updateSubscriptionHandler(e.data))
|
||||
this.registry?.registerWebSocketEventHandler('plugin_statuses_changed', (e: any) => wsClient.pluginStatusesChangedHandler(e.data))
|
||||
this.registry?.registerWebSocketEventHandler('preferences_changed', (e: any) => {
|
||||
let preferences
|
||||
try {
|
||||
preferences = JSON.parse(e.data.preferences)
|
||||
} catch {
|
||||
preferences = []
|
||||
}
|
||||
if (preferences) {
|
||||
for (const preference of preferences) {
|
||||
if (preference.category === 'theme' && theme !== preference.value) {
|
||||
setMattermostTheme(JSON.parse(preference.value))
|
||||
theme = preference.value
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
windowAny.setTeamInSidebar = (teamID: string) => {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
@ -309,6 +370,12 @@ export default class Plugin {
|
||||
if (this.channelHeaderButtonId) {
|
||||
this.registry?.unregisterComponent(this.channelHeaderButtonId)
|
||||
}
|
||||
if (this.rhsId) {
|
||||
this.registry?.unregisterComponent(this.rhsId)
|
||||
}
|
||||
if (this.boardSelectorId) {
|
||||
this.registry?.unregisterComponent(this.boardSelectorId)
|
||||
}
|
||||
|
||||
// unregister websocket handlers
|
||||
this.registry?.unregisterWebSocketEventHandler(wsClient.clientPrefix + ACTION_UPDATE_BLOCK)
|
||||
|
@ -13,9 +13,18 @@
|
||||
background: rgba(var(--center-channel-color-rgb), 0.08);
|
||||
|
||||
div {
|
||||
color: var(--link-color-rgb);
|
||||
color: rgb(var(--link-color-rgb));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
img.boards-rhs-header-logo {
|
||||
color: white;
|
||||
background: var(--button-bg);
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
@ -1,7 +1,7 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {Channel, ChannelMembership} from 'mattermost-redux/types/channels';
|
||||
import {Channel, ChannelMembership} from 'mattermost-redux/types/channels'
|
||||
|
||||
export interface PluginRegistry {
|
||||
registerPostTypeComponent(typeName: string, component: React.ElementType)
|
||||
@ -15,6 +15,8 @@ export interface PluginRegistry {
|
||||
registerWebSocketEventHandler(event: string, handler: (e: any) => void)
|
||||
unregisterWebSocketEventHandler(event: string)
|
||||
registerAppBarComponent(iconURL: string, action: (channel: Channel, member: ChannelMembership) => void, tooltipText: React.ReactNode)
|
||||
registerRightHandSidebarComponent(component: React.ElementType, title: React.Element)
|
||||
registerRootComponent(component: React.ElementType)
|
||||
|
||||
// Add more if needed from https://developers.mattermost.com/extend/plugins/webapp/reference
|
||||
}
|
||||
|
@ -1,4 +1,8 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import 'mattermost-webapp/tests/setup'
|
||||
// This won't exist locally when running in CI
|
||||
// eslint-disable-next-line no-process-env
|
||||
if (!process.env.CI) {
|
||||
require('mattermost-webapp/tests/setup')
|
||||
}
|
||||
|
1
mattermost-plugin/webapp/tests/style_mock.json
Normal file
1
mattermost-plugin/webapp/tests/style_mock.json
Normal file
@ -0,0 +1 @@
|
||||
{}
|
@ -61,6 +61,7 @@ module.exports = {
|
||||
],
|
||||
alias: {
|
||||
moment: path.resolve(__dirname, '../../webapp/node_modules/moment/'),
|
||||
'react-intl': path.resolve(__dirname, '../../webapp/node_modules/react-intl/'),
|
||||
},
|
||||
extensions: ['*', '.js', '.jsx', '.ts', '.tsx'],
|
||||
},
|
||||
@ -126,6 +127,7 @@ module.exports = {
|
||||
'mm-react-router-dom': 'ReactRouterDom',
|
||||
'prop-types': 'PropTypes',
|
||||
'react-bootstrap': 'ReactBootstrap',
|
||||
|
||||
},
|
||||
output: {
|
||||
devtoolNamespace: PLUGIN_ID,
|
||||
|
28
noticegen/Readme.md
Normal file
28
noticegen/Readme.md
Normal file
@ -0,0 +1,28 @@
|
||||
# Notice.txt File Configuration
|
||||
|
||||
We are automatically generating Notice.txt by using first-level dependencies of the project. The related pipeline uses `config.yaml` stored in this folder.
|
||||
|
||||
|
||||
## Configuration
|
||||
|
||||
Sample:
|
||||
|
||||
```
|
||||
title: "Mattermost Playbooks"
|
||||
copyright: "©2015-present Mattermost, Inc. All Rights Reserved. See LICENSE for license information."
|
||||
description: "This document includes a list of open source components used in Mattermost Playbooks, including those that have been modified."
|
||||
search:
|
||||
- "go.mod"
|
||||
- "client/go.mod"
|
||||
dependencies: []
|
||||
devDependencies: []
|
||||
```
|
||||
|
||||
| Field | Type | Purpose |
|
||||
| :-- | :-- | :-- |
|
||||
| title | string | Field content will be used as a title of the application. See first line of `NOTICE.txt` file. |
|
||||
| copyright | string | Field content will be used as a copyright message. See second line of `NOTICE.txt` file. |
|
||||
| description | string | Field content will be used as notice file description. See third line of `NOTICE.txt` file. |
|
||||
| dependencies | array | If any dependency name mentioned, it will be automatically added even if it is not a first-level dependency. |
|
||||
| devDependencies | array | If any dependency name mentioned, it will be added when it is referenced in devDependency section. |
|
||||
| search | array | Pipeline will search for package.json/go.mod files mentioned here. Globstar format is supported ie. `x/**/go.mod`. |
|
14
noticegen/config.yaml
Normal file
14
noticegen/config.yaml
Normal file
@ -0,0 +1,14 @@
|
||||
---
|
||||
|
||||
title: "Mattermost Focalboard"
|
||||
copyright: "©2015-present Mattermost,Inc. All Rights Reserved. See LICENSE for license information."
|
||||
description: "This document includes a list of open source components used in Mattermost Focalboard, including those that have been modified."
|
||||
search:
|
||||
- "mattermost-plugin/go.mod"
|
||||
- "mattermost-plugin/build/go.mod"
|
||||
- "server/go.mod"
|
||||
- "linux/go.mod"
|
||||
dependencies: []
|
||||
devDependencies: []
|
||||
|
||||
...
|
@ -65,3 +65,4 @@ linters:
|
||||
- unconvert
|
||||
- unused
|
||||
- whitespace
|
||||
- gocyclo
|
||||
|
@ -27,6 +27,7 @@ const (
|
||||
HeaderRequestedWith = "X-Requested-With"
|
||||
HeaderRequestedWithXML = "XMLHttpRequest"
|
||||
UploadFormFileKey = "file"
|
||||
True = "true"
|
||||
)
|
||||
|
||||
const (
|
||||
@ -34,6 +35,8 @@ const (
|
||||
ErrorNoTeamMessage = "No team"
|
||||
)
|
||||
|
||||
var errAPINotSupportedInStandaloneMode = errors.New("API not supported in standalone mode")
|
||||
|
||||
type PermissionError struct {
|
||||
msg string
|
||||
}
|
||||
@ -51,12 +54,20 @@ type API struct {
|
||||
permissions permissions.PermissionsService
|
||||
singleUserToken string
|
||||
MattermostAuth bool
|
||||
logger *mlog.Logger
|
||||
logger mlog.LoggerIFace
|
||||
audit *audit.Audit
|
||||
isPlugin bool
|
||||
}
|
||||
|
||||
func NewAPI(app *app.App, singleUserToken string, authService string, permissions permissions.PermissionsService,
|
||||
logger *mlog.Logger, audit *audit.Audit) *API {
|
||||
func NewAPI(
|
||||
app *app.App,
|
||||
singleUserToken string,
|
||||
authService string,
|
||||
permissions permissions.PermissionsService,
|
||||
logger mlog.LoggerIFace,
|
||||
audit *audit.Audit,
|
||||
isPlugin bool,
|
||||
) *API {
|
||||
return &API{
|
||||
app: app,
|
||||
singleUserToken: singleUserToken,
|
||||
@ -64,6 +75,7 @@ func NewAPI(app *app.App, singleUserToken string, authService string, permission
|
||||
permissions: permissions,
|
||||
logger: logger,
|
||||
audit: audit,
|
||||
isPlugin: isPlugin,
|
||||
}
|
||||
}
|
||||
|
||||
@ -72,11 +84,22 @@ func (a *API) RegisterRoutes(r *mux.Router) {
|
||||
apiv2.Use(a.panicHandler)
|
||||
apiv2.Use(a.requireCSRFToken)
|
||||
|
||||
// personal-server specific routes. These are not needed in plugin mode.
|
||||
if !a.isPlugin {
|
||||
apiv2.HandleFunc("/login", a.handleLogin).Methods("POST")
|
||||
apiv2.HandleFunc("/logout", a.sessionRequired(a.handleLogout)).Methods("POST")
|
||||
apiv2.HandleFunc("/register", a.handleRegister).Methods("POST")
|
||||
apiv2.HandleFunc("/teams/{teamID}/regenerate_signup_token", a.sessionRequired(a.handlePostTeamRegenerateSignupToken)).Methods("POST")
|
||||
}
|
||||
|
||||
// Board APIs
|
||||
apiv2.HandleFunc("/teams/{teamID}/channels", a.sessionRequired(a.handleSearchMyChannels)).Methods("GET")
|
||||
apiv2.HandleFunc("/teams/{teamID}/channels/{channelID}", a.sessionRequired(a.handleGetChannel)).Methods("GET")
|
||||
apiv2.HandleFunc("/teams/{teamID}/boards", a.sessionRequired(a.handleGetBoards)).Methods("GET")
|
||||
apiv2.HandleFunc("/teams/{teamID}/boards/search", a.sessionRequired(a.handleSearchBoards)).Methods("GET")
|
||||
apiv2.HandleFunc("/teams/{teamID}/templates", a.sessionRequired(a.handleGetTemplates)).Methods("GET")
|
||||
apiv2.HandleFunc("/boards", a.sessionRequired(a.handleCreateBoard)).Methods("POST")
|
||||
apiv2.HandleFunc("/boards/search", a.sessionRequired(a.handleSearchAllBoards)).Methods("GET")
|
||||
apiv2.HandleFunc("/boards/{boardID}", a.attachSession(a.handleGetBoard, false)).Methods("GET")
|
||||
apiv2.HandleFunc("/boards/{boardID}", a.sessionRequired(a.handlePatchBoard)).Methods("PATCH")
|
||||
apiv2.HandleFunc("/boards/{boardID}", a.sessionRequired(a.handleDeleteBoard)).Methods("DELETE")
|
||||
@ -106,7 +129,6 @@ func (a *API) RegisterRoutes(r *mux.Router) {
|
||||
// Team APIs
|
||||
apiv2.HandleFunc("/teams", a.sessionRequired(a.handleGetTeams)).Methods("GET")
|
||||
apiv2.HandleFunc("/teams/{teamID}", a.sessionRequired(a.handleGetTeam)).Methods("GET")
|
||||
apiv2.HandleFunc("/teams/{teamID}/regenerate_signup_token", a.sessionRequired(a.handlePostTeamRegenerateSignupToken)).Methods("POST")
|
||||
apiv2.HandleFunc("/teams/{teamID}/users", a.sessionRequired(a.handleGetTeamUsers)).Methods("GET")
|
||||
apiv2.HandleFunc("/teams/{teamID}/archive/export", a.sessionRequired(a.handleArchiveExportTeam)).Methods("GET")
|
||||
apiv2.HandleFunc("/teams/{teamID}/{boardID}/files", a.sessionRequired(a.handleUploadFile)).Methods("POST")
|
||||
@ -124,9 +146,6 @@ func (a *API) RegisterRoutes(r *mux.Router) {
|
||||
apiv2.HandleFunc("/boards-and-blocks", a.sessionRequired(a.handleDeleteBoardsAndBlocks)).Methods("DELETE")
|
||||
|
||||
// Auth APIs
|
||||
apiv2.HandleFunc("/login", a.handleLogin).Methods("POST")
|
||||
apiv2.HandleFunc("/logout", a.sessionRequired(a.handleLogout)).Methods("POST")
|
||||
apiv2.HandleFunc("/register", a.handleRegister).Methods("POST")
|
||||
apiv2.HandleFunc("/clientConfig", a.getClientConfig).Methods("GET")
|
||||
|
||||
// Category APIs
|
||||
@ -141,17 +160,24 @@ func (a *API) RegisterRoutes(r *mux.Router) {
|
||||
// Get Files API
|
||||
apiv2.HandleFunc("/files/teams/{teamID}/{boardID}/{filename}", a.attachSession(a.handleServeFile, false)).Methods("GET")
|
||||
|
||||
// Subscriptions
|
||||
// Subscription APIs
|
||||
apiv2.HandleFunc("/subscriptions", a.sessionRequired(a.handleCreateSubscription)).Methods("POST")
|
||||
apiv2.HandleFunc("/subscriptions/{blockID}/{subscriberID}", a.sessionRequired(a.handleDeleteSubscription)).Methods("DELETE")
|
||||
apiv2.HandleFunc("/subscriptions/{subscriberID}", a.sessionRequired(a.handleGetSubscriptions)).Methods("GET")
|
||||
|
||||
// onboarding tour endpoints
|
||||
// Onboarding tour endpoints APIs
|
||||
apiv2.HandleFunc("/teams/{teamID}/onboard", a.sessionRequired(a.handleOnboard)).Methods(http.MethodPost)
|
||||
|
||||
// archives
|
||||
// Archive APIs
|
||||
apiv2.HandleFunc("/boards/{boardID}/archive/export", a.sessionRequired(a.handleArchiveExportBoard)).Methods("GET")
|
||||
apiv2.HandleFunc("/teams/{teamID}/archive/import", a.sessionRequired(a.handleArchiveImport)).Methods("POST")
|
||||
|
||||
// limits
|
||||
apiv2.HandleFunc("/limits", a.sessionRequired(a.handleCloudLimits)).Methods("GET")
|
||||
apiv2.HandleFunc("/teams/{teamID}/notifyadminupgrade", a.sessionRequired(a.handleNotifyAdminUpgrade)).Methods(http.MethodPost)
|
||||
|
||||
// System APIs
|
||||
r.HandleFunc("/hello", a.handleHello).Methods("GET")
|
||||
}
|
||||
|
||||
func (a *API) RegisterAdminRoutes(r *mux.Router) {
|
||||
@ -372,6 +398,13 @@ func (a *API) handleGetBlocks(w http.ResponseWriter, r *http.Request) {
|
||||
mlog.Int("block_count", len(blocks)),
|
||||
)
|
||||
|
||||
var bErr error
|
||||
blocks, bErr = a.app.ApplyCloudLimits(blocks)
|
||||
if bErr != nil {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", bErr)
|
||||
return
|
||||
}
|
||||
|
||||
json, err := json.Marshal(blocks)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err)
|
||||
@ -778,6 +811,11 @@ func (a *API) handlePostBlocks(w http.ResponseWriter, r *http.Request) {
|
||||
// description: Board ID
|
||||
// required: true
|
||||
// type: string
|
||||
// - name: disable_notify
|
||||
// in: query
|
||||
// description: Disables notifications (for bulk data inserting)
|
||||
// required: false
|
||||
// type: bool
|
||||
// - name: Body
|
||||
// in: body
|
||||
// description: array of blocks to insert or update
|
||||
@ -803,6 +841,9 @@ func (a *API) handlePostBlocks(w http.ResponseWriter, r *http.Request) {
|
||||
boardID := mux.Vars(r)["boardID"]
|
||||
userID := getUserID(r)
|
||||
|
||||
val := r.URL.Query().Get("disable_notify")
|
||||
disableNotify := val == True
|
||||
|
||||
// in phase 1 we use "manage_board_cards", but we would have to
|
||||
// check on specific actions for phase 2
|
||||
if !a.permissions.HasPermissionToBoard(userID, boardID, model.PermissionManageBoardCards) {
|
||||
@ -855,6 +896,7 @@ func (a *API) handlePostBlocks(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
auditRec := a.makeAuditRecord(r, "postBlocks", audit.Fail)
|
||||
defer a.audit.LogRecord(audit.LevelModify, auditRec)
|
||||
auditRec.AddMeta("disable_notify", disableNotify)
|
||||
|
||||
ctx := r.Context()
|
||||
session := ctx.Value(sessionContextKey).(*model.Session)
|
||||
@ -870,13 +912,21 @@ func (a *API) handlePostBlocks(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
newBlocks, err := a.app.InsertBlocks(blocks, session.UserID, true)
|
||||
newBlocks, err := a.app.InsertBlocks(blocks, session.UserID, !disableNotify)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err)
|
||||
if errors.Is(err, app.ErrViewsLimitReached) {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusBadRequest, err.Error(), err)
|
||||
} else {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
a.logger.Debug("POST Blocks", mlog.Int("block_count", len(blocks)))
|
||||
a.logger.Debug("POST Blocks",
|
||||
mlog.Int("block_count", len(blocks)),
|
||||
mlog.Bool("disable_notify", disableNotify),
|
||||
)
|
||||
|
||||
json, err := json.Marshal(newBlocks)
|
||||
if err != nil {
|
||||
@ -1042,12 +1092,13 @@ func (a *API) handleGetMe(w http.ResponseWriter, r *http.Request) {
|
||||
defer a.audit.LogRecord(audit.LevelRead, auditRec)
|
||||
|
||||
if userID == model.SingleUser {
|
||||
ws, _ := a.app.GetRootTeam()
|
||||
now := utils.GetMillis()
|
||||
user = &model.User{
|
||||
ID: model.SingleUser,
|
||||
Username: model.SingleUser,
|
||||
Email: model.SingleUser,
|
||||
CreateAt: now,
|
||||
CreateAt: ws.UpdateAt,
|
||||
UpdateAt: now,
|
||||
}
|
||||
} else {
|
||||
@ -1063,7 +1114,6 @@ func (a *API) handleGetMe(w http.ResponseWriter, r *http.Request) {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err)
|
||||
return
|
||||
}
|
||||
|
||||
jsonBytesResponse(w, http.StatusOK, userData)
|
||||
|
||||
auditRec.AddMeta("userID", user.ID)
|
||||
@ -1404,6 +1454,10 @@ func (a *API) handlePatchBlock(w http.ResponseWriter, r *http.Request) {
|
||||
auditRec.AddMeta("blockID", blockID)
|
||||
|
||||
err = a.app.PatchBlock(blockID, patch, userID)
|
||||
if errors.Is(err, app.ErrPatchUpdatesLimitedCards) {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusForbidden, "", err)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err)
|
||||
return
|
||||
@ -1485,6 +1539,10 @@ func (a *API) handlePatchBlocks(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
err = a.app.PatchBlocks(teamID, patches, userID)
|
||||
if errors.Is(err, app.ErrPatchUpdatesLimitedCards) {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusForbidden, "", err)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err)
|
||||
return
|
||||
@ -1545,7 +1603,7 @@ func (a *API) handleGetSharing(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if sharing == nil {
|
||||
jsonStringResponse(w, http.StatusOK, "{}")
|
||||
jsonStringResponse(w, http.StatusOK, "")
|
||||
return
|
||||
}
|
||||
|
||||
@ -1826,7 +1884,7 @@ func (a *API) handlePostTeamRegenerateSignupToken(w http.ResponseWriter, r *http
|
||||
// File upload
|
||||
|
||||
func (a *API) handleServeFile(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:operation GET "api/v2/files/teams/{teamID}/{boardID}/{filename} getFile
|
||||
// swagger:operation GET /files/teams/{teamID}/{boardID}/{filename} getFile
|
||||
//
|
||||
// Returns the contents of an uploaded file
|
||||
//
|
||||
@ -1909,6 +1967,31 @@ func (a *API) handleServeFile(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
|
||||
fileInfo, err := a.app.GetFileInfo(filename)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err)
|
||||
return
|
||||
}
|
||||
|
||||
if fileInfo != nil && fileInfo.Archived {
|
||||
fileMetadata := map[string]interface{}{
|
||||
"archived": true,
|
||||
"name": fileInfo.Name,
|
||||
"size": fileInfo.Size,
|
||||
"extension": fileInfo.Extension,
|
||||
}
|
||||
|
||||
data, jsonErr := json.Marshal(fileMetadata)
|
||||
if jsonErr != nil {
|
||||
a.logger.Error("failed to marshal archived file metadata", mlog.String("filename", filename), mlog.Err(jsonErr))
|
||||
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", jsonErr)
|
||||
return
|
||||
}
|
||||
|
||||
jsonBytesResponse(w, http.StatusBadRequest, data)
|
||||
return
|
||||
}
|
||||
|
||||
fileReader, err := a.app.GetFileReader(board.TeamID, boardID, filename)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err)
|
||||
@ -2102,6 +2185,168 @@ func (a *API) handleGetTeamUsers(w http.ResponseWriter, r *http.Request) {
|
||||
auditRec.Success()
|
||||
}
|
||||
|
||||
func (a *API) handleSearchMyChannels(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:operation GET /teams/{teamID}/channels searchMyChannels
|
||||
//
|
||||
// Returns the user available channels
|
||||
//
|
||||
// ---
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: teamID
|
||||
// in: path
|
||||
// description: Team ID
|
||||
// required: true
|
||||
// type: string
|
||||
// - name: search
|
||||
// in: query
|
||||
// description: string to filter channels list
|
||||
// required: false
|
||||
// type: string
|
||||
// security:
|
||||
// - BearerAuth: []
|
||||
// responses:
|
||||
// '200':
|
||||
// description: success
|
||||
// schema:
|
||||
// type: array
|
||||
// items:
|
||||
// "$ref": "#/definitions/Channel"
|
||||
// default:
|
||||
// description: internal error
|
||||
// schema:
|
||||
// "$ref": "#/definitions/ErrorResponse"
|
||||
|
||||
if !a.MattermostAuth {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusNotImplemented, "not permitted in standalone mode", nil)
|
||||
return
|
||||
}
|
||||
|
||||
query := r.URL.Query()
|
||||
searchQuery := query.Get("search")
|
||||
|
||||
teamID := mux.Vars(r)["teamID"]
|
||||
userID := getUserID(r)
|
||||
|
||||
if !a.permissions.HasPermissionToTeam(userID, teamID, model.PermissionViewTeam) {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusForbidden, "", PermissionError{"access denied to team"})
|
||||
return
|
||||
}
|
||||
|
||||
auditRec := a.makeAuditRecord(r, "searchMyChannels", audit.Fail)
|
||||
defer a.audit.LogRecord(audit.LevelRead, auditRec)
|
||||
auditRec.AddMeta("teamID", teamID)
|
||||
|
||||
channels, err := a.app.SearchUserChannels(teamID, userID, searchQuery)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err)
|
||||
return
|
||||
}
|
||||
|
||||
a.logger.Debug("GetUserChannels",
|
||||
mlog.String("teamID", teamID),
|
||||
mlog.Int("channelsCount", len(channels)),
|
||||
)
|
||||
|
||||
data, err := json.Marshal(channels)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err)
|
||||
return
|
||||
}
|
||||
|
||||
// response
|
||||
jsonBytesResponse(w, http.StatusOK, data)
|
||||
|
||||
auditRec.AddMeta("channelsCount", len(channels))
|
||||
auditRec.Success()
|
||||
}
|
||||
|
||||
func (a *API) handleGetChannel(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:operation GET /teams/{teamID}/channels/{channelID} getChannel
|
||||
//
|
||||
// Returns the requested channel
|
||||
//
|
||||
// ---
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: teamID
|
||||
// in: path
|
||||
// description: Team ID
|
||||
// required: true
|
||||
// type: string
|
||||
// - name: channelID
|
||||
// in: path
|
||||
// description: Channel ID
|
||||
// required: true
|
||||
// type: string
|
||||
// security:
|
||||
// - BearerAuth: []
|
||||
// responses:
|
||||
// '200':
|
||||
// description: success
|
||||
// schema:
|
||||
// type: array
|
||||
// items:
|
||||
// "$ref": "#/definitions/Channel"
|
||||
// default:
|
||||
// description: internal error
|
||||
// schema:
|
||||
// "$ref": "#/definitions/ErrorResponse"
|
||||
|
||||
if !a.MattermostAuth {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusNotImplemented, "not permitted in standalone mode", nil)
|
||||
return
|
||||
}
|
||||
|
||||
teamID := mux.Vars(r)["teamID"]
|
||||
channelID := mux.Vars(r)["channelID"]
|
||||
userID := getUserID(r)
|
||||
|
||||
if !a.permissions.HasPermissionToTeam(userID, teamID, model.PermissionViewTeam) {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusForbidden, "", PermissionError{"access denied to team"})
|
||||
return
|
||||
}
|
||||
|
||||
if !a.permissions.HasPermissionToChannel(userID, channelID, model.PermissionReadChannel) {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusForbidden, "", PermissionError{"access denied to channel"})
|
||||
return
|
||||
}
|
||||
|
||||
auditRec := a.makeAuditRecord(r, "getChannel", audit.Fail)
|
||||
defer a.audit.LogRecord(audit.LevelRead, auditRec)
|
||||
auditRec.AddMeta("teamID", teamID)
|
||||
auditRec.AddMeta("channelID", teamID)
|
||||
|
||||
channel, err := a.app.GetChannel(teamID, channelID)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err)
|
||||
return
|
||||
}
|
||||
|
||||
a.logger.Debug("GetChannel",
|
||||
mlog.String("teamID", teamID),
|
||||
mlog.String("channelID", channelID),
|
||||
)
|
||||
|
||||
if channel.TeamId != teamID {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusNotFound, "", nil)
|
||||
return
|
||||
}
|
||||
|
||||
data, err := json.Marshal(channel)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err)
|
||||
return
|
||||
}
|
||||
|
||||
// response
|
||||
jsonBytesResponse(w, http.StatusOK, data)
|
||||
|
||||
auditRec.Success()
|
||||
}
|
||||
|
||||
func (a *API) handleGetBoards(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:operation GET /teams/{teamID}/boards getBoards
|
||||
//
|
||||
@ -2755,12 +3000,18 @@ func (a *API) handlePatchBoard(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if patch.Type != nil {
|
||||
if patch.Type != nil || patch.MinimumRole != nil {
|
||||
if !a.permissions.HasPermissionToBoard(userID, boardID, model.PermissionManageBoardType) {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusForbidden, "", PermissionError{"access denied to modifying board type"})
|
||||
return
|
||||
}
|
||||
}
|
||||
if patch.ChannelID != nil {
|
||||
if !a.permissions.HasPermissionToBoard(userID, boardID, model.PermissionManageBoardRoles) {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusForbidden, "", PermissionError{"access denied to modifying board access"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
auditRec := a.makeAuditRecord(r, "patchBoard", audit.Fail)
|
||||
defer a.audit.LogRecord(audit.LevelModify, auditRec)
|
||||
@ -2905,6 +3156,11 @@ func (a *API) handleDuplicateBoard(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if toTeam != "" && !a.permissions.HasPermissionToTeam(userID, toTeam, model.PermissionViewTeam) {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusForbidden, "", PermissionError{"access denied to team"})
|
||||
return
|
||||
}
|
||||
|
||||
if board.IsTemplate && board.Type == model.BoardTypeOpen {
|
||||
if board.TeamID != model.GlobalTeamID && !a.permissions.HasPermissionToTeam(userID, board.TeamID, model.PermissionViewTeam) {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusForbidden, "", PermissionError{"access denied to board"})
|
||||
@ -2925,7 +3181,7 @@ func (a *API) handleDuplicateBoard(w http.ResponseWriter, r *http.Request) {
|
||||
mlog.String("boardID", boardID),
|
||||
)
|
||||
|
||||
boardsAndBlocks, _, err := a.app.DuplicateBoard(boardID, userID, toTeam, asTemplate == "true")
|
||||
boardsAndBlocks, _, err := a.app.DuplicateBoard(boardID, userID, toTeam, asTemplate == True)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, err.Error(), err)
|
||||
return
|
||||
@ -3028,7 +3284,7 @@ func (a *API) handleDuplicateBlock(w http.ResponseWriter, r *http.Request) {
|
||||
mlog.String("blockID", blockID),
|
||||
)
|
||||
|
||||
blocks, err := a.app.DuplicateBlock(boardID, blockID, userID, asTemplate == "true")
|
||||
blocks, err := a.app.DuplicateBlock(boardID, blockID, userID, asTemplate == True)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, err.Error(), err)
|
||||
return
|
||||
@ -3124,7 +3380,7 @@ func (a *API) handleGetBoardMetadata(w http.ResponseWriter, r *http.Request) {
|
||||
func (a *API) handleSearchBoards(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:operation GET /teams/{teamID}/boards/search searchBoards
|
||||
//
|
||||
// Returns the boards that match with a search term
|
||||
// Returns the boards that match with a search term in the team
|
||||
//
|
||||
// ---
|
||||
// produces:
|
||||
@ -3173,7 +3429,7 @@ func (a *API) handleSearchBoards(w http.ResponseWriter, r *http.Request) {
|
||||
auditRec.AddMeta("teamID", teamID)
|
||||
|
||||
// retrieve boards list
|
||||
boards, err := a.app.SearchBoardsForUser(term, userID, teamID)
|
||||
boards, err := a.app.SearchBoardsForUserInTeam(teamID, term, userID)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err)
|
||||
return
|
||||
@ -3197,6 +3453,69 @@ func (a *API) handleSearchBoards(w http.ResponseWriter, r *http.Request) {
|
||||
auditRec.Success()
|
||||
}
|
||||
|
||||
func (a *API) handleSearchAllBoards(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:operation GET /boards/search searchBoards
|
||||
//
|
||||
// Returns the boards that match with a search term
|
||||
//
|
||||
// ---
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: q
|
||||
// in: query
|
||||
// description: The search term. Must have at least one character
|
||||
// required: true
|
||||
// type: string
|
||||
// security:
|
||||
// - BearerAuth: []
|
||||
// responses:
|
||||
// '200':
|
||||
// description: success
|
||||
// schema:
|
||||
// type: array
|
||||
// items:
|
||||
// "$ref": "#/definitions/Board"
|
||||
// default:
|
||||
// description: internal error
|
||||
// schema:
|
||||
// "$ref": "#/definitions/ErrorResponse"
|
||||
|
||||
term := r.URL.Query().Get("q")
|
||||
userID := getUserID(r)
|
||||
|
||||
if len(term) == 0 {
|
||||
jsonStringResponse(w, http.StatusOK, "[]")
|
||||
return
|
||||
}
|
||||
|
||||
auditRec := a.makeAuditRecord(r, "searchAllBoards", audit.Fail)
|
||||
defer a.audit.LogRecord(audit.LevelRead, auditRec)
|
||||
|
||||
// retrieve boards list
|
||||
boards, err := a.app.SearchBoardsForUser(term, userID)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err)
|
||||
return
|
||||
}
|
||||
|
||||
a.logger.Debug("SearchAllBoards",
|
||||
mlog.Int("boardsCount", len(boards)),
|
||||
)
|
||||
|
||||
data, err := json.Marshal(boards)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err)
|
||||
return
|
||||
}
|
||||
|
||||
// response
|
||||
jsonBytesResponse(w, http.StatusOK, data)
|
||||
|
||||
auditRec.AddMeta("boardsCount", len(boards))
|
||||
auditRec.Success()
|
||||
}
|
||||
|
||||
func (a *API) handleGetMembersForBoard(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:operation GET /boards/{boardID}/members getMembersForBoard
|
||||
//
|
||||
@ -3420,12 +3739,13 @@ func (a *API) handleJoinBoard(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// currently all memberships are created as editors by default
|
||||
// TODO: Support different public roles
|
||||
newBoardMember := &model.BoardMember{
|
||||
UserID: userID,
|
||||
BoardID: boardID,
|
||||
SchemeEditor: true,
|
||||
UserID: userID,
|
||||
BoardID: boardID,
|
||||
SchemeAdmin: board.MinimumRole == model.BoardRoleAdmin,
|
||||
SchemeEditor: board.MinimumRole == model.BoardRoleNone || board.MinimumRole == model.BoardRoleEditor,
|
||||
SchemeCommenter: board.MinimumRole == model.BoardRoleCommenter,
|
||||
SchemeViewer: board.MinimumRole == model.BoardRoleViewer,
|
||||
}
|
||||
|
||||
auditRec := a.makeAuditRecord(r, "joinBoard", audit.Fail)
|
||||
@ -3913,7 +4233,7 @@ func (a *API) handlePatchBoardsAndBlocks(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
if patch.Type != nil {
|
||||
if patch.Type != nil || patch.MinimumRole != nil {
|
||||
if !a.permissions.HasPermissionToBoard(userID, boardID, model.PermissionManageBoardType) {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusForbidden, "", PermissionError{"access denied to modifying board type"})
|
||||
return
|
||||
@ -3967,6 +4287,10 @@ func (a *API) handlePatchBoardsAndBlocks(w http.ResponseWriter, r *http.Request)
|
||||
auditRec.AddMeta("blocksCount", len(pbab.BlockIDs))
|
||||
|
||||
bab, err := a.app.PatchBoardsAndBlocks(pbab, userID)
|
||||
if errors.Is(err, app.ErrPatchUpdatesLimitedCards) {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusForbidden, "", err)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err)
|
||||
return
|
||||
@ -4106,6 +4430,86 @@ func (a *API) handleDeleteBoardsAndBlocks(w http.ResponseWriter, r *http.Request
|
||||
auditRec.Success()
|
||||
}
|
||||
|
||||
func (a *API) handleCloudLimits(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:operation GET /limits cloudLimits
|
||||
//
|
||||
// Fetches the cloud limits of the server.
|
||||
//
|
||||
// ---
|
||||
// produces:
|
||||
// - application/json
|
||||
// security:
|
||||
// - BearerAuth: []
|
||||
// responses:
|
||||
// '200':
|
||||
// description: success
|
||||
// schema:
|
||||
// "$ref": "#/definitions/BoardsCloudLimits"
|
||||
// default:
|
||||
// description: internal error
|
||||
// schema:
|
||||
// "$ref": "#/definitions/ErrorResponse"
|
||||
|
||||
boardsCloudLimits, err := a.app.GetBoardsCloudLimits()
|
||||
if err != nil {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err)
|
||||
return
|
||||
}
|
||||
|
||||
data, err := json.Marshal(boardsCloudLimits)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err)
|
||||
return
|
||||
}
|
||||
|
||||
jsonBytesResponse(w, http.StatusOK, data)
|
||||
}
|
||||
|
||||
func (a *API) handleHello(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:operation GET /hello hello
|
||||
//
|
||||
// Responds with `Hello` if the web service is running.
|
||||
//
|
||||
// ---
|
||||
// produces:
|
||||
// - text/plain
|
||||
// responses:
|
||||
// '200':
|
||||
// description: success
|
||||
stringResponse(w, "Hello")
|
||||
}
|
||||
|
||||
func (a *API) handleNotifyAdminUpgrade(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:operation GET /api/v2/teams/{teamID}/notifyadminupgrade handleNotifyAdminUpgrade
|
||||
//
|
||||
// Notifies admins for upgrade request.
|
||||
//
|
||||
// ---
|
||||
// produces:
|
||||
// - application/json
|
||||
// security:
|
||||
// - BearerAuth: []
|
||||
// responses:
|
||||
// '200':
|
||||
// description: success
|
||||
// default:
|
||||
// description: internal error
|
||||
// schema:
|
||||
// "$ref": "#/definitions/ErrorResponse"
|
||||
|
||||
if !a.MattermostAuth {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusNotFound, "", errAPINotSupportedInStandaloneMode)
|
||||
return
|
||||
}
|
||||
|
||||
vars := mux.Vars(r)
|
||||
teamID := vars["teamID"]
|
||||
|
||||
if err := a.app.NotifyPortalAdminsUpgradeRequest(teamID); err != nil {
|
||||
jsonStringResponse(w, http.StatusOK, "{}")
|
||||
}
|
||||
}
|
||||
|
||||
// Response helpers
|
||||
|
||||
func (a *API) errorResponse(w http.ResponseWriter, api string, code int, message string, sourceError error) {
|
||||
@ -4134,13 +4538,18 @@ func (a *API) errorResponse(w http.ResponseWriter, api string, code int, message
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
func jsonStringResponse(w http.ResponseWriter, code int, message string) { //nolint:unparam
|
||||
func stringResponse(w http.ResponseWriter, message string) {
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
_, _ = fmt.Fprint(w, message)
|
||||
}
|
||||
|
||||
func jsonStringResponse(w http.ResponseWriter, code int, message string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(code)
|
||||
fmt.Fprint(w, message)
|
||||
}
|
||||
|
||||
func jsonBytesResponse(w http.ResponseWriter, code int, json []byte) { //nolint:unparam
|
||||
func jsonBytesResponse(w http.ResponseWriter, code int, json []byte) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(code)
|
||||
_, _ = w.Write(json)
|
||||
|
@ -8,7 +8,7 @@ import (
|
||||
)
|
||||
|
||||
// makeAuditRecord creates an audit record pre-populated with data from the request.
|
||||
func (a *API) makeAuditRecord(r *http.Request, event string, initialStatus string) *audit.Record { //nolint:unparam
|
||||
func (a *API) makeAuditRecord(r *http.Request, event string, initialStatus string) *audit.Record {
|
||||
ctx := r.Context()
|
||||
var sessionID string
|
||||
var userID string
|
||||
|
@ -1,6 +1,8 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"io"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/mattermost/focalboard/server/auth"
|
||||
@ -13,27 +15,43 @@ import (
|
||||
"github.com/mattermost/focalboard/server/utils"
|
||||
"github.com/mattermost/focalboard/server/ws"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/shared/mlog"
|
||||
|
||||
mm_model "github.com/mattermost/mattermost-server/v6/model"
|
||||
"github.com/mattermost/mattermost-server/v6/shared/filestore"
|
||||
"github.com/mattermost/mattermost-server/v6/shared/mlog"
|
||||
)
|
||||
|
||||
const (
|
||||
blockChangeNotifierQueueSize = 100
|
||||
blockChangeNotifierQueueSize = 1000
|
||||
blockChangeNotifierPoolSize = 10
|
||||
blockChangeNotifierShutdownTimeout = time.Second * 10
|
||||
)
|
||||
|
||||
type servicesAPI interface {
|
||||
GetUsersFromProfiles(options *mm_model.UserGetOptions) ([]*mm_model.User, error)
|
||||
}
|
||||
|
||||
type ReadCloseSeeker = filestore.ReadCloseSeeker
|
||||
|
||||
type fileBackend interface {
|
||||
Reader(path string) (ReadCloseSeeker, error)
|
||||
FileExists(path string) (bool, error)
|
||||
CopyFile(oldPath, newPath string) error
|
||||
MoveFile(oldPath, newPath string) error
|
||||
WriteFile(fr io.Reader, path string) (int64, error)
|
||||
RemoveFile(path string) error
|
||||
}
|
||||
|
||||
type Services struct {
|
||||
Auth *auth.Auth
|
||||
Store store.Store
|
||||
FilesBackend filestore.FileBackend
|
||||
FilesBackend fileBackend
|
||||
Webhook *webhook.Client
|
||||
Metrics *metrics.Metrics
|
||||
Notifications *notify.Service
|
||||
Logger *mlog.Logger
|
||||
Logger mlog.LoggerIFace
|
||||
Permissions permissions.PermissionsService
|
||||
SkipTemplateInit bool
|
||||
ServicesAPI servicesAPI
|
||||
}
|
||||
|
||||
type App struct {
|
||||
@ -41,12 +59,16 @@ type App struct {
|
||||
store store.Store
|
||||
auth *auth.Auth
|
||||
wsAdapter ws.Adapter
|
||||
filesBackend filestore.FileBackend
|
||||
filesBackend fileBackend
|
||||
webhook *webhook.Client
|
||||
metrics *metrics.Metrics
|
||||
notifications *notify.Service
|
||||
logger *mlog.Logger
|
||||
logger mlog.LoggerIFace
|
||||
blockChangeNotifier *utils.CallbackQueue
|
||||
servicesAPI servicesAPI
|
||||
|
||||
cardLimitMux sync.RWMutex
|
||||
cardLimit int
|
||||
}
|
||||
|
||||
func (a *App) SetConfig(config *config.Configuration) {
|
||||
@ -69,7 +91,20 @@ func New(config *config.Configuration, wsAdapter ws.Adapter, services Services)
|
||||
notifications: services.Notifications,
|
||||
logger: services.Logger,
|
||||
blockChangeNotifier: utils.NewCallbackQueue("blockChangeNotifier", blockChangeNotifierQueueSize, blockChangeNotifierPoolSize, services.Logger),
|
||||
servicesAPI: services.ServicesAPI,
|
||||
}
|
||||
app.initialize(services.SkipTemplateInit)
|
||||
return app
|
||||
}
|
||||
|
||||
func (a *App) CardLimit() int {
|
||||
a.cardLimitMux.RLock()
|
||||
defer a.cardLimitMux.RUnlock()
|
||||
return a.cardLimit
|
||||
}
|
||||
|
||||
func (a *App) SetCardLimit(cardLimit int) {
|
||||
a.cardLimitMux.Lock()
|
||||
defer a.cardLimitMux.Unlock()
|
||||
a.cardLimit = cardLimit
|
||||
}
|
||||
|
@ -1,7 +1,6 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
@ -114,7 +113,6 @@ func TestRegisterUser(t *testing.T) {
|
||||
|
||||
for _, test := range testcases {
|
||||
t.Run(test.title, func(t *testing.T) {
|
||||
fmt.Println(test.email)
|
||||
err := th.App.RegisterUser(test.userName, test.email, test.password)
|
||||
if test.isError {
|
||||
require.Error(t, err)
|
||||
|
@ -13,6 +13,8 @@ import (
|
||||
)
|
||||
|
||||
var ErrBlocksFromMultipleBoards = errors.New("the block set contain blocks from multiple boards")
|
||||
var ErrViewsLimitReached = errors.New("views limit reached for board")
|
||||
var ErrPatchUpdatesLimitedCards = errors.New("patch updates cards that are limited")
|
||||
|
||||
func (a *App) GetBlocks(boardID, parentID string, blockType string) ([]model.Block, error) {
|
||||
if boardID == "" {
|
||||
@ -50,6 +52,16 @@ func (a *App) DuplicateBlock(boardID string, blockID string, userID string, asTe
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
go func() {
|
||||
if uErr := a.UpdateCardLimitTimestamp(); uErr != nil {
|
||||
a.logger.Error(
|
||||
"UpdateCardLimitTimestamp failed duplicating a block",
|
||||
mlog.Err(uErr),
|
||||
)
|
||||
}
|
||||
}()
|
||||
|
||||
return blocks, err
|
||||
}
|
||||
|
||||
@ -60,7 +72,17 @@ func (a *App) GetBlocksWithBoardID(boardID string) ([]model.Block, error) {
|
||||
func (a *App) PatchBlock(blockID string, blockPatch *model.BlockPatch, modifiedByID string) error {
|
||||
oldBlock, err := a.store.GetBlock(blockID)
|
||||
if err != nil {
|
||||
return nil
|
||||
return err
|
||||
}
|
||||
|
||||
if a.IsCloudLimited() {
|
||||
containsLimitedBlocks, lErr := a.ContainsLimitedBlocks([]model.Block{*oldBlock})
|
||||
if lErr != nil {
|
||||
return lErr
|
||||
}
|
||||
if containsLimitedBlocks {
|
||||
return ErrPatchUpdatesLimitedCards
|
||||
}
|
||||
}
|
||||
|
||||
board, err := a.store.GetBoard(oldBlock.BoardID)
|
||||
@ -76,7 +98,7 @@ func (a *App) PatchBlock(blockID string, blockPatch *model.BlockPatch, modifiedB
|
||||
a.metrics.IncrementBlocksPatched(1)
|
||||
block, err := a.store.GetBlock(blockID)
|
||||
if err != nil {
|
||||
return nil
|
||||
return err
|
||||
}
|
||||
a.blockChangeNotifier.Enqueue(func() error {
|
||||
// broadcast on websocket
|
||||
@ -93,17 +115,22 @@ func (a *App) PatchBlock(blockID string, blockPatch *model.BlockPatch, modifiedB
|
||||
}
|
||||
|
||||
func (a *App) PatchBlocks(teamID string, blockPatches *model.BlockPatchBatch, modifiedByID string) error {
|
||||
oldBlocks := make([]model.Block, 0, len(blockPatches.BlockIDs))
|
||||
for _, blockID := range blockPatches.BlockIDs {
|
||||
oldBlock, err := a.store.GetBlock(blockID)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
oldBlocks = append(oldBlocks, *oldBlock)
|
||||
oldBlocks, err := a.store.GetBlocksByIDs(blockPatches.BlockIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err := a.store.PatchBlocks(blockPatches, modifiedByID)
|
||||
if err != nil {
|
||||
if a.IsCloudLimited() {
|
||||
containsLimitedBlocks, err := a.ContainsLimitedBlocks(oldBlocks)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if containsLimitedBlocks {
|
||||
return ErrPatchUpdatesLimitedCards
|
||||
}
|
||||
}
|
||||
|
||||
if err := a.store.PatchBlocks(blockPatches, modifiedByID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@ -112,7 +139,7 @@ func (a *App) PatchBlocks(teamID string, blockPatches *model.BlockPatchBatch, mo
|
||||
for i, blockID := range blockPatches.BlockIDs {
|
||||
newBlock, err := a.store.GetBlock(blockID)
|
||||
if err != nil {
|
||||
return nil
|
||||
return err
|
||||
}
|
||||
a.wsAdapter.BroadcastBlockChange(teamID, *newBlock)
|
||||
a.webhook.NotifyUpdate(*newBlock)
|
||||
@ -136,12 +163,45 @@ func (a *App) InsertBlock(block model.Block, modifiedByID string) error {
|
||||
a.metrics.IncrementBlocksInserted(1)
|
||||
a.webhook.NotifyUpdate(block)
|
||||
a.notifyBlockChanged(notify.Add, &block, nil, modifiedByID)
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
go func() {
|
||||
if uErr := a.UpdateCardLimitTimestamp(); uErr != nil {
|
||||
a.logger.Error(
|
||||
"UpdateCardLimitTimestamp failed after inserting a block",
|
||||
mlog.Err(uErr),
|
||||
)
|
||||
}
|
||||
}()
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *App) isWithinViewsLimit(boardID string, block model.Block) (bool, error) {
|
||||
limits, err := a.GetBoardsCloudLimits()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if limits.Views == model.LimitUnlimited {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
views, err := a.store.GetBlocksWithParentAndType(boardID, block.ParentID, model.TypeView)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
// < rather than <= because we'll be creating new view if this
|
||||
// check passes. When that view is created, the limit will be reached.
|
||||
// That's why we need to check for if existing + the being-created
|
||||
// view doesn't exceed the limit.
|
||||
return len(views) < limits.Views, nil
|
||||
}
|
||||
|
||||
func (a *App) InsertBlocks(blocks []model.Block, modifiedByID string, allowNotifications bool) ([]model.Block, error) {
|
||||
if len(blocks) == 0 {
|
||||
return []model.Block{}, nil
|
||||
@ -162,6 +222,20 @@ func (a *App) InsertBlocks(blocks []model.Block, modifiedByID string, allowNotif
|
||||
|
||||
needsNotify := make([]model.Block, 0, len(blocks))
|
||||
for i := range blocks {
|
||||
// this check is needed to whitelist inbuilt template
|
||||
// initialization. They do contain more than 5 views per board.
|
||||
if boardID != "0" && blocks[i].Type == model.TypeView {
|
||||
withinLimit, err := a.isWithinViewsLimit(board.ID, blocks[i])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !withinLimit {
|
||||
a.logger.Info("views limit reached on board", mlog.String("board_id", blocks[i].ParentID), mlog.String("team_id", board.TeamID))
|
||||
return nil, ErrViewsLimitReached
|
||||
}
|
||||
}
|
||||
|
||||
err := a.store.InsertBlock(&blocks[i], modifiedByID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@ -180,55 +254,77 @@ func (a *App) InsertBlocks(blocks []model.Block, modifiedByID string, allowNotif
|
||||
a.notifyBlockChanged(notify.Add, &block, nil, modifiedByID)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
go func() {
|
||||
if err := a.UpdateCardLimitTimestamp(); err != nil {
|
||||
a.logger.Error(
|
||||
"UpdateCardLimitTimestamp failed after inserting blocks",
|
||||
mlog.Err(err),
|
||||
)
|
||||
}
|
||||
}()
|
||||
|
||||
return blocks, nil
|
||||
}
|
||||
|
||||
func (a *App) CopyCardFiles(sourceBoardID string, blocks []model.Block) error {
|
||||
func (a *App) CopyCardFiles(sourceBoardID string, copiedBlocks []model.Block) error {
|
||||
// Images attached in cards have a path comprising the card's board ID.
|
||||
// When we create a template from this board, we need to copy the files
|
||||
// with the new board ID in path.
|
||||
// Not doing so causing images in templates (and boards created from this
|
||||
// template) to fail to load.
|
||||
|
||||
// look up ID of source board, which may be different than the blocks.
|
||||
board, err := a.GetBlockByID(sourceBoardID)
|
||||
if err != nil || board == nil {
|
||||
return fmt.Errorf("cannot fetch board %s for CopyCardFiles: %w", sourceBoardID, err)
|
||||
// look up ID of source sourceBoard, which may be different than the blocks.
|
||||
sourceBoard, err := a.GetBoard(sourceBoardID)
|
||||
if err != nil || sourceBoard == nil {
|
||||
return fmt.Errorf("cannot fetch source board %s for CopyCardFiles: %w", sourceBoardID, err)
|
||||
}
|
||||
|
||||
for i := range blocks {
|
||||
block := blocks[i]
|
||||
var destTeamID string
|
||||
var destBoardID string
|
||||
|
||||
for i := range copiedBlocks {
|
||||
block := copiedBlocks[i]
|
||||
|
||||
fileName, ok := block.Fields["fileId"]
|
||||
if block.Type == model.TypeImage && ok {
|
||||
// create unique filename in case we are copying cards within the same board.
|
||||
ext := filepath.Ext(fileName.(string))
|
||||
destFilename := utils.NewID(utils.IDTypeNone) + ext
|
||||
if !ok || fileName == "" {
|
||||
continue // doesn't have a file attachment
|
||||
}
|
||||
|
||||
sourceFilePath := filepath.Join(sourceBoardID, fileName.(string))
|
||||
destinationFilePath := filepath.Join(block.BoardID, destFilename)
|
||||
// create unique filename in case we are copying cards within the same board.
|
||||
ext := filepath.Ext(fileName.(string))
|
||||
destFilename := utils.NewID(utils.IDTypeNone) + ext
|
||||
|
||||
a.logger.Debug(
|
||||
"Copying card file",
|
||||
if destBoardID == "" || block.BoardID != destBoardID {
|
||||
destBoardID = block.BoardID
|
||||
destBoard, err := a.GetBoard(destBoardID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot fetch destination board %s for CopyCardFiles: %w", sourceBoardID, err)
|
||||
}
|
||||
destTeamID = destBoard.TeamID
|
||||
}
|
||||
|
||||
sourceFilePath := filepath.Join(sourceBoard.TeamID, sourceBoard.ID, fileName.(string))
|
||||
destinationFilePath := filepath.Join(destTeamID, block.BoardID, destFilename)
|
||||
|
||||
a.logger.Debug(
|
||||
"Copying card file",
|
||||
mlog.String("sourceFilePath", sourceFilePath),
|
||||
mlog.String("destinationFilePath", destinationFilePath),
|
||||
)
|
||||
|
||||
if err := a.filesBackend.CopyFile(sourceFilePath, destinationFilePath); err != nil {
|
||||
a.logger.Error(
|
||||
"CopyCardFiles failed to copy file",
|
||||
mlog.String("sourceFilePath", sourceFilePath),
|
||||
mlog.String("destinationFilePath", destinationFilePath),
|
||||
mlog.Err(err),
|
||||
)
|
||||
|
||||
if err := a.filesBackend.CopyFile(sourceFilePath, destinationFilePath); err != nil {
|
||||
a.logger.Error(
|
||||
"CopyCardFiles failed to copy file",
|
||||
mlog.String("sourceFilePath", sourceFilePath),
|
||||
mlog.String("destinationFilePath", destinationFilePath),
|
||||
mlog.Err(err),
|
||||
)
|
||||
|
||||
return err
|
||||
}
|
||||
block.Fields["fileId"] = destFilename
|
||||
}
|
||||
block.Fields["fileId"] = destFilename
|
||||
}
|
||||
|
||||
return nil
|
||||
@ -277,8 +373,19 @@ func (a *App) DeleteBlock(blockID string, modifiedBy string) error {
|
||||
a.wsAdapter.BroadcastBlockDelete(board.TeamID, blockID, block.BoardID)
|
||||
a.metrics.IncrementBlocksDeleted(1)
|
||||
a.notifyBlockChanged(notify.Delete, block, block, modifiedBy)
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
go func() {
|
||||
if err := a.UpdateCardLimitTimestamp(); err != nil {
|
||||
a.logger.Error(
|
||||
"UpdateCardLimitTimestamp failed after deleting a block",
|
||||
mlog.Err(err),
|
||||
)
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -329,9 +436,19 @@ func (a *App) UndeleteBlock(blockID string, modifiedBy string) (*model.Block, er
|
||||
a.metrics.IncrementBlocksInserted(1)
|
||||
a.webhook.NotifyUpdate(*block)
|
||||
a.notifyBlockChanged(notify.Add, block, nil, modifiedBy)
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
go func() {
|
||||
if err := a.UpdateCardLimitTimestamp(); err != nil {
|
||||
a.logger.Error(
|
||||
"UpdateCardLimitTimestamp failed after undeleting a block",
|
||||
mlog.Err(err),
|
||||
)
|
||||
}
|
||||
}()
|
||||
|
||||
return block, nil
|
||||
}
|
||||
|
||||
|
@ -1,12 +1,17 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"testing"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"github.com/mattermost/focalboard/server/model"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
mmModel "github.com/mattermost/mattermost-server/v6/model"
|
||||
|
||||
"github.com/mattermost/focalboard/server/model"
|
||||
)
|
||||
|
||||
type blockError struct {
|
||||
@ -21,7 +26,7 @@ func TestInsertBlock(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
t.Run("success scenerio", func(t *testing.T) {
|
||||
t.Run("success scenario", func(t *testing.T) {
|
||||
boardID := testBoardID
|
||||
block := model.Block{BoardID: boardID}
|
||||
board := &model.Board{ID: boardID}
|
||||
@ -32,7 +37,7 @@ func TestInsertBlock(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("error scenerio", func(t *testing.T) {
|
||||
t.Run("error scenario", func(t *testing.T) {
|
||||
boardID := testBoardID
|
||||
block := model.Block{BoardID: boardID}
|
||||
board := &model.Board{ID: boardID}
|
||||
@ -47,18 +52,64 @@ func TestPatchBlocks(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
t.Run("patchBlocks success scenerio", func(t *testing.T) {
|
||||
blockPatches := model.BlockPatchBatch{}
|
||||
t.Run("patchBlocks success scenario", func(t *testing.T) {
|
||||
blockPatches := model.BlockPatchBatch{
|
||||
BlockIDs: []string{"block1"},
|
||||
BlockPatches: []model.BlockPatch{
|
||||
{Title: mmModel.NewString("new title")},
|
||||
},
|
||||
}
|
||||
|
||||
block1 := model.Block{ID: "block1"}
|
||||
th.Store.EXPECT().GetBlocksByIDs([]string{"block1"}).Return([]model.Block{block1}, nil)
|
||||
th.Store.EXPECT().PatchBlocks(gomock.Eq(&blockPatches), gomock.Eq("user-id-1")).Return(nil)
|
||||
th.Store.EXPECT().GetBlock("block1").Return(&block1, nil)
|
||||
// this call comes from the WS server notification
|
||||
th.Store.EXPECT().GetMembersForBoard(gomock.Any()).Times(1)
|
||||
err := th.App.PatchBlocks("team-id", &blockPatches, "user-id-1")
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("patchBlocks error scenerio", func(t *testing.T) {
|
||||
blockPatches := model.BlockPatchBatch{}
|
||||
th.Store.EXPECT().PatchBlocks(gomock.Eq(&blockPatches), gomock.Eq("user-id-1")).Return(blockError{"error"})
|
||||
t.Run("patchBlocks error scenario", func(t *testing.T) {
|
||||
blockPatches := model.BlockPatchBatch{BlockIDs: []string{}}
|
||||
th.Store.EXPECT().GetBlocksByIDs([]string{}).Return(nil, sql.ErrNoRows)
|
||||
err := th.App.PatchBlocks("team-id", &blockPatches, "user-id-1")
|
||||
require.Error(t, err, "error")
|
||||
require.ErrorIs(t, err, sql.ErrNoRows)
|
||||
})
|
||||
|
||||
t.Run("cloud limit error scenario", func(t *testing.T) {
|
||||
th.App.SetCardLimit(5)
|
||||
|
||||
fakeLicense := &mmModel.License{
|
||||
Features: &mmModel.Features{Cloud: mmModel.NewBool(true)},
|
||||
}
|
||||
|
||||
blockPatches := model.BlockPatchBatch{
|
||||
BlockIDs: []string{"block1"},
|
||||
BlockPatches: []model.BlockPatch{
|
||||
{Title: mmModel.NewString("new title")},
|
||||
},
|
||||
}
|
||||
|
||||
block1 := model.Block{
|
||||
ID: "block1",
|
||||
Type: model.TypeCard,
|
||||
ParentID: "board-id",
|
||||
BoardID: "board-id",
|
||||
UpdateAt: 100,
|
||||
}
|
||||
|
||||
board1 := &model.Board{
|
||||
ID: "board-id",
|
||||
Type: model.BoardTypeOpen,
|
||||
}
|
||||
|
||||
th.Store.EXPECT().GetBlocksByIDs([]string{"block1"}).Return([]model.Block{block1}, nil)
|
||||
th.Store.EXPECT().GetBoard("board-id").Return(board1, nil)
|
||||
th.Store.EXPECT().GetLicense().Return(fakeLicense)
|
||||
th.Store.EXPECT().GetCardLimitTimestamp().Return(int64(150), nil)
|
||||
err := th.App.PatchBlocks("team-id", &blockPatches, "user-id-1")
|
||||
require.ErrorIs(t, err, ErrPatchUpdatesLimitedCards)
|
||||
})
|
||||
}
|
||||
|
||||
@ -66,7 +117,7 @@ func TestDeleteBlock(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
t.Run("success scenerio", func(t *testing.T) {
|
||||
t.Run("success scenario", func(t *testing.T) {
|
||||
boardID := testBoardID
|
||||
board := &model.Board{ID: boardID}
|
||||
block := model.Block{
|
||||
@ -81,7 +132,7 @@ func TestDeleteBlock(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("error scenerio", func(t *testing.T) {
|
||||
t.Run("error scenario", func(t *testing.T) {
|
||||
boardID := testBoardID
|
||||
board := &model.Board{ID: boardID}
|
||||
block := model.Block{
|
||||
@ -100,7 +151,7 @@ func TestUndeleteBlock(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
t.Run("success scenerio", func(t *testing.T) {
|
||||
t.Run("success scenario", func(t *testing.T) {
|
||||
boardID := testBoardID
|
||||
board := &model.Board{ID: boardID}
|
||||
block := model.Block{
|
||||
@ -119,7 +170,7 @@ func TestUndeleteBlock(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("error scenerio", func(t *testing.T) {
|
||||
t.Run("error scenario", func(t *testing.T) {
|
||||
block := model.Block{
|
||||
ID: "block-id",
|
||||
}
|
||||
@ -128,8 +179,228 @@ func TestUndeleteBlock(t *testing.T) {
|
||||
gomock.Eq(model.QueryBlockHistoryOptions{Limit: 1, Descending: true}),
|
||||
).Return([]model.Block{block}, nil)
|
||||
th.Store.EXPECT().UndeleteBlock(gomock.Eq("block-id"), gomock.Eq("user-id-1")).Return(blockError{"error"})
|
||||
th.Store.EXPECT().GetBlock(gomock.Eq("block-id")).Return(&block, nil)
|
||||
_, err := th.App.UndeleteBlock("block-id", "user-id-1")
|
||||
require.Error(t, err, "error")
|
||||
})
|
||||
}
|
||||
|
||||
func TestIsWithinViewsLimit(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
fakeLicense := &mmModel.License{
|
||||
Features: &mmModel.Features{Cloud: mmModel.NewBool(true)},
|
||||
}
|
||||
|
||||
t.Run("within views limit", func(t *testing.T) {
|
||||
th.Store.EXPECT().GetLicense().Return(fakeLicense)
|
||||
|
||||
cloudLimit := &mmModel.ProductLimits{
|
||||
Boards: &mmModel.BoardsLimits{
|
||||
Views: mmModel.NewInt(2),
|
||||
},
|
||||
}
|
||||
th.Store.EXPECT().GetCloudLimits().Return(cloudLimit, nil)
|
||||
th.Store.EXPECT().GetUsedCardsCount().Return(1, nil)
|
||||
th.Store.EXPECT().GetCardLimitTimestamp().Return(int64(1), nil)
|
||||
th.Store.EXPECT().GetBlocksWithParentAndType("board_id", "parent_id", "view").Return([]model.Block{{}}, nil)
|
||||
|
||||
withinLimits, err := th.App.isWithinViewsLimit("board_id", model.Block{ParentID: "parent_id"})
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, withinLimits)
|
||||
})
|
||||
|
||||
t.Run("view limit exactly reached", func(t *testing.T) {
|
||||
th.Store.EXPECT().GetLicense().Return(fakeLicense)
|
||||
|
||||
cloudLimit := &mmModel.ProductLimits{
|
||||
Boards: &mmModel.BoardsLimits{
|
||||
Views: mmModel.NewInt(1),
|
||||
},
|
||||
}
|
||||
th.Store.EXPECT().GetCloudLimits().Return(cloudLimit, nil)
|
||||
th.Store.EXPECT().GetUsedCardsCount().Return(1, nil)
|
||||
th.Store.EXPECT().GetCardLimitTimestamp().Return(int64(1), nil)
|
||||
th.Store.EXPECT().GetBlocksWithParentAndType("board_id", "parent_id", "view").Return([]model.Block{{}}, nil)
|
||||
|
||||
withinLimits, err := th.App.isWithinViewsLimit("board_id", model.Block{ParentID: "parent_id"})
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, withinLimits)
|
||||
})
|
||||
|
||||
t.Run("view limit already exceeded", func(t *testing.T) {
|
||||
th.Store.EXPECT().GetLicense().Return(fakeLicense)
|
||||
|
||||
cloudLimit := &mmModel.ProductLimits{
|
||||
Boards: &mmModel.BoardsLimits{
|
||||
Views: mmModel.NewInt(2),
|
||||
},
|
||||
}
|
||||
th.Store.EXPECT().GetCloudLimits().Return(cloudLimit, nil)
|
||||
th.Store.EXPECT().GetUsedCardsCount().Return(1, nil)
|
||||
th.Store.EXPECT().GetCardLimitTimestamp().Return(int64(1), nil)
|
||||
th.Store.EXPECT().GetBlocksWithParentAndType("board_id", "parent_id", "view").Return([]model.Block{{}, {}, {}}, nil)
|
||||
|
||||
withinLimits, err := th.App.isWithinViewsLimit("board_id", model.Block{ParentID: "parent_id"})
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, withinLimits)
|
||||
})
|
||||
|
||||
t.Run("creating first view", func(t *testing.T) {
|
||||
th.Store.EXPECT().GetLicense().Return(fakeLicense)
|
||||
|
||||
cloudLimit := &mmModel.ProductLimits{
|
||||
Boards: &mmModel.BoardsLimits{
|
||||
Views: mmModel.NewInt(2),
|
||||
},
|
||||
}
|
||||
th.Store.EXPECT().GetCloudLimits().Return(cloudLimit, nil)
|
||||
th.Store.EXPECT().GetUsedCardsCount().Return(1, nil)
|
||||
th.Store.EXPECT().GetCardLimitTimestamp().Return(int64(1), nil)
|
||||
th.Store.EXPECT().GetBlocksWithParentAndType("board_id", "parent_id", "view").Return([]model.Block{}, nil)
|
||||
|
||||
withinLimits, err := th.App.isWithinViewsLimit("board_id", model.Block{ParentID: "parent_id"})
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, withinLimits)
|
||||
})
|
||||
|
||||
t.Run("is not a cloud SKU so limits don't apply", func(t *testing.T) {
|
||||
nonCloudLicense := &mmModel.License{
|
||||
Features: &mmModel.Features{Cloud: mmModel.NewBool(false)},
|
||||
}
|
||||
th.Store.EXPECT().GetLicense().Return(nonCloudLicense)
|
||||
|
||||
withinLimits, err := th.App.isWithinViewsLimit("board_id", model.Block{ParentID: "parent_id"})
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, withinLimits)
|
||||
})
|
||||
}
|
||||
|
||||
func TestInsertBlocks(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
t.Run("success scenario", func(t *testing.T) {
|
||||
boardID := testBoardID
|
||||
block := model.Block{BoardID: boardID}
|
||||
board := &model.Board{ID: boardID}
|
||||
th.Store.EXPECT().GetBoard(boardID).Return(board, nil)
|
||||
th.Store.EXPECT().InsertBlock(&block, "user-id-1").Return(nil)
|
||||
th.Store.EXPECT().GetMembersForBoard(boardID).Return([]*model.BoardMember{}, nil)
|
||||
_, err := th.App.InsertBlocks([]model.Block{block}, "user-id-1", false)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("error scenario", func(t *testing.T) {
|
||||
boardID := testBoardID
|
||||
block := model.Block{BoardID: boardID}
|
||||
board := &model.Board{ID: boardID}
|
||||
th.Store.EXPECT().GetBoard(boardID).Return(board, nil)
|
||||
th.Store.EXPECT().InsertBlock(&block, "user-id-1").Return(blockError{"error"})
|
||||
_, err := th.App.InsertBlocks([]model.Block{block}, "user-id-1", false)
|
||||
require.Error(t, err, "error")
|
||||
})
|
||||
|
||||
t.Run("create view within limits", func(t *testing.T) {
|
||||
boardID := testBoardID
|
||||
block := model.Block{
|
||||
Type: model.TypeView,
|
||||
ParentID: "parent_id",
|
||||
BoardID: boardID,
|
||||
}
|
||||
board := &model.Board{ID: boardID}
|
||||
th.Store.EXPECT().GetBoard(boardID).Return(board, nil)
|
||||
th.Store.EXPECT().InsertBlock(&block, "user-id-1").Return(nil)
|
||||
th.Store.EXPECT().GetMembersForBoard(boardID).Return([]*model.BoardMember{}, nil)
|
||||
|
||||
// setting up mocks for limits
|
||||
fakeLicense := &mmModel.License{
|
||||
Features: &mmModel.Features{Cloud: mmModel.NewBool(true)},
|
||||
}
|
||||
th.Store.EXPECT().GetLicense().Return(fakeLicense)
|
||||
|
||||
cloudLimit := &mmModel.ProductLimits{
|
||||
Boards: &mmModel.BoardsLimits{
|
||||
Views: mmModel.NewInt(2),
|
||||
},
|
||||
}
|
||||
th.Store.EXPECT().GetCloudLimits().Return(cloudLimit, nil)
|
||||
th.Store.EXPECT().GetUsedCardsCount().Return(1, nil)
|
||||
th.Store.EXPECT().GetCardLimitTimestamp().Return(int64(1), nil)
|
||||
th.Store.EXPECT().GetBlocksWithParentAndType("test-board-id", "parent_id", "view").Return([]model.Block{{}}, nil)
|
||||
|
||||
_, err := th.App.InsertBlocks([]model.Block{block}, "user-id-1", false)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("create view exceeding limits", func(t *testing.T) {
|
||||
boardID := testBoardID
|
||||
block := model.Block{
|
||||
Type: model.TypeView,
|
||||
ParentID: "parent_id",
|
||||
BoardID: boardID,
|
||||
}
|
||||
board := &model.Board{ID: boardID}
|
||||
th.Store.EXPECT().GetBoard(boardID).Return(board, nil)
|
||||
|
||||
// setting up mocks for limits
|
||||
fakeLicense := &mmModel.License{
|
||||
Features: &mmModel.Features{Cloud: mmModel.NewBool(true)},
|
||||
}
|
||||
th.Store.EXPECT().GetLicense().Return(fakeLicense)
|
||||
|
||||
cloudLimit := &mmModel.ProductLimits{
|
||||
Boards: &mmModel.BoardsLimits{
|
||||
Views: mmModel.NewInt(2),
|
||||
},
|
||||
}
|
||||
th.Store.EXPECT().GetCloudLimits().Return(cloudLimit, nil)
|
||||
th.Store.EXPECT().GetUsedCardsCount().Return(1, nil)
|
||||
th.Store.EXPECT().GetCardLimitTimestamp().Return(int64(1), nil)
|
||||
th.Store.EXPECT().GetBlocksWithParentAndType("test-board-id", "parent_id", "view").Return([]model.Block{{}, {}}, nil)
|
||||
|
||||
_, err := th.App.InsertBlocks([]model.Block{block}, "user-id-1", false)
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("creating multiple views, reaching limit in the process", func(t *testing.T) {
|
||||
t.Skipf("Will be fixed soon")
|
||||
|
||||
boardID := testBoardID
|
||||
view1 := model.Block{
|
||||
Type: model.TypeView,
|
||||
ParentID: "parent_id",
|
||||
BoardID: boardID,
|
||||
}
|
||||
|
||||
view2 := model.Block{
|
||||
Type: model.TypeView,
|
||||
ParentID: "parent_id",
|
||||
BoardID: boardID,
|
||||
}
|
||||
|
||||
board := &model.Board{ID: boardID}
|
||||
th.Store.EXPECT().GetBoard(boardID).Return(board, nil)
|
||||
th.Store.EXPECT().InsertBlock(&view1, "user-id-1").Return(nil).Times(2)
|
||||
th.Store.EXPECT().GetMembersForBoard(boardID).Return([]*model.BoardMember{}, nil).Times(2)
|
||||
|
||||
// setting up mocks for limits
|
||||
fakeLicense := &mmModel.License{
|
||||
Features: &mmModel.Features{Cloud: mmModel.NewBool(true)},
|
||||
}
|
||||
th.Store.EXPECT().GetLicense().Return(fakeLicense).Times(2)
|
||||
|
||||
cloudLimit := &mmModel.ProductLimits{
|
||||
Boards: &mmModel.BoardsLimits{
|
||||
Views: mmModel.NewInt(2),
|
||||
},
|
||||
}
|
||||
th.Store.EXPECT().GetCloudLimits().Return(cloudLimit, nil).Times(2)
|
||||
th.Store.EXPECT().GetUsedCardsCount().Return(1, nil).Times(2)
|
||||
th.Store.EXPECT().GetCardLimitTimestamp().Return(int64(1), nil).Times(2)
|
||||
th.Store.EXPECT().GetBlocksWithParentAndType("test-board-id", "parent_id", "view").Return([]model.Block{{}}, nil).Times(2)
|
||||
|
||||
_, err := th.App.InsertBlocks([]model.Block{view1, view2}, "user-id-1", false)
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
@ -1,12 +1,17 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/mattermost/focalboard/server/model"
|
||||
"github.com/mattermost/focalboard/server/services/notify"
|
||||
"github.com/mattermost/focalboard/server/utils"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/shared/mlog"
|
||||
)
|
||||
|
||||
var (
|
||||
@ -17,7 +22,7 @@ var (
|
||||
|
||||
func (a *App) GetBoard(boardID string) (*model.Board, error) {
|
||||
board, err := a.store.GetBoard(boardID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
if model.IsErrNotFound(err) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
@ -145,19 +150,70 @@ func (a *App) DuplicateBoard(boardID, userID, toTeam string, asTemplate bool) (*
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
go func() {
|
||||
|
||||
// copy any file attachments from the duplicated blocks.
|
||||
if err = a.CopyCardFiles(boardID, bab.Blocks); err != nil {
|
||||
a.logger.Error("Could not copy files while duplicating board", mlog.String("BoardID", boardID), mlog.Err(err))
|
||||
}
|
||||
|
||||
// bab.Blocks now has updated file ids for any blocks containing files. We need to store them.
|
||||
blockIDs := make([]string, 0)
|
||||
blockPatches := make([]model.BlockPatch, 0)
|
||||
|
||||
for _, block := range bab.Blocks {
|
||||
if fileID, ok := block.Fields["fileId"]; ok {
|
||||
blockIDs = append(blockIDs, block.ID)
|
||||
blockPatches = append(blockPatches, model.BlockPatch{
|
||||
UpdatedFields: map[string]interface{}{
|
||||
"fileId": fileID,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
a.logger.Debug("Duplicate boards patching file IDs", mlog.Int("count", len(blockIDs)))
|
||||
|
||||
if len(blockIDs) != 0 {
|
||||
patches := &model.BlockPatchBatch{
|
||||
BlockIDs: blockIDs,
|
||||
BlockPatches: blockPatches,
|
||||
}
|
||||
if err = a.store.PatchBlocks(patches, userID); err != nil {
|
||||
dbab := model.NewDeleteBoardsAndBlocksFromBabs(bab)
|
||||
if err = a.store.DeleteBoardsAndBlocks(dbab, userID); err != nil {
|
||||
a.logger.Error("Cannot delete board after duplication error when updating block's file info", mlog.String("boardID", bab.Boards[0].ID), mlog.Err(err))
|
||||
}
|
||||
return nil, nil, fmt.Errorf("could not patch file IDs while duplicating board %s: %w", boardID, err)
|
||||
}
|
||||
}
|
||||
|
||||
a.blockChangeNotifier.Enqueue(func() error {
|
||||
teamID := ""
|
||||
for _, board := range bab.Boards {
|
||||
teamID = board.TeamID
|
||||
a.wsAdapter.BroadcastBoardChange(teamID, board)
|
||||
}
|
||||
for _, block := range bab.Blocks {
|
||||
a.wsAdapter.BroadcastBlockChange(teamID, block)
|
||||
blk := block
|
||||
a.wsAdapter.BroadcastBlockChange(teamID, blk)
|
||||
a.notifyBlockChanged(notify.Add, &blk, nil, userID)
|
||||
}
|
||||
for _, member := range members {
|
||||
a.wsAdapter.BroadcastMemberChange(teamID, member.BoardID, member)
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
})
|
||||
|
||||
if len(bab.Blocks) != 0 {
|
||||
go func() {
|
||||
if uErr := a.UpdateCardLimitTimestamp(); uErr != nil {
|
||||
a.logger.Error(
|
||||
"UpdateCardLimitTimestamp failed after duplicating a board",
|
||||
mlog.Err(uErr),
|
||||
)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
return bab, members, err
|
||||
}
|
||||
|
||||
@ -188,13 +244,14 @@ func (a *App) CreateBoard(board *model.Board, userID string, addMember bool) (*m
|
||||
return nil, err
|
||||
}
|
||||
|
||||
go func() {
|
||||
a.blockChangeNotifier.Enqueue(func() error {
|
||||
a.wsAdapter.BroadcastBoardChange(newBoard.TeamID, newBoard)
|
||||
|
||||
if addMember {
|
||||
a.wsAdapter.BroadcastMemberChange(newBoard.TeamID, newBoard.ID, member)
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
})
|
||||
|
||||
return newBoard, nil
|
||||
}
|
||||
@ -205,16 +262,17 @@ func (a *App) PatchBoard(patch *model.BoardPatch, boardID, userID string) (*mode
|
||||
return nil, err
|
||||
}
|
||||
|
||||
go func() {
|
||||
a.blockChangeNotifier.Enqueue(func() error {
|
||||
a.wsAdapter.BroadcastBoardChange(updatedBoard.TeamID, updatedBoard)
|
||||
}()
|
||||
return nil
|
||||
})
|
||||
|
||||
return updatedBoard, nil
|
||||
}
|
||||
|
||||
func (a *App) DeleteBoard(boardID, userID string) error {
|
||||
board, err := a.store.GetBoard(boardID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
if model.IsErrNotFound(err) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
@ -225,8 +283,18 @@ func (a *App) DeleteBoard(boardID, userID string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
go func() {
|
||||
a.blockChangeNotifier.Enqueue(func() error {
|
||||
a.wsAdapter.BroadcastBoardDelete(board.TeamID, boardID)
|
||||
return nil
|
||||
})
|
||||
|
||||
go func() {
|
||||
if err := a.UpdateCardLimitTimestamp(); err != nil {
|
||||
a.logger.Error(
|
||||
"UpdateCardLimitTimestamp failed after deleting a board",
|
||||
mlog.Err(err),
|
||||
)
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
@ -246,7 +314,7 @@ func (a *App) GetMemberForBoard(boardID string, userID string) (*model.BoardMemb
|
||||
|
||||
func (a *App) AddMemberToBoard(member *model.BoardMember) (*model.BoardMember, error) {
|
||||
board, err := a.store.GetBoard(member.BoardID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
if model.IsErrNotFound(err) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
@ -254,7 +322,7 @@ func (a *App) AddMemberToBoard(member *model.BoardMember) (*model.BoardMember, e
|
||||
}
|
||||
|
||||
existingMembership, err := a.store.GetMemberForBoard(member.BoardID, member.UserID)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
if err != nil && !model.IsErrNotFound(err) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@ -267,16 +335,17 @@ func (a *App) AddMemberToBoard(member *model.BoardMember) (*model.BoardMember, e
|
||||
return nil, err
|
||||
}
|
||||
|
||||
go func() {
|
||||
a.blockChangeNotifier.Enqueue(func() error {
|
||||
a.wsAdapter.BroadcastMemberChange(board.TeamID, member.BoardID, member)
|
||||
}()
|
||||
return nil
|
||||
})
|
||||
|
||||
return newMember, nil
|
||||
}
|
||||
|
||||
func (a *App) UpdateBoardMember(member *model.BoardMember) (*model.BoardMember, error) {
|
||||
board, bErr := a.store.GetBoard(member.BoardID)
|
||||
if errors.Is(bErr, sql.ErrNoRows) {
|
||||
if model.IsErrNotFound(bErr) {
|
||||
return nil, nil
|
||||
}
|
||||
if bErr != nil {
|
||||
@ -284,7 +353,7 @@ func (a *App) UpdateBoardMember(member *model.BoardMember) (*model.BoardMember,
|
||||
}
|
||||
|
||||
oldMember, err := a.store.GetMemberForBoard(member.BoardID, member.UserID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
if model.IsErrNotFound(err) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
@ -308,9 +377,10 @@ func (a *App) UpdateBoardMember(member *model.BoardMember) (*model.BoardMember,
|
||||
return nil, err
|
||||
}
|
||||
|
||||
go func() {
|
||||
a.blockChangeNotifier.Enqueue(func() error {
|
||||
a.wsAdapter.BroadcastMemberChange(board.TeamID, member.BoardID, member)
|
||||
}()
|
||||
return nil
|
||||
})
|
||||
|
||||
return newMember, nil
|
||||
}
|
||||
@ -331,7 +401,7 @@ func (a *App) isLastAdmin(userID, boardID string) (bool, error) {
|
||||
|
||||
func (a *App) DeleteBoardMember(boardID, userID string) error {
|
||||
board, bErr := a.store.GetBoard(boardID)
|
||||
if errors.Is(bErr, sql.ErrNoRows) {
|
||||
if model.IsErrNotFound(bErr) {
|
||||
return nil
|
||||
}
|
||||
if bErr != nil {
|
||||
@ -339,7 +409,7 @@ func (a *App) DeleteBoardMember(boardID, userID string) error {
|
||||
}
|
||||
|
||||
oldMember, err := a.store.GetMemberForBoard(boardID, userID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
if model.IsErrNotFound(err) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
@ -362,15 +432,20 @@ func (a *App) DeleteBoardMember(boardID, userID string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
go func() {
|
||||
a.blockChangeNotifier.Enqueue(func() error {
|
||||
a.wsAdapter.BroadcastMemberDelete(board.TeamID, boardID, userID)
|
||||
}()
|
||||
return nil
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) SearchBoardsForUser(term, userID, teamID string) ([]*model.Board, error) {
|
||||
return a.store.SearchBoardsForUser(term, userID, teamID)
|
||||
func (a *App) SearchBoardsForUser(term, userID string) ([]*model.Board, error) {
|
||||
return a.store.SearchBoardsForUser(term, userID)
|
||||
}
|
||||
|
||||
func (a *App) SearchBoardsForUserInTeam(teamID, term, userID string) ([]*model.Board, error) {
|
||||
return a.store.SearchBoardsForUserInTeam(teamID, term, userID)
|
||||
}
|
||||
|
||||
func (a *App) UndeleteBoard(boardID string, modifiedBy string) error {
|
||||
@ -404,5 +479,14 @@ func (a *App) UndeleteBoard(boardID string, modifiedBy string) error {
|
||||
return nil
|
||||
})
|
||||
|
||||
go func() {
|
||||
if err := a.UpdateCardLimitTimestamp(); err != nil {
|
||||
a.logger.Error(
|
||||
"UpdateCardLimitTimestamp failed after undeleting a board",
|
||||
mlog.Err(err),
|
||||
)
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
@ -44,17 +44,39 @@ func (a *App) CreateBoardsAndBlocks(bab *model.BoardsAndBlocks, userID string, a
|
||||
}
|
||||
}
|
||||
|
||||
if len(newBab.Blocks) != 0 {
|
||||
go func() {
|
||||
if uErr := a.UpdateCardLimitTimestamp(); uErr != nil {
|
||||
a.logger.Error(
|
||||
"UpdateCardLimitTimestamp failed after creating boards and blocks",
|
||||
mlog.Err(uErr),
|
||||
)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
return newBab, nil
|
||||
}
|
||||
|
||||
func (a *App) PatchBoardsAndBlocks(pbab *model.PatchBoardsAndBlocks, userID string) (*model.BoardsAndBlocks, error) {
|
||||
oldBlocksMap := map[string]*model.Block{}
|
||||
for _, blockID := range pbab.BlockIDs {
|
||||
block, err := a.store.GetBlock(blockID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
oldBlocks, err := a.store.GetBlocksByIDs(pbab.BlockIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if a.IsCloudLimited() {
|
||||
containsLimitedBlocks, cErr := a.ContainsLimitedBlocks(oldBlocks)
|
||||
if cErr != nil {
|
||||
return nil, cErr
|
||||
}
|
||||
oldBlocksMap[blockID] = block
|
||||
if containsLimitedBlocks {
|
||||
return nil, ErrPatchUpdatesLimitedCards
|
||||
}
|
||||
}
|
||||
|
||||
oldBlocksMap := map[string]model.Block{}
|
||||
for _, block := range oldBlocks {
|
||||
oldBlocksMap[block.ID] = block
|
||||
}
|
||||
|
||||
bab, err := a.store.PatchBoardsAndBlocks(pbab, userID)
|
||||
@ -76,7 +98,7 @@ func (a *App) PatchBoardsAndBlocks(pbab *model.PatchBoardsAndBlocks, userID stri
|
||||
a.metrics.IncrementBlocksPatched(1)
|
||||
a.wsAdapter.BroadcastBlockChange(teamID, b)
|
||||
a.webhook.NotifyUpdate(b)
|
||||
a.notifyBlockChanged(notify.Update, &b, oldBlock, userID)
|
||||
a.notifyBlockChanged(notify.Update, &b, &oldBlock, userID)
|
||||
}
|
||||
|
||||
for _, board := range bab.Boards {
|
||||
@ -122,5 +144,16 @@ func (a *App) DeleteBoardsAndBlocks(dbab *model.DeleteBoardsAndBlocks, userID st
|
||||
return nil
|
||||
})
|
||||
|
||||
if len(dbab.Blocks) != 0 {
|
||||
go func() {
|
||||
if uErr := a.UpdateCardLimitTimestamp(); uErr != nil {
|
||||
a.logger.Error(
|
||||
"UpdateCardLimitTimestamp failed after deleting boards and blocks",
|
||||
mlog.Err(uErr),
|
||||
)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
@ -9,6 +9,7 @@ func (a *App) GetClientConfig() *model.ClientConfig {
|
||||
Telemetry: a.config.Telemetry,
|
||||
TelemetryID: a.config.TelemetryID,
|
||||
EnablePublicSharedBoards: a.config.EnablePublicSharedBoards,
|
||||
TeammateNameDisplay: a.config.TeammateNameDisplay,
|
||||
FeatureFlags: a.config.FeatureFlags,
|
||||
}
|
||||
}
|
||||
|
@ -19,6 +19,7 @@ func TestGetClientConfig(t *testing.T) {
|
||||
newConfiguration.FeatureFlags = make(map[string]string)
|
||||
newConfiguration.FeatureFlags["BoardsFeature1"] = "true"
|
||||
newConfiguration.FeatureFlags["BoardsFeature2"] = "true"
|
||||
newConfiguration.TeammateNameDisplay = "username"
|
||||
th.App.SetConfig(&newConfiguration)
|
||||
|
||||
clientConfig := th.App.GetClientConfig()
|
||||
@ -26,5 +27,6 @@ func TestGetClientConfig(t *testing.T) {
|
||||
require.True(t, clientConfig.Telemetry)
|
||||
require.Equal(t, "abcde", clientConfig.TelemetryID)
|
||||
require.Equal(t, 2, len(clientConfig.FeatureFlags))
|
||||
require.Equal(t, "username", clientConfig.TeammateNameDisplay)
|
||||
})
|
||||
}
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user