Merge branch 'master' into MM-50363-add-telemetry-to-self-hosted-expansion
Этот коммит содержится в:
134
.github/workflows/artifacts.yml
поставляемый
134
.github/workflows/artifacts.yml
поставляемый
@@ -4,15 +4,12 @@ on:
|
||||
workflows: ["Mattermost Build"]
|
||||
types:
|
||||
- completed
|
||||
|
||||
jobs:
|
||||
upload-s3:
|
||||
name: cd/Upload artifacts to S3
|
||||
runs-on: ubuntu-22.04
|
||||
env:
|
||||
REPO_NAME: ${{ github.event.repository.name }}
|
||||
if: >
|
||||
github.event.workflow_run.event == 'pull_request' &&
|
||||
github.event.workflow_run.conclusion == 'success'
|
||||
if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success'
|
||||
steps:
|
||||
- name: cd/Configure AWS
|
||||
uses: aws-actions/configure-aws-credentials@07c2f971bac433df982ccc261983ae443861db49 # v1-node16
|
||||
@@ -28,48 +25,46 @@ jobs:
|
||||
workflow_conclusion: success
|
||||
name: server-dist-artifact
|
||||
path: server/dist
|
||||
# Get Branch name from calling workflow
|
||||
# Search for the string "pull" and replace it with "PR" in branch-name
|
||||
- name: cd/Get branch name
|
||||
run: echo "BRANCH_NAME=$(echo ${{ github.event.workflow_run.head_branch }} | sed 's/^pull\//PR-/g')" >> $GITHUB_ENV
|
||||
- name: cd/Upload artifacts to S3
|
||||
env:
|
||||
REPO_NAME: ${{ github.event.repository.name }}
|
||||
COMMIT_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||
run: |
|
||||
aws s3 cp server/dist/ s3://pr-builds.mattermost.com/$REPO_NAME/$BRANCH_NAME/ --acl public-read --cache-control "no-cache" --recursive --no-progress
|
||||
aws s3 cp server/dist/ s3://pr-builds.mattermost.com/$REPO_NAME/commit/${{ github.sha }}/ --acl public-read --cache-control "no-cache" --recursive --no-progress
|
||||
aws s3 cp server/dist/ s3://pr-builds.mattermost.com/$REPO_NAME/commit/$COMMIT_SHA/ --acl public-read --cache-control "no-cache" --recursive --no-progress
|
||||
|
||||
build-docker:
|
||||
name: cd/Build and push docker image
|
||||
needs: upload-s3
|
||||
env:
|
||||
REPO_NAME: ${{ github.event.repository.name }}
|
||||
runs-on: ubuntu-22.04
|
||||
if: >
|
||||
github.event.workflow_run.event == 'pull_request' &&
|
||||
github.event.workflow_run.conclusion == 'success'
|
||||
if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success'
|
||||
steps:
|
||||
- name: cd/Login to Docker Hub
|
||||
uses: docker/login-action@3da7dc6e2b31f99ef2cb9fb4c50fb0971e0d0139 # v2.1.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_DEV_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_DEV_TOKEN }}
|
||||
- name: cd/Download artifacts
|
||||
uses: dawidd6/action-download-artifact@0c49384d39ceb023b8040f480a25596fd6cf441b # v2.26.0
|
||||
with:
|
||||
workflow: ${{ github.event.workflow_run.workflow_id }}
|
||||
run_id: ${{ github.event.workflow_run.id }}
|
||||
workflow_conclusion: success
|
||||
name: server-build-artifact
|
||||
path: server/build/
|
||||
- name: cd/Setup Docker Buildx
|
||||
uses: docker/setup-buildx-action@11e8a2e2910826a92412015c515187a2d6750279 # v2.4
|
||||
- name: cd/Docker build and push
|
||||
env:
|
||||
DOCKER_CLI_EXPERIMENTAL: enabled
|
||||
run: |
|
||||
export TAG=$(echo "${{ github.event.pull_request.head.sha || github.sha }}" | cut -c1-7)
|
||||
cd server/build
|
||||
export DOCKER_CLI_EXPERIMENTAL=enabled
|
||||
export MM_PACKAGE=https://pr-builds.mattermost.com/$REPO_NAME/commit/${{ github.sha }}/mattermost-team-linux-amd64.tar.gz
|
||||
docker buildx build --push --build-arg MM_PACKAGE=$MM_PACKAGE -t mattermostdevelopment/mm-te-test:${TAG} .
|
||||
- name: cd/Login to Docker Hub
|
||||
uses: docker/login-action@3da7dc6e2b31f99ef2cb9fb4c50fb0971e0d0139 # v2.1.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_DEV_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_DEV_TOKEN }}
|
||||
- name: cd/Download artifacts
|
||||
uses: dawidd6/action-download-artifact@0c49384d39ceb023b8040f480a25596fd6cf441b # v2.26.0
|
||||
with:
|
||||
workflow: ${{ github.event.workflow_run.workflow_id }}
|
||||
run_id: ${{ github.event.workflow_run.id }}
|
||||
workflow_conclusion: success
|
||||
name: server-build-artifact
|
||||
path: server/build/
|
||||
- name: cd/Setup Docker Buildx
|
||||
uses: docker/setup-buildx-action@11e8a2e2910826a92412015c515187a2d6750279 # v2.4
|
||||
- name: cd/Docker build and push
|
||||
env:
|
||||
DOCKER_CLI_EXPERIMENTAL: enabled
|
||||
REPO_NAME: ${{ github.event.repository.name }}
|
||||
COMMIT_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||
run: |
|
||||
export TAG=$(echo "${{ github.event.pull_request.head.sha || github.event.workflow_run.head_sha }}" | cut -c1-7)
|
||||
cd server/build
|
||||
export DOCKER_CLI_EXPERIMENTAL=enabled
|
||||
export MM_PACKAGE=https://pr-builds.mattermost.com/$REPO_NAME/commit/$COMMIT_SHA/mattermost-team-linux-amd64.tar.gz
|
||||
docker buildx build --push --build-arg MM_PACKAGE=$MM_PACKAGE -t mattermostdevelopment/mm-te-test:${TAG} .
|
||||
|
||||
# Temporary uploading also to mattermost/mm-te-test:${TAG} except mattermostdevelopment/mm-te-test:${TAG}
|
||||
# Context: https://community.mattermost.com/private-core/pl/3jzzxzfiji8hx833ewyuthzkjh
|
||||
build-docker-temp:
|
||||
@@ -77,40 +72,41 @@ jobs:
|
||||
needs: upload-s3
|
||||
env:
|
||||
REPO_NAME: ${{ github.event.repository.name }}
|
||||
|
||||
runs-on: ubuntu-22.04
|
||||
if: >
|
||||
github.event.workflow_run.event == 'pull_request' &&
|
||||
github.event.workflow_run.conclusion == 'success'
|
||||
if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success'
|
||||
steps:
|
||||
- name: cd/Login to Docker Hub
|
||||
uses: docker/login-action@3da7dc6e2b31f99ef2cb9fb4c50fb0971e0d0139 # v2.1.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: cd/Download artifacts
|
||||
uses: dawidd6/action-download-artifact@0c49384d39ceb023b8040f480a25596fd6cf441b # v2.26.0
|
||||
with:
|
||||
workflow: ${{ github.event.workflow_run.workflow_id }}
|
||||
run_id: ${{ github.event.workflow_run.id }}
|
||||
workflow_conclusion: success
|
||||
name: server-build-artifact
|
||||
path: server/build/
|
||||
- name: cd/Setup Docker Buildx
|
||||
uses: docker/setup-buildx-action@11e8a2e2910826a92412015c515187a2d6750279 # v2.4
|
||||
- name: cd/Docker build and push
|
||||
env:
|
||||
DOCKER_CLI_EXPERIMENTAL: enabled
|
||||
run: |
|
||||
export TAG=$(echo "${{ github.event.pull_request.head.sha || github.sha }}" | cut -c1-7)
|
||||
cd server/build
|
||||
export DOCKER_CLI_EXPERIMENTAL=enabled
|
||||
export MM_PACKAGE=https://pr-builds.mattermost.com/$REPO_NAME/commit/${{ github.sha }}/mattermost-team-linux-amd64.tar.gz
|
||||
docker buildx build --push --build-arg MM_PACKAGE=$MM_PACKAGE -t mattermost/mm-te-test:${TAG} .
|
||||
- name: cd/Login to Docker Hub
|
||||
uses: docker/login-action@3da7dc6e2b31f99ef2cb9fb4c50fb0971e0d0139 # v2.1.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: cd/Download artifacts
|
||||
uses: dawidd6/action-download-artifact@0c49384d39ceb023b8040f480a25596fd6cf441b # v2.26.0
|
||||
with:
|
||||
workflow: ${{ github.event.workflow_run.workflow_id }}
|
||||
run_id: ${{ github.event.workflow_run.id }}
|
||||
workflow_conclusion: success
|
||||
name: server-build-artifact
|
||||
path: server/build/
|
||||
- name: cd/Setup Docker Buildx
|
||||
uses: docker/setup-buildx-action@11e8a2e2910826a92412015c515187a2d6750279 # v2.4
|
||||
- name: cd/Docker build and push
|
||||
env:
|
||||
DOCKER_CLI_EXPERIMENTAL: enabled
|
||||
REPO_NAME: ${{ github.event.repository.name }}
|
||||
COMMIT_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||
run: |
|
||||
export TAG=$(echo "${{ github.event.pull_request.head.sha || github.event.workflow_run.head_sha }}" | cut -c1-7)
|
||||
cd server/build
|
||||
export DOCKER_CLI_EXPERIMENTAL=enabled
|
||||
export MM_PACKAGE=https://pr-builds.mattermost.com/$REPO_NAME/commit/$COMMIT_SHA/mattermost-team-linux-amd64.tar.gz
|
||||
docker buildx build --push --build-arg MM_PACKAGE=$MM_PACKAGE -t mattermost/mm-te-test:${TAG} .
|
||||
|
||||
sentry:
|
||||
name: Send build info to sentry
|
||||
if: >
|
||||
github.event.workflow_run.event == 'pull_request' &&
|
||||
github.event.workflow_run.conclusion == 'success'
|
||||
github.event.workflow_run.event == 'push'
|
||||
runs-on: ubuntu-22.04
|
||||
env:
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.MM_SERVER_SENTRY_AUTH_TOKEN }}
|
||||
|
||||
2
.github/workflows/ci.yml
поставляемый
2
.github/workflows/ci.yml
поставляемый
@@ -66,7 +66,7 @@ jobs:
|
||||
run: if [[ -n $(git status --porcelain) ]]; then echo "Please update the serialized files using 'make gen-serialized'"; exit 1; fi
|
||||
check-mattermost-vet:
|
||||
name: Check style
|
||||
runs-on: ubuntu-latest-8-cores
|
||||
runs-on: ubuntu-22.04
|
||||
defaults:
|
||||
run:
|
||||
working-directory: server
|
||||
|
||||
13
e2e-tests/cypress/package-lock.json
сгенерированный
13
e2e-tests/cypress/package-lock.json
сгенерированный
@@ -12,7 +12,6 @@
|
||||
"@babel/eslint-parser": "7.19.1",
|
||||
"@babel/eslint-plugin": "7.19.1",
|
||||
"@cypress/request": "2.88.11",
|
||||
"@cypress/skip-test": "2.6.1",
|
||||
"@mattermost/types": "7.4.0",
|
||||
"@testing-library/cypress": "9.0.0",
|
||||
"@types/async": "3.2.16",
|
||||
@@ -2250,12 +2249,6 @@
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/@cypress/skip-test": {
|
||||
"version": "2.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@cypress/skip-test/-/skip-test-2.6.1.tgz",
|
||||
"integrity": "sha512-X+ibefBiuOmC5gKG91wRIT0/OqXeETYvu7zXktjZ3yLeO186Y8ia0K7/gQUpAwuUi28DuqMd1+7tBQVtPkzbPA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@cypress/xvfb": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@cypress/xvfb/-/xvfb-1.2.4.tgz",
|
||||
@@ -19062,12 +19055,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"@cypress/skip-test": {
|
||||
"version": "2.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@cypress/skip-test/-/skip-test-2.6.1.tgz",
|
||||
"integrity": "sha512-X+ibefBiuOmC5gKG91wRIT0/OqXeETYvu7zXktjZ3yLeO186Y8ia0K7/gQUpAwuUi28DuqMd1+7tBQVtPkzbPA==",
|
||||
"dev": true
|
||||
},
|
||||
"@cypress/xvfb": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@cypress/xvfb/-/xvfb-1.2.4.tgz",
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
"@babel/eslint-parser": "7.19.1",
|
||||
"@babel/eslint-plugin": "7.19.1",
|
||||
"@cypress/request": "2.88.11",
|
||||
"@cypress/skip-test": "2.6.1",
|
||||
"@mattermost/types": "7.4.0",
|
||||
"@testing-library/cypress": "9.0.0",
|
||||
"@types/async": "3.2.16",
|
||||
|
||||
@@ -18,7 +18,6 @@ describe('Verify Accessibility Support in Post', () => {
|
||||
let otherUser;
|
||||
let testTeam;
|
||||
let testChannel;
|
||||
let emojiPickerEnabled;
|
||||
|
||||
before(() => {
|
||||
cy.apiInitSetup().then(({team, channel, user}) => {
|
||||
@@ -33,10 +32,6 @@ describe('Verify Accessibility Support in Post', () => {
|
||||
cy.apiAddUserToChannel(testChannel.id, otherUser.id);
|
||||
});
|
||||
});
|
||||
|
||||
cy.apiGetConfig().then(({config}) => {
|
||||
emojiPickerEnabled = config.ServiceSettings.EnableEmojiPicker;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -179,17 +174,10 @@ describe('Verify Accessibility Support in Post', () => {
|
||||
cy.get(`#CENTER_time_${postId}`).should('be.focused');
|
||||
cy.focused().tab();
|
||||
|
||||
// eslint-disable-next-line no-negated-condition
|
||||
if (!emojiPickerEnabled) {
|
||||
// * Verify focus is on the actions button
|
||||
cy.get(`#CENTER_button_${postId}`).should('be.focused').and('have.attr', 'aria-label', 'more');
|
||||
for (let i = 0; i < 3; i++) {
|
||||
// * Verify focus is on the reactions button
|
||||
cy.get(`#recent_reaction_${i}`).should('have.class', 'emoticon--post-menu').and('have.attr', 'aria-label');
|
||||
cy.focused().tab();
|
||||
} else {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
// * Verify focus is on the reactions button
|
||||
cy.get(`#recent_reaction_${i}`).should('have.class', 'emoticon--post-menu').and('have.attr', 'aria-label');
|
||||
cy.focused().tab();
|
||||
}
|
||||
}
|
||||
|
||||
// * Verify focus is on the reactions button
|
||||
@@ -200,15 +188,17 @@ describe('Verify Accessibility Support in Post', () => {
|
||||
cy.get(`#CENTER_flagIcon_${postId}`).should('be.focused').and('have.attr', 'aria-label', 'save');
|
||||
cy.focused().tab();
|
||||
|
||||
// * Verify focus is on message actions button
|
||||
cy.get(`#CENTER_actions_button_${postId}`).should('be.focused').and('have.attr', 'aria-label', 'actions');
|
||||
cy.focused().tab();
|
||||
|
||||
// * Verify focus is on the comment button
|
||||
cy.get(`#CENTER_commentIcon_${postId}`).should('be.focused').and('have.attr', 'aria-label', 'reply');
|
||||
cy.focused().tab();
|
||||
|
||||
if (emojiPickerEnabled) {
|
||||
// * Verify focus is on the more button
|
||||
cy.get(`#CENTER_button_${postId}`).should('be.focused').and('have.attr', 'aria-label', 'More');
|
||||
cy.focused().tab();
|
||||
}
|
||||
// * Verify focus is on the more button
|
||||
cy.get(`#CENTER_button_${postId}`).should('be.focused').and('have.attr', 'aria-label', 'more');
|
||||
cy.focused().tab();
|
||||
|
||||
// * Verify focus is on the post text
|
||||
cy.get(`#postMessageText_${postId}`).should('be.focused').and('have.attr', 'aria-readonly', 'true');
|
||||
@@ -244,11 +234,13 @@ describe('Verify Accessibility Support in Post', () => {
|
||||
cy.get(`#rhsPostMessageText_${postId}`).should('be.focused').and('have.attr', 'aria-readonly', 'true');
|
||||
cy.focused().tab({shift: true});
|
||||
|
||||
if (emojiPickerEnabled) {
|
||||
// * Verify focus is on the actions button
|
||||
cy.get(`#RHS_COMMENT_button_${postId}`).should('be.focused').and('have.attr', 'aria-label', 'More');
|
||||
cy.focused().tab({shift: true});
|
||||
}
|
||||
// * Verify focus is on the more button
|
||||
cy.get(`#RHS_COMMENT_button_${postId}`).should('be.focused').and('have.attr', 'aria-label', 'more');
|
||||
cy.focused().tab({shift: true});
|
||||
|
||||
// * Verify focus is on message actions button
|
||||
cy.get(`#RHS_COMMENT_actions_button_${postId}`).should('be.focused').and('have.attr', 'aria-label', 'actions');
|
||||
cy.focused().tab({shift: true});
|
||||
|
||||
// * Verify focus is on the save icon
|
||||
cy.get(`#RHS_COMMENT_flagIcon_${postId}`).should('be.focused').and('have.attr', 'aria-label', 'save');
|
||||
@@ -258,15 +250,9 @@ describe('Verify Accessibility Support in Post', () => {
|
||||
cy.get(`#RHS_COMMENT_reaction_${postId}`).should('be.focused').and('have.attr', 'aria-label', 'add reaction');
|
||||
cy.focused().tab({shift: true});
|
||||
|
||||
// eslint-disable-next-line no-negated-condition
|
||||
if (!emojiPickerEnabled) {
|
||||
// * Verify focus is on the actions button
|
||||
cy.get(`#RHS_COMMENT_button_${postId}`).should('be.focused').and('have.attr', 'aria-label', 'more');
|
||||
cy.focused().tab({shift: true});
|
||||
} else {
|
||||
cy.get('#recent_reaction_0').should('have.class', 'emoticon--post-menu').and('have.attr', 'aria-label');
|
||||
cy.focused().tab({shift: true});
|
||||
}
|
||||
// * Verify focus is on most recent action
|
||||
cy.get('#recent_reaction_0').should('have.class', 'emoticon--post-menu').and('have.attr', 'aria-label');
|
||||
cy.focused().tab({shift: true});
|
||||
|
||||
// * Verify focus is on the time
|
||||
cy.get(`#RHS_COMMENT_time_${postId}`).should('be.focused');
|
||||
|
||||
@@ -15,7 +15,10 @@ import {getRandomId} from '../../../utils';
|
||||
describe('Leave an archived channel', () => {
|
||||
let testTeam;
|
||||
let offTopicUrl;
|
||||
|
||||
const channelType = {
|
||||
public: 'Channel Type: Public',
|
||||
archived: 'Channel Type: Archived',
|
||||
};
|
||||
before(() => {
|
||||
cy.apiUpdateConfig({
|
||||
TeamSettings: {
|
||||
@@ -97,7 +100,7 @@ describe('Leave an archived channel', () => {
|
||||
// # More channels modal opens
|
||||
cy.get('#moreChannelsModal').should('be.visible').within(() => {
|
||||
// # Click on dropdown
|
||||
cy.findByText('Show: Public Channels').should('be.visible').click();
|
||||
cy.findByText(channelType.public).should('be.visible').click();
|
||||
|
||||
// # Click archived channels
|
||||
cy.findByText('Archived Channels').click();
|
||||
@@ -145,7 +148,7 @@ describe('Leave an archived channel', () => {
|
||||
// # More channels modal opens
|
||||
cy.get('.more-modal').should('be.visible').within(() => {
|
||||
// # Public channel list opens by default
|
||||
cy.findByText('Show: Public Channels').should('be.visible').click();
|
||||
cy.findByText(channelType.public).should('be.visible').click();
|
||||
|
||||
// # Click on archived channels
|
||||
cy.findByText('Archived Channels').click();
|
||||
@@ -198,7 +201,7 @@ describe('Leave an archived channel', () => {
|
||||
// # More channels modal opens
|
||||
cy.get('.more-modal').should('be.visible').within(() => {
|
||||
// # Public channels are shown by default
|
||||
cy.findByText('Show: Public Channels').should('be.visible').click();
|
||||
cy.findByText(channelType.public).should('be.visible').click();
|
||||
|
||||
// # Go to archived channels
|
||||
cy.findByText('Archived Channels').click();
|
||||
@@ -252,7 +255,7 @@ describe('Leave an archived channel', () => {
|
||||
// # More channels modal opens
|
||||
cy.get('.more-modal').should('be.visible').within(() => {
|
||||
// # Show public channels is visible by default
|
||||
cy.findByText('Show: Public Channels').should('be.visible').click();
|
||||
cy.findByText(channelType.public).should('be.visible').click();
|
||||
|
||||
// # Go to archived channels
|
||||
cy.findByText('Archived Channels').click();
|
||||
@@ -286,7 +289,7 @@ describe('Leave an archived channel', () => {
|
||||
|
||||
// # More channels modal opens and lands on public channels
|
||||
cy.get('#moreChannelsModal').should('be.visible').within(() => {
|
||||
cy.findByText('Show: Public Channels').should('be.visible').click();
|
||||
cy.findByText(channelType.public).should('be.visible').click();
|
||||
|
||||
// # Go to archived channels
|
||||
cy.findByText('Archived Channels').click();
|
||||
|
||||
@@ -14,6 +14,11 @@ import * as TIMEOUTS from '../../../fixtures/timeouts';
|
||||
|
||||
import {createPrivateChannel} from '../enterprise/elasticsearch_autocomplete/helpers';
|
||||
|
||||
const channelType = {
|
||||
public: 'Channel Type: Public',
|
||||
archived: 'Channel Type: Archived',
|
||||
};
|
||||
|
||||
describe('Channels', () => {
|
||||
let testUser;
|
||||
let otherUser;
|
||||
@@ -65,7 +70,7 @@ describe('Channels', () => {
|
||||
|
||||
cy.get('#moreChannelsModal').should('be.visible').within(() => {
|
||||
// * Dropdown should be visible, defaulting to "Public Channels"
|
||||
cy.get('#channelsMoreDropdown').should('be.visible').and('contain', 'Show: Public Channels').wait(TIMEOUTS.HALF_SEC);
|
||||
cy.get('#channelsMoreDropdown').should('be.visible').and('contain', channelType.public).wait(TIMEOUTS.HALF_SEC);
|
||||
|
||||
cy.get('#searchChannelsTextbox').should('be.visible').type(testChannel.display_name).wait(TIMEOUTS.HALF_SEC);
|
||||
cy.get('#moreChannelsList').should('be.visible').children().should('have.length', 1).within(() => {
|
||||
@@ -113,7 +118,7 @@ describe('Channels', () => {
|
||||
cy.findByText('Archived Channels').should('be.visible').click();
|
||||
|
||||
// * Channel test should be visible as an archived channel in the list
|
||||
cy.wrap(el).should('contain', 'Show: Archived Channels');
|
||||
cy.wrap(el).should('contain', channelType.archived);
|
||||
});
|
||||
|
||||
cy.get('#searchChannelsTextbox').should('be.visible').type(testChannel.display_name).wait(TIMEOUTS.HALF_SEC);
|
||||
@@ -196,7 +201,7 @@ describe('Channels', () => {
|
||||
|
||||
// * Dropdown should be visible, defaulting to "Public Channels"
|
||||
cy.get('#channelsMoreDropdown').should('be.visible').within((el) => {
|
||||
cy.wrap(el).should('contain', 'Show: Public Channels');
|
||||
cy.wrap(el).should('contain', channelType.public);
|
||||
});
|
||||
|
||||
// * Users should be able to type and search
|
||||
@@ -207,12 +212,12 @@ describe('Channels', () => {
|
||||
|
||||
cy.get('#moreChannelsModal').should('be.visible').within(() => {
|
||||
// * Users should be able to switch to "Archived Channels" list
|
||||
cy.get('#channelsMoreDropdown').should('be.visible').and('contain', 'Show: Public Channels').click().within((el) => {
|
||||
cy.get('#channelsMoreDropdown').should('be.visible').and('contain', channelType.public).click().within((el) => {
|
||||
// # Click on archived channels item
|
||||
cy.findByText('Archived Channels').should('be.visible').click();
|
||||
|
||||
// * Modal should show the archived channels list
|
||||
cy.wrap(el).should('contain', 'Show: Archived Channels');
|
||||
cy.wrap(el).should('contain', channelType.archived);
|
||||
}).wait(TIMEOUTS.HALF_SEC);
|
||||
cy.get('#searchChannelsTextbox').clear();
|
||||
cy.get('#moreChannelsList').should('be.visible').children().should('have.length', 2);
|
||||
@@ -250,7 +255,7 @@ function verifyMoreChannelsModal(isEnabled) {
|
||||
// * Verify that the more channels modal is open and with or without option to view archived channels
|
||||
cy.get('#moreChannelsModal').should('be.visible').within(() => {
|
||||
if (isEnabled) {
|
||||
cy.get('#channelsMoreDropdown').should('be.visible').and('have.text', 'Show: Public Channels');
|
||||
cy.get('#channelsMoreDropdown').should('be.visible').and('have.text', channelType.public);
|
||||
} else {
|
||||
cy.get('#channelsMoreDropdown').should('not.exist');
|
||||
}
|
||||
|
||||
@@ -142,7 +142,7 @@ describe('System Console - Subscriptions section', () => {
|
||||
cy.get('.RHS').find('button').should('be.enabled');
|
||||
|
||||
// # Change the user seats field to a value smaller than the current number of users
|
||||
const lessThanUserCount = count - 5;
|
||||
const lessThanUserCount = 1;
|
||||
cy.get('#input_UserSeats').clear().type(lessThanUserCount);
|
||||
|
||||
// * Ensure that the yearly, monthly, and yearly saving prices match the new user seats value entered
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
// ***************************************************************
|
||||
// - [#] indicates a test step (e.g. # Go to a page)
|
||||
// - [*] indicates an assertion (e.g. * Check the title)
|
||||
// - Use element ID when selecting an element. Create one if none.
|
||||
// ***************************************************************
|
||||
// Stage: @prod
|
||||
|
||||
describe('Insights', () => {
|
||||
let teamA;
|
||||
|
||||
before(() => {
|
||||
cy.shouldHaveFeatureFlag('InsightsEnabled', true);
|
||||
|
||||
cy.apiInitSetup().then(({team}) => {
|
||||
teamA = team;
|
||||
});
|
||||
});
|
||||
it('Check all the cards exist', () => {
|
||||
cy.apiAdminLogin();
|
||||
|
||||
// # Go to the Insights view
|
||||
cy.visit(`/${teamA.name}/activity-and-insights`);
|
||||
|
||||
// * Check top channels exists
|
||||
cy.get('.top-channels-card').should('exist');
|
||||
|
||||
// * Check top threads exists
|
||||
cy.get('.top-threads-card').should('exist');
|
||||
|
||||
// * Check top boards exists because product mode is enabled
|
||||
cy.get('.top-boards-card').should('exist');
|
||||
|
||||
// * Check top reactions exists
|
||||
cy.get('.top-reactions-card').should('exist');
|
||||
|
||||
// * Check top dms exists
|
||||
cy.get('.top-dms-card').should('exist');
|
||||
|
||||
// * Check least active channels exists
|
||||
cy.get('.least-active-channels-card').should('exist');
|
||||
|
||||
// * Check top playbooks exists because product mode is enabled
|
||||
cy.get('.top-playbooks-card').should('exist');
|
||||
});
|
||||
});
|
||||
@@ -9,71 +9,45 @@
|
||||
// Stage: @prod
|
||||
// Group: @playbooks
|
||||
|
||||
import {onlyOn} from '@cypress/skip-test';
|
||||
|
||||
describe('channels > App Bar', {testIsolation: true}, () => {
|
||||
let testTeam;
|
||||
let testUser;
|
||||
let testPlaybook;
|
||||
let appBarEnabled;
|
||||
|
||||
before(() => {
|
||||
cy.apiInitSetup().then(({team, user}) => {
|
||||
testTeam = team;
|
||||
testUser = user;
|
||||
|
||||
// # Login as testUser
|
||||
cy.apiLogin(testUser);
|
||||
|
||||
// # Create a playbook
|
||||
cy.apiCreateTestPlaybook({
|
||||
teamId: testTeam.id,
|
||||
title: 'Playbook',
|
||||
userId: testUser.id,
|
||||
}).then((playbook) => {
|
||||
testPlaybook = playbook;
|
||||
|
||||
// # Start a playbook run
|
||||
cy.apiRunPlaybook({
|
||||
teamId: testTeam.id,
|
||||
playbookId: testPlaybook.id,
|
||||
playbookRunName: 'Playbook Run',
|
||||
ownerUserId: testUser.id,
|
||||
});
|
||||
});
|
||||
|
||||
cy.apiGetConfig(true).then(({config}) => {
|
||||
appBarEnabled = config.EnableAppBar === 'true';
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// # Size the viewport to show the RHS without covering posts.
|
||||
cy.viewport('macbook-13');
|
||||
|
||||
// # Login as testUser
|
||||
cy.apiLogin(testUser);
|
||||
cy.apiAdminLogin();
|
||||
});
|
||||
|
||||
describe('App Bar disabled', () => {
|
||||
it('should not show the Playbook App Bar icon', () => {
|
||||
onlyOn(!appBarEnabled);
|
||||
cy.apiUpdateConfig({ExperimentalSettings: {EnableAppBar: false}});
|
||||
|
||||
// # Login as testUser
|
||||
cy.apiLogin(testUser);
|
||||
|
||||
// # Navigate directly to a non-playbook run channel
|
||||
cy.visit(`/${testTeam.name}/channels/town-square`);
|
||||
|
||||
// * Verify App Bar icon is not showing
|
||||
cy.get('#channel_view').within(() => {
|
||||
cy.getPlaybooksAppBarIcon().should('not.exist');
|
||||
});
|
||||
cy.get('.app-bar').should('not.exist');
|
||||
});
|
||||
});
|
||||
|
||||
describe('App Bar enabled', () => {
|
||||
it('should show the Playbook App Bar icon', () => {
|
||||
onlyOn(appBarEnabled);
|
||||
beforeEach(() => {
|
||||
cy.apiUpdateConfig({ExperimentalSettings: {EnableAppBar: true}});
|
||||
|
||||
// # Login as testUser
|
||||
cy.apiLogin(testUser);
|
||||
});
|
||||
|
||||
it('should show the Playbook App Bar icon', () => {
|
||||
// # Navigate directly to a non-playbook run channel
|
||||
cy.visit(`/${testTeam.name}/channels/town-square`);
|
||||
|
||||
@@ -82,8 +56,6 @@ describe('channels > App Bar', {testIsolation: true}, () => {
|
||||
});
|
||||
|
||||
it('should show "Playbooks" tooltip for Playbook App Bar icon', () => {
|
||||
onlyOn(appBarEnabled);
|
||||
|
||||
// # Navigate directly to a non-playbook run channel
|
||||
cy.visit(`/${testTeam.name}/channels/town-square`);
|
||||
|
||||
|
||||
@@ -9,14 +9,11 @@
|
||||
// Stage: @prod
|
||||
// Group: @playbooks
|
||||
|
||||
import {onlyOn} from '@cypress/skip-test';
|
||||
|
||||
describe('channels > channel header', {testIsolation: true}, () => {
|
||||
let testTeam;
|
||||
let testUser;
|
||||
let testPlaybook;
|
||||
let testPlaybookRun;
|
||||
let appBarEnabled;
|
||||
|
||||
before(() => {
|
||||
cy.apiInitSetup().then(({team, user}) => {
|
||||
@@ -44,24 +41,16 @@ describe('channels > channel header', {testIsolation: true}, () => {
|
||||
testPlaybookRun = run;
|
||||
});
|
||||
});
|
||||
|
||||
cy.apiGetConfig(true).then(({config}) => {
|
||||
appBarEnabled = config.EnableAppBar === 'true';
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// # Size the viewport to show the RHS without covering posts.
|
||||
cy.viewport('macbook-13');
|
||||
|
||||
// # Login as testUser
|
||||
cy.apiLogin(testUser);
|
||||
});
|
||||
|
||||
describe('App Bar enabled', () => {
|
||||
it('webapp should hide the Playbook channel header button', () => {
|
||||
onlyOn(appBarEnabled);
|
||||
cy.apiAdminLogin();
|
||||
cy.apiUpdateConfig({ExperimentalSettings: {EnableAppBar: true}});
|
||||
|
||||
// # Login as testUser
|
||||
cy.apiLogin(testUser);
|
||||
|
||||
// # Navigate directly to a non-playbook run channel
|
||||
cy.visit(`/${testTeam.name}/channels/town-square`);
|
||||
@@ -74,9 +63,15 @@ describe('channels > channel header', {testIsolation: true}, () => {
|
||||
});
|
||||
|
||||
describe('App Bar disabled', () => {
|
||||
it('webapp should show the Playbook channel header button', () => {
|
||||
onlyOn(!appBarEnabled);
|
||||
beforeEach(() => {
|
||||
cy.apiAdminLogin();
|
||||
cy.apiUpdateConfig({ExperimentalSettings: {EnableAppBar: false}});
|
||||
|
||||
// # Login as testUser
|
||||
cy.apiLogin(testUser);
|
||||
});
|
||||
|
||||
it('webapp should show the Playbook channel header button', () => {
|
||||
// # Navigate directly to a non-playbook run channel
|
||||
cy.visit(`/${testTeam.name}/channels/town-square`);
|
||||
|
||||
@@ -87,8 +82,6 @@ describe('channels > channel header', {testIsolation: true}, () => {
|
||||
});
|
||||
|
||||
it('tooltip text should show "Playbooks" for Playbook channel header button', () => {
|
||||
onlyOn(!appBarEnabled);
|
||||
|
||||
// # Navigate directly to a non-playbook run channel
|
||||
cy.visit(`/${testTeam.name}/channels/town-square`);
|
||||
|
||||
@@ -103,6 +96,11 @@ describe('channels > channel header', {testIsolation: true}, () => {
|
||||
});
|
||||
|
||||
describe('description text', () => {
|
||||
beforeEach(() => {
|
||||
// # Login as testUser
|
||||
cy.apiLogin(testUser);
|
||||
});
|
||||
|
||||
it('should contain a link to the playbook', () => {
|
||||
// # Navigate directly to a playbook run channel
|
||||
cy.visit(`/${testTeam.name}/channels/playbook-run`);
|
||||
@@ -112,6 +110,7 @@ describe('channels > channel header', {testIsolation: true}, () => {
|
||||
expect(href).to.equals(`/playbooks/playbooks/${testPlaybook.id}`);
|
||||
});
|
||||
});
|
||||
|
||||
it('should contain a link to the overview page', () => {
|
||||
// # Navigate directly to a playbook run channel
|
||||
cy.visit(`/${testTeam.name}/channels/playbook-run`);
|
||||
|
||||
@@ -82,7 +82,7 @@ describe('channels > rhs > status update', {testIsolation: true}, () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.skip('description link navigates to run overview', () => {
|
||||
it('description link navigates to run overview', () => {
|
||||
// # Run the `/playbook update` slash command.
|
||||
cy.uiPostMessageQuickly('/playbook update');
|
||||
|
||||
|
||||
@@ -153,7 +153,7 @@ describe('lhs', {testIsolation: true}, () => {
|
||||
cy.findByTestId('dropdownmenu').should('be.visible');
|
||||
});
|
||||
|
||||
it.skip('can copy link', () => {
|
||||
it('can copy link', () => {
|
||||
// # Visit the playbook run
|
||||
cy.visit(`/playbooks/runs/${playbookRun.id}`);
|
||||
stubClipboard().as('clipboard');
|
||||
@@ -295,7 +295,7 @@ describe('lhs', {testIsolation: true}, () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.skip('leave run, when on rdp of the same run', () => {
|
||||
it('leave run, when on rdp of the same run', () => {
|
||||
// # Click on leave menu item
|
||||
getRunDropdownItemByText('Runs', playbookRun.name, 'Leave and unfollow run').click();
|
||||
|
||||
|
||||
@@ -290,7 +290,7 @@ describe('playbooks > edit_metrics', {testIsolation: true}, () => {
|
||||
});
|
||||
|
||||
describe('delete metric', () => {
|
||||
it.skip('verifies when clicking delete button; saved metrics have different confirmation text; deleted metrics are deleted', () => {
|
||||
it('verifies when clicking delete button; saved metrics have different confirmation text; deleted metrics are deleted', () => {
|
||||
// # Visit the selected playbook
|
||||
cy.visit(`/playbooks/playbooks/${testPlaybook.id}`);
|
||||
|
||||
|
||||
@@ -160,9 +160,7 @@ describe('runs > permissions', {testIsolation: true}, () => {
|
||||
});
|
||||
|
||||
describe('should be visible', () => {
|
||||
// XXX: Skipping this test, since public playbooks currently have no members. This will
|
||||
// likely change in the future, so keeping the skeleton.
|
||||
it.skip('to playbook members', () => {
|
||||
it('to playbook members', () => {
|
||||
assertRunIsVisible(run, playbookMember);
|
||||
});
|
||||
|
||||
@@ -242,9 +240,7 @@ describe('runs > permissions', {testIsolation: true}, () => {
|
||||
});
|
||||
|
||||
describe('should be visible', () => {
|
||||
// XXX: Skipping this test, since public playbooks currently have no members. This will
|
||||
// likely change in the future.
|
||||
it.skip('to playbook members', () => {
|
||||
it('to playbook members', () => {
|
||||
assertRunIsVisible(run, playbookMember);
|
||||
});
|
||||
|
||||
@@ -332,10 +328,9 @@ describe('runs > permissions', {testIsolation: true}, () => {
|
||||
assertRunIsVisible(run, runParticipant);
|
||||
});
|
||||
|
||||
// Skipping this test, since followers cannot follow a run with a private channel from
|
||||
// a private playbook. (But leaving it for clarity in the code.)
|
||||
it.skip('to run followers', () => {
|
||||
assertRunIsVisible(run, runFollower);
|
||||
// Followers cannot follow a run with a private channel from a private playbook
|
||||
it('to run followers', () => {
|
||||
assertRunIsNotVisible(run, runFollower);
|
||||
});
|
||||
|
||||
it('to admins in the team', () => {
|
||||
@@ -414,10 +409,9 @@ describe('runs > permissions', {testIsolation: true}, () => {
|
||||
assertRunIsVisible(run, runParticipant);
|
||||
});
|
||||
|
||||
// Skipping this test, since followers cannot follow a run with a private channel from
|
||||
// a private playbook. (But leaving it for clarity in the code.)
|
||||
it.skip('to run followers', () => {
|
||||
assertRunIsVisible(run, runFollower);
|
||||
// Followers cannot follow a run with a private channel from a private playbook
|
||||
it('to run followers', () => {
|
||||
assertRunIsNotVisible(run, runFollower);
|
||||
});
|
||||
|
||||
it('to admins in the team', () => {
|
||||
|
||||
@@ -692,7 +692,7 @@ describe('runs > run details page > header', {testIsolation: true}, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe.skip('Join action disabled', () => {
|
||||
describe('Join action disabled', () => {
|
||||
beforeEach(() => {
|
||||
cy.apiLogin(testUser);
|
||||
|
||||
|
||||
@@ -267,7 +267,7 @@ describe('runs > run details page > status update', {testIsolation: true}, () =>
|
||||
});
|
||||
});
|
||||
|
||||
it.skip('requests an update and confirm', () => {
|
||||
it('requests an update and confirm', () => {
|
||||
// # Click on request update
|
||||
cy.findByTestId('run-statusupdate-section').
|
||||
should('be.visible').
|
||||
@@ -281,11 +281,11 @@ describe('runs > run details page > status update', {testIsolation: true}, () =>
|
||||
cy.visit(`${testTeam.name}/channels/${playbookRunChannelName}`);
|
||||
|
||||
// * Assert that message has been sent
|
||||
cy.getLastPost().contains(`${testUser.username} requested a status update for ${testPublicPlaybook.name}.`);
|
||||
cy.getLastPost().contains(`${testViewerUser.username} requested a status update for ${testRun.name}.`);
|
||||
});
|
||||
});
|
||||
|
||||
it.skip('requests an update and cancel', () => {
|
||||
it('requests an update and cancel', () => {
|
||||
// # Click request update
|
||||
cy.findByTestId('run-statusupdate-section').
|
||||
should('be.visible').
|
||||
|
||||
Различия файлов скрыты, потому что одна или несколько строк слишком длинны
@@ -153,7 +153,7 @@ PLUGIN_PACKAGES += mattermost-plugin-nps-v1.3.1
|
||||
PLUGIN_PACKAGES += mattermost-plugin-todo-v0.6.1
|
||||
PLUGIN_PACKAGES += mattermost-plugin-welcomebot-v1.2.0
|
||||
PLUGIN_PACKAGES += mattermost-plugin-zoom-v1.6.0
|
||||
PLUGIN_PACKAGES += mattermost-plugin-apps-v1.2.0
|
||||
PLUGIN_PACKAGES += mattermost-plugin-apps-v1.2.1
|
||||
|
||||
# Prepares the enterprise build if exists. The IGNORE stuff is a hack to get the Makefile to execute the commands outside a target
|
||||
ifeq ($(BUILD_ENTERPRISE_READY),true)
|
||||
@@ -292,7 +292,7 @@ endif
|
||||
|
||||
golangci-lint: ## Run golangci-lint on codebase
|
||||
@# Keep the version in sync with the command in .circleci/config.yml
|
||||
$(GO) install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.50.1
|
||||
$(GO) install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.52.2
|
||||
|
||||
@echo Running golangci-lint
|
||||
$(GOBIN)/golangci-lint run ./...
|
||||
|
||||
@@ -304,7 +304,7 @@ func (a *API) handlePostBlocks(w http.ResponseWriter, r *http.Request) {
|
||||
// this query param exists when creating template from board, or board from template
|
||||
sourceBoardID := r.URL.Query().Get("sourceBoardID")
|
||||
if sourceBoardID != "" {
|
||||
if updateFileIDsErr := a.app.CopyCardFiles(sourceBoardID, blocks); updateFileIDsErr != nil {
|
||||
if updateFileIDsErr := a.app.CopyAndUpdateCardFiles(sourceBoardID, userID, blocks, false); updateFileIDsErr != nil {
|
||||
a.errorResponse(w, r, updateFileIDsErr)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -312,7 +312,7 @@ func (a *API) handleUploadFile(w http.ResponseWriter, r *http.Request) {
|
||||
auditRec.AddMeta("teamID", board.TeamID)
|
||||
auditRec.AddMeta("filename", handle.Filename)
|
||||
|
||||
fileID, err := a.app.SaveFile(file, board.TeamID, boardID, handle.Filename)
|
||||
fileID, err := a.app.SaveFile(file, board.TeamID, boardID, handle.Filename, board.IsTemplate)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
|
||||
@@ -7,11 +7,9 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/mattermost/mattermost-server/server/v8/boards/model"
|
||||
"github.com/mattermost/mattermost-server/server/v8/boards/services/notify"
|
||||
"github.com/mattermost/mattermost-server/server/v8/boards/utils"
|
||||
|
||||
"github.com/mattermost/mattermost-server/server/v8/platform/shared/mlog"
|
||||
)
|
||||
@@ -39,6 +37,11 @@ func (a *App) DuplicateBlock(boardID string, blockID string, userID string, asTe
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = a.CopyAndUpdateCardFiles(boardID, userID, blocks, asTemplate)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
a.blockChangeNotifier.Enqueue(func() error {
|
||||
for _, block := range blocks {
|
||||
a.wsAdapter.BroadcastBlockChange(board.TeamID, block)
|
||||
@@ -286,95 +289,6 @@ func (a *App) InsertBlocksAndNotify(blocks []*model.Block, modifiedByID string,
|
||||
return blocks, nil
|
||||
}
|
||||
|
||||
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 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)
|
||||
}
|
||||
|
||||
var destTeamID string
|
||||
var destBoardID string
|
||||
|
||||
for i := range copiedBlocks {
|
||||
block := copiedBlocks[i]
|
||||
fileName := ""
|
||||
isOk := false
|
||||
|
||||
switch block.Type {
|
||||
case model.TypeImage:
|
||||
fileName, isOk = block.Fields["fileId"].(string)
|
||||
if !isOk || fileName == "" {
|
||||
continue
|
||||
}
|
||||
case model.TypeAttachment:
|
||||
fileName, isOk = block.Fields["attachmentId"].(string)
|
||||
if !isOk || fileName == "" {
|
||||
continue
|
||||
}
|
||||
default:
|
||||
continue
|
||||
}
|
||||
|
||||
// create unique filename in case we are copying cards within the same board.
|
||||
ext := filepath.Ext(fileName)
|
||||
destFilename := utils.NewID(utils.IDTypeNone) + ext
|
||||
|
||||
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)
|
||||
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 block.Type == model.TypeAttachment {
|
||||
block.Fields["attachmentId"] = destFilename
|
||||
parts := strings.Split(fileName, ".")
|
||||
fileInfoID := parts[0][1:]
|
||||
fileInfo, err := a.store.GetFileInfo(fileInfoID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("CopyCardFiles: cannot retrieve original fileinfo: %w", err)
|
||||
}
|
||||
newParts := strings.Split(destFilename, ".")
|
||||
newFileID := newParts[0][1:]
|
||||
fileInfo.Id = newFileID
|
||||
err = a.store.SaveFileInfo(fileInfo)
|
||||
if err != nil {
|
||||
return fmt.Errorf("CopyCardFiles: cannot create fileinfo: %w", err)
|
||||
}
|
||||
} else {
|
||||
block.Fields["fileId"] = destFilename
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) GetBlockByID(blockID string) (*model.Block, error) {
|
||||
return a.store.GetBlock(blockID)
|
||||
}
|
||||
|
||||
@@ -184,8 +184,13 @@ func (a *App) DuplicateBoard(boardID, userID, toTeam string, asTemplate bool) (*
|
||||
}
|
||||
|
||||
// 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))
|
||||
err = a.CopyAndUpdateCardFiles(boardID, userID, bab.Blocks, asTemplate)
|
||||
if 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)
|
||||
}
|
||||
|
||||
if !asTemplate {
|
||||
@@ -196,44 +201,6 @@ func (a *App) DuplicateBoard(boardID, userID, toTeam string, asTemplate bool) (*
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
fieldName := ""
|
||||
if block.Type == model.TypeImage {
|
||||
fieldName = "fileId"
|
||||
} else if block.Type == model.TypeAttachment {
|
||||
fieldName = "attachmentId"
|
||||
}
|
||||
if fieldName != "" {
|
||||
if fieldID, ok := block.Fields[fieldName]; ok {
|
||||
blockIDs = append(blockIDs, block.ID)
|
||||
blockPatches = append(blockPatches, model.BlockPatch{
|
||||
UpdatedFields: map[string]interface{}{
|
||||
fieldName: fieldID,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
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 {
|
||||
|
||||
@@ -95,10 +95,10 @@ func (a *App) writeArchiveBoard(zw *zip.Writer, board model.Board, opt model.Exp
|
||||
if err = a.writeArchiveBlockLine(w, block); err != nil {
|
||||
return err
|
||||
}
|
||||
if block.Type == model.TypeImage {
|
||||
filename, err2 := extractImageFilename(block)
|
||||
if block.Type == model.TypeImage || block.Type == model.TypeAttachment {
|
||||
filename, err2 := extractFilename(block)
|
||||
if err2 != nil {
|
||||
return err
|
||||
return err2
|
||||
}
|
||||
files = append(files, filename)
|
||||
}
|
||||
@@ -208,7 +208,10 @@ func (a *App) writeArchiveFile(zw *zip.Writer, filename string, boardID string,
|
||||
return err
|
||||
}
|
||||
|
||||
src, err := a.GetFileReader(opt.TeamID, boardID, filename)
|
||||
_, fileReader, err := a.GetFile(opt.TeamID, boardID, filename)
|
||||
if err != nil && !model.IsErrNotFound(err) {
|
||||
return err
|
||||
}
|
||||
if err != nil {
|
||||
// just log this; image file is missing but we'll still export an equivalent board
|
||||
a.logger.Error("image file missing for export",
|
||||
@@ -218,9 +221,9 @@ func (a *App) writeArchiveFile(zw *zip.Writer, filename string, boardID string,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
defer src.Close()
|
||||
defer fileReader.Close()
|
||||
|
||||
_, err = io.Copy(dest, src)
|
||||
_, err = io.Copy(dest, fileReader)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -239,10 +242,13 @@ func (a *App) getBoardsForArchive(boardIDs []string) ([]model.Board, error) {
|
||||
return boards, nil
|
||||
}
|
||||
|
||||
func extractImageFilename(imageBlock *model.Block) (string, error) {
|
||||
f, ok := imageBlock.Fields["fileId"]
|
||||
func extractFilename(block *model.Block) (string, error) {
|
||||
f, ok := block.Fields["fileId"]
|
||||
if !ok {
|
||||
return "", model.ErrInvalidImageBlock
|
||||
f, ok = block.Fields["attachmentId"]
|
||||
if !ok {
|
||||
return "", model.ErrInvalidImageBlock
|
||||
}
|
||||
}
|
||||
|
||||
filename, ok := f.(string)
|
||||
|
||||
@@ -18,12 +18,10 @@ import (
|
||||
"github.com/mattermost/mattermost-server/server/v8/platform/shared/mlog"
|
||||
)
|
||||
|
||||
const emptyString = "empty"
|
||||
|
||||
var errEmptyFilename = errors.New("IsFileArchived: empty filename not allowed")
|
||||
var ErrFileNotFound = errors.New("file not found")
|
||||
|
||||
func (a *App) SaveFile(reader io.Reader, teamID, rootID, filename string) (string, error) {
|
||||
func (a *App) SaveFile(reader io.Reader, teamID, boardID, filename string, asTemplate bool) (string, error) {
|
||||
// NOTE: File extension includes the dot
|
||||
fileExtension := strings.ToLower(filepath.Ext(filename))
|
||||
if fileExtension == ".jpeg" {
|
||||
@@ -31,44 +29,26 @@ func (a *App) SaveFile(reader io.Reader, teamID, rootID, filename string) (strin
|
||||
}
|
||||
|
||||
createdFilename := utils.NewID(utils.IDTypeNone)
|
||||
fullFilename := fmt.Sprintf(`%s%s`, createdFilename, fileExtension)
|
||||
filePath := filepath.Join(utils.GetBaseFilePath(), fullFilename)
|
||||
newFileName := fmt.Sprintf(`%s%s`, createdFilename, fileExtension)
|
||||
if asTemplate {
|
||||
newFileName = filename
|
||||
}
|
||||
filePath := getDestinationFilePath(asTemplate, teamID, boardID, newFileName)
|
||||
|
||||
fileSize, appErr := a.filesBackend.WriteFile(reader, filePath)
|
||||
if appErr != nil {
|
||||
return "", fmt.Errorf("unable to store the file in the files storage: %w", appErr)
|
||||
}
|
||||
|
||||
now := utils.GetMillis()
|
||||
|
||||
fileInfo := &mm_model.FileInfo{
|
||||
Id: createdFilename[1:],
|
||||
CreatorId: "boards",
|
||||
PostId: emptyString,
|
||||
ChannelId: emptyString,
|
||||
CreateAt: now,
|
||||
UpdateAt: now,
|
||||
DeleteAt: 0,
|
||||
Path: filePath,
|
||||
ThumbnailPath: emptyString,
|
||||
PreviewPath: emptyString,
|
||||
Name: filename,
|
||||
Extension: fileExtension,
|
||||
Size: fileSize,
|
||||
MimeType: emptyString,
|
||||
Width: 0,
|
||||
Height: 0,
|
||||
HasPreviewImage: false,
|
||||
MiniPreview: nil,
|
||||
Content: "",
|
||||
RemoteId: nil,
|
||||
}
|
||||
fileInfo := model.NewFileInfo(filename)
|
||||
fileInfo.Id = getFileInfoID(createdFilename)
|
||||
fileInfo.Path = filePath
|
||||
fileInfo.Size = fileSize
|
||||
err := a.store.SaveFileInfo(fileInfo)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return fullFilename, nil
|
||||
return newFileName, nil
|
||||
}
|
||||
|
||||
func (a *App) GetFileInfo(filename string) (*mm_model.FileInfo, error) {
|
||||
@@ -79,8 +59,7 @@ func (a *App) GetFileInfo(filename string) (*mm_model.FileInfo, error) {
|
||||
// filename is in the format 7<some-alphanumeric-string>.<extension>
|
||||
// we want to extract the <some-alphanumeric-string> part of this as this
|
||||
// will be the fileinfo id.
|
||||
parts := strings.Split(filename, ".")
|
||||
fileInfoID := parts[0][1:]
|
||||
fileInfoID := getFileInfoID(strings.Split(filename, ".")[0])
|
||||
fileInfo, err := a.store.GetFileInfo(fileInfoID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -90,10 +69,33 @@ func (a *App) GetFileInfo(filename string) (*mm_model.FileInfo, error) {
|
||||
}
|
||||
|
||||
func (a *App) GetFile(teamID, rootID, fileName string) (*mm_model.FileInfo, filestore.ReadCloseSeeker, error) {
|
||||
fileInfo, filePath, err := a.GetFilePath(teamID, rootID, fileName)
|
||||
if err != nil {
|
||||
a.logger.Error("GetFile: Failed to GetFilePath.", mlog.String("Team", teamID), mlog.String("board", rootID), mlog.String("filename", fileName), mlog.Err(err))
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
exists, err := a.filesBackend.FileExists(filePath)
|
||||
if err != nil {
|
||||
a.logger.Error("GetFile: Failed to check if file exists as path. ", mlog.String("Path", filePath), mlog.Err(err))
|
||||
return nil, nil, err
|
||||
}
|
||||
if !exists {
|
||||
return nil, nil, ErrFileNotFound
|
||||
}
|
||||
|
||||
reader, err := a.filesBackend.Reader(filePath)
|
||||
if err != nil {
|
||||
a.logger.Error("GetFile: Failed to get file reader of existing file at path", mlog.String("Path", filePath), mlog.Err(err))
|
||||
return nil, nil, err
|
||||
}
|
||||
return fileInfo, reader, nil
|
||||
}
|
||||
|
||||
func (a *App) GetFilePath(teamID, rootID, fileName string) (*mm_model.FileInfo, string, error) {
|
||||
fileInfo, err := a.GetFileInfo(fileName)
|
||||
if err != nil && !model.IsErrNotFound(err) {
|
||||
a.logger.Error("111")
|
||||
return nil, nil, err
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
var filePath string
|
||||
@@ -104,22 +106,23 @@ func (a *App) GetFile(teamID, rootID, fileName string) (*mm_model.FileInfo, file
|
||||
filePath = filepath.Join(teamID, rootID, fileName)
|
||||
}
|
||||
|
||||
exists, err := a.filesBackend.FileExists(filePath)
|
||||
if err != nil {
|
||||
a.logger.Error(fmt.Sprintf("GetFile: Failed to check if file exists as path. Path: %s, error: %e", filePath, err))
|
||||
return nil, nil, err
|
||||
}
|
||||
return fileInfo, filePath, nil
|
||||
}
|
||||
|
||||
if !exists {
|
||||
return nil, nil, ErrFileNotFound
|
||||
func getDestinationFilePath(isTemplate bool, teamID, boardID, filename string) string {
|
||||
// if saving a file for a template, save using the "old method" that is /teamID/boardID/fileName
|
||||
// this will prevent template files from being deleted by DataRetention,
|
||||
// which deletes all files inside the "date" subdirectory
|
||||
if isTemplate {
|
||||
return filepath.Join(teamID, boardID, filename)
|
||||
}
|
||||
return filepath.Join(utils.GetBaseFilePath(), filename)
|
||||
}
|
||||
|
||||
reader, err := a.filesBackend.Reader(filePath)
|
||||
if err != nil {
|
||||
a.logger.Error(fmt.Sprintf("GetFile: Failed to get file reader of existing file at path: %s, error: %e", filePath, err))
|
||||
return nil, nil, err
|
||||
}
|
||||
return fileInfo, reader, nil
|
||||
func getFileInfoID(fileName string) string {
|
||||
// Boards ids are 27 characters long with a prefix character.
|
||||
// removing the prefix, returns the 26 character uuid
|
||||
return fileName[1:]
|
||||
}
|
||||
|
||||
func (a *App) GetFileReader(teamID, rootID, filename string) (filestore.ReadCloseSeeker, error) {
|
||||
@@ -175,3 +178,121 @@ func (a *App) MoveFile(channelID, teamID, boardID, filename string) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) CopyAndUpdateCardFiles(boardID, userID string, blocks []*model.Block, asTemplate bool) error {
|
||||
newFileNames, err := a.CopyCardFiles(boardID, blocks, asTemplate)
|
||||
if err != nil {
|
||||
a.logger.Error("Could not copy files while duplicating board", mlog.String("BoardID", boardID), mlog.Err(err))
|
||||
}
|
||||
|
||||
// blocks now has updated file ids for any blocks containing files. We need to update the database for them.
|
||||
blockIDs := make([]string, 0)
|
||||
blockPatches := make([]model.BlockPatch, 0)
|
||||
for _, block := range blocks {
|
||||
if block.Type == model.TypeImage || block.Type == model.TypeAttachment {
|
||||
if fileID, ok := block.Fields["fileId"].(string); ok {
|
||||
blockIDs = append(blockIDs, block.ID)
|
||||
blockPatches = append(blockPatches, model.BlockPatch{
|
||||
UpdatedFields: map[string]interface{}{
|
||||
"fileId": newFileNames[fileID],
|
||||
},
|
||||
DeletedFields: []string{"attachmentId"},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
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 {
|
||||
return fmt.Errorf("could not patch file IDs while duplicating board %s: %w", boardID, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) CopyCardFiles(sourceBoardID string, copiedBlocks []*model.Block, asTemplate bool) (map[string]string, 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 sourceBoard, which may be different than the blocks.
|
||||
sourceBoard, err := a.GetBoard(sourceBoardID)
|
||||
if err != nil || sourceBoard == nil {
|
||||
return nil, fmt.Errorf("cannot fetch source board %s for CopyCardFiles: %w", sourceBoardID, err)
|
||||
}
|
||||
|
||||
var destBoard *model.Board
|
||||
newFileNames := make(map[string]string)
|
||||
for _, block := range copiedBlocks {
|
||||
if block.Type != model.TypeImage && block.Type != model.TypeAttachment {
|
||||
continue
|
||||
}
|
||||
|
||||
fileId, isOk := block.Fields["fileId"].(string)
|
||||
if !isOk {
|
||||
fileId, isOk = block.Fields["attachmentId"].(string)
|
||||
if !isOk {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// create unique filename
|
||||
ext := filepath.Ext(fileId)
|
||||
fileInfoID := utils.NewID(utils.IDTypeNone)
|
||||
destFilename := fileInfoID + ext
|
||||
|
||||
if destBoard == nil || block.BoardID != destBoard.ID {
|
||||
destBoard = sourceBoard
|
||||
if block.BoardID != destBoard.ID {
|
||||
destBoard, err = a.GetBoard(block.BoardID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot fetch destination board %s for CopyCardFiles: %w", sourceBoardID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GetFilePath will retrieve the correct path
|
||||
// depending on whether FileInfo table is used for the file.
|
||||
fileInfo, sourceFilePath, err := a.GetFilePath(sourceBoard.TeamID, sourceBoard.ID, fileId)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot fetch destination board %s for CopyCardFiles: %w", sourceBoardID, err)
|
||||
}
|
||||
destinationFilePath := getDestinationFilePath(asTemplate, destBoard.TeamID, destBoard.ID, destFilename)
|
||||
|
||||
if fileInfo == nil {
|
||||
fileInfo = model.NewFileInfo(destFilename)
|
||||
}
|
||||
fileInfo.Id = getFileInfoID(fileInfoID)
|
||||
fileInfo.Path = destinationFilePath
|
||||
err = a.store.SaveFileInfo(fileInfo)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("CopyCardFiles: cannot create fileinfo: %w", err)
|
||||
}
|
||||
|
||||
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),
|
||||
)
|
||||
}
|
||||
newFileNames[fileId] = destFilename
|
||||
}
|
||||
|
||||
return newFileNames, nil
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/golang/mock/gomock"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/mattermost/mattermost-server/server/v8/boards/model"
|
||||
mm_model "github.com/mattermost/mattermost-server/server/v8/model"
|
||||
"github.com/mattermost/mattermost-server/server/v8/platform/shared/filestore"
|
||||
"github.com/mattermost/mattermost-server/server/v8/platform/shared/filestore/mocks"
|
||||
@@ -210,7 +211,7 @@ func TestSaveFile(t *testing.T) {
|
||||
}
|
||||
|
||||
mockedFileBackend.On("WriteFile", mockedReadCloseSeek, mock.Anything).Return(writeFileFunc, writeFileErrorFunc)
|
||||
actual, err := th.App.SaveFile(mockedReadCloseSeek, "1", testBoardID, fileName)
|
||||
actual, err := th.App.SaveFile(mockedReadCloseSeek, "1", testBoardID, fileName, false)
|
||||
assert.Equal(t, fileName, actual)
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
@@ -234,7 +235,7 @@ func TestSaveFile(t *testing.T) {
|
||||
}
|
||||
|
||||
mockedFileBackend.On("WriteFile", mockedReadCloseSeek, mock.Anything).Return(writeFileFunc, writeFileErrorFunc)
|
||||
actual, err := th.App.SaveFile(mockedReadCloseSeek, "1", "test-board-id", fileName)
|
||||
actual, err := th.App.SaveFile(mockedReadCloseSeek, "1", "test-board-id", fileName, false)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, actual)
|
||||
})
|
||||
@@ -258,7 +259,7 @@ func TestSaveFile(t *testing.T) {
|
||||
}
|
||||
|
||||
mockedFileBackend.On("WriteFile", mockedReadCloseSeek, mock.Anything).Return(writeFileFunc, writeFileErrorFunc)
|
||||
actual, err := th.App.SaveFile(mockedReadCloseSeek, "1", "test-board-id", fileName)
|
||||
actual, err := th.App.SaveFile(mockedReadCloseSeek, "1", "test-board-id", fileName, false)
|
||||
assert.Equal(t, "", actual)
|
||||
assert.Equal(t, "unable to store the file in the files storage: Mocked File backend error", err.Error())
|
||||
})
|
||||
@@ -312,7 +313,7 @@ func TestGetFileInfo(t *testing.T) {
|
||||
func TestGetFile(t *testing.T) {
|
||||
th, _ := SetupTestHelper(t)
|
||||
|
||||
t.Run("when FileInfo exists", func(t *testing.T) {
|
||||
t.Run("happy path, no errors", func(t *testing.T) {
|
||||
th.Store.EXPECT().GetFileInfo("fileInfoID").Return(&mm_model.FileInfo{
|
||||
Id: "fileInfoID",
|
||||
Path: "/path/to/file/fileName.txt",
|
||||
@@ -337,27 +338,72 @@ func TestGetFile(t *testing.T) {
|
||||
assert.NotNil(t, seeker)
|
||||
})
|
||||
|
||||
t.Run("when FileInfo doesn't exist", func(t *testing.T) {
|
||||
th.Store.EXPECT().GetFileInfo("fileInfoID").Return(nil, nil)
|
||||
t.Run("when GetFilePath() throws error", func(t *testing.T) {
|
||||
th.Store.EXPECT().GetFileInfo("fileInfoID").Return(nil, errDummy)
|
||||
|
||||
fileInfo, seeker, err := th.App.GetFile("teamID", "boardID", "7fileInfoID.txt")
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, fileInfo)
|
||||
assert.Nil(t, seeker)
|
||||
})
|
||||
|
||||
t.Run("when FileExists returns false", func(t *testing.T) {
|
||||
th.Store.EXPECT().GetFileInfo("fileInfoID").Return(&mm_model.FileInfo{
|
||||
Id: "fileInfoID",
|
||||
Path: "/path/to/file/fileName.txt",
|
||||
}, nil)
|
||||
|
||||
mockedFileBackend := &mocks.FileBackend{}
|
||||
th.App.filesBackend = mockedFileBackend
|
||||
mockedReadCloseSeek := &mocks.ReadCloseSeeker{}
|
||||
readerFunc := func(path string) filestore.ReadCloseSeeker {
|
||||
return mockedReadCloseSeek
|
||||
}
|
||||
|
||||
readerErrorFunc := func(path string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
mockedFileBackend.On("Reader", "teamID/boardID/7fileInfoID.txt").Return(readerFunc, readerErrorFunc)
|
||||
mockedFileBackend.On("FileExists", "teamID/boardID/7fileInfoID.txt").Return(true, nil)
|
||||
mockedFileBackend.On("FileExists", "/path/to/file/fileName.txt").Return(false, nil)
|
||||
|
||||
fileInfo, seeker, err := th.App.GetFile("teamID", "boardID", "7fileInfoID.txt")
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, fileInfo)
|
||||
assert.Nil(t, seeker)
|
||||
})
|
||||
t.Run("when FileReader throws error", func(t *testing.T) {
|
||||
th.Store.EXPECT().GetFileInfo("fileInfoID").Return(&mm_model.FileInfo{
|
||||
Id: "fileInfoID",
|
||||
Path: "/path/to/file/fileName.txt",
|
||||
}, nil)
|
||||
|
||||
mockedFileBackend := &mocks.FileBackend{}
|
||||
th.App.filesBackend = mockedFileBackend
|
||||
mockedFileBackend.On("Reader", "/path/to/file/fileName.txt").Return(nil, errDummy)
|
||||
mockedFileBackend.On("FileExists", "/path/to/file/fileName.txt").Return(true, nil)
|
||||
|
||||
fileInfo, seeker, err := th.App.GetFile("teamID", "boardID", "7fileInfoID.txt")
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, fileInfo)
|
||||
assert.Nil(t, seeker)
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
func TestGetFilePath(t *testing.T) {
|
||||
th, _ := SetupTestHelper(t)
|
||||
|
||||
t.Run("when FileInfo exists", func(t *testing.T) {
|
||||
path := "/path/to/file/fileName.txt"
|
||||
th.Store.EXPECT().GetFileInfo("fileInfoID").Return(&mm_model.FileInfo{
|
||||
Id: "fileInfoID",
|
||||
Path: path,
|
||||
}, nil)
|
||||
|
||||
fileInfo, filePath, err := th.App.GetFilePath("teamID", "boardID", "7fileInfoID.txt")
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, fileInfo)
|
||||
assert.Equal(t, path, filePath)
|
||||
})
|
||||
|
||||
t.Run("when FileInfo doesn't exist", func(t *testing.T) {
|
||||
th.Store.EXPECT().GetFileInfo("fileInfoID").Return(nil, nil)
|
||||
|
||||
fileInfo, filePath, err := th.App.GetFilePath("teamID", "boardID", "7fileInfoID.txt")
|
||||
assert.NoError(t, err)
|
||||
assert.Nil(t, fileInfo)
|
||||
assert.NotNil(t, seeker)
|
||||
assert.Equal(t, "teamID/boardID/7fileInfoID.txt", filePath)
|
||||
})
|
||||
|
||||
t.Run("when FileInfo exists but FileInfo.Path is not set", func(t *testing.T) {
|
||||
@@ -366,22 +412,158 @@ func TestGetFile(t *testing.T) {
|
||||
Path: "",
|
||||
}, nil)
|
||||
|
||||
mockedFileBackend := &mocks.FileBackend{}
|
||||
th.App.filesBackend = mockedFileBackend
|
||||
mockedReadCloseSeek := &mocks.ReadCloseSeeker{}
|
||||
readerFunc := func(path string) filestore.ReadCloseSeeker {
|
||||
return mockedReadCloseSeek
|
||||
}
|
||||
|
||||
readerErrorFunc := func(path string) error {
|
||||
return nil
|
||||
}
|
||||
mockedFileBackend.On("Reader", "teamID/boardID/7fileInfoID.txt").Return(readerFunc, readerErrorFunc)
|
||||
mockedFileBackend.On("FileExists", "teamID/boardID/7fileInfoID.txt").Return(true, nil)
|
||||
|
||||
fileInfo, seeker, err := th.App.GetFile("teamID", "boardID", "7fileInfoID.txt")
|
||||
fileInfo, filePath, err := th.App.GetFilePath("teamID", "boardID", "7fileInfoID.txt")
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, fileInfo)
|
||||
assert.NotNil(t, seeker)
|
||||
assert.Equal(t, "teamID/boardID/7fileInfoID.txt", filePath)
|
||||
})
|
||||
}
|
||||
|
||||
func TestCopyCard(t *testing.T) {
|
||||
th, _ := SetupTestHelper(t)
|
||||
imageBlock := &model.Block{
|
||||
ID: "imageBlock",
|
||||
ParentID: "c3zqnh6fsu3f4mr6hzq9hizwske",
|
||||
CreatedBy: "6k6ynxdp47dujjhhojw9nqhmyh",
|
||||
ModifiedBy: "6k6ynxdp47dujjhhojw9nqhmyh",
|
||||
Schema: 1,
|
||||
Type: "image",
|
||||
Title: "",
|
||||
Fields: map[string]interface{}{"fileId": "7fileName.jpg"},
|
||||
CreateAt: 1680725585250,
|
||||
UpdateAt: 1680725585250,
|
||||
DeleteAt: 0,
|
||||
BoardID: "boardID",
|
||||
}
|
||||
t.Run("Board doesn't exist", func(t *testing.T) {
|
||||
th.Store.EXPECT().GetBoard("boardID").Return(nil, errDummy)
|
||||
_, err := th.App.CopyCardFiles("boardID", []*model.Block{}, false)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("Board exists, image block, with FileInfo", func(t *testing.T) {
|
||||
path := "/path/to/file/fileName.txt"
|
||||
fileInfo := &mm_model.FileInfo{
|
||||
Id: "imageBlock",
|
||||
Path: path,
|
||||
}
|
||||
th.Store.EXPECT().GetBoard("boardID").Return(&model.Board{
|
||||
ID: "boardID",
|
||||
IsTemplate: false,
|
||||
}, nil)
|
||||
th.Store.EXPECT().GetFileInfo("fileName").Return(fileInfo, nil)
|
||||
th.Store.EXPECT().SaveFileInfo(fileInfo).Return(nil)
|
||||
|
||||
mockedFileBackend := &mocks.FileBackend{}
|
||||
th.App.filesBackend = mockedFileBackend
|
||||
mockedFileBackend.On("CopyFile", mock.Anything, mock.Anything).Return(nil)
|
||||
|
||||
updatedFileNames, err := th.App.CopyCardFiles("boardID", []*model.Block{imageBlock}, false)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "7fileName.jpg", imageBlock.Fields["fileId"])
|
||||
assert.NotNil(t, updatedFileNames["7fileName.jpg"])
|
||||
assert.NotNil(t, updatedFileNames[imageBlock.Fields["fileId"].(string)])
|
||||
})
|
||||
|
||||
t.Run("Board exists, attachment block, with FileInfo", func(t *testing.T) {
|
||||
attachmentBlock := &model.Block{
|
||||
ID: "attachmentBlock",
|
||||
ParentID: "c3zqnh6fsu3f4mr6hzq9hizwske",
|
||||
CreatedBy: "6k6ynxdp47dujjhhojw9nqhmyh",
|
||||
ModifiedBy: "6k6ynxdp47dujjhhojw9nqhmyh",
|
||||
Schema: 1,
|
||||
Type: "attachment",
|
||||
Title: "",
|
||||
Fields: map[string]interface{}{"fileId": "7fileName.jpg"},
|
||||
CreateAt: 1680725585250,
|
||||
UpdateAt: 1680725585250,
|
||||
DeleteAt: 0,
|
||||
BoardID: "boardID",
|
||||
}
|
||||
|
||||
path := "/path/to/file/fileName.txt"
|
||||
fileInfo := &mm_model.FileInfo{
|
||||
Id: "attachmentBlock",
|
||||
Path: path,
|
||||
}
|
||||
th.Store.EXPECT().GetBoard("boardID").Return(&model.Board{
|
||||
ID: "boardID",
|
||||
IsTemplate: false,
|
||||
}, nil)
|
||||
th.Store.EXPECT().GetFileInfo("fileName").Return(fileInfo, nil)
|
||||
th.Store.EXPECT().SaveFileInfo(fileInfo).Return(nil)
|
||||
|
||||
mockedFileBackend := &mocks.FileBackend{}
|
||||
th.App.filesBackend = mockedFileBackend
|
||||
mockedFileBackend.On("CopyFile", mock.Anything, mock.Anything).Return(nil)
|
||||
|
||||
updatedFileNames, err := th.App.CopyCardFiles("boardID", []*model.Block{attachmentBlock}, false)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, updatedFileNames[imageBlock.Fields["fileId"].(string)])
|
||||
})
|
||||
|
||||
t.Run("Board exists, image block, without FileInfo", func(t *testing.T) {
|
||||
// path := "/path/to/file/fileName.txt"
|
||||
// fileInfo := &mm_model.FileInfo{
|
||||
// Id: "imageBlock",
|
||||
// Path: path,
|
||||
// }
|
||||
th.Store.EXPECT().GetBoard("boardID").Return(&model.Board{
|
||||
ID: "boardID",
|
||||
IsTemplate: false,
|
||||
}, nil)
|
||||
th.Store.EXPECT().GetFileInfo(gomock.Any()).Return(nil, nil)
|
||||
th.Store.EXPECT().SaveFileInfo(gomock.Any()).Return(nil)
|
||||
|
||||
mockedFileBackend := &mocks.FileBackend{}
|
||||
th.App.filesBackend = mockedFileBackend
|
||||
mockedFileBackend.On("CopyFile", mock.Anything, mock.Anything).Return(nil)
|
||||
|
||||
updatedFileNames, err := th.App.CopyCardFiles("boardID", []*model.Block{imageBlock}, false)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, imageBlock.Fields["fileId"].(string))
|
||||
assert.NotNil(t, updatedFileNames[imageBlock.Fields["fileId"].(string)])
|
||||
})
|
||||
}
|
||||
|
||||
func TestCopyAndUpdateCardFiles(t *testing.T) {
|
||||
th, _ := SetupTestHelper(t)
|
||||
imageBlock := &model.Block{
|
||||
ID: "imageBlock",
|
||||
ParentID: "c3zqnh6fsu3f4mr6hzq9hizwske",
|
||||
CreatedBy: "6k6ynxdp47dujjhhojw9nqhmyh",
|
||||
ModifiedBy: "6k6ynxdp47dujjhhojw9nqhmyh",
|
||||
Schema: 1,
|
||||
Type: "image",
|
||||
Title: "",
|
||||
Fields: map[string]interface{}{"fileId": "7fileName.jpg"},
|
||||
CreateAt: 1680725585250,
|
||||
UpdateAt: 1680725585250,
|
||||
DeleteAt: 0,
|
||||
BoardID: "boardID",
|
||||
}
|
||||
|
||||
t.Run("Board exists, image block, with FileInfo", func(t *testing.T) {
|
||||
path := "/path/to/file/fileName.txt"
|
||||
fileInfo := &mm_model.FileInfo{
|
||||
Id: "imageBlock",
|
||||
Path: path,
|
||||
}
|
||||
th.Store.EXPECT().GetBoard("boardID").Return(&model.Board{
|
||||
ID: "boardID",
|
||||
IsTemplate: false,
|
||||
}, nil)
|
||||
th.Store.EXPECT().GetFileInfo("fileName").Return(fileInfo, nil)
|
||||
th.Store.EXPECT().SaveFileInfo(fileInfo).Return(nil)
|
||||
th.Store.EXPECT().PatchBlocks(gomock.Any(), "userID").Return(nil)
|
||||
|
||||
mockedFileBackend := &mocks.FileBackend{}
|
||||
th.App.filesBackend = mockedFileBackend
|
||||
mockedFileBackend.On("CopyFile", mock.Anything, mock.Anything).Return(nil)
|
||||
|
||||
err := th.App.CopyAndUpdateCardFiles("boardID", "userID", []*model.Block{imageBlock}, false)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.NotEqual(t, path, imageBlock.Fields["fileId"])
|
||||
})
|
||||
}
|
||||
|
||||
@@ -44,27 +44,19 @@ func (a *App) ImportArchive(r io.Reader, opt model.ImportArchiveOptions) error {
|
||||
a.logger.Debug("importing legacy archive")
|
||||
_, errImport := a.ImportBoardJSONL(br, opt)
|
||||
|
||||
go func() {
|
||||
if err := a.UpdateCardLimitTimestamp(); err != nil {
|
||||
a.logger.Error(
|
||||
"UpdateCardLimitTimestamp failed after importing a legacy file",
|
||||
mlog.Err(err),
|
||||
)
|
||||
}
|
||||
}()
|
||||
|
||||
return errImport
|
||||
}
|
||||
|
||||
a.logger.Debug("importing archive")
|
||||
zr := zipstream.NewReader(br)
|
||||
|
||||
boardMap := make(map[string]string) // maps old board ids to new
|
||||
boardMap := make(map[string]*model.Board) // maps old board ids to new
|
||||
fileMap := make(map[string]string) // maps old fileIds to new
|
||||
|
||||
for {
|
||||
hdr, err := zr.Next()
|
||||
if err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
a.fixImagesAttachments(boardMap, fileMap, opt.TeamID, opt.ModifiedBy)
|
||||
a.logger.Debug("import archive - done", mlog.Int("boards_imported", len(boardMap)))
|
||||
return nil
|
||||
}
|
||||
@@ -84,14 +76,14 @@ func (a *App) ImportArchive(r io.Reader, opt model.ImportArchiveOptions) error {
|
||||
return model.NewErrUnsupportedArchiveVersion(ver, archiveVersion)
|
||||
}
|
||||
case "board.jsonl":
|
||||
boardID, err := a.ImportBoardJSONL(zr, opt)
|
||||
board, err := a.ImportBoardJSONL(zr, opt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot import board %s: %w", dir, err)
|
||||
}
|
||||
boardMap[dir] = boardID
|
||||
boardMap[dir] = board
|
||||
default:
|
||||
// import file/image; dir is the old board id
|
||||
boardID, ok := boardMap[dir]
|
||||
board, ok := boardMap[dir]
|
||||
if !ok {
|
||||
a.logger.Warn("skipping orphan image in archive",
|
||||
mlog.String("dir", dir),
|
||||
@@ -99,33 +91,63 @@ func (a *App) ImportArchive(r io.Reader, opt model.ImportArchiveOptions) error {
|
||||
)
|
||||
continue
|
||||
}
|
||||
// save file with original filename so it matches name in image block.
|
||||
filePath := filepath.Join(opt.TeamID, boardID, filename)
|
||||
_, err := a.filesBackend.WriteFile(zr, filePath)
|
||||
|
||||
newFileName, err := a.SaveFile(zr, opt.TeamID, board.ID, filename, board.IsTemplate)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot import file %s for board %s: %w", filename, dir, err)
|
||||
}
|
||||
fileMap[filename] = newFileName
|
||||
|
||||
a.logger.Debug("import archive file",
|
||||
mlog.String("TeamID", opt.TeamID),
|
||||
mlog.String("boardID", board.ID),
|
||||
mlog.String("filename", filename),
|
||||
mlog.String("newFileName", newFileName),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update image and attachment blocks
|
||||
func (a *App) fixImagesAttachments(boardMap map[string]*model.Board, fileMap map[string]string, teamID string, userId string) {
|
||||
blockIDs := make([]string, 0)
|
||||
blockPatches := make([]model.BlockPatch, 0)
|
||||
for _, board := range boardMap {
|
||||
if board.IsTemplate {
|
||||
continue
|
||||
}
|
||||
|
||||
a.logger.Trace("import archive file",
|
||||
mlog.String("dir", dir),
|
||||
mlog.String("filename", filename),
|
||||
)
|
||||
opts := model.QueryBlocksOptions{
|
||||
BoardID: board.ID,
|
||||
}
|
||||
newBlocks, err := a.GetBlocks(opts)
|
||||
if err != nil {
|
||||
a.logger.Info("cannot retrieve imported blocks for board", mlog.String("BoardID", board.ID), mlog.Err(err))
|
||||
return
|
||||
}
|
||||
|
||||
go func() {
|
||||
if err := a.UpdateCardLimitTimestamp(); err != nil {
|
||||
a.logger.Error(
|
||||
"UpdateCardLimitTimestamp failed after importing an archive",
|
||||
mlog.Err(err),
|
||||
)
|
||||
for _, block := range newBlocks {
|
||||
if block.Type == "image" || block.Type == "attachment" {
|
||||
fieldName := "fileId"
|
||||
oldId := block.Fields[fieldName]
|
||||
blockIDs = append(blockIDs, block.ID)
|
||||
|
||||
blockPatches = append(blockPatches, model.BlockPatch{
|
||||
UpdatedFields: map[string]interface{}{
|
||||
fieldName: fileMap[oldId.(string)],
|
||||
},
|
||||
})
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
blockPatchBatch := model.BlockPatchBatch{BlockIDs: blockIDs, BlockPatches: blockPatches}
|
||||
a.PatchBlocks(teamID, &blockPatchBatch, userId)
|
||||
}
|
||||
}
|
||||
|
||||
// ImportBoardJSONL imports a JSONL file containing blocks for one board. The resulting
|
||||
// board id is returned.
|
||||
func (a *App) ImportBoardJSONL(r io.Reader, opt model.ImportArchiveOptions) (string, error) {
|
||||
func (a *App) ImportBoardJSONL(r io.Reader, opt model.ImportArchiveOptions) (*model.Board, error) {
|
||||
// TODO: Stream this once `model.GenerateBlockIDs` can take a stream of blocks.
|
||||
// We don't want to load the whole file in memory, even though it's a single board.
|
||||
boardsAndBlocks := &model.BoardsAndBlocks{
|
||||
@@ -158,7 +180,7 @@ func (a *App) ImportBoardJSONL(r io.Reader, opt model.ImportArchiveOptions) (str
|
||||
if !skip {
|
||||
var archiveLine model.ArchiveLine
|
||||
if err := json.Unmarshal(line, &archiveLine); err != nil {
|
||||
return "", fmt.Errorf("error parsing archive line %d: %w", lineNum, err)
|
||||
return nil, fmt.Errorf("error parsing archive line %d: %w", lineNum, err)
|
||||
}
|
||||
|
||||
// first line must be a board
|
||||
@@ -170,7 +192,7 @@ func (a *App) ImportBoardJSONL(r io.Reader, opt model.ImportArchiveOptions) (str
|
||||
case "board":
|
||||
var board model.Board
|
||||
if err2 := json.Unmarshal(archiveLine.Data, &board); err2 != nil {
|
||||
return "", fmt.Errorf("invalid board in archive line %d: %w", lineNum, err2)
|
||||
return nil, fmt.Errorf("invalid board in archive line %d: %w", lineNum, err2)
|
||||
}
|
||||
board.ModifiedBy = userID
|
||||
board.UpdateAt = now
|
||||
@@ -181,20 +203,20 @@ func (a *App) ImportBoardJSONL(r io.Reader, opt model.ImportArchiveOptions) (str
|
||||
// legacy archives encoded boards as blocks; we need to convert them to real boards.
|
||||
var block *model.Block
|
||||
if err2 := json.Unmarshal(archiveLine.Data, &block); err2 != nil {
|
||||
return "", fmt.Errorf("invalid board block in archive line %d: %w", lineNum, err2)
|
||||
return nil, fmt.Errorf("invalid board block in archive line %d: %w", lineNum, err2)
|
||||
}
|
||||
block.ModifiedBy = userID
|
||||
block.UpdateAt = now
|
||||
board, err := a.blockToBoard(block, opt)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot convert archive line %d to block: %w", lineNum, err)
|
||||
return nil, fmt.Errorf("cannot convert archive line %d to block: %w", lineNum, err)
|
||||
}
|
||||
boardsAndBlocks.Boards = append(boardsAndBlocks.Boards, board)
|
||||
boardID = board.ID
|
||||
case "block":
|
||||
var block *model.Block
|
||||
if err2 := json.Unmarshal(archiveLine.Data, &block); err2 != nil {
|
||||
return "", fmt.Errorf("invalid block in archive line %d: %w", lineNum, err2)
|
||||
return nil, fmt.Errorf("invalid block in archive line %d: %w", lineNum, err2)
|
||||
}
|
||||
block.ModifiedBy = userID
|
||||
block.UpdateAt = now
|
||||
@@ -203,11 +225,11 @@ func (a *App) ImportBoardJSONL(r io.Reader, opt model.ImportArchiveOptions) (str
|
||||
case "boardMember":
|
||||
var boardMember *model.BoardMember
|
||||
if err2 := json.Unmarshal(archiveLine.Data, &boardMember); err2 != nil {
|
||||
return "", fmt.Errorf("invalid board Member in archive line %d: %w", lineNum, err2)
|
||||
return nil, fmt.Errorf("invalid board Member in archive line %d: %w", lineNum, err2)
|
||||
}
|
||||
boardMembers = append(boardMembers, boardMember)
|
||||
default:
|
||||
return "", model.NewErrUnsupportedArchiveLineType(lineNum, archiveLine.Type)
|
||||
return nil, model.NewErrUnsupportedArchiveLineType(lineNum, archiveLine.Type)
|
||||
}
|
||||
firstLine = false
|
||||
}
|
||||
@@ -217,7 +239,7 @@ func (a *App) ImportBoardJSONL(r io.Reader, opt model.ImportArchiveOptions) (str
|
||||
if errors.Is(errRead, io.EOF) {
|
||||
break
|
||||
}
|
||||
return "", fmt.Errorf("error reading archive line %d: %w", lineNum, errRead)
|
||||
return nil, fmt.Errorf("error reading archive line %d: %w", lineNum, errRead)
|
||||
}
|
||||
lineNum++
|
||||
}
|
||||
@@ -234,12 +256,12 @@ func (a *App) ImportBoardJSONL(r io.Reader, opt model.ImportArchiveOptions) (str
|
||||
var err error
|
||||
boardsAndBlocks, err = model.GenerateBoardsAndBlocksIDs(boardsAndBlocks, a.logger)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error generating archive block IDs: %w", err)
|
||||
return nil, fmt.Errorf("error generating archive block IDs: %w", err)
|
||||
}
|
||||
|
||||
boardsAndBlocks, err = a.CreateBoardsAndBlocks(boardsAndBlocks, opt.ModifiedBy, false)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error inserting archive blocks: %w", err)
|
||||
return nil, fmt.Errorf("error inserting archive blocks: %w", err)
|
||||
}
|
||||
|
||||
// add users to all the new boards (if not the fake system user).
|
||||
@@ -251,7 +273,7 @@ func (a *App) ImportBoardJSONL(r io.Reader, opt model.ImportArchiveOptions) (str
|
||||
SchemeAdmin: true,
|
||||
}
|
||||
if _, err2 := a.AddMemberToBoard(adminMember); err2 != nil {
|
||||
return "", fmt.Errorf("cannot add adminMember to board: %w", err2)
|
||||
return nil, fmt.Errorf("cannot add adminMember to board: %w", err2)
|
||||
}
|
||||
for _, boardMember := range boardMembers {
|
||||
bm := &model.BoardMember{
|
||||
@@ -266,16 +288,16 @@ func (a *App) ImportBoardJSONL(r io.Reader, opt model.ImportArchiveOptions) (str
|
||||
Synthetic: boardMember.Synthetic,
|
||||
}
|
||||
if _, err2 := a.AddMemberToBoard(bm); err2 != nil {
|
||||
return "", fmt.Errorf("cannot add member to board: %w", err2)
|
||||
return nil, fmt.Errorf("cannot add member to board: %w", err2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// find new board id
|
||||
for _, board := range boardsAndBlocks.Boards {
|
||||
return board.ID, nil
|
||||
return board, nil
|
||||
}
|
||||
return "", fmt.Errorf("missing board in archive: %w", model.ErrInvalidBoardBlock)
|
||||
return nil, fmt.Errorf("missing board in archive: %w", model.ErrInvalidBoardBlock)
|
||||
}
|
||||
|
||||
// fixBoardsandBlocks allows the caller of `ImportArchive` to modify or filters boards and blocks being
|
||||
|
||||
@@ -138,9 +138,76 @@ func TestApp_ImportArchive(t *testing.T) {
|
||||
th.Store.EXPECT().GetUserByID("hxxzooc3ff8cubsgtcmpn8733e").AnyTimes().Return(user2, nil)
|
||||
th.Store.EXPECT().GetUserByID("nto73edn5ir6ifimo5a53y1dwa").AnyTimes().Return(user3, nil)
|
||||
|
||||
boardID, err := th.App.ImportBoardJSONL(r, opts)
|
||||
require.Equal(t, board.ID, boardID, "Board ID should be same")
|
||||
newBoard, err := th.App.ImportBoardJSONL(r, opts)
|
||||
require.NoError(t, err, "import archive should not fail")
|
||||
require.Equal(t, board.ID, newBoard.ID, "Board ID should be same")
|
||||
})
|
||||
|
||||
t.Run("fix image and attachment", func(t *testing.T) {
|
||||
boardMap := map[string]*model.Board{
|
||||
"test": board,
|
||||
}
|
||||
|
||||
fileMap := map[string]string{
|
||||
"oldFileName1.jpg": "newFileName1.jpg",
|
||||
"oldFileName2.jpg": "newFileName2.jpg",
|
||||
}
|
||||
|
||||
imageBlock := &model.Block{
|
||||
ID: "blockID-1",
|
||||
ParentID: "c3zqnh6fsu3f4mr6hzq9hizwske",
|
||||
CreatedBy: "6k6ynxdp47dujjhhojw9nqhmyh",
|
||||
ModifiedBy: "6k6ynxdp47dujjhhojw9nqhmyh",
|
||||
Schema: 1,
|
||||
Type: "image",
|
||||
Title: "",
|
||||
Fields: map[string]interface{}{"fileId": "oldFileName1.jpg"},
|
||||
CreateAt: 1680725585250,
|
||||
UpdateAt: 1680725585250,
|
||||
DeleteAt: 0,
|
||||
BoardID: "board-id",
|
||||
}
|
||||
|
||||
attachmentBlock := &model.Block{
|
||||
ID: "blockID-2",
|
||||
ParentID: "c3zqnh6fsu3f4mr6hzq9hizwske",
|
||||
CreatedBy: "6k6ynxdp47dujjhhojw9nqhmyh",
|
||||
ModifiedBy: "6k6ynxdp47dujjhhojw9nqhmyh",
|
||||
Schema: 1,
|
||||
Type: "attachment",
|
||||
Title: "",
|
||||
Fields: map[string]interface{}{"fileId": "oldFileName2.jpg"},
|
||||
CreateAt: 1680725585250,
|
||||
UpdateAt: 1680725585250,
|
||||
DeleteAt: 0,
|
||||
BoardID: "board-id",
|
||||
}
|
||||
|
||||
blockIDs := []string{"blockID-1", "blockID-2"}
|
||||
|
||||
blockPatch := model.BlockPatch{
|
||||
UpdatedFields: map[string]interface{}{"fileId": "newFileName1.jpg"},
|
||||
}
|
||||
|
||||
blockPatch2 := model.BlockPatch{
|
||||
UpdatedFields: map[string]interface{}{"fileId": "newFileName2.jpg"},
|
||||
}
|
||||
|
||||
blockPatches := []model.BlockPatch{blockPatch, blockPatch2}
|
||||
|
||||
blockPatchesBatch := model.BlockPatchBatch{BlockIDs: blockIDs, BlockPatches: blockPatches}
|
||||
|
||||
opts := model.QueryBlocksOptions{
|
||||
BoardID: board.ID,
|
||||
}
|
||||
th.Store.EXPECT().GetBlocks(opts).Return([]*model.Block{imageBlock, attachmentBlock}, nil)
|
||||
th.Store.EXPECT().GetBlocksByIDs(blockIDs).Return([]*model.Block{imageBlock, attachmentBlock}, nil)
|
||||
th.Store.EXPECT().GetBlock(blockIDs[0]).Return(imageBlock, nil)
|
||||
th.Store.EXPECT().GetBlock(blockIDs[1]).Return(attachmentBlock, nil)
|
||||
th.Store.EXPECT().GetMembersForBoard("board-id").AnyTimes().Return([]*model.BoardMember{}, nil)
|
||||
|
||||
th.Store.EXPECT().PatchBlocks(&blockPatchesBatch, "my-userid")
|
||||
th.App.fixImagesAttachments(boardMap, fileMap, "test-team", "my-userid")
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ func TestApp_initializeTemplates(t *testing.T) {
|
||||
th.Store.EXPECT().GetMembersForBoard(board.ID).AnyTimes().Return([]*model.BoardMember{}, nil)
|
||||
th.Store.EXPECT().GetBoard(board.ID).AnyTimes().Return(board, nil)
|
||||
th.Store.EXPECT().GetMemberForBoard(gomock.Any(), gomock.Any()).AnyTimes().Return(boardMember, nil)
|
||||
th.Store.EXPECT().SaveFileInfo(gomock.Any()).Return(nil).AnyTimes()
|
||||
|
||||
th.FilesBackend.On("WriteFile", mock.Anything, mock.Anything).Return(int64(1), nil)
|
||||
|
||||
|
||||
@@ -3379,7 +3379,7 @@ func TestPermissionsGetFile(t *testing.T) {
|
||||
clients := setupClients(th)
|
||||
testData := setupData(t, th)
|
||||
|
||||
newFileID, err := th.Server.App().SaveFile(bytes.NewBuffer([]byte("test")), "test-team", testData.privateBoard.ID, "test.png")
|
||||
newFileID, err := th.Server.App().SaveFile(bytes.NewBuffer([]byte("test")), "test-team", testData.privateBoard.ID, "test.png", false)
|
||||
require.NoError(t, err)
|
||||
|
||||
ttCases := ttCasesF()
|
||||
@@ -3394,7 +3394,7 @@ func TestPermissionsGetFile(t *testing.T) {
|
||||
clients := setupLocalClients(th)
|
||||
testData := setupData(t, th)
|
||||
|
||||
newFileID, err := th.Server.App().SaveFile(bytes.NewBuffer([]byte("test")), "test-team", testData.privateBoard.ID, "test.png")
|
||||
newFileID, err := th.Server.App().SaveFile(bytes.NewBuffer([]byte("test")), "test-team", testData.privateBoard.ID, "test.png", false)
|
||||
require.NoError(t, err)
|
||||
|
||||
ttCases := ttCasesF()
|
||||
|
||||
27
server/boards/model/file.go
Обычный файл
27
server/boards/model/file.go
Обычный файл
@@ -0,0 +1,27 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
package model
|
||||
|
||||
import (
|
||||
"mime"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/mattermost/mattermost-server/server/v8/boards/utils"
|
||||
mm_model "github.com/mattermost/mattermost-server/server/v8/model"
|
||||
)
|
||||
|
||||
func NewFileInfo(name string) *mm_model.FileInfo {
|
||||
|
||||
extension := strings.ToLower(filepath.Ext(name))
|
||||
now := utils.GetMillis()
|
||||
return &mm_model.FileInfo{
|
||||
CreatorId: "boards",
|
||||
CreateAt: now,
|
||||
UpdateAt: now,
|
||||
Name: name,
|
||||
Extension: extension,
|
||||
MimeType: mime.TypeByExtension(extension),
|
||||
}
|
||||
|
||||
}
|
||||
@@ -126,7 +126,7 @@ func (s *SQLStore) isSchemaMigrationNeeded() (bool, error) {
|
||||
case model.MysqlDBType:
|
||||
query = query.Where(sq.Eq{"TABLE_SCHEMA": s.schemaName})
|
||||
case model.PostgresDBType:
|
||||
query = query.Where(sq.Eq{"TABLE_SCHEMA": "current_schema()"})
|
||||
query = query.Where("table_schema = current_schema()")
|
||||
}
|
||||
|
||||
rows, err := query.Query()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM debian:buster-slim@sha256:5b0b1a9a54651bbe9d4d3ee96bbda2b2a1da3d2fa198ddebbced46dfdca7f216
|
||||
FROM ubuntu:jammy-20230308@sha256:7a57c69fe1e9d5b97c5fe649849e79f2cfc3bf11d10bbd5218b4eb61716aebe6
|
||||
|
||||
|
||||
# Setting bash as our shell, and enabling pipefail option
|
||||
@@ -13,68 +13,13 @@ ARG MM_PACKAGE="https://releases.mattermost.com/7.10.0/mattermost-7.10.0-linux-a
|
||||
# # Install needed packages and indirect dependencies
|
||||
RUN apt-get update \
|
||||
&& apt-get install --no-install-recommends -y \
|
||||
ca-certificates=20200601~deb10u2 \
|
||||
curl=7.64.0-4+deb10u2 \
|
||||
mime-support=3.62 \
|
||||
unrtf=0.21.10-clean-1 \
|
||||
wv=1.2.9-4.2+b2 \
|
||||
poppler-utils=0.71.0-5 \
|
||||
tidy=2:5.6.0-10 \
|
||||
libssl1.1=1.1.1n-0+deb10u3 \
|
||||
sensible-utils=0.0.12 \
|
||||
libsasl2-modules-db=2.1.27+dfsg-1+deb10u2 \
|
||||
libsasl2-2=2.1.27+dfsg-1+deb10u2 \
|
||||
libldap-common=2.4.47+dfsg-3+deb10u7 \
|
||||
libldap-2.4-2=2.4.47+dfsg-3+deb10u7 \
|
||||
libicu63=63.1-6+deb10u3 \
|
||||
libxml2=2.9.4+dfsg1-7+deb10u4 \
|
||||
ucf=3.0038+nmu1 \
|
||||
openssl=1.1.1n-0+deb10u3 \
|
||||
libkeyutils1=1.6-6 \
|
||||
libkrb5support0=1.17-3+deb10u4 \
|
||||
libk5crypto3=1.17-3+deb10u4 \
|
||||
libkrb5-3=1.17-3+deb10u4 \
|
||||
libgssapi-krb5-2=1.17-3+deb10u4 \
|
||||
libnghttp2-14=1.36.0-2+deb10u1 \
|
||||
libpsl5=0.20.2-2 \
|
||||
librtmp1=2.4+20151223.gitfa8646d.1-2 \
|
||||
libssh2-1=1.8.0-2.1 \
|
||||
libcurl4=7.64.0-4+deb10u2 \
|
||||
fonts-dejavu-core=2.37-1 \
|
||||
fontconfig-config=2.13.1-2 \
|
||||
libbsd0=0.9.1-2+deb10u1 \
|
||||
libexpat1=2.2.6-2+deb10u4 \
|
||||
libpng16-16=1.6.36-6 \
|
||||
libfreetype6=2.9.1-3+deb10u2 \
|
||||
libfontconfig1=2.13.1-2 \
|
||||
libpixman-1-0=0.36.0-1 \
|
||||
libxau6=1:1.0.8-1+b2 \
|
||||
libxdmcp6=1:1.1.2-3 \
|
||||
libxcb1=1.13.1-2 \
|
||||
libx11-data=2:1.6.7-1+deb10u2 \
|
||||
libx11-6=2:1.6.7-1+deb10u2 \
|
||||
libxcb-render0=1.13.1-2 \
|
||||
libxcb-shm0=1.13.1-2 \
|
||||
libxext6=2:1.3.3-1+b2 \
|
||||
libxrender1=1:0.9.10-1 \
|
||||
libcairo2=1.16.0-4+deb10u1 \
|
||||
libcurl3-gnutls=7.64.0-4+deb10u5 \
|
||||
libglib2.0-0=2.58.3-2+deb10u3 \
|
||||
libgsf-1-common=1.14.45-1 \
|
||||
libgsf-1-114=1.14.45-1 \
|
||||
libjbig0=2.1-3.1+b2 \
|
||||
libjpeg62-turbo=1:1.5.2-2+deb10u1 \
|
||||
liblcms2-2=2.9-3 \
|
||||
libnspr4=2:4.20-1 \
|
||||
libsqlite3-0=3.27.2-3+deb10u1 \
|
||||
libnss3=2:3.42.1-1+deb10u5 \
|
||||
libopenjp2-7=2.3.0-2+deb10u2 \
|
||||
libwebp6=0.6.1-2+deb10u1 \
|
||||
libtiff5=4.1.0+git191117-2~deb10u4 \
|
||||
libpoppler82=0.71.0-5 \
|
||||
libtidy5deb1=2:5.6.0-10 \
|
||||
libwmf0.2-7=0.2.8.4-14 \
|
||||
libwv-1.2-4=1.2.9-4.2+b2 \
|
||||
ca-certificates \
|
||||
curl \
|
||||
mime-support \
|
||||
unrtf \
|
||||
wv \
|
||||
poppler-utils \
|
||||
tidy \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Set mattermost group/user and download Mattermost
|
||||
|
||||
@@ -329,13 +329,6 @@ func executeCommand(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// For compatibility reasons, PermissionCreatePost is also checked.
|
||||
// TODO: Remove in 8.0: https://mattermost.atlassian.net/browse/MM-51274
|
||||
if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), commandArgs.ChannelId, model.PermissionUseSlashCommands) {
|
||||
c.SetPermissionError(model.PermissionUseSlashCommands)
|
||||
return
|
||||
}
|
||||
|
||||
channel, err := c.App.GetChannel(c.AppContext, commandArgs.ChannelId)
|
||||
if err != nil {
|
||||
c.Err = err
|
||||
@@ -354,13 +347,6 @@ func executeCommand(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
c.SetPermissionError(model.PermissionCreatePost)
|
||||
return
|
||||
}
|
||||
|
||||
// For compatibility reasons, PermissionCreatePost is also checked.
|
||||
// TODO: Remove in 8.0: https://mattermost.atlassian.net/browse/MM-51274
|
||||
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionUseSlashCommands) {
|
||||
c.SetPermissionError(model.PermissionUseSlashCommands)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1722,14 +1722,26 @@ func TestInstallMarketplacePluginPrepackagedDisabled(t *testing.T) {
|
||||
appErr := th.App.AddPublicKey("pub_key", key)
|
||||
require.Nil(t, appErr)
|
||||
|
||||
t.Cleanup(func() {
|
||||
appErr = th.App.DeletePublicKey("pub_key")
|
||||
require.Nil(t, appErr)
|
||||
})
|
||||
|
||||
testServer := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) {
|
||||
serverVersion := req.URL.Query().Get("server_version")
|
||||
require.NotEmpty(t, serverVersion)
|
||||
require.Equal(t, model.CurrentVersion, serverVersion)
|
||||
res.WriteHeader(http.StatusOK)
|
||||
|
||||
var out []byte
|
||||
out, err = json.Marshal([]*model.MarketplacePlugin{samplePlugins[1]})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Return something if testplugin2 or no specific plugin is requested
|
||||
pluginID := req.URL.Query().Get("plugin_id")
|
||||
if pluginID == "" || pluginID == samplePlugins[1].Manifest.Id {
|
||||
out, err = json.Marshal([]*model.MarketplacePlugin{samplePlugins[1]})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
res.Write(out)
|
||||
}))
|
||||
defer testServer.Close()
|
||||
@@ -1748,43 +1760,52 @@ func TestInstallMarketplacePluginPrepackagedDisabled(t *testing.T) {
|
||||
require.Len(t, pluginsResp.Active, 0)
|
||||
require.Len(t, pluginsResp.Inactive, 0)
|
||||
|
||||
// Should fail to install unknown prepackaged plugin
|
||||
pRequest := &model.InstallMarketplacePluginRequest{Id: "testpluginXX"}
|
||||
manifest, resp, err := client.InstallMarketplacePlugin(pRequest)
|
||||
require.Error(t, err)
|
||||
CheckInternalErrorStatus(t, resp)
|
||||
require.Nil(t, manifest)
|
||||
t.Run("Should fail to install unknown prepackaged plugin", func(t *testing.T) {
|
||||
pRequest := &model.InstallMarketplacePluginRequest{Id: "testpluginXX"}
|
||||
manifest, resp, err := client.InstallMarketplacePlugin(pRequest)
|
||||
require.Error(t, err)
|
||||
CheckInternalErrorStatus(t, resp)
|
||||
require.Nil(t, manifest)
|
||||
|
||||
plugins := env.PrepackagedPlugins()
|
||||
require.Len(t, plugins, 1)
|
||||
require.Equal(t, "testplugin", plugins[0].Manifest.Id)
|
||||
require.Equal(t, pluginSignatureData, plugins[0].Signature)
|
||||
plugins := env.PrepackagedPlugins()
|
||||
require.Len(t, plugins, 1)
|
||||
require.Equal(t, "testplugin", plugins[0].Manifest.Id)
|
||||
require.Equal(t, pluginSignatureData, plugins[0].Signature)
|
||||
|
||||
pluginsResp, _, err = client.GetPlugins()
|
||||
require.NoError(t, err)
|
||||
require.Len(t, pluginsResp.Active, 0)
|
||||
require.Len(t, pluginsResp.Inactive, 0)
|
||||
pluginsResp, _, err = client.GetPlugins()
|
||||
require.NoError(t, err)
|
||||
require.Len(t, pluginsResp.Active, 0)
|
||||
require.Len(t, pluginsResp.Inactive, 0)
|
||||
})
|
||||
|
||||
pRequest = &model.InstallMarketplacePluginRequest{Id: "testplugin"}
|
||||
manifest1, _, err := client.InstallMarketplacePlugin(pRequest)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, manifest1)
|
||||
require.Equal(t, "testplugin", manifest1.Id)
|
||||
require.Equal(t, "0.0.1", manifest1.Version)
|
||||
t.Run("Install prepackaged plugin with Marketplace disabled", func(t *testing.T) {
|
||||
pRequest := &model.InstallMarketplacePluginRequest{Id: "testplugin"}
|
||||
manifest, _, err := client.InstallMarketplacePlugin(pRequest)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, manifest)
|
||||
require.Equal(t, "testplugin", manifest.Id)
|
||||
require.Equal(t, "0.0.1", manifest.Version)
|
||||
|
||||
pluginsResp, _, err = client.GetPlugins()
|
||||
require.NoError(t, err)
|
||||
require.Len(t, pluginsResp.Active, 0)
|
||||
require.Equal(t, pluginsResp.Inactive, []*model.PluginInfo{{
|
||||
Manifest: *manifest1,
|
||||
}})
|
||||
t.Cleanup(func() {
|
||||
_, err = client.RemovePlugin(manifest.Id)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
// Try to install remote marketplace plugin
|
||||
pRequest = &model.InstallMarketplacePluginRequest{Id: "testplugin2"}
|
||||
manifest, resp, err = client.InstallMarketplacePlugin(pRequest)
|
||||
require.Error(t, err)
|
||||
CheckInternalErrorStatus(t, resp)
|
||||
require.Nil(t, manifest)
|
||||
pluginsResp, _, err = client.GetPlugins()
|
||||
require.NoError(t, err)
|
||||
require.Len(t, pluginsResp.Active, 0)
|
||||
require.Equal(t, pluginsResp.Inactive, []*model.PluginInfo{{
|
||||
Manifest: *manifest,
|
||||
}})
|
||||
})
|
||||
|
||||
t.Run("Try to install remote marketplace plugin while Marketplace is disabled", func(t *testing.T) {
|
||||
pRequest := &model.InstallMarketplacePluginRequest{Id: "testplugin2"}
|
||||
manifest, resp, err := client.InstallMarketplacePlugin(pRequest)
|
||||
require.Error(t, err)
|
||||
CheckInternalErrorStatus(t, resp)
|
||||
require.Nil(t, manifest)
|
||||
})
|
||||
|
||||
// Enable remote marketplace
|
||||
th.App.UpdateConfig(func(cfg *model.Config) {
|
||||
@@ -1794,31 +1815,58 @@ func TestInstallMarketplacePluginPrepackagedDisabled(t *testing.T) {
|
||||
*cfg.PluginSettings.AllowInsecureDownloadURL = true
|
||||
})
|
||||
|
||||
pRequest = &model.InstallMarketplacePluginRequest{Id: "testplugin2"}
|
||||
manifest2, _, err := client.InstallMarketplacePlugin(pRequest)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, manifest2)
|
||||
require.Equal(t, "testplugin2", manifest2.Id)
|
||||
require.Equal(t, "1.2.3", manifest2.Version)
|
||||
t.Run("Install prepackaged, not listed plugin with Marketplace enabled", func(t *testing.T) {
|
||||
pRequest := &model.InstallMarketplacePluginRequest{Id: "testplugin"}
|
||||
manifest, _, err := client.InstallMarketplacePlugin(pRequest)
|
||||
require.NoError(t, err)
|
||||
|
||||
pluginsResp, _, err = client.GetPlugins()
|
||||
require.NoError(t, err)
|
||||
require.Len(t, pluginsResp.Active, 0)
|
||||
require.ElementsMatch(t, pluginsResp.Inactive, []*model.PluginInfo{
|
||||
{
|
||||
Manifest: *manifest1,
|
||||
},
|
||||
{
|
||||
Manifest: *manifest2,
|
||||
},
|
||||
t.Cleanup(func() {
|
||||
_, err = client.RemovePlugin(manifest.Id)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
require.NotNil(t, manifest)
|
||||
assert.Equal(t, "testplugin", manifest.Id)
|
||||
assert.Equal(t, "0.0.1", manifest.Version)
|
||||
})
|
||||
|
||||
// Clean up
|
||||
_, err = client.RemovePlugin(manifest1.Id)
|
||||
require.NoError(t, err)
|
||||
t.Run("Install both a prepacked and a Marketplace plugin", func(t *testing.T) {
|
||||
pRequest := &model.InstallMarketplacePluginRequest{Id: "testplugin"}
|
||||
manifest1, _, err := client.InstallMarketplacePlugin(pRequest)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, manifest1)
|
||||
assert.Equal(t, "testplugin", manifest1.Id)
|
||||
assert.Equal(t, "0.0.1", manifest1.Version)
|
||||
|
||||
_, err = client.RemovePlugin(manifest2.Id)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
_, err = client.RemovePlugin(manifest1.Id)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
pRequest = &model.InstallMarketplacePluginRequest{Id: "testplugin2"}
|
||||
manifest2, _, err := client.InstallMarketplacePlugin(pRequest)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, manifest2)
|
||||
require.Equal(t, "testplugin2", manifest2.Id)
|
||||
require.Equal(t, "1.2.3", manifest2.Version)
|
||||
|
||||
t.Cleanup(func() {
|
||||
_, err = client.RemovePlugin(manifest2.Id)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
pluginsResp, _, err = client.GetPlugins()
|
||||
require.NoError(t, err)
|
||||
require.Len(t, pluginsResp.Active, 0)
|
||||
require.ElementsMatch(t, pluginsResp.Inactive, []*model.PluginInfo{
|
||||
{
|
||||
Manifest: *manifest1,
|
||||
},
|
||||
{
|
||||
Manifest: *manifest2,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
appErr = th.App.DeletePublicKey("pub_key")
|
||||
require.Nil(t, appErr)
|
||||
|
||||
@@ -119,7 +119,6 @@ func TestDoAdvancedPermissionsMigration(t *testing.T) {
|
||||
model.PermissionGetPublicLink.Id,
|
||||
model.PermissionCreatePost.Id,
|
||||
model.PermissionUseChannelMentions.Id,
|
||||
model.PermissionUseSlashCommands.Id,
|
||||
model.PermissionManagePublicChannelProperties.Id,
|
||||
model.PermissionDeletePublicChannel.Id,
|
||||
model.PermissionManagePrivateChannelProperties.Id,
|
||||
|
||||
@@ -459,7 +459,7 @@ func TestImportImportRole(t *testing.T) {
|
||||
// Try changing all the params and reimporting.
|
||||
data.DisplayName = ptrStr("new display name")
|
||||
data.Description = ptrStr("description")
|
||||
data.Permissions = &[]string{"use_slash_commands"}
|
||||
data.Permissions = &[]string{"manage_slash_commands"}
|
||||
|
||||
err = th.App.importRole(th.Context, &data, false, true)
|
||||
require.Nil(t, err, "Should have succeeded. %v", err)
|
||||
|
||||
@@ -114,7 +114,7 @@ func TestImportPermissions(t *testing.T) {
|
||||
}
|
||||
beforeCount = len(results)
|
||||
|
||||
json := fmt.Sprintf(`{"display_name":"%v","name":"%v","description":"%v","scope":"%v","default_team_admin_role":"","default_team_user_role":"","default_channel_admin_role":"%v","default_channel_user_role":"%v","roles":[{"id":"yzfx3g9xjjfw8cqo6bpn33xr7o","name":"%v","display_name":"Channel Admin Role for Scheme my_scheme_1526475590","description":"","create_at":1526475589687,"update_at":1526475589687,"delete_at":0,"permissions":["manage_channel_roles"],"scheme_managed":true,"built_in":false},{"id":"a7s3cp4n33dfxbsrmyh9djao3a","name":"%v","display_name":"Channel User Role for Scheme my_scheme_1526475590","description":"","create_at":1526475589688,"update_at":1526475589688,"delete_at":0,"permissions":["read_channel","add_reaction","remove_reaction","manage_public_channel_members","upload_file","get_public_link","create_post","use_slash_commands","manage_private_channel_members","delete_post","edit_post"],"scheme_managed":true,"built_in":false}]}`, displayName, name, description, scope, roleName1, roleName2, roleName1, roleName2)
|
||||
json := fmt.Sprintf(`{"display_name":"%v","name":"%v","description":"%v","scope":"%v","default_team_admin_role":"","default_team_user_role":"","default_channel_admin_role":"%v","default_channel_user_role":"%v","roles":[{"id":"yzfx3g9xjjfw8cqo6bpn33xr7o","name":"%v","display_name":"Channel Admin Role for Scheme my_scheme_1526475590","description":"","create_at":1526475589687,"update_at":1526475589687,"delete_at":0,"permissions":["manage_channel_roles"],"scheme_managed":true,"built_in":false},{"id":"a7s3cp4n33dfxbsrmyh9djao3a","name":"%v","display_name":"Channel User Role for Scheme my_scheme_1526475590","description":"","create_at":1526475589688,"update_at":1526475589688,"delete_at":0,"permissions":["read_channel","add_reaction","remove_reaction","manage_public_channel_members","upload_file","get_public_link","create_post","manage_private_channel_members","delete_post","edit_post"],"scheme_managed":true,"built_in":false}]}`, displayName, name, description, scope, roleName1, roleName2, roleName1, roleName2)
|
||||
r := strings.NewReader(json)
|
||||
|
||||
err := th.App.ImportPermissions(r)
|
||||
@@ -183,7 +183,7 @@ func TestImportPermissions_idempotentScheme(t *testing.T) {
|
||||
roleName1 := model.NewId()
|
||||
roleName2 := model.NewId()
|
||||
|
||||
json := fmt.Sprintf(`{"display_name":"%v","name":"%v","description":"%v","scope":"%v","default_team_admin_role":"","default_team_user_role":"","default_channel_admin_role":"%v","default_channel_user_role":"%v","roles":[{"id":"yzfx3g9xjjfw8cqo6bpn33xr7o","name":"%v","display_name":"Channel Admin Role for Scheme my_scheme_1526475590","description":"","create_at":1526475589687,"update_at":1526475589687,"delete_at":0,"permissions":["manage_channel_roles"],"scheme_managed":true,"built_in":false},{"id":"a7s3cp4n33dfxbsrmyh9djao3a","name":"%v","display_name":"Channel User Role for Scheme my_scheme_1526475590","description":"","create_at":1526475589688,"update_at":1526475589688,"delete_at":0,"permissions":["read_channel","add_reaction","remove_reaction","manage_public_channel_members","upload_file","get_public_link","create_post","use_slash_commands","manage_private_channel_members","delete_post","edit_post"],"scheme_managed":true,"built_in":false}]}`, displayName, name, description, scope, roleName1, roleName2, roleName1, roleName2)
|
||||
json := fmt.Sprintf(`{"display_name":"%v","name":"%v","description":"%v","scope":"%v","default_team_admin_role":"","default_team_user_role":"","default_channel_admin_role":"%v","default_channel_user_role":"%v","roles":[{"id":"yzfx3g9xjjfw8cqo6bpn33xr7o","name":"%v","display_name":"Channel Admin Role for Scheme my_scheme_1526475590","description":"","create_at":1526475589687,"update_at":1526475589687,"delete_at":0,"permissions":["manage_channel_roles"],"scheme_managed":true,"built_in":false},{"id":"a7s3cp4n33dfxbsrmyh9djao3a","name":"%v","display_name":"Channel User Role for Scheme my_scheme_1526475590","description":"","create_at":1526475589688,"update_at":1526475589688,"delete_at":0,"permissions":["read_channel","add_reaction","remove_reaction","manage_public_channel_members","upload_file","get_public_link","create_post","manage_private_channel_members","delete_post","edit_post"],"scheme_managed":true,"built_in":false}]}`, displayName, name, description, scope, roleName1, roleName2, roleName1, roleName2)
|
||||
jsonl := strings.Repeat(json+"\n", 4)
|
||||
r := strings.NewReader(jsonl)
|
||||
|
||||
@@ -226,7 +226,7 @@ func TestImportPermissions_schemeDeletedOnRoleFailure(t *testing.T) {
|
||||
roleName1 := model.NewId()
|
||||
roleName2 := model.NewId()
|
||||
|
||||
jsonl := fmt.Sprintf(`{"display_name":"%v","name":"%v","description":"%v","scope":"%v","default_team_admin_role":"","default_team_user_role":"","default_channel_admin_role":"%v","default_channel_user_role":"%v","roles":[{"id":"yzfx3g9xjjfw8cqo6bpn33xr7o","name":"%v","display_name":"Channel Admin Role for Scheme my_scheme_1526475590","description":"","create_at":1526475589687,"update_at":1526475589687,"delete_at":0,"permissions":["manage_channel_roles"],"scheme_managed":true,"built_in":false},{"id":"a7s3cp4n33dfxbsrmyh9djao3a","name":"%v","display_name":"Channel User Role for Scheme my_scheme_1526475590","description":"","create_at":1526475589688,"update_at":1526475589688,"delete_at":0,"permissions":["read_channel","add_reaction","remove_reaction","manage_public_channel_members","upload_file","get_public_link","create_post","use_slash_commands","manage_private_channel_members","delete_post","edit_post"],"scheme_managed":true,"built_in":false}]}`, displayName, name, description, scope, roleName1, roleName2, roleName1, roleName2)
|
||||
jsonl := fmt.Sprintf(`{"display_name":"%v","name":"%v","description":"%v","scope":"%v","default_team_admin_role":"","default_team_user_role":"","default_channel_admin_role":"%v","default_channel_user_role":"%v","roles":[{"id":"yzfx3g9xjjfw8cqo6bpn33xr7o","name":"%v","display_name":"Channel Admin Role for Scheme my_scheme_1526475590","description":"","create_at":1526475589687,"update_at":1526475589687,"delete_at":0,"permissions":["manage_channel_roles"],"scheme_managed":true,"built_in":false},{"id":"a7s3cp4n33dfxbsrmyh9djao3a","name":"%v","display_name":"Channel User Role for Scheme my_scheme_1526475590","description":"","create_at":1526475589688,"update_at":1526475589688,"delete_at":0,"permissions":["read_channel","add_reaction","remove_reaction","manage_public_channel_members","upload_file","get_public_link","create_post","manage_private_channel_members","delete_post","edit_post"],"scheme_managed":true,"built_in":false}]}`, displayName, name, description, scope, roleName1, roleName2, roleName1, roleName2)
|
||||
r := strings.NewReader(jsonl)
|
||||
|
||||
var results []*model.Scheme
|
||||
|
||||
@@ -203,35 +203,38 @@ func (ch *Channels) InstallMarketplacePlugin(request *model.InstallMarketplacePl
|
||||
if *ch.cfgSvc.Config().PluginSettings.EnableRemoteMarketplace {
|
||||
var plugin *model.BaseMarketplacePlugin
|
||||
plugin, appErr = ch.getRemoteMarketplacePlugin(request.Id, request.Version)
|
||||
if appErr != nil {
|
||||
return nil, appErr
|
||||
// The plugin might only be prepackaged and not on the Marketplace.
|
||||
if appErr != nil && appErr.Id != "app.plugin.marketplace_plugins.not_found.app_error" {
|
||||
mlog.Warn("Failed to reach Marketplace to install plugin", mlog.String("plugin_id", request.Id), mlog.Err(appErr))
|
||||
}
|
||||
|
||||
var prepackagedVersion semver.Version
|
||||
if prepackagedPlugin != nil {
|
||||
var err error
|
||||
prepackagedVersion, err = semver.Parse(prepackagedPlugin.Manifest.Version)
|
||||
if err != nil {
|
||||
return nil, model.NewAppError("InstallMarketplacePlugin", "app.plugin.invalid_version.app_error", nil, "", http.StatusBadRequest).Wrap(err)
|
||||
if plugin != nil {
|
||||
var prepackagedVersion semver.Version
|
||||
if prepackagedPlugin != nil {
|
||||
var err error
|
||||
prepackagedVersion, err = semver.Parse(prepackagedPlugin.Manifest.Version)
|
||||
if err != nil {
|
||||
return nil, model.NewAppError("InstallMarketplacePlugin", "app.plugin.invalid_version.app_error", nil, "", http.StatusBadRequest).Wrap(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
marketplaceVersion, err := semver.Parse(plugin.Manifest.Version)
|
||||
if err != nil {
|
||||
return nil, model.NewAppError("InstallMarketplacePlugin", "app.prepackged-plugin.invalid_version.app_error", nil, "", http.StatusBadRequest).Wrap(err)
|
||||
}
|
||||
marketplaceVersion, err := semver.Parse(plugin.Manifest.Version)
|
||||
if err != nil {
|
||||
return nil, model.NewAppError("InstallMarketplacePlugin", "app.prepackged-plugin.invalid_version.app_error", nil, "", http.StatusBadRequest).Wrap(err)
|
||||
}
|
||||
|
||||
if prepackagedVersion.LT(marketplaceVersion) { // Always true if no prepackaged plugin was found
|
||||
downloadedPluginBytes, err := ch.srv.downloadFromURL(plugin.DownloadURL)
|
||||
if err != nil {
|
||||
return nil, model.NewAppError("InstallMarketplacePlugin", "app.plugin.install_marketplace_plugin.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
if prepackagedVersion.LT(marketplaceVersion) { // Always true if no prepackaged plugin was found
|
||||
downloadedPluginBytes, err := ch.srv.downloadFromURL(plugin.DownloadURL)
|
||||
if err != nil {
|
||||
return nil, model.NewAppError("InstallMarketplacePlugin", "app.plugin.install_marketplace_plugin.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
signature, err := plugin.DecodeSignature()
|
||||
if err != nil {
|
||||
return nil, model.NewAppError("InstallMarketplacePlugin", "app.plugin.signature_decode.app_error", nil, "", http.StatusNotImplemented).Wrap(err)
|
||||
}
|
||||
pluginFile = bytes.NewReader(downloadedPluginBytes)
|
||||
signatureFile = signature
|
||||
}
|
||||
signature, err := plugin.DecodeSignature()
|
||||
if err != nil {
|
||||
return nil, model.NewAppError("InstallMarketplacePlugin", "app.plugin.signature_decode.app_error", nil, "", http.StatusNotImplemented).Wrap(err)
|
||||
}
|
||||
pluginFile = bytes.NewReader(downloadedPluginBytes)
|
||||
signatureFile = signature
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -93,6 +93,7 @@ var wt{{.MD5}} = &WorkTemplate{
|
||||
Illustration: "{{.Playbook.Illustration}}",
|
||||
},{{end}}{{if .Integration}}Integration: &Integration{
|
||||
ID: "{{.Integration.ID}}",
|
||||
Recommended: {{.Integration.Recommended}},
|
||||
},{{end}}
|
||||
},
|
||||
{{end}}
|
||||
|
||||
@@ -45,8 +45,10 @@ content:
|
||||
illustration: "/static/worktemplates/playbooks/product_release.png"
|
||||
- integration:
|
||||
id: jira
|
||||
recommended: true
|
||||
- integration:
|
||||
id: github
|
||||
recommended: true
|
||||
---
|
||||
id: 'product_teams/goals_and_okrs:v1'
|
||||
category: product_teams
|
||||
@@ -86,7 +88,7 @@ content:
|
||||
channel: channel-1674845108569
|
||||
- integration:
|
||||
id: zoom
|
||||
|
||||
recommended: true
|
||||
---
|
||||
id: 'product_teams/bug_bash:v1'
|
||||
category: product_teams
|
||||
@@ -120,6 +122,7 @@ content:
|
||||
playbook: playbook-1674844017943
|
||||
- integration:
|
||||
id: jira
|
||||
recommended: true
|
||||
---
|
||||
id: 'product_teams/sprint_planning:v1'
|
||||
category: product_teams
|
||||
@@ -153,6 +156,7 @@ content:
|
||||
channel: channel-1674850783500
|
||||
- integration:
|
||||
id: zoom
|
||||
recommended: true
|
||||
---
|
||||
id: 'product_teams/product_roadmap:v1'
|
||||
category: product_teams
|
||||
@@ -282,6 +286,7 @@ content:
|
||||
channel: channel-1674845108569
|
||||
- integration:
|
||||
id: zoom
|
||||
recommended: true
|
||||
---
|
||||
id: 'companywide/create_project:v1'
|
||||
category: companywide
|
||||
@@ -316,10 +321,13 @@ content:
|
||||
channel: channel-1674851940114
|
||||
- integration:
|
||||
id: jira
|
||||
recommended: true
|
||||
- integration:
|
||||
id: github
|
||||
recommended: true
|
||||
- integration:
|
||||
id: zoom
|
||||
recommended: true
|
||||
---
|
||||
######################
|
||||
# Leadership
|
||||
@@ -356,4 +364,4 @@ content:
|
||||
channel: channel-1674845108569
|
||||
- integration:
|
||||
id: zoom
|
||||
|
||||
recommended: true
|
||||
|
||||
@@ -108,7 +108,8 @@ func (wt WorkTemplate) ToModelWorkTemplate(t i18n.TranslateFunc) *model.WorkTemp
|
||||
if content.Integration != nil {
|
||||
mwt.Content = append(mwt.Content, model.WorkTemplateContent{
|
||||
Integration: &model.WorkTemplateIntegration{
|
||||
ID: content.Integration.ID,
|
||||
ID: content.Integration.ID,
|
||||
Recommended: content.Integration.Recommended,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -320,7 +321,8 @@ func (p *Playbook) Validate() error {
|
||||
}
|
||||
|
||||
type Integration struct {
|
||||
ID string `yaml:"id"`
|
||||
ID string `yaml:"id"`
|
||||
Recommended bool `yaml:"recommended"`
|
||||
}
|
||||
|
||||
func (i *Integration) Validate() error {
|
||||
|
||||
@@ -148,12 +148,14 @@ var wt00a1b44a5831c0a3acb14787b3fdd352 = &WorkTemplate{
|
||||
},
|
||||
{
|
||||
Integration: &Integration{
|
||||
ID: "jira",
|
||||
ID: "jira",
|
||||
Recommended: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
Integration: &Integration{
|
||||
ID: "github",
|
||||
ID: "github",
|
||||
Recommended: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -214,7 +216,8 @@ var wt5baa68055bf9ea423273662e01ccc575 = &WorkTemplate{
|
||||
},
|
||||
{
|
||||
Integration: &Integration{
|
||||
ID: "zoom",
|
||||
ID: "zoom",
|
||||
Recommended: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -265,7 +268,8 @@ var wtfeb56bc6a8f277c47b503bd1c92d830e = &WorkTemplate{
|
||||
},
|
||||
{
|
||||
Integration: &Integration{
|
||||
ID: "jira",
|
||||
ID: "jira",
|
||||
Recommended: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -317,7 +321,8 @@ var wt8d2ef53deac5517eb349dc5de6150196 = &WorkTemplate{
|
||||
},
|
||||
{
|
||||
Integration: &Integration{
|
||||
ID: "zoom",
|
||||
ID: "zoom",
|
||||
Recommended: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -518,7 +523,8 @@ var wtf7b846d35810f8272eeb9a1a562025b5 = &WorkTemplate{
|
||||
},
|
||||
{
|
||||
Integration: &Integration{
|
||||
ID: "zoom",
|
||||
ID: "zoom",
|
||||
Recommended: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -570,17 +576,20 @@ var wtb9ab412890c2410c7b49eec8f12e7edc = &WorkTemplate{
|
||||
},
|
||||
{
|
||||
Integration: &Integration{
|
||||
ID: "jira",
|
||||
ID: "jira",
|
||||
Recommended: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
Integration: &Integration{
|
||||
ID: "github",
|
||||
ID: "github",
|
||||
Recommended: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
Integration: &Integration{
|
||||
ID: "zoom",
|
||||
ID: "zoom",
|
||||
Recommended: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -632,7 +641,8 @@ var wt32ab773bfe021e3d4913931041552559 = &WorkTemplate{
|
||||
},
|
||||
{
|
||||
Integration: &Integration{
|
||||
ID: "zoom",
|
||||
ID: "zoom",
|
||||
Recommended: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -3035,7 +3035,8 @@ func (s SqlChannelStore) Autocomplete(userID, term string, includeDeleted, isGue
|
||||
sq.Expr("t.id = tm.TeamId"),
|
||||
sq.Eq{"tm.UserId": userID},
|
||||
}).
|
||||
OrderBy("c.DisplayName")
|
||||
OrderBy("c.DisplayName").
|
||||
Limit(model.ChannelSearchDefaultLimit)
|
||||
|
||||
if !includeDeleted {
|
||||
query = query.Where(sq.And{
|
||||
@@ -3073,7 +3074,7 @@ func (s SqlChannelStore) Autocomplete(userID, term string, includeDeleted, isGue
|
||||
channels := model.ChannelListWithTeamData{}
|
||||
err = s.GetReplicaX().Select(&channels, sql, args...)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, "could not find channel with term=%s", term)
|
||||
return nil, errors.Wrapf(err, "could not find channel with term=%s", trimInput(term))
|
||||
}
|
||||
return channels, nil
|
||||
}
|
||||
@@ -3186,7 +3187,7 @@ func (s SqlChannelStore) AutocompleteInTeamForSearch(teamID string, userID strin
|
||||
// query the database
|
||||
err = s.GetReplicaX().Select(&channels, sql, args...)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, "failed to find Channels with term='%s'", term)
|
||||
return nil, errors.Wrapf(err, "failed to find Channels with term='%s'", trimInput(term))
|
||||
}
|
||||
|
||||
directChannels, err := s.autocompleteInTeamForSearchDirectMessages(userID, term)
|
||||
@@ -3242,7 +3243,7 @@ func (s SqlChannelStore) autocompleteInTeamForSearchDirectMessages(userID string
|
||||
// query the channel list from the database using SQLX
|
||||
channels := model.ChannelList{}
|
||||
if err := s.GetReplicaX().Select(&channels, sql, args...); err != nil {
|
||||
return nil, errors.Wrapf(err, "failed to find Channels with term='%s' (%s %% %v)", term, sql, args)
|
||||
return nil, errors.Wrapf(err, "failed to find Channels with term='%s'", trimInput(term))
|
||||
}
|
||||
|
||||
return channels, nil
|
||||
@@ -3461,7 +3462,7 @@ func (s SqlChannelStore) SearchAllChannels(term string, opts store.ChannelSearch
|
||||
}
|
||||
channels := model.ChannelListWithTeamData{}
|
||||
if err2 := s.GetReplicaX().Select(&channels, queryString, args...); err2 != nil {
|
||||
return nil, 0, errors.Wrapf(err2, "failed to find Channels with term='%s'", term)
|
||||
return nil, 0, errors.Wrapf(err2, "failed to find Channels with term='%s'", trimInput(term))
|
||||
}
|
||||
|
||||
var totalCount int64
|
||||
@@ -3474,7 +3475,7 @@ func (s SqlChannelStore) SearchAllChannels(term string, opts store.ChannelSearch
|
||||
return nil, 0, errors.Wrap(err, "channel_tosql")
|
||||
}
|
||||
if err2 := s.GetReplicaX().Get(&totalCount, queryString, args...); err2 != nil {
|
||||
return nil, 0, errors.Wrapf(err2, "failed to find Channels with term='%s'", term)
|
||||
return nil, 0, errors.Wrapf(err2, "failed to find Channels with term='%s'", trimInput(term))
|
||||
}
|
||||
} else {
|
||||
totalCount = int64(len(channels))
|
||||
@@ -3651,7 +3652,7 @@ func (s SqlChannelStore) performSearch(searchQuery sq.SelectBuilder, term string
|
||||
channels := model.ChannelList{}
|
||||
err = s.GetReplicaX().Select(&channels, sql, args...)
|
||||
if err != nil {
|
||||
return channels, errors.Wrapf(err, "failed to find Channels with term='%s'", term)
|
||||
return channels, errors.Wrapf(err, "failed to find Channels with term='%s'", trimInput(term))
|
||||
}
|
||||
|
||||
return channels, nil
|
||||
@@ -3744,7 +3745,7 @@ func (s SqlChannelStore) SearchGroupChannels(userId, term string) (model.Channel
|
||||
|
||||
groupChannels := model.ChannelList{}
|
||||
if err := s.GetReplicaX().Select(&groupChannels, sql, params...); err != nil {
|
||||
return nil, errors.Wrapf(err, "failed to find Channels with term='%s' and userId=%s", term, userId)
|
||||
return nil, errors.Wrapf(err, "failed to find Channels with term='%s' and userId=%s", trimInput(term), userId)
|
||||
}
|
||||
return groupChannels, nil
|
||||
}
|
||||
|
||||
@@ -681,7 +681,7 @@ func (fs SqlFileInfoStore) Search(paramsList []*model.SearchParams, userId, team
|
||||
items := []fileInfoWithChannelID{}
|
||||
err = fs.GetSearchReplicaX().Select(&items, queryString, args...)
|
||||
if err != nil {
|
||||
mlog.Warn("Query error searching files.", mlog.Err(err))
|
||||
mlog.Warn("Query error searching files.", mlog.String("error", trimInput(err.Error())))
|
||||
// Don't return the error to the caller as it is of no use to the user. Instead return an empty set of search results.
|
||||
} else {
|
||||
for _, item := range items {
|
||||
|
||||
@@ -2075,7 +2075,7 @@ func (s *SqlPostStore) search(teamId string, userId string, params *model.Search
|
||||
var posts []*model.Post
|
||||
|
||||
if err := s.GetSearchReplicaX().Select(&posts, searchQuery, searchQueryArgs...); err != nil {
|
||||
mlog.Warn("Query error searching posts.", mlog.Err(err))
|
||||
mlog.Warn("Query error searching posts.", mlog.String("error", trimInput(err.Error())))
|
||||
// Don't return the error to the caller as it is of no use to the user. Instead return an empty set of search results.
|
||||
} else {
|
||||
for _, p := range posts {
|
||||
|
||||
@@ -233,3 +233,14 @@ func SanitizeDataSource(driverName, dataSource string) (string, error) {
|
||||
return "", errors.New("invalid drivername. Not postgres or mysql.")
|
||||
}
|
||||
}
|
||||
|
||||
const maxTokenSize = 50
|
||||
|
||||
// trimInput limits the string to a max size to prevent clogging up disk space
|
||||
// while logging
|
||||
func trimInput(input string) string {
|
||||
if len(input) > maxTokenSize {
|
||||
input = input[:maxTokenSize] + "..."
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
@@ -115,7 +115,7 @@ func TestChannelStore(t *testing.T, ss store.Store, s SqlStore) {
|
||||
t.Run("GetGuestCount", func(t *testing.T) { testGetGuestCount(t, ss) })
|
||||
t.Run("SearchMore", func(t *testing.T) { testChannelStoreSearchMore(t, ss) })
|
||||
t.Run("SearchInTeam", func(t *testing.T) { testChannelStoreSearchInTeam(t, ss) })
|
||||
t.Run("Autocomplete", func(t *testing.T) { testAutocomplete(t, ss) })
|
||||
t.Run("Autocomplete", func(t *testing.T) { testAutocomplete(t, ss, s) })
|
||||
t.Run("SearchArchivedInTeam", func(t *testing.T) { testChannelStoreSearchArchivedInTeam(t, ss, s) })
|
||||
t.Run("SearchForUserInTeam", func(t *testing.T) { testChannelStoreSearchForUserInTeam(t, ss) })
|
||||
t.Run("SearchAllChannels", func(t *testing.T) { testChannelStoreSearchAllChannels(t, ss) })
|
||||
@@ -5986,7 +5986,7 @@ func testChannelStoreSearchInTeam(t *testing.T, ss store.Store) {
|
||||
}
|
||||
}
|
||||
|
||||
func testAutocomplete(t *testing.T, ss store.Store) {
|
||||
func testAutocomplete(t *testing.T, ss store.Store, s SqlStore) {
|
||||
t1 := &model.Team{
|
||||
DisplayName: "t1",
|
||||
Name: NewTestId(),
|
||||
@@ -6165,9 +6165,9 @@ func testAutocomplete(t *testing.T, ss store.Store) {
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run("Autocomplete/"+testCase.Description, func(t *testing.T) {
|
||||
channels, err := ss.Channel().Autocomplete(testCase.UserID, testCase.Term, testCase.IncludeDeleted, testCase.IsGuest)
|
||||
require.NoError(t, err)
|
||||
t.Run(testCase.Description, func(t *testing.T) {
|
||||
channels, err2 := ss.Channel().Autocomplete(testCase.UserID, testCase.Term, testCase.IncludeDeleted, testCase.IsGuest)
|
||||
require.NoError(t, err2)
|
||||
var gotChannelIds []string
|
||||
var gotTeamNames []string
|
||||
for _, ch := range channels {
|
||||
@@ -6178,6 +6178,24 @@ func testAutocomplete(t *testing.T, ss store.Store) {
|
||||
require.ElementsMatch(t, testCase.ExpectedTeamNames, gotTeamNames, "team names are not as expected")
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("Limit", func(t *testing.T) {
|
||||
for i := 0; i < model.ChannelSearchDefaultLimit+10; i++ {
|
||||
_, err = ss.Channel().Save(&model.Channel{
|
||||
TeamId: teamID,
|
||||
DisplayName: "Channel " + strconv.Itoa(i),
|
||||
Name: NewTestId(),
|
||||
Type: model.ChannelTypeOpen,
|
||||
}, -1)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
channels, err := ss.Channel().Autocomplete(m1.UserId, "Chann", false, false)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, channels, model.ChannelSearchDefaultLimit)
|
||||
})
|
||||
|
||||
// Manually truncate Channels table until testlib can handle cleanups
|
||||
s.GetMasterX().Exec("TRUNCATE Channels")
|
||||
}
|
||||
|
||||
func testChannelStoreSearchForUserInTeam(t *testing.T, ss store.Store) {
|
||||
|
||||
6
server/channels/testlib/testdata/mysql_migration_warmup.sql
поставляемый
6
server/channels/testlib/testdata/mysql_migration_warmup.sql
поставляемый
Различия файлов скрыты, потому что одна или несколько строк слишком длинны
Различия файлов скрыты, потому что одна или несколько строк слишком длинны
@@ -21,10 +21,6 @@ type Permission struct {
|
||||
|
||||
var PermissionInviteUser *Permission
|
||||
var PermissionAddUserToTeam *Permission
|
||||
|
||||
// Deprecated: PermissionCreatePost should be used to determine if a slash command can be executed.
|
||||
// TODO: Remove in 8.0: https://mattermost.atlassian.net/browse/MM-51274
|
||||
var PermissionUseSlashCommands *Permission
|
||||
var PermissionManageSlashCommands *Permission
|
||||
var PermissionManageOthersSlashCommands *Permission
|
||||
var PermissionCreatePublicChannel *Permission
|
||||
@@ -393,12 +389,6 @@ func initializePermissions() {
|
||||
"authentication.permissions.add_user_to_team.description",
|
||||
PermissionScopeTeam,
|
||||
}
|
||||
PermissionUseSlashCommands = &Permission{
|
||||
"use_slash_commands",
|
||||
"authentication.permissions.team_use_slash_commands.name",
|
||||
"authentication.permissions.team_use_slash_commands.description",
|
||||
PermissionScopeChannel,
|
||||
}
|
||||
PermissionManageSlashCommands = &Permission{
|
||||
"manage_slash_commands",
|
||||
"authentication.permissions.manage_slash_commands.name",
|
||||
@@ -2318,7 +2308,6 @@ func initializePermissions() {
|
||||
}
|
||||
|
||||
ChannelScopedPermissions := []*Permission{
|
||||
PermissionUseSlashCommands,
|
||||
PermissionManagePublicChannelMembers,
|
||||
PermissionManagePrivateChannelMembers,
|
||||
PermissionManageChannelRoles,
|
||||
|
||||
@@ -755,7 +755,6 @@ func MakeDefaultRoles() map[string]*Role {
|
||||
PermissionEditPost.Id,
|
||||
PermissionCreatePost.Id,
|
||||
PermissionUseChannelMentions.Id,
|
||||
PermissionUseSlashCommands.Id,
|
||||
},
|
||||
SchemeManaged: true,
|
||||
BuiltIn: true,
|
||||
@@ -774,7 +773,6 @@ func MakeDefaultRoles() map[string]*Role {
|
||||
PermissionGetPublicLink.Id,
|
||||
PermissionCreatePost.Id,
|
||||
PermissionUseChannelMentions.Id,
|
||||
PermissionUseSlashCommands.Id,
|
||||
PermissionManagePublicChannelProperties.Id,
|
||||
PermissionDeletePublicChannel.Id,
|
||||
PermissionManagePrivateChannelProperties.Id,
|
||||
|
||||
@@ -71,7 +71,6 @@ func TestRolePatchFromChannelModerationsPatch(t *testing.T) {
|
||||
PermissionManagePublicChannelMembers.Id,
|
||||
PermissionUploadFile.Id,
|
||||
PermissionGetPublicLink.Id,
|
||||
PermissionUseSlashCommands.Id,
|
||||
}
|
||||
|
||||
baseModeratedPermissions := []string{
|
||||
|
||||
@@ -251,6 +251,8 @@ type AppError struct {
|
||||
wrapped error
|
||||
}
|
||||
|
||||
const maxErrorLength = 1024
|
||||
|
||||
func (er *AppError) Error() string {
|
||||
var sb strings.Builder
|
||||
|
||||
@@ -276,7 +278,11 @@ func (er *AppError) Error() string {
|
||||
sb.WriteString(err.Error())
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
res := sb.String()
|
||||
if len(res) > maxErrorLength {
|
||||
res = res[:maxErrorLength] + "..."
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func (er *AppError) Translate(T i18n.TranslateFunc) {
|
||||
|
||||
@@ -116,6 +116,13 @@ func TestAppErrorRender(t *testing.T) {
|
||||
aerr := NewAppError("here", "message", nil, "details", http.StatusTeapot).Wrap(fmt.Errorf("my error (%w)", fmt.Errorf("inner error")))
|
||||
assert.EqualError(t, aerr, "here: message, details, my error (inner error)")
|
||||
})
|
||||
|
||||
t.Run("MaxLength", func(t *testing.T) {
|
||||
str := strings.Repeat("error", 65536)
|
||||
msg := "msg"
|
||||
aerr := NewAppError("id", msg, nil, str, http.StatusTeapot).Wrap(errors.New(str))
|
||||
assert.Len(t, aerr.Error(), maxErrorLength+len(msg))
|
||||
})
|
||||
}
|
||||
|
||||
func TestAppErrorSerialize(t *testing.T) {
|
||||
|
||||
@@ -69,7 +69,8 @@ type WorkTemplatePlaybook struct {
|
||||
}
|
||||
|
||||
type WorkTemplateIntegration struct {
|
||||
ID string `json:"id"`
|
||||
ID string `json:"id"`
|
||||
Recommended bool `json:"recommended"`
|
||||
}
|
||||
|
||||
type WorkTemplateContent struct {
|
||||
|
||||
@@ -628,7 +628,7 @@ func MergeInlineText(inlines []Inline) []Inline {
|
||||
}
|
||||
|
||||
func Unescape(markdown string) string {
|
||||
ret := ""
|
||||
var ret strings.Builder
|
||||
|
||||
position := 0
|
||||
for position < len(markdown) {
|
||||
@@ -637,27 +637,27 @@ func Unescape(markdown string) string {
|
||||
switch c {
|
||||
case '\\':
|
||||
if position+1 < len(markdown) && isEscapableByte(markdown[position+1]) {
|
||||
ret += string(markdown[position+1])
|
||||
ret.WriteByte(markdown[position+1])
|
||||
position += 2
|
||||
} else {
|
||||
ret += `\`
|
||||
ret.WriteString(`\`)
|
||||
position++
|
||||
}
|
||||
case '&':
|
||||
position++
|
||||
if semicolon := strings.IndexByte(markdown[position:], ';'); semicolon == -1 {
|
||||
ret += "&"
|
||||
ret.WriteString("&")
|
||||
} else if s := CharacterReference(markdown[position : position+semicolon]); s != "" {
|
||||
position += semicolon + 1
|
||||
ret += s
|
||||
ret.WriteString(s)
|
||||
} else {
|
||||
ret += "&"
|
||||
ret.WriteString("&")
|
||||
}
|
||||
default:
|
||||
ret += string(c)
|
||||
ret.WriteRune(c)
|
||||
position += cSize
|
||||
}
|
||||
}
|
||||
|
||||
return ret
|
||||
return ret.String()
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ info:
|
||||
servers:
|
||||
- url: http://localhost:8065/plugins/playbooks/api/v0
|
||||
paths:
|
||||
/runs:
|
||||
/plugins/playbooks/api/v0/runs:
|
||||
get:
|
||||
summary: List all playbook runs
|
||||
description: Retrieve a paged list of playbook runs, filtered by team, status, owner, name and/or members, and sorted by ID, name, status, creation date, end date, team or owner ID.
|
||||
@@ -198,7 +198,7 @@ paths:
|
||||
500:
|
||||
$ref: "#/components/responses/500"
|
||||
|
||||
/runs/dialog:
|
||||
/plugins/playbooks/api/v0/runs/dialog:
|
||||
post:
|
||||
summary: Create a new playbook run from dialog
|
||||
description: This is an internal endpoint to create a playbook run from the submission of an interactive dialog, filled by a user in the webapp. See [Interactive Dialogs](https://docs.mattermost.com/developer/interactive-dialogs.html) for more information.
|
||||
@@ -276,7 +276,7 @@ paths:
|
||||
500:
|
||||
$ref: "#/components/responses/500"
|
||||
|
||||
/runs/owners:
|
||||
/plugins/playbooks/api/v0/runs/owners:
|
||||
get:
|
||||
summary: Get all owners
|
||||
description: Get the owners of all playbook runs, filtered by team.
|
||||
@@ -314,7 +314,7 @@ paths:
|
||||
500:
|
||||
$ref: "#/components/responses/500"
|
||||
|
||||
/runs/channels:
|
||||
/plugins/playbooks/api/v0/runs/channels:
|
||||
get:
|
||||
summary: Get playbook run channels
|
||||
description: Get all channels associated with a playbook run, filtered by team, status, owner, name and/or members, and sorted by ID, name, status, creation date, end date, team, or owner ID.
|
||||
@@ -413,7 +413,7 @@ paths:
|
||||
500:
|
||||
$ref: "#/components/responses/500"
|
||||
|
||||
/runs/checklist-autocomplete:
|
||||
/plugins/playbooks/api/v0/runs/checklist-autocomplete:
|
||||
get:
|
||||
summary: Get autocomplete data for /playbook check
|
||||
description: This is an internal endpoint used by the autocomplete system to retrieve the data needed to show the list of items that the user can check.
|
||||
@@ -459,7 +459,7 @@ paths:
|
||||
500:
|
||||
$ref: "#/components/responses/500"
|
||||
|
||||
/runs/channel/{channel_id}:
|
||||
/plugins/playbooks/api/v0/runs/channel/{channel_id}:
|
||||
get:
|
||||
summary: Find playbook run by channel ID
|
||||
operationId: getPlaybookRunByChannelId
|
||||
@@ -492,7 +492,7 @@ paths:
|
||||
500:
|
||||
$ref: "#/components/responses/500"
|
||||
|
||||
/runs/{id}:
|
||||
/plugins/playbooks/api/v0/runs/{id}:
|
||||
get:
|
||||
summary: Get a playbook run
|
||||
operationId: getPlaybookRun
|
||||
@@ -565,7 +565,7 @@ paths:
|
||||
500:
|
||||
$ref: "#/components/responses/500"
|
||||
|
||||
/runs/{id}/metadata:
|
||||
/plugins/playbooks/api/v0/runs/{id}/metadata:
|
||||
get:
|
||||
summary: Get playbook run metadata
|
||||
operationId: getPlaybookRunMetadata
|
||||
@@ -598,7 +598,7 @@ paths:
|
||||
500:
|
||||
$ref: "#/components/responses/500"
|
||||
|
||||
/runs/{id}/end:
|
||||
/plugins/playbooks/api/v0/runs/{id}/end:
|
||||
put:
|
||||
summary: End a playbook run
|
||||
operationId: endPlaybookRun
|
||||
@@ -651,7 +651,7 @@ paths:
|
||||
500:
|
||||
$ref: "#/components/responses/500"
|
||||
|
||||
/runs/{id}/restart:
|
||||
/plugins/playbooks/api/v0/runs/{id}/restart:
|
||||
put:
|
||||
summary: Restart a playbook run
|
||||
operationId: restartPlaybookRun
|
||||
@@ -678,7 +678,7 @@ paths:
|
||||
500:
|
||||
$ref: "#/components/responses/500"
|
||||
|
||||
/runs/{id}/status:
|
||||
/plugins/playbooks/api/v0/runs/{id}/status:
|
||||
post:
|
||||
summary: Update a playbook run's status
|
||||
operationId: status
|
||||
@@ -728,7 +728,7 @@ paths:
|
||||
500:
|
||||
$ref: "#/components/responses/500"
|
||||
|
||||
/runs/{id}/finish:
|
||||
/plugins/playbooks/api/v0/runs/{id}/finish:
|
||||
put:
|
||||
summary: Finish a playbook
|
||||
operationId: finish
|
||||
@@ -755,7 +755,7 @@ paths:
|
||||
500:
|
||||
$ref: "#/components/responses/500"
|
||||
|
||||
/runs/{id}/owner:
|
||||
/plugins/playbooks/api/v0/runs/{id}/owner:
|
||||
post:
|
||||
summary: Update playbook run owner
|
||||
operationId: changeOwner
|
||||
@@ -800,7 +800,7 @@ paths:
|
||||
500:
|
||||
$ref: "#/components/responses/500"
|
||||
|
||||
/runs/{id}/next-stage-dialog:
|
||||
/plugins/playbooks/api/v0/runs/{id}/next-stage-dialog:
|
||||
post:
|
||||
summary: Go to next stage from dialog
|
||||
description: This is an internal endpoint to go to the next stage via a confirmation dialog, submitted by a user in the webapp.
|
||||
@@ -835,7 +835,7 @@ paths:
|
||||
500:
|
||||
$ref: "#/components/responses/500"
|
||||
|
||||
/runs/{id}/checklists/{checklist}/add:
|
||||
/plugins/playbooks/api/v0/runs/{id}/checklists/{checklist}/add:
|
||||
put:
|
||||
summary: Add an item to a playbook run's checklist
|
||||
description: The most common pattern to add a new item is to only send its title as the request payload. By default, it is an open item, with no assignee and no slash command.
|
||||
@@ -923,7 +923,7 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
|
||||
/runs/{id}/checklists/{checklist}/reorder:
|
||||
/plugins/playbooks/api/v0/runs/{id}/checklists/{checklist}/reorder:
|
||||
put:
|
||||
summary: Reorder an item in a playbook run's checklist
|
||||
operationId: reoderChecklistItem
|
||||
@@ -978,7 +978,7 @@ paths:
|
||||
500:
|
||||
$ref: "#/components/responses/500"
|
||||
|
||||
/runs/{id}/checklists/{checklist}/item/{item}:
|
||||
/plugins/playbooks/api/v0/runs/{id}/checklists/{checklist}/item/{item}:
|
||||
put:
|
||||
summary: Update an item of a playbook run's checklist
|
||||
description: Update the title and the slash command of an item in one of the playbook run's checklists.
|
||||
@@ -1083,7 +1083,7 @@ paths:
|
||||
500:
|
||||
$ref: "#/components/responses/500"
|
||||
|
||||
/runs/{id}/checklists/{checklist}/item/{item}/state:
|
||||
/plugins/playbooks/api/v0/runs/{id}/checklists/{checklist}/item/{item}/state:
|
||||
put:
|
||||
summary: Update the state of an item
|
||||
operationId: itemSetState
|
||||
@@ -1145,7 +1145,7 @@ paths:
|
||||
500:
|
||||
$ref: "#/components/responses/500"
|
||||
|
||||
/runs/{id}/checklists/{checklist}/item/{item}/assignee:
|
||||
/plugins/playbooks/api/v0/runs/{id}/checklists/{checklist}/item/{item}/assignee:
|
||||
put:
|
||||
summary: Update the assignee of an item
|
||||
operationId: itemSetAssignee
|
||||
@@ -1202,7 +1202,7 @@ paths:
|
||||
500:
|
||||
$ref: "#/components/responses/500"
|
||||
|
||||
/runs/{id}/checklists/{checklist}/item/{item}/run:
|
||||
/plugins/playbooks/api/v0/runs/{id}/checklists/{checklist}/item/{item}/run:
|
||||
put:
|
||||
summary: Run an item's slash command
|
||||
operationId: itemRun
|
||||
@@ -1249,7 +1249,7 @@ paths:
|
||||
500:
|
||||
$ref: "#/components/responses/500"
|
||||
|
||||
/runs/{id}/timeline/{event_id}/:
|
||||
/plugins/playbooks/api/v0/runs/{id}/timeline/{event_id}/:
|
||||
delete:
|
||||
summary: Remove a timeline event from the playbook run
|
||||
operationId: removeTimelineEvent
|
||||
@@ -1285,7 +1285,7 @@ paths:
|
||||
500:
|
||||
$ref: "#/components/responses/500"
|
||||
|
||||
/playbooks:
|
||||
/plugins/playbooks/api/v0/playbooks:
|
||||
get:
|
||||
summary: List all playbooks
|
||||
description: Retrieve a paged list of playbooks, filtered by team, and sorted by title, number of stages or number of steps.
|
||||
@@ -1562,7 +1562,7 @@ paths:
|
||||
500:
|
||||
$ref: "#/components/responses/500"
|
||||
|
||||
/playbooks/{id}:
|
||||
/plugins/playbooks/api/v0/playbooks/{id}:
|
||||
get:
|
||||
summary: Get a playbook
|
||||
operationId: getPlaybook
|
||||
@@ -1658,7 +1658,7 @@ paths:
|
||||
500:
|
||||
$ref: "#/components/responses/500"
|
||||
|
||||
/playbooks/{id}/autofollows:
|
||||
/plugins/playbooks/api/v0/playbooks/{id}/autofollows:
|
||||
get:
|
||||
summary: Get the list of followers' user IDs of a playbook
|
||||
operationId: getAutoFollows
|
||||
|
||||
@@ -10,14 +10,12 @@ import (
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/mattermost/mattermost-server/server/v8/model"
|
||||
"github.com/mattermost/mattermost-server/server/v8/playbooks/server/app"
|
||||
"github.com/mattermost/mattermost-server/server/v8/playbooks/server/config"
|
||||
"github.com/mattermost/mattermost-server/server/v8/playbooks/server/playbooks"
|
||||
"github.com/mattermost/mattermost-server/server/v8/playbooks/server/timeutils"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
@@ -693,14 +691,8 @@ func (h *PlaybookHandler) getTopPlaybooksForUser(c *Context, w http.ResponseWrit
|
||||
h.HandleErrorWithCode(w, c.logger, http.StatusBadRequest, "unable to get user", err)
|
||||
return
|
||||
}
|
||||
timezone, err := timeutils.GetUserTimezone(user)
|
||||
if err != nil {
|
||||
h.HandleErrorWithCode(w, c.logger, http.StatusBadRequest, "unable to get user timezone", err)
|
||||
return
|
||||
}
|
||||
if timezone == nil {
|
||||
timezone = time.Now().UTC().Location()
|
||||
}
|
||||
timezone := user.GetTimezoneLocation()
|
||||
|
||||
// get unix time for duration
|
||||
startTime, appErr := model.GetStartOfDayForTimeRange(timeRange, timezone)
|
||||
if appErr != nil {
|
||||
@@ -750,14 +742,8 @@ func (h *PlaybookHandler) getTopPlaybooksForTeam(c *Context, w http.ResponseWrit
|
||||
h.HandleErrorWithCode(w, c.logger, http.StatusBadRequest, "unable to get user", err)
|
||||
return
|
||||
}
|
||||
timezone, err := timeutils.GetUserTimezone(user)
|
||||
if err != nil {
|
||||
h.HandleErrorWithCode(w, c.logger, http.StatusBadRequest, "unable to get user timezone", err)
|
||||
return
|
||||
}
|
||||
if timezone == nil {
|
||||
timezone = time.Now().UTC().Location()
|
||||
}
|
||||
timezone := user.GetTimezoneLocation()
|
||||
|
||||
// get unix time for duration
|
||||
startTime, appErr := model.GetStartOfDayForTimeRange(timeRange, timezone)
|
||||
if appErr != nil {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import {Block, createBlock} from './block'
|
||||
|
||||
type AttachmentBlockFields = {
|
||||
attachmentId: string
|
||||
fileId: string
|
||||
}
|
||||
|
||||
type AttachmentBlock = Block & {
|
||||
@@ -18,7 +18,7 @@ function createAttachmentBlock(block?: Block): AttachmentBlock {
|
||||
...createBlock(block),
|
||||
type: 'attachment',
|
||||
fields: {
|
||||
attachmentId: block?.fields.attachmentId || '',
|
||||
fileId: block?.fields.attachmentId || block?.fields.fileId || '',
|
||||
},
|
||||
isUploading: false,
|
||||
uploadingPercent: 0,
|
||||
|
||||
@@ -151,7 +151,7 @@ const CardDialog = (props: Props): JSX.Element => {
|
||||
Utils.selectLocalFile(async (attachment) => {
|
||||
const uploadingBlock = createBlock()
|
||||
uploadingBlock.title = attachment.name
|
||||
uploadingBlock.fields.attachmentId = attachment.name
|
||||
uploadingBlock.fields.fileId = attachment.name
|
||||
uploadingBlock.boardId = boardId
|
||||
if (card) {
|
||||
uploadingBlock.parentId = card.id
|
||||
@@ -177,11 +177,11 @@ const CardDialog = (props: Props): JSX.Element => {
|
||||
xhr.onload = () => {
|
||||
if (xhr.status === 200 && xhr.readyState === 4) {
|
||||
const json = JSON.parse(xhr.response)
|
||||
const attachmentId = json.fileId
|
||||
if (attachmentId) {
|
||||
const fileId = json.fileId
|
||||
if (fileId) {
|
||||
removeUploadingAttachment(uploadingBlock)
|
||||
const block = createAttachmentBlock()
|
||||
block.fields.attachmentId = attachmentId || ''
|
||||
block.fields.fileId = fileId || ''
|
||||
block.title = attachment.name
|
||||
sendFlashMessage({content: intl.formatMessage({id: 'AttachmentBlock.uploadSuccess', defaultMessage: 'Attachment uploaded.'}), severity: 'normal'})
|
||||
resolve(block)
|
||||
|
||||
@@ -39,7 +39,7 @@ describe('component/content/FileBlock', () => {
|
||||
type: 'attachment',
|
||||
title: 'test-title',
|
||||
fields: {
|
||||
attachmentId: 'test.txt',
|
||||
fileId: 'test.txt',
|
||||
},
|
||||
createdBy: 'test-user-id',
|
||||
createAt: 0,
|
||||
|
||||
@@ -50,7 +50,7 @@ const AttachmentElement = (props: Props): JSX.Element|null => {
|
||||
})
|
||||
return
|
||||
}
|
||||
const attachmentInfo = await octoClient.getFileInfo(block.boardId, block.fields.attachmentId)
|
||||
const attachmentInfo = await octoClient.getFileInfo(block.boardId, block.fields.fileId)
|
||||
setFileInfo(attachmentInfo)
|
||||
}
|
||||
loadFile()
|
||||
@@ -113,7 +113,7 @@ const AttachmentElement = (props: Props): JSX.Element|null => {
|
||||
}
|
||||
|
||||
const attachmentDownloadHandler = async () => {
|
||||
const attachment = await octoClient.getFileAsDataUrl(block.boardId, block.fields.attachmentId)
|
||||
const attachment = await octoClient.getFileAsDataUrl(block.boardId, block.fields.fileId)
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = attachment.url || ''
|
||||
anchor.download = fileInfo.name || ''
|
||||
|
||||
@@ -25,6 +25,7 @@ import CompassIcon from 'src/widgets/icons/compassIcon'
|
||||
import OptionsIcon from 'src/widgets/icons/options'
|
||||
import Menu from 'src/widgets/menu'
|
||||
import MenuWrapper from 'src/widgets/menuWrapper'
|
||||
import {UserSettings} from 'src/userSettings'
|
||||
|
||||
import './sidebarCategory.scss'
|
||||
import {Category, CategoryBoardMetadata, CategoryBoards} from 'src/store/sidebar'
|
||||
@@ -202,12 +203,24 @@ const SidebarCategory = (props: Props) => {
|
||||
setTimeout(() => {
|
||||
showBoard(props.boards[nextBoardId as number].id)
|
||||
}, 120)
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
const newPath = generatePath('/team/:teamId', {teamId: teamID,})
|
||||
history.push(newPath)
|
||||
}, 120)
|
||||
}
|
||||
},
|
||||
async () => {
|
||||
showBoard(deleteBoard.id)
|
||||
},
|
||||
)
|
||||
if (
|
||||
UserSettings.lastBoardId &&
|
||||
UserSettings.lastBoardId[deleteBoard.teamId] == deleteBoard.id
|
||||
) {
|
||||
UserSettings.setLastBoardID(deleteBoard.teamId, null)
|
||||
UserSettings.setLastViewId(deleteBoard.id, null)
|
||||
}
|
||||
}, [showBoard, deleteBoard, props.boards])
|
||||
|
||||
const updateCategory = useCallback(async (value: boolean) => {
|
||||
|
||||
@@ -186,7 +186,9 @@ const BoardPage = (props: Props): JSX.Element => {
|
||||
const joinBoard = async (myUser: IUser, boardTeamId: string, boardId: string, allowAdmin: boolean) => {
|
||||
const member = await octoClient.joinBoard(boardId, allowAdmin)
|
||||
if (!member) {
|
||||
if (myUser.permissions?.find((s) => s === 'manage_system' || s === 'manage_team')) {
|
||||
// if allowAdmin is true, then we failed to join the board
|
||||
// as an admin, normally, this is deleted/missing board
|
||||
if (!allowAdmin && myUser.permissions?.find((s) => s === 'manage_system' || s === 'manage_team')) {
|
||||
setShowJoinBoardDialog(true)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -12,8 +12,8 @@ import {
|
||||
import {logout, loadMe, loadMeREST} from 'mattermost-redux/actions/users';
|
||||
import {Preferences} from 'mattermost-redux/constants';
|
||||
import {getConfig, isPerformanceDebuggingEnabled} from 'mattermost-redux/selectors/entities/general';
|
||||
import {getCurrentTeamId, getMyTeams, getTeam, getMyTeamMember, getTeamMemberships} from 'mattermost-redux/selectors/entities/teams';
|
||||
import {getBool, isCollapsedThreadsEnabled, isGraphQLEnabled} from 'mattermost-redux/selectors/entities/preferences';
|
||||
import {getCurrentTeamId, getMyTeams, getTeam, getMyTeamMember, getTeamMemberships, getActiveTeamsList} from 'mattermost-redux/selectors/entities/teams';
|
||||
import {getBool, getIsOnboardingFlowEnabled, isCollapsedThreadsEnabled, isGraphQLEnabled} from 'mattermost-redux/selectors/entities/preferences';
|
||||
import {getCurrentUser, getCurrentUserId, isFirstAdmin} from 'mattermost-redux/selectors/entities/users';
|
||||
import {getCurrentChannelStats, getCurrentChannelId, getMyChannelMember, getRedirectChannelNameForTeam, getChannelsNameMapInTeam, getAllDirectChannels, getChannelMessageCount} from 'mattermost-redux/selectors/entities/channels';
|
||||
import {appsEnabled} from 'mattermost-redux/selectors/entities/apps';
|
||||
@@ -352,7 +352,7 @@ export async function redirectUserToDefaultTeam() {
|
||||
// Assume we need to load the user if they don't have any team memberships loaded or the user loaded
|
||||
let user = getCurrentUser(state);
|
||||
const shouldLoadUser = Utils.isEmptyObject(getTeamMemberships(state)) || !user;
|
||||
|
||||
const onboardingFlowEnabled = getIsOnboardingFlowEnabled(state);
|
||||
if (shouldLoadUser) {
|
||||
if (isGraphQLEnabled(state)) {
|
||||
await dispatch(loadMe());
|
||||
@@ -374,8 +374,9 @@ export async function redirectUserToDefaultTeam() {
|
||||
const teamId = LocalStorageStore.getPreviousTeamId(user.id);
|
||||
|
||||
let myTeams = getMyTeams(state);
|
||||
if (myTeams.length === 0) {
|
||||
if (isUserFirstAdmin) {
|
||||
const teams = getActiveTeamsList(state);
|
||||
if (teams.length === 0) {
|
||||
if (isUserFirstAdmin && onboardingFlowEnabled) {
|
||||
getHistory().push('/preparing-workspace');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users';
|
||||
import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams';
|
||||
|
||||
import {selectLhsItem} from 'actions/views/lhs';
|
||||
import {GlobalState} from 'types/store';
|
||||
import {LhsItemType, LhsPage} from 'types/store/lhs';
|
||||
|
||||
import {CardSizes, InsightsWidgetTypes, TimeFrame, TimeFrames} from '@mattermost/types/insights';
|
||||
@@ -41,17 +40,20 @@ type SelectOption = {
|
||||
|
||||
const Insights = () => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
// check if either of focalboard plugin or boards product is enabled
|
||||
const focalboardPluginEnabled = useSelector((state: GlobalState) => state.plugins.plugins?.focalboard);
|
||||
let focalboardProductEnabled = false;
|
||||
const products = useProducts();
|
||||
if (products) {
|
||||
focalboardProductEnabled = products.some((product) => product.pluginId === suitePluginIds.focalboard || product.pluginId === suitePluginIds.boards);
|
||||
}
|
||||
const focalboardEnabled = focalboardPluginEnabled || focalboardProductEnabled;
|
||||
|
||||
const playbooksEnabled = useSelector((state: GlobalState) => state.plugins.plugins?.playbooks);
|
||||
let focalboardEnabled = false;
|
||||
let playbooksEnabled = false;
|
||||
if (products) {
|
||||
products.forEach((product) => {
|
||||
if (product.pluginId === suitePluginIds.boards) {
|
||||
focalboardEnabled = true;
|
||||
} else if (product.pluginId === suitePluginIds.playbooks) {
|
||||
playbooksEnabled = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const currentUserId = useSelector(getCurrentUserId);
|
||||
const currentTeamId = useSelector(getCurrentTeamId);
|
||||
|
||||
|
||||
@@ -59,13 +59,13 @@ const ContactSalesCard = (props: Props) => {
|
||||
title = (
|
||||
<FormattedMessage
|
||||
id='admin.billing.subscription.privateCloudCard.cloudEnterprise.title'
|
||||
defaultMessage='Looking for an annual discount? '
|
||||
defaultMessage='Looking to rollout Mattermost for your entire organization? '
|
||||
/>
|
||||
);
|
||||
description = (
|
||||
<FormattedMessage
|
||||
id='admin.billing.subscription.privateCloudCard.cloudEnterprise.description'
|
||||
defaultMessage='At Mattermost, we work with you and your team to meet your needs throughout the product. If you are looking for an annual discount, please reach out to our sales team.'
|
||||
defaultMessage='At Mattermost, we work with you and your organization to meet your needs throughout the product. If you’re considering a wider rollout, talk to us.'
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
@@ -103,13 +103,13 @@ const ContactSalesCard = (props: Props) => {
|
||||
title = (
|
||||
<FormattedMessage
|
||||
id='admin.billing.subscription.privateCloudCard.cloudEnterprise.title'
|
||||
defaultMessage='Looking for an annual discount? '
|
||||
defaultMessage='Looking to rollout Mattermost for your entire organization? '
|
||||
/>
|
||||
);
|
||||
description = (
|
||||
<FormattedMessage
|
||||
id='admin.billing.subscription.privateCloudCard.cloudEnterprise.description'
|
||||
defaultMessage='At Mattermost, we work with you and your team to meet your needs throughout the product. If you are looking for an annual discount, please reach out to our sales team.'
|
||||
defaultMessage='At Mattermost, we work with you and your organization to meet your needs throughout the product. If you’re considering a wider rollout, talk to us.'
|
||||
/>
|
||||
);
|
||||
break;
|
||||
|
||||
@@ -163,7 +163,7 @@ const ToYearlyNudgeBannerDismissable = () => {
|
||||
type={announcementType}
|
||||
showCloseButton={daysToProMonthlyEnd > 10}
|
||||
onButtonClick={() => openPurchaseModal({trackingLocation: 'to_yearly_nudge_annoucement_bar'})}
|
||||
modalButtonText={t('cloud_billing.nudge_to_yearly.learn_more')}
|
||||
modalButtonText={t('cloud_billing.nudge_to_yearly.update_billing')}
|
||||
modalButtonDefaultText='Update billing'
|
||||
message={message}
|
||||
showLinkAsButton={true}
|
||||
|
||||
@@ -55,7 +55,7 @@ export const noBillingHistory = (
|
||||
</div>
|
||||
);
|
||||
|
||||
export const freeTrial = (onUpgradeMattermostCloud: (callerInfo: string) => void, daysLeftOnTrial: number) => (
|
||||
export const freeTrial = (onUpgradeMattermostCloud: (callerInfo: string) => void, daysLeftOnTrial: number, reverseTrial: boolean) => (
|
||||
<div className='UpgradeMattermostCloud'>
|
||||
<div className='UpgradeMattermostCloud__image'>
|
||||
<UpgradeSvg
|
||||
@@ -104,10 +104,21 @@ export const freeTrial = (onUpgradeMattermostCloud: (callerInfo: string) => void
|
||||
onClick={() => onUpgradeMattermostCloud('billing_summary_free_trial_upgrade_button')}
|
||||
className='UpgradeMattermostCloud__upgradeButton'
|
||||
>
|
||||
<FormattedMessage
|
||||
id='admin.billing.subscription.cloudTrial.subscribeButton'
|
||||
defaultMessage='Upgrade Now'
|
||||
/>
|
||||
{
|
||||
reverseTrial ? (
|
||||
<FormattedMessage
|
||||
id='admin.billing.subscription.cloudTrial.purchaseButton'
|
||||
defaultMessage='Purchase Now'
|
||||
/>
|
||||
|
||||
) : (
|
||||
<FormattedMessage
|
||||
id='admin.billing.subscription.cloudTrial.subscribeButton'
|
||||
defaultMessage='Upgrade Now'
|
||||
/>
|
||||
)
|
||||
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -5,6 +5,7 @@ import React from 'react';
|
||||
import {useSelector} from 'react-redux';
|
||||
|
||||
import {getSubscriptionProduct, checkHadPriorTrial, getCloudSubscription} from 'mattermost-redux/selectors/entities/cloud';
|
||||
import {cloudReverseTrial} from 'mattermost-redux/selectors/entities/preferences';
|
||||
|
||||
import {CloudProducts} from 'utils/constants';
|
||||
|
||||
@@ -27,20 +28,23 @@ type BillingSummaryProps = {
|
||||
const BillingSummary = ({isFreeTrial, daysLeftOnTrial, onUpgradeMattermostCloud}: BillingSummaryProps) => {
|
||||
const subscription = useSelector(getCloudSubscription);
|
||||
const product = useSelector(getSubscriptionProduct);
|
||||
const reverseTrial = useSelector(cloudReverseTrial);
|
||||
|
||||
let body = noBillingHistory;
|
||||
|
||||
const isPreTrial = subscription?.is_free_trial === 'false' && subscription?.trial_end_at === 0;
|
||||
const hasPriorTrial = useSelector(checkHadPriorTrial);
|
||||
const showTryEnterprise = product?.sku === CloudProducts.STARTER && isPreTrial;
|
||||
const showUpgradeProfessional = product?.sku === CloudProducts.STARTER && hasPriorTrial;
|
||||
const isStarterPreTrial = product?.sku === CloudProducts.STARTER && isPreTrial;
|
||||
const isStarterPostTrial = product?.sku === CloudProducts.STARTER && hasPriorTrial;
|
||||
|
||||
if (showTryEnterprise) {
|
||||
if (isStarterPreTrial && reverseTrial) {
|
||||
body = <UpgradeToProfessionalCard/>;
|
||||
} else if (isStarterPreTrial) {
|
||||
body = tryEnterpriseCard;
|
||||
} else if (showUpgradeProfessional) {
|
||||
} else if (isStarterPostTrial) {
|
||||
body = <UpgradeToProfessionalCard/>;
|
||||
} else if (isFreeTrial) {
|
||||
body = freeTrial(onUpgradeMattermostCloud, daysLeftOnTrial);
|
||||
body = freeTrial(onUpgradeMattermostCloud, daysLeftOnTrial, reverseTrial);
|
||||
} else if (subscription?.last_invoice && !subscription?.upcoming_invoice) {
|
||||
const invoice = subscription.last_invoice;
|
||||
const fullCharges = invoice.line_items.filter((item) => item.type === 'full');
|
||||
|
||||
@@ -12,6 +12,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
&__Icon {
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
&__Title {
|
||||
color: var(--sys-denim-center-channel-text);
|
||||
font-family: Metropolis;
|
||||
@@ -21,15 +25,17 @@
|
||||
}
|
||||
|
||||
&__Usage {
|
||||
color: var(--center-channel-color);
|
||||
text-align: left;
|
||||
|
||||
&-Highlighted {
|
||||
color: black;
|
||||
font-weight: 700;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
&__Warning {
|
||||
color: var(--center-channel-color);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
|
||||
@@ -186,8 +186,8 @@ export default function DeleteWorkspaceModal(props: Props) {
|
||||
className='DeleteWorkspaceModal'
|
||||
onExited={handleClickCancel}
|
||||
>
|
||||
<div>
|
||||
<LaptopAlertSVG/>
|
||||
<div className='DeleteWorkspaceModal__Icon'>
|
||||
<LaptopAlertSVG height={156}/>
|
||||
</div>
|
||||
<div className='DeleteWorkspaceModal__Title'>
|
||||
<FormattedMessage
|
||||
@@ -198,7 +198,7 @@ export default function DeleteWorkspaceModal(props: Props) {
|
||||
<div className='DeleteWorkspaceModal__Usage'>
|
||||
<FormattedMessage
|
||||
id='admin.billing.subscription.deleteWorkspaceModal.usage'
|
||||
defaultMessage='As part of your paid subscription to Mattermost {product_name} you have created '
|
||||
defaultMessage='As part of your subscription to Mattermost {sku} you have created '
|
||||
values={{
|
||||
sku: product?.name,
|
||||
}}
|
||||
|
||||
@@ -31,6 +31,7 @@ describe('components/feature_discovery', () => {
|
||||
hadPrevCloudTrial={false}
|
||||
isSubscriptionLoaded={true}
|
||||
isPaidSubscription={false}
|
||||
cloudFreeDeprecated={false}
|
||||
actions={{
|
||||
getPrevTrialLicense: jest.fn(),
|
||||
getCloudSubscription: jest.fn(),
|
||||
@@ -58,6 +59,7 @@ describe('components/feature_discovery', () => {
|
||||
isCloudTrial={false}
|
||||
hadPrevCloudTrial={false}
|
||||
isPaidSubscription={false}
|
||||
cloudFreeDeprecated={false}
|
||||
isSubscriptionLoaded={true}
|
||||
actions={{
|
||||
getPrevTrialLicense: jest.fn(),
|
||||
@@ -87,6 +89,7 @@ describe('components/feature_discovery', () => {
|
||||
isCloudTrial={false}
|
||||
hadPrevCloudTrial={false}
|
||||
isSubscriptionLoaded={false}
|
||||
cloudFreeDeprecated={false}
|
||||
isPaidSubscription={false}
|
||||
actions={{
|
||||
getPrevTrialLicense: jest.fn(),
|
||||
|
||||
@@ -59,6 +59,7 @@ type Props = {
|
||||
isSubscriptionLoaded: boolean;
|
||||
isPaidSubscription: boolean;
|
||||
customer?: CloudCustomer;
|
||||
cloudFreeDeprecated: boolean;
|
||||
}
|
||||
|
||||
type State = {
|
||||
@@ -205,6 +206,23 @@ export default class FeatureDiscovery extends React.PureComponent<Props, State>
|
||||
extraClass='btn btn-primary'
|
||||
/>
|
||||
);
|
||||
if (this.props.cloudFreeDeprecated) {
|
||||
ctaPrimaryButton = (
|
||||
<button
|
||||
className='btn btn-primary'
|
||||
data-testid='featureDiscovery_primaryCallToAction'
|
||||
onClick={() => {
|
||||
trackEvent(TELEMETRY_CATEGORIES.SELF_HOSTED_ADMIN, 'click_enterprise_contact_sales_feature_discovery');
|
||||
this.contactSalesFunc();
|
||||
}}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='admin.ldap_feature_discovery_cloud.call_to_action.primary_sales'
|
||||
defaultMessage='Contact sales'
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
} else if (hadPrevCloudTrial) {
|
||||
// if it is cloud, but this account already had a free trial, then the cta button must be Upgrade now
|
||||
ctaPrimaryButton = (
|
||||
@@ -259,7 +277,7 @@ export default class FeatureDiscovery extends React.PureComponent<Props, State>
|
||||
/>
|
||||
</ExternalLink>
|
||||
{gettingTrialError}
|
||||
{(!this.props.isCloud || canRequestCloudFreeTrial) && <p className='trial-legal-terms'>
|
||||
{((!this.props.isCloud || canRequestCloudFreeTrial) && !this.props.cloudFreeDeprecated) && <p className='trial-legal-terms'>
|
||||
{canRequestCloudFreeTrial ? (
|
||||
<FormattedMessage
|
||||
id='admin.feature_discovery.trial-request.accept-terms.cloudFree'
|
||||
|
||||
@@ -9,6 +9,7 @@ import {getCloudSubscription} from 'mattermost-redux/actions/cloud';
|
||||
import {Action, GenericAction} from 'mattermost-redux/types/actions';
|
||||
import {checkHadPriorTrial, getCloudCustomer} from 'mattermost-redux/selectors/entities/cloud';
|
||||
import {getLicense} from 'mattermost-redux/selectors/entities/general';
|
||||
import {deprecateCloudFree} from 'mattermost-redux/selectors/entities/preferences';
|
||||
|
||||
import {ModalData} from 'types/actions';
|
||||
import {GlobalState} from 'types/store';
|
||||
@@ -29,6 +30,7 @@ function mapStateToProps(state: GlobalState) {
|
||||
const hasPriorTrial = checkHadPriorTrial(state);
|
||||
const isCloudTrial = subscription?.is_free_trial === 'true';
|
||||
const customer = getCloudCustomer(state);
|
||||
const cloudFreeDeprecated = deprecateCloudFree(state);
|
||||
return {
|
||||
stats: state.entities.admin.analytics,
|
||||
prevTrialLicense: state.entities.admin.prevTrialLicense,
|
||||
@@ -38,6 +40,7 @@ function mapStateToProps(state: GlobalState) {
|
||||
hadPrevCloudTrial: hasPriorTrial,
|
||||
isPaidSubscription: isCloud && license?.SkuShortName !== LicenseSkus.Starter && !isCloudTrial,
|
||||
customer,
|
||||
cloudFreeDeprecated,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import {screen} from '@testing-library/react';
|
||||
import {renderWithIntlAndStore} from 'tests/react_testing_utils';
|
||||
import * as cloudActions from 'mattermost-redux/actions/cloud';
|
||||
|
||||
import {CloudProducts} from 'utils/constants';
|
||||
|
||||
import PaymentAnnouncementBar from './';
|
||||
|
||||
jest.mock('mattermost-redux/actions/cloud', () => {
|
||||
const original = jest.requireActual('mattermost-redux/actions/cloud');
|
||||
return {
|
||||
...original,
|
||||
__esModule: true,
|
||||
|
||||
// just testing that it fired, not that the result updated or anything like that
|
||||
getCloudCustomer: jest.fn(() => ({type: 'bogus'})),
|
||||
};
|
||||
});
|
||||
|
||||
describe('PaymentAnnouncementBar', () => {
|
||||
const happyPathStore = {
|
||||
entities: {
|
||||
users: {
|
||||
currentUserId: 'me',
|
||||
profiles: {
|
||||
me: {
|
||||
roles: 'system_admin',
|
||||
},
|
||||
},
|
||||
},
|
||||
general: {
|
||||
license: {
|
||||
Cloud: 'true',
|
||||
},
|
||||
},
|
||||
cloud: {
|
||||
subscription: {
|
||||
product_id: 'prod_something',
|
||||
last_invoice: {
|
||||
status: 'failed',
|
||||
},
|
||||
},
|
||||
customer: {
|
||||
payment_method: {
|
||||
exp_month: 12,
|
||||
exp_year: (new Date()).getFullYear() + 1,
|
||||
},
|
||||
},
|
||||
products: {
|
||||
prod_something: {
|
||||
id: 'prod_something',
|
||||
sku: CloudProducts.PROFESSIONAL,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
views: {
|
||||
announcementBar: {
|
||||
announcementBarState: {
|
||||
announcementBarCount: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
it('when most recent payment failed, shows that', () => {
|
||||
renderWithIntlAndStore(<PaymentAnnouncementBar/>, happyPathStore);
|
||||
screen.getByText('Your most recent payment failed');
|
||||
});
|
||||
|
||||
it('when card is expired, shows that', () => {
|
||||
const store = JSON.parse(JSON.stringify(happyPathStore));
|
||||
store.entities.cloud.customer.payment_method.exp_year = (new Date()).getFullYear() - 1;
|
||||
store.entities.cloud.subscription.last_invoice.status = 'success';
|
||||
renderWithIntlAndStore(<PaymentAnnouncementBar/>, store);
|
||||
screen.getByText('Your credit card has expired', {exact: false});
|
||||
});
|
||||
|
||||
it('when needed, fetches, customer', () => {
|
||||
const store = JSON.parse(JSON.stringify(happyPathStore));
|
||||
store.entities.cloud.customer = null;
|
||||
store.entities.cloud.subscription.last_invoice.status = 'success';
|
||||
renderWithIntlAndStore(<PaymentAnnouncementBar/>, store);
|
||||
expect(cloudActions.getCloudCustomer).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('when not an admin, does not fetch customer', () => {
|
||||
const store = JSON.parse(JSON.stringify(happyPathStore));
|
||||
store.entities.users.profiles.me.roles = '';
|
||||
renderWithIntlAndStore(<PaymentAnnouncementBar/>, store);
|
||||
expect(cloudActions.getCloudCustomer).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,53 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {connect} from 'react-redux';
|
||||
import {bindActionCreators, Dispatch} from 'redux';
|
||||
|
||||
import {savePreferences} from 'mattermost-redux/actions/preferences';
|
||||
import {getLicense} from 'mattermost-redux/selectors/entities/general';
|
||||
import {GenericAction} from 'mattermost-redux/types/actions';
|
||||
import {getCloudSubscription, getCloudCustomer} from 'mattermost-redux/actions/cloud';
|
||||
|
||||
import {isCurrentUserSystemAdmin} from 'mattermost-redux/selectors/entities/users';
|
||||
import {
|
||||
getCloudSubscription as selectCloudSubscription,
|
||||
getCloudCustomer as selectCloudCustomer,
|
||||
getSubscriptionProduct,
|
||||
} from 'mattermost-redux/selectors/entities/cloud';
|
||||
import {CloudProducts} from 'utils/constants';
|
||||
|
||||
import {openModal} from 'actions/views/modals';
|
||||
|
||||
import {GlobalState} from 'types/store';
|
||||
|
||||
import PaymentAnnouncementBar from './payment_announcement_bar';
|
||||
|
||||
function mapStateToProps(state: GlobalState) {
|
||||
const subscription = selectCloudSubscription(state);
|
||||
const customer = selectCloudCustomer(state);
|
||||
const subscriptionProduct = getSubscriptionProduct(state);
|
||||
return {
|
||||
userIsAdmin: isCurrentUserSystemAdmin(state),
|
||||
isCloud: getLicense(state).Cloud === 'true',
|
||||
subscription,
|
||||
customer,
|
||||
isStarterFree: subscriptionProduct?.sku === CloudProducts.STARTER,
|
||||
};
|
||||
}
|
||||
|
||||
function mapDispatchToProps(dispatch: Dispatch<GenericAction>) {
|
||||
return {
|
||||
actions: bindActionCreators(
|
||||
{
|
||||
savePreferences,
|
||||
openModal,
|
||||
getCloudSubscription,
|
||||
getCloudCustomer,
|
||||
},
|
||||
dispatch,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(PaymentAnnouncementBar);
|
||||
@@ -0,0 +1,89 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useEffect, useState} from 'react';
|
||||
import {FormattedMessage} from 'react-intl';
|
||||
import {useSelector, useDispatch} from 'react-redux';
|
||||
import {isEmpty} from 'lodash';
|
||||
|
||||
import {DispatchFunc} from 'mattermost-redux/types/actions';
|
||||
import {getCloudCustomer} from 'mattermost-redux/actions/cloud';
|
||||
import {getLicense} from 'mattermost-redux/selectors/entities/general';
|
||||
import {
|
||||
getCloudSubscription as selectCloudSubscription,
|
||||
getCloudCustomer as selectCloudCustomer,
|
||||
getSubscriptionProduct,
|
||||
} from 'mattermost-redux/selectors/entities/cloud';
|
||||
import {isCurrentUserSystemAdmin} from 'mattermost-redux/selectors/entities/users';
|
||||
|
||||
import {getHistory} from 'utils/browser_history';
|
||||
import {isCustomerCardExpired} from 'utils/cloud_utils';
|
||||
import {AnnouncementBarTypes, CloudProducts, ConsolePages} from 'utils/constants';
|
||||
import {t} from 'utils/i18n';
|
||||
|
||||
import AnnouncementBar from '../default_announcement_bar';
|
||||
|
||||
export default function PaymentAnnouncementBar() {
|
||||
const [requestedCustomer, setRequestedCustomer] = useState(false);
|
||||
const dispatch = useDispatch<DispatchFunc>();
|
||||
const subscription = useSelector(selectCloudSubscription);
|
||||
const customer = useSelector(selectCloudCustomer);
|
||||
const isStarterFree = useSelector(getSubscriptionProduct)?.sku === CloudProducts.STARTER;
|
||||
const userIsAdmin = useSelector(isCurrentUserSystemAdmin);
|
||||
const isCloud = useSelector(getLicense).Cloud === 'true';
|
||||
|
||||
useEffect(() => {
|
||||
if (isCloud && !isStarterFree && isEmpty(customer) && userIsAdmin && !requestedCustomer) {
|
||||
setRequestedCustomer(true);
|
||||
dispatch(getCloudCustomer());
|
||||
}
|
||||
},
|
||||
[isCloud, isStarterFree, customer, userIsAdmin, requestedCustomer]);
|
||||
|
||||
const mostRecentPaymentFailed = subscription?.last_invoice?.status === 'failed';
|
||||
|
||||
if (
|
||||
// Prevents banner flashes if the subscription hasn't been loaded yet
|
||||
isEmpty(subscription) ||
|
||||
isStarterFree ||
|
||||
!isCloud ||
|
||||
!userIsAdmin ||
|
||||
isEmpty(customer) ||
|
||||
(!isCustomerCardExpired(customer) && !mostRecentPaymentFailed)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const updatePaymentInfo = () => {
|
||||
getHistory().push(ConsolePages.PAYMENT_INFO);
|
||||
};
|
||||
|
||||
let message = (
|
||||
<FormattedMessage
|
||||
id='admin.billing.subscription.creditCardExpired'
|
||||
defaultMessage='Your credit card has expired. Update your payment information to avoid disruption.'
|
||||
/>
|
||||
);
|
||||
|
||||
if (mostRecentPaymentFailed) {
|
||||
message = (
|
||||
<FormattedMessage
|
||||
id='admin.billing.subscription.mostRecentPaymentFailed'
|
||||
defaultMessage='Your most recent payment failed'
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AnnouncementBar
|
||||
type={AnnouncementBarTypes.CRITICAL}
|
||||
showCloseButton={false}
|
||||
onButtonClick={updatePaymentInfo}
|
||||
modalButtonText={t('admin.billing.subscription.updatePaymentInfo')}
|
||||
modalButtonDefaultText={'Update payment info'}
|
||||
message={message}
|
||||
showLinkAsButton={true}
|
||||
isTallBanner={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import {isEmpty} from 'lodash';
|
||||
|
||||
import {CloudCustomer, Subscription} from '@mattermost/types/cloud';
|
||||
|
||||
import {getHistory} from 'utils/browser_history';
|
||||
import {isCustomerCardExpired} from 'utils/cloud_utils';
|
||||
import {AnnouncementBarTypes} from 'utils/constants';
|
||||
import {t} from 'utils/i18n';
|
||||
|
||||
import AnnouncementBar from '../default_announcement_bar';
|
||||
|
||||
type Props = {
|
||||
userIsAdmin: boolean;
|
||||
isCloud: boolean;
|
||||
subscription?: Subscription;
|
||||
customer?: CloudCustomer;
|
||||
isStarterFree: boolean;
|
||||
actions: {
|
||||
getCloudSubscription: () => void;
|
||||
getCloudCustomer: () => void;
|
||||
};
|
||||
};
|
||||
|
||||
class PaymentAnnouncementBar extends React.PureComponent<Props> {
|
||||
async componentDidMount() {
|
||||
if (isEmpty(this.props.customer)) {
|
||||
await this.props.actions.getCloudCustomer();
|
||||
}
|
||||
}
|
||||
|
||||
isMostRecentPaymentFailed = () => {
|
||||
return this.props.subscription?.last_invoice?.status === 'failed';
|
||||
};
|
||||
|
||||
shouldShowBanner = () => {
|
||||
const {userIsAdmin, isCloud, subscription} = this.props;
|
||||
|
||||
// Prevents banner flashes if the subscription hasn't been loaded yet
|
||||
if (subscription === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.props.isStarterFree) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isCloud) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!userIsAdmin) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isCustomerCardExpired(this.props.customer) && !this.isMostRecentPaymentFailed()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
updatePaymentInfo = () => {
|
||||
getHistory().push('/admin_console/billing/payment_info');
|
||||
};
|
||||
|
||||
render() {
|
||||
if (isEmpty(this.props.customer) || isEmpty(this.props.subscription)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!this.shouldShowBanner()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<AnnouncementBar
|
||||
type={AnnouncementBarTypes.CRITICAL}
|
||||
showCloseButton={false}
|
||||
onButtonClick={this.updatePaymentInfo}
|
||||
modalButtonText={t('admin.billing.subscription.updatePaymentInfo')}
|
||||
modalButtonDefaultText={'Update payment info'}
|
||||
message={this.isMostRecentPaymentFailed() ? t('admin.billing.subscription.mostRecentPaymentFailed') : t('admin.billing.subscription.creditCardExpired')}
|
||||
showLinkAsButton={true}
|
||||
isTallBanner={true}
|
||||
/>
|
||||
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default PaymentAnnouncementBar;
|
||||
@@ -6,6 +6,7 @@ import {useIntl} from 'react-intl';
|
||||
import {useSelector, useDispatch} from 'react-redux';
|
||||
import {useLocation, useHistory} from 'react-router-dom';
|
||||
|
||||
import {redirectUserToDefaultTeam} from 'actions/global_actions';
|
||||
import {trackEvent} from 'actions/telemetry_actions.jsx';
|
||||
|
||||
import LaptopAlertSVG from 'components/common/svg_images_components/laptop_alert_svg';
|
||||
@@ -14,6 +15,7 @@ import LoadingScreen from 'components/loading_screen';
|
||||
|
||||
import {clearErrors, logError} from 'mattermost-redux/actions/errors';
|
||||
import {verifyUserEmail, getMe} from 'mattermost-redux/actions/users';
|
||||
import {getIsOnboardingFlowEnabled} from 'mattermost-redux/selectors/entities/preferences';
|
||||
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users';
|
||||
import {DispatchFunc} from 'mattermost-redux/types/actions';
|
||||
|
||||
@@ -38,6 +40,7 @@ const DoVerifyEmail = () => {
|
||||
const token = params.get('token') ?? '';
|
||||
|
||||
const loggedIn = Boolean(useSelector(getCurrentUserId));
|
||||
const onboardingFlowEnabled = useSelector(getIsOnboardingFlowEnabled);
|
||||
|
||||
const [verifyStatus, setVerifyStatus] = useState(VerifyStatus.PENDING);
|
||||
const [serverError, setServerError] = useState('');
|
||||
@@ -49,11 +52,15 @@ const DoVerifyEmail = () => {
|
||||
|
||||
const handleRedirect = () => {
|
||||
if (loggedIn) {
|
||||
// need info about whether admin or not,
|
||||
// and whether admin has already completed
|
||||
// first time onboarding. Instead of fetching and orchestrating that here,
|
||||
// let the default root component handle it.
|
||||
history.push('/');
|
||||
if (onboardingFlowEnabled) {
|
||||
// need info about whether admin or not,
|
||||
// and whether admin has already completed
|
||||
// first time onboarding. Instead of fetching and orchestrating that here,
|
||||
// let the default root component handle it.
|
||||
history.push('/');
|
||||
return;
|
||||
}
|
||||
redirectUserToDefaultTeam();
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ Object {
|
||||
aria-controls="CENTER_dropdown_post_id_1"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="true"
|
||||
aria-label="Actions"
|
||||
aria-label="more"
|
||||
class="post-menu__item"
|
||||
data-testid="PostDotMenu-Button-post_id_1"
|
||||
id="CENTER_button_post_id_1"
|
||||
@@ -34,7 +34,7 @@ Object {
|
||||
aria-controls="CENTER_dropdown_post_id_1"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="true"
|
||||
aria-label="Actions"
|
||||
aria-label="more"
|
||||
class="post-menu__item"
|
||||
data-testid="PostDotMenu-Button-post_id_1"
|
||||
id="CENTER_button_post_id_1"
|
||||
@@ -121,7 +121,7 @@ exports[`components/dot_menu/DotMenu should match snapshot, on Center 1`] = `
|
||||
}
|
||||
menuButton={
|
||||
Object {
|
||||
"aria-label": "Actions",
|
||||
"aria-label": "more",
|
||||
"children": <DotsHorizontalIcon
|
||||
size={16}
|
||||
/>,
|
||||
|
||||
@@ -497,7 +497,7 @@ export class DotMenuClass extends React.PureComponent<Props, State> {
|
||||
class: classNames('post-menu__item', {
|
||||
'post-menu__item--active': this.props.isMenuOpen,
|
||||
}),
|
||||
'aria-label': formatMessage({id: 'post_info.dot_menu.tooltip.actions', defaultMessage: 'Actions'}),
|
||||
'aria-label': formatMessage({id: 'post_info.dot_menu.tooltip.more', defaultMessage: 'More'}).toLowerCase(),
|
||||
children: <DotsHorizontalIcon size={16}/>,
|
||||
}}
|
||||
menu={{
|
||||
|
||||
@@ -13,6 +13,7 @@ import {checkHadPriorTrial} from 'mattermost-redux/selectors/entities/cloud';
|
||||
import {isCurrentUserSystemAdmin} from 'mattermost-redux/selectors/entities/users';
|
||||
import {getLicense} from 'mattermost-redux/selectors/entities/general';
|
||||
import {getPrevTrialLicense} from 'mattermost-redux/actions/admin';
|
||||
import {deprecateCloudFree} from 'mattermost-redux/selectors/entities/preferences';
|
||||
|
||||
import CloudStartTrialButton from 'components/cloud_start_trial/cloud_start_trial_btn';
|
||||
import StartTrialBtn from 'components/learn_more_trial_modal/start_trial_btn';
|
||||
@@ -59,6 +60,7 @@ const FeatureRestrictedModal = ({
|
||||
dispatch(getPrevTrialLicense());
|
||||
}, []);
|
||||
|
||||
const cloudFreeDeprecated = useSelector(deprecateCloudFree);
|
||||
const hasCloudPriorTrial = useSelector(checkHadPriorTrial);
|
||||
const prevTrialLicense = useSelector((state: GlobalState) => state.entities.admin.prevTrialLicense);
|
||||
const hasSelfHostedPriorTrial = prevTrialLicense.IsLicensed === 'true';
|
||||
@@ -100,7 +102,7 @@ const FeatureRestrictedModal = ({
|
||||
|
||||
const getTitle = () => {
|
||||
if (isSystemAdmin) {
|
||||
return hasPriorTrial ? titleAdminPostTrial : titleAdminPreTrial;
|
||||
return (hasPriorTrial || cloudFreeDeprecated) ? titleAdminPostTrial : titleAdminPreTrial;
|
||||
}
|
||||
|
||||
return titleEndUser;
|
||||
@@ -108,13 +110,13 @@ const FeatureRestrictedModal = ({
|
||||
|
||||
const getMessage = () => {
|
||||
if (isSystemAdmin) {
|
||||
return hasPriorTrial ? messageAdminPostTrial : messageAdminPreTrial;
|
||||
return (hasPriorTrial || cloudFreeDeprecated) ? messageAdminPostTrial : messageAdminPreTrial;
|
||||
}
|
||||
|
||||
return messageEndUser;
|
||||
};
|
||||
|
||||
const showStartTrial = isSystemAdmin && !hasPriorTrial;
|
||||
const showStartTrial = isSystemAdmin && !hasPriorTrial && !cloudFreeDeprecated;
|
||||
|
||||
// define what is the secondary button text and action, by default will be the View Plan button
|
||||
let secondaryBtnMsg = formatMessage({id: 'feature_restricted_modal.button.plans', defaultMessage: 'View plans'});
|
||||
|
||||
@@ -8,6 +8,7 @@ import {withRouter} from 'react-router-dom';
|
||||
import {getConfig} from 'mattermost-redux/selectors/entities/general';
|
||||
import {GenericAction} from 'mattermost-redux/types/actions';
|
||||
import {getCurrentRelativeTeamUrl} from 'mattermost-redux/selectors/entities/teams';
|
||||
import {getIsOnboardingFlowEnabled} from 'mattermost-redux/selectors/entities/preferences';
|
||||
import {isFirstAdmin} from 'mattermost-redux/selectors/entities/users';
|
||||
|
||||
import {getUserGuideDropdownPluginMenuItems} from 'selectors/plugins';
|
||||
@@ -31,6 +32,7 @@ function mapStateToProps(state: GlobalState) {
|
||||
teamUrl: getCurrentRelativeTeamUrl(state),
|
||||
pluginMenuItems: getUserGuideDropdownPluginMenuItems(state),
|
||||
isFirstAdmin: isFirstAdmin(state),
|
||||
onboardingFlowEnabled: getIsOnboardingFlowEnabled(state),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ describe('components/channel_header/components/UserGuideDropdown', () => {
|
||||
},
|
||||
pluginMenuItems: [],
|
||||
isFirstAdmin: false,
|
||||
onboardingFlowEnabled: false,
|
||||
};
|
||||
|
||||
test('should match snapshot', () => {
|
||||
|
||||
@@ -14,6 +14,7 @@ import {isCurrentUserSystemAdmin} from 'mattermost-redux/selectors/entities/user
|
||||
import {getSubscriptionProduct, checkHadPriorTrial} from 'mattermost-redux/selectors/entities/cloud';
|
||||
import {DispatchFunc} from 'mattermost-redux/types/actions';
|
||||
import {getPrevTrialLicense} from 'mattermost-redux/actions/admin';
|
||||
import {deprecateCloudFree} from 'mattermost-redux/selectors/entities/preferences';
|
||||
|
||||
import {closeModal, openModal} from 'actions/views/modals';
|
||||
|
||||
@@ -43,6 +44,7 @@ export type Props = {
|
||||
export default function InviteAs(props: Props) {
|
||||
const {formatMessage} = useIntl();
|
||||
const license = useSelector(getLicense);
|
||||
const cloudFreeDeprecated = useSelector(deprecateCloudFree);
|
||||
const dispatch = useDispatch<DispatchFunc>();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -85,7 +87,7 @@ export default function InviteAs(props: Props) {
|
||||
if (isFreeTrial) {
|
||||
ctaExtraContentMsg = formatMessage({id: 'free.professional_feature.professional', defaultMessage: 'Professional feature'});
|
||||
} else {
|
||||
ctaExtraContentMsg = hasPriorTrial ? formatMessage({id: 'free.professional_feature.upgrade', defaultMessage: 'Upgrade'}) : formatMessage({id: 'free.professional_feature.try_free', defaultMessage: 'Professional feature- try it out free'});
|
||||
ctaExtraContentMsg = (hasPriorTrial || cloudFreeDeprecated) ? formatMessage({id: 'free.professional_feature.upgrade', defaultMessage: 'Upgrade'}) : formatMessage({id: 'free.professional_feature.try_free', defaultMessage: 'Professional feature- try it out free'});
|
||||
}
|
||||
|
||||
const restrictedIndicator = (
|
||||
|
||||
@@ -33,6 +33,12 @@ describe('components/learn_more_trial_modal/learn_more_trial_modal', () => {
|
||||
entities: {
|
||||
users: {
|
||||
currentUserId: 'current_user_id',
|
||||
profiles: {
|
||||
current_user_id: {
|
||||
id: 'current_user_id',
|
||||
roles: '',
|
||||
},
|
||||
},
|
||||
},
|
||||
admin: {
|
||||
analytics: {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useCallback, useEffect, useMemo, useState} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
import {FormattedMessage, useIntl} from 'react-intl';
|
||||
import {useSelector, useDispatch} from 'react-redux';
|
||||
|
||||
import {trackEvent} from 'actions/telemetry_actions';
|
||||
@@ -16,10 +16,13 @@ import MonitorImacLikeSVG from 'components/common/svg_images_components/monitor_
|
||||
import SystemRolesSVG from 'components/admin_console/feature_discovery/features/images/system_roles_svg';
|
||||
import CloudStartTrialButton from 'components/cloud_start_trial/cloud_start_trial_btn';
|
||||
import {BtnStyle} from 'components/common/carousel/carousel_button';
|
||||
import useOpenSalesLink from 'components/common/hooks/useOpenSalesLink';
|
||||
import ExternalLink from 'components/external_link';
|
||||
|
||||
import {closeModal} from 'actions/views/modals';
|
||||
import {DispatchFunc} from 'mattermost-redux/types/actions';
|
||||
import {getLicense} from 'mattermost-redux/selectors/entities/general';
|
||||
import {deprecateCloudFree} from 'mattermost-redux/selectors/entities/preferences';
|
||||
|
||||
import StartTrialBtn from './start_trial_btn';
|
||||
|
||||
@@ -43,8 +46,11 @@ const LearnMoreTrialModal = (
|
||||
const [embargoed, setEmbargoed] = useState(false);
|
||||
const dispatch = useDispatch<DispatchFunc>();
|
||||
|
||||
const [, salesLink] = useOpenSalesLink();
|
||||
|
||||
// Cloud conditions
|
||||
const license = useSelector(getLicense);
|
||||
const cloudFreeDeprecated = useSelector(deprecateCloudFree);
|
||||
const isCloud = license?.Cloud === 'true';
|
||||
|
||||
const handleEmbargoError = useCallback(() => {
|
||||
@@ -78,6 +84,20 @@ const LearnMoreTrialModal = (
|
||||
extraClass={'btn btn-primary start-cloud-trial-btn'}
|
||||
/>
|
||||
);
|
||||
if (cloudFreeDeprecated) {
|
||||
startTrialBtn = (
|
||||
<ExternalLink
|
||||
location='learn_more_trial_modal'
|
||||
href={salesLink}
|
||||
className='btn btn-primary start-cloud-trial-btn'
|
||||
>
|
||||
<FormattedMessage
|
||||
id='learn_more_trial_modal.contact_sales'
|
||||
defaultMessage='Contact sales'
|
||||
/>
|
||||
</ExternalLink>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const handleOnClose = useCallback(() => {
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import {useSelector} from 'react-redux';
|
||||
import {FormattedMessage} from 'react-intl';
|
||||
|
||||
import TrialBenefitsModalStepMore from 'components/trial_benefits_modal/trial_benefits_modal_step_more';
|
||||
import {deprecateCloudFree} from 'mattermost-redux/selectors/entities/preferences';
|
||||
|
||||
import './learn_more_trial_modal_step.scss';
|
||||
import {AboutLinks, LicenseLinks} from 'utils/constants';
|
||||
@@ -35,6 +36,7 @@ const LearnMoreTrialModalStep = (
|
||||
buttonLabel,
|
||||
handleOnClose,
|
||||
}: LearnMoreTrialModalStepProps) => {
|
||||
const cloudFreeDeprecated = useSelector(deprecateCloudFree);
|
||||
return (
|
||||
<div
|
||||
id={`learnMoreTrialModalStep-${id}`}
|
||||
@@ -59,32 +61,36 @@ const LearnMoreTrialModalStep = (
|
||||
telemetryId={'learn_more_trial_modal'}
|
||||
/>
|
||||
)}
|
||||
<div className='disclaimer'>
|
||||
<span>
|
||||
<FormattedMessage
|
||||
id='start_trial.modal.disclaimer'
|
||||
defaultMessage='By clicking “Start trial”, I agree to the <linkEvaluation>Mattermost Software and Services License Agreement</linkEvaluation>, <linkPrivacy>privacy policy</linkPrivacy> and receiving product emails.'
|
||||
values={{
|
||||
linkEvaluation: (msg: React.ReactNode) => (
|
||||
<ExternalLink
|
||||
href={LicenseLinks.SOFTWARE_SERVICES_LICENSE_AGREEMENT}
|
||||
location='learn_more_trial_modal_step'
|
||||
>
|
||||
{msg}
|
||||
</ExternalLink>
|
||||
),
|
||||
linkPrivacy: (msg: React.ReactNode) => (
|
||||
<ExternalLink
|
||||
href={AboutLinks.PRIVACY_POLICY}
|
||||
location='learn_more_trial_modal_step'
|
||||
>
|
||||
{msg}
|
||||
</ExternalLink>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
{
|
||||
cloudFreeDeprecated ? '' : (
|
||||
<div className='disclaimer'>
|
||||
<span>
|
||||
<FormattedMessage
|
||||
id='start_trial.modal.disclaimer'
|
||||
defaultMessage='By clicking “Start trial”, I agree to the <linkEvaluation>Mattermost Software and Services License Agreement</linkEvaluation>, <linkPrivacy>privacy policy</linkPrivacy> and receiving product emails.'
|
||||
values={{
|
||||
linkEvaluation: (msg: React.ReactNode) => (
|
||||
<ExternalLink
|
||||
href={LicenseLinks.SOFTWARE_SERVICES_LICENSE_AGREEMENT}
|
||||
location='learn_more_trial_modal_step'
|
||||
>
|
||||
{msg}
|
||||
</ExternalLink>
|
||||
),
|
||||
linkPrivacy: (msg: React.ReactNode) => (
|
||||
<ExternalLink
|
||||
href={AboutLinks.PRIVACY_POLICY}
|
||||
location='learn_more_trial_modal_step'
|
||||
>
|
||||
{msg}
|
||||
</ExternalLink>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{bottomLeftMessage && (
|
||||
<div className='bottom-text-left-message'>
|
||||
{bottomLeftMessage}
|
||||
|
||||
@@ -13,7 +13,7 @@ import {UserProfile} from '@mattermost/types/users';
|
||||
|
||||
import {Client4} from 'mattermost-redux/client';
|
||||
import {getConfig, getLicense} from 'mattermost-redux/selectors/entities/general';
|
||||
import {isGraphQLEnabled} from 'mattermost-redux/selectors/entities/preferences';
|
||||
import {getIsOnboardingFlowEnabled, isGraphQLEnabled} from 'mattermost-redux/selectors/entities/preferences';
|
||||
import {getTeamByName, getMyTeamMember} from 'mattermost-redux/selectors/entities/teams';
|
||||
import {getCurrentUser} from 'mattermost-redux/selectors/entities/users';
|
||||
import {isSystemAdmin} from 'mattermost-redux/utils/user_utils';
|
||||
@@ -104,6 +104,7 @@ const Login = ({onCustomizeHeader}: LoginProps) => {
|
||||
const currentUser = useSelector(getCurrentUser);
|
||||
const experimentalPrimaryTeam = useSelector((state: GlobalState) => (ExperimentalPrimaryTeam ? getTeamByName(state, ExperimentalPrimaryTeam) : undefined));
|
||||
const experimentalPrimaryTeamMember = useSelector((state: GlobalState) => getMyTeamMember(state, experimentalPrimaryTeam?.id ?? ''));
|
||||
const onboardingFlowEnabled = useSelector(getIsOnboardingFlowEnabled);
|
||||
const isCloud = useSelector(isCurrentLicenseCloud);
|
||||
const graphQLEnabled = useSelector(isGraphQLEnabled);
|
||||
|
||||
@@ -634,12 +635,14 @@ const Login = ({onCustomizeHeader}: LoginProps) => {
|
||||
} else if (experimentalPrimaryTeamMember.team_id) {
|
||||
// Only set experimental team if user is on that team
|
||||
history.push(`/${ExperimentalPrimaryTeam}`);
|
||||
} else {
|
||||
} else if (onboardingFlowEnabled) {
|
||||
// need info about whether admin or not,
|
||||
// and whether admin has already completed
|
||||
// first time onboarding. Instead of fetching and orchestrating that here,
|
||||
// let the default root component handle it.
|
||||
history.push('/');
|
||||
} else {
|
||||
redirectUserToDefaultTeam();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
getBool,
|
||||
isCollapsedThreadsEnabled,
|
||||
} from 'mattermost-redux/selectors/entities/preferences';
|
||||
import {getCurrentTeam, getCurrentTeamId, getTeam, getTeamMemberships} from 'mattermost-redux/selectors/entities/teams';
|
||||
import {getCurrentTeam, getTeam, getTeamMemberships} from 'mattermost-redux/selectors/entities/teams';
|
||||
import {getCurrentUserId, getUser} from 'mattermost-redux/selectors/entities/users';
|
||||
|
||||
import {Emoji} from '@mattermost/types/emojis';
|
||||
@@ -48,7 +48,6 @@ interface OwnProps {
|
||||
post?: Post | UserActivityPost;
|
||||
previousPostId?: string;
|
||||
postId?: string;
|
||||
teamId?: string;
|
||||
shouldHighlight?: boolean;
|
||||
location: keyof typeof Locations;
|
||||
}
|
||||
@@ -120,7 +119,6 @@ function makeMapStateToProps() {
|
||||
const config = getConfig(state);
|
||||
const enableEmojiPicker = config.EnableEmojiPicker === 'true';
|
||||
const enablePostUsernameOverride = config.EnablePostUsernameOverride === 'true';
|
||||
const teamId = ownProps.teamId || getCurrentTeamId(state);
|
||||
const channel = state.entities.channels.channels[post.channel_id];
|
||||
const shortcutReactToLastPostEmittedFrom = getShortcutReactToLastPostEmittedFrom(state);
|
||||
|
||||
@@ -148,6 +146,7 @@ function makeMapStateToProps() {
|
||||
}
|
||||
|
||||
const currentTeam = getCurrentTeam(state);
|
||||
const team = getTeam(state, channel.team_id);
|
||||
let teamName = currentTeam.name;
|
||||
let teamDisplayName = '';
|
||||
|
||||
@@ -159,7 +158,6 @@ function makeMapStateToProps() {
|
||||
!isDMorGM && // Not show for DM or GMs since they don't belong to a team
|
||||
memberships && Object.values(memberships).length > 1 // Not show if the user only belongs to one team
|
||||
) {
|
||||
const team = getTeam(state, channel.team_id);
|
||||
teamDisplayName = team?.display_name;
|
||||
teamName = team?.name || currentTeam.name;
|
||||
}
|
||||
@@ -186,7 +184,6 @@ function makeMapStateToProps() {
|
||||
enablePostUsernameOverride,
|
||||
isEmbedVisible: isEmbedVisible(state, post.id),
|
||||
isReadOnly: false,
|
||||
teamId,
|
||||
currentUserId: getCurrentUserId(state),
|
||||
isFirstReply: previousPost ? isFirstReply(post, previousPost) : false,
|
||||
hasReplies: getReplyCount(state, post) > 0,
|
||||
@@ -200,7 +197,8 @@ function makeMapStateToProps() {
|
||||
compactDisplay: get(state, Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.MESSAGE_DISPLAY, Preferences.MESSAGE_DISPLAY_DEFAULT) === Preferences.MESSAGE_DISPLAY_COMPACT,
|
||||
colorizeUsernames: get(state, Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.COLORIZE_USERNAMES, Preferences.COLORIZE_USERNAMES_DEFAULT) === 'true',
|
||||
shouldShowActionsMenu: shouldShowActionsMenu(state, post),
|
||||
|
||||
currentTeam,
|
||||
team,
|
||||
shortcutReactToLastPostEmittedFrom,
|
||||
isBot,
|
||||
collapsedThreadsEnabled: isCollapsedThreadsEnabled(state),
|
||||
|
||||
@@ -50,10 +50,12 @@ import {Emoji} from '@mattermost/types/emojis';
|
||||
|
||||
import PostUserProfile from './user_profile';
|
||||
import PostOptions from './post_options';
|
||||
import {Team} from '@mattermost/types/teams';
|
||||
|
||||
export type Props = {
|
||||
post: Post;
|
||||
teamId: string;
|
||||
currentTeam: Team;
|
||||
team?: Team;
|
||||
currentUserId: string;
|
||||
compactDisplay?: boolean;
|
||||
colorizeUsernames?: boolean;
|
||||
@@ -123,6 +125,7 @@ const PostComponent = (props: Props): JSX.Element => {
|
||||
const isRHS = props.location === Locations.RHS_ROOT || props.location === Locations.RHS_COMMENT || props.location === Locations.SEARCH;
|
||||
const postRef = useRef<HTMLDivElement>(null);
|
||||
const postHeaderRef = useRef<HTMLDivElement>(null);
|
||||
const teamId = props.team?.id || props.currentTeam.id;
|
||||
|
||||
const [hover, setHover] = useState(false);
|
||||
const [a11yActive, setA11y] = useState(false);
|
||||
@@ -355,7 +358,15 @@ const PostComponent = (props: Props): JSX.Element => {
|
||||
return;
|
||||
}
|
||||
props.actions.selectPostFromRightHandSideSearch(post);
|
||||
}, [post, props.actions]);
|
||||
}, [post, props.actions, props.actions.selectPostFromRightHandSideSearch]);
|
||||
|
||||
const handleThreadClick = useCallback((e: React.MouseEvent) => {
|
||||
if (props.currentTeam.id === props.team?.id) {
|
||||
handleCommentClick(e);
|
||||
} else {
|
||||
handleJumpClick(e);
|
||||
}
|
||||
}, [handleCommentClick, handleJumpClick]);
|
||||
|
||||
const postClass = classNames('post__body', {'post--edited': PostUtils.isEdited(post), 'search-item-snippet': isSearchResultItem});
|
||||
|
||||
@@ -435,7 +446,7 @@ const PostComponent = (props: Props): JSX.Element => {
|
||||
const threadFooter = props.location !== Locations.RHS_ROOT && props.isCollapsedThreadsEnabled && !post.root_id && (props.hasReplies || post.is_following) ? (
|
||||
<ThreadFooter
|
||||
threadId={post.id}
|
||||
replyClick={handleCommentClick}
|
||||
replyClick={handleThreadClick}
|
||||
/>
|
||||
) : null;
|
||||
const currentPostDay = getDateForUnixTicks(post.create_at);
|
||||
@@ -538,6 +549,7 @@ const PostComponent = (props: Props): JSX.Element => {
|
||||
{((!hideProfilePicture && props.location === Locations.CENTER) || hover || props.location !== Locations.CENTER) &&
|
||||
<PostTime
|
||||
isPermalink={!(Posts.POST_DELETED === post.state || isPostPendingOrFailed(post))}
|
||||
teamName={props.team?.name}
|
||||
eventTime={post.create_at}
|
||||
postId={post.id}
|
||||
location={props.location}
|
||||
@@ -577,6 +589,7 @@ const PostComponent = (props: Props): JSX.Element => {
|
||||
{!props.isPostBeingEdited &&
|
||||
<PostOptions
|
||||
{...props}
|
||||
teamId={teamId}
|
||||
setActionsMenuInitialisationState={props.actions.setActionsMenuInitialisationState}
|
||||
handleDropdownOpened={handleDropdownOpened}
|
||||
handleCommentClick={handleCommentClick}
|
||||
|
||||
@@ -95,7 +95,11 @@ const PostUserProfile = (props: Props): JSX.Element | null => {
|
||||
/>
|
||||
);
|
||||
|
||||
botIndicator = (<BotTag/>);
|
||||
// user profile component checks and add bot tag in case webhook is from bot account, but if webhook is from user account we need this.
|
||||
|
||||
if (!isBot) {
|
||||
botIndicator = (<BotTag/>);
|
||||
}
|
||||
} else if (isFromAutoResponder) {
|
||||
userProfile = (
|
||||
<span className='auto-responder'>
|
||||
|
||||
@@ -71,7 +71,145 @@ exports[`InviteMembers component should match snapshot 1`] = `
|
||||
</div>
|
||||
<div
|
||||
class="PageLine PageLine--no-left"
|
||||
style="margin-top: 50px; margin-left: 50px; height: calc(30vh);"
|
||||
style="margin-top: 50px; margin-left: 50px; height: calc(35vh);"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`InviteMembers component should match snapshot when it is cloud 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="InviteMembers-body test-class"
|
||||
>
|
||||
<div
|
||||
class="SingleColumnLayout"
|
||||
style="width: 547px;"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
class="PageLine PageLine--no-left"
|
||||
style="margin-bottom: 50px; margin-left: 50px; height: calc(25vh);"
|
||||
/>
|
||||
<div>
|
||||
Previous step
|
||||
</div>
|
||||
<h1
|
||||
class="PreparingWorkspaceTitle"
|
||||
>
|
||||
<span>
|
||||
Who works with you?
|
||||
</span>
|
||||
</h1>
|
||||
<p
|
||||
class="PreparingWorkspaceDescription"
|
||||
>
|
||||
<span>
|
||||
Collaboration is tough by yourself. Invite a few team members. Separate each email address with a space or comma.
|
||||
</span>
|
||||
</p>
|
||||
<div
|
||||
class="PreparingWorkspacePageBody"
|
||||
>
|
||||
<div
|
||||
class="UsersEmailsInput empty no-selections css-2b097c-container"
|
||||
>
|
||||
<span
|
||||
aria-live="assertive"
|
||||
class="css-1laao21-a11yText"
|
||||
>
|
||||
<p
|
||||
id="aria-selection-event"
|
||||
>
|
||||
|
||||
|
||||
</p>
|
||||
<p
|
||||
id="aria-context"
|
||||
>
|
||||
|
||||
0 results available. Use Up and Down to choose options, press Enter to select the currently focused option, press Escape to exit the menu, press Tab to select the option and exit the menu.
|
||||
</p>
|
||||
</span>
|
||||
<div
|
||||
class="users-emails-input__control users-emails-input__control--is-focused users-emails-input__control--menu-is-open css-1pahdxg-control"
|
||||
>
|
||||
<div
|
||||
class="users-emails-input__value-container users-emails-input__value-container--is-multi css-1hwfws3"
|
||||
>
|
||||
<div
|
||||
class="users-emails-input__placeholder css-1lxtzh0-placeholder"
|
||||
>
|
||||
Enter email addresses
|
||||
</div>
|
||||
<div
|
||||
class="css-gftnqw-Input"
|
||||
>
|
||||
<div
|
||||
class="users-emails-input__input"
|
||||
style="display: inline-block;"
|
||||
>
|
||||
<input
|
||||
aria-autocomplete="list"
|
||||
aria-label="Invite People"
|
||||
autocapitalize="none"
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
id="react-select-2-input"
|
||||
spellcheck="false"
|
||||
style="box-sizing: content-box; width: 2px; border: 0px; opacity: 1; outline: 0; padding: 0px;"
|
||||
tabindex="0"
|
||||
type="text"
|
||||
value=""
|
||||
/>
|
||||
<div
|
||||
style="position: absolute; top: 0px; left: 0px; visibility: hidden; height: 0px; overflow: scroll; white-space: pre; font-family: -webkit-small-control; letter-spacing: normal; text-transform: none;"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="users-emails-input__menu css-26l3qy-menu"
|
||||
>
|
||||
<div
|
||||
class="users-emails-input__menu-list users-emails-input__menu-list--is-multi css-4ljt47-MenuList"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="InviteMembers__submit"
|
||||
>
|
||||
<button
|
||||
class="primary-button"
|
||||
disabled=""
|
||||
>
|
||||
<span>
|
||||
Send invites
|
||||
</span>
|
||||
</button>
|
||||
<div
|
||||
class="InviteMembersLink"
|
||||
>
|
||||
<button
|
||||
class="InviteMembersLink__button--single"
|
||||
data-testid="shareLinkInputButton"
|
||||
>
|
||||
<i
|
||||
class="icon icon-content-copy"
|
||||
/>
|
||||
<span>
|
||||
Copy Link
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="PageLine PageLine--no-left"
|
||||
style="margin-top: 50px; margin-left: 50px; height: calc(35vh);"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,26 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`components/preparing-workspace/invite_members_link should match snapshot 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="InviteMembersLink"
|
||||
>
|
||||
<button
|
||||
class="InviteMembersLink__button--single"
|
||||
data-testid="shareLinkInputButton"
|
||||
>
|
||||
<i
|
||||
class="icon icon-content-copy"
|
||||
/>
|
||||
<span>
|
||||
Copy Link
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`components/preparing-workspace/invite_members_link should match snapshot when displayed including the input field 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="InviteMembersLink"
|
||||
|
||||
@@ -1,23 +1,70 @@
|
||||
@import 'utils/mixins';
|
||||
|
||||
// UX decision to show no more than about 5 1/4 lines of users/emails at a time.
|
||||
$less-than-6-user-lines-height: 227px;
|
||||
|
||||
.InviteMembers-body {
|
||||
display: flex;
|
||||
// page width - channels preview width - progress dots width - people overlap width
|
||||
max-width: calc(100vw - 600px - 120px - 30px);
|
||||
|
||||
.PreparingWorkspaceDescription span {
|
||||
display: inline-block;
|
||||
max-width: 530px;
|
||||
}
|
||||
|
||||
.UsersEmailsInput {
|
||||
max-width: 420px;
|
||||
max-width: 540px;
|
||||
|
||||
&.no-selections {
|
||||
// placeholder and input position is difficult to change.
|
||||
// This overrides the default positioning of the input & placeholders
|
||||
// to make the taller than normal input look ok when nothing has yet been selected
|
||||
.users-emails-input__value-container {
|
||||
margin-top: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.users-emails-input__control {
|
||||
overflow: auto;
|
||||
min-height: 108px;
|
||||
max-height: $less-than-6-user-lines-height;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.InviteMembers {
|
||||
&__submit {
|
||||
display: flex;
|
||||
width: 400px;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.InviteMembersLink__button--single {
|
||||
width: fit-content;
|
||||
min-width: 148px;
|
||||
margin-right: 8px;
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
.fade-in-skip-button {
|
||||
animation: fade-in 2s forwards;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@include simple-in-and-out-before("InviteMembers");
|
||||
|
||||
.ChannelsPreview--enter-from-after {
|
||||
|
||||
@@ -9,6 +9,7 @@ import InviteMembers from './invite_members';
|
||||
|
||||
describe('InviteMembers component', () => {
|
||||
let defaultProps: ComponentProps<any>;
|
||||
const setEmailsFn = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
defaultProps = {
|
||||
@@ -21,8 +22,12 @@ describe('InviteMembers component', () => {
|
||||
onPageView: jest.fn(),
|
||||
previous: <div>{'Previous step'}</div>,
|
||||
next: jest.fn(),
|
||||
setEmails: setEmailsFn,
|
||||
show: true,
|
||||
transitionDirection: 'forward',
|
||||
inferredProtocol: null,
|
||||
isSelfHosted: true,
|
||||
emails: [],
|
||||
};
|
||||
});
|
||||
|
||||
@@ -32,6 +37,17 @@ describe('InviteMembers component', () => {
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should match snapshot when it is cloud', () => {
|
||||
const component = withIntl(
|
||||
<InviteMembers
|
||||
{...defaultProps}
|
||||
isSelfHosted={false}
|
||||
/>,
|
||||
);
|
||||
const {container} = render(component);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders invite URL', () => {
|
||||
const component = withIntl(<InviteMembers {...defaultProps}/>);
|
||||
render(component);
|
||||
@@ -68,4 +84,16 @@ describe('InviteMembers component', () => {
|
||||
fireEvent.click(button);
|
||||
expect(defaultProps.next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows send invites button when in cloud', () => {
|
||||
const component = withIntl(
|
||||
<InviteMembers
|
||||
{...defaultProps}
|
||||
isSelfHosted={false}
|
||||
/>,
|
||||
);
|
||||
render(component);
|
||||
const button = screen.getByRole('button', {name: 'Send invites'});
|
||||
expect(button).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useMemo, useEffect} from 'react';
|
||||
import React, {useState, useMemo, useEffect} from 'react';
|
||||
import {CSSTransition} from 'react-transition-group';
|
||||
import {FormattedMessage} from 'react-intl';
|
||||
import {FormattedMessage, useIntl} from 'react-intl';
|
||||
|
||||
import {UserProfile} from '@mattermost/types/users';
|
||||
|
||||
import {t} from 'utils/i18n';
|
||||
import {Constants} from 'utils/constants';
|
||||
|
||||
import UsersEmailsInput from 'components/widgets/inputs/users_emails_input';
|
||||
|
||||
import {Animations, mapAnimationReasonToClass, Form, PreparingWorkspacePageProps} from './steps';
|
||||
|
||||
@@ -12,20 +19,30 @@ import Description from './description';
|
||||
import PageBody from './page_body';
|
||||
import SingleColumnLayout from './single_column_layout';
|
||||
|
||||
import InviteMembersLink from './invite_members_link';
|
||||
import PageLine from './page_line';
|
||||
import InviteMembersLink from './invite_members_link';
|
||||
|
||||
import './invite_members.scss';
|
||||
|
||||
type Props = PreparingWorkspacePageProps & {
|
||||
disableEdits: boolean;
|
||||
className?: string;
|
||||
teamInviteId?: string;
|
||||
emails: Form['teamMembers']['invites'];
|
||||
setEmails: (emails: Form['teamMembers']['invites']) => void;
|
||||
teamInviteId: string;
|
||||
formUrl: Form['url'];
|
||||
configSiteUrl?: string;
|
||||
browserSiteUrl: string;
|
||||
inferredProtocol: 'http' | 'https' | null;
|
||||
isSelfHosted: boolean;
|
||||
show: boolean;
|
||||
}
|
||||
|
||||
const InviteMembers = (props: Props) => {
|
||||
const [email, setEmail] = useState('');
|
||||
const [showSkipButton, setShowSkipButton] = useState(false);
|
||||
|
||||
const {formatMessage} = useIntl();
|
||||
let className = 'InviteMembers-body';
|
||||
if (props.className) {
|
||||
className += ' ' + props.className;
|
||||
@@ -33,6 +50,30 @@ const InviteMembers = (props: Props) => {
|
||||
|
||||
useEffect(props.onPageView, []);
|
||||
|
||||
useEffect(() => {
|
||||
setShowSkipButton(false);
|
||||
const timer = setTimeout(() => {
|
||||
setShowSkipButton(true);
|
||||
}, 3000);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [props.show]);
|
||||
|
||||
const placeholder = formatMessage({
|
||||
id: 'onboarding_wizard.invite_members.placeholder',
|
||||
defaultMessage: 'Enter email addresses',
|
||||
});
|
||||
const errorProperties = {
|
||||
showError: false,
|
||||
errorMessageId: t(
|
||||
'invitation_modal.invite_members.exceeded_max_add_members_batch',
|
||||
),
|
||||
errorMessageDefault: 'No more than **{text}** people can be invited at once',
|
||||
errorMessageValues: {
|
||||
text: Constants.MAX_ADD_MEMBERS_BATCH.toString(),
|
||||
},
|
||||
};
|
||||
|
||||
const inviteURL = useMemo(() => {
|
||||
let urlBase = '';
|
||||
if (props.configSiteUrl && !props.configSiteUrl.includes('localhost')) {
|
||||
@@ -45,14 +86,128 @@ const InviteMembers = (props: Props) => {
|
||||
return `${urlBase}/signup_user_complete/?id=${props.teamInviteId}`;
|
||||
}, [props.teamInviteId, props.configSiteUrl, props.browserSiteUrl, props.formUrl]);
|
||||
|
||||
const description = (
|
||||
<FormattedMessage
|
||||
id={'onboarding_wizard.invite_members.description_link'}
|
||||
defaultMessage='Collaboration is tough by yourself. Invite a few team members using the invitation link below.'
|
||||
let suppressNoOptionsMessage = true;
|
||||
if (props.emails?.length > Constants.MAX_ADD_MEMBERS_BATCH) {
|
||||
errorProperties.showError = true;
|
||||
|
||||
// We want to suppress the no options message, unless the message that is going to be displayed
|
||||
// is the max users warning
|
||||
suppressNoOptionsMessage = false;
|
||||
}
|
||||
|
||||
const cloudInviteMembersInput = (
|
||||
<UsersEmailsInput
|
||||
{...errorProperties}
|
||||
usersLoader={() => Promise.resolve([])}
|
||||
placeholder={placeholder}
|
||||
ariaLabel={formatMessage({
|
||||
id: 'invitation_modal.members.search_and_add.title',
|
||||
defaultMessage: 'Invite People',
|
||||
})}
|
||||
onChange={(emails: Array<UserProfile | string>) => {
|
||||
// There should not be any users found or passed,
|
||||
// because the usersLoader should never return any.
|
||||
// Filtering them out in case there are any
|
||||
// and to resolve Typescript errors
|
||||
props.setEmails(emails.filter((x) => typeof x === 'string') as string[]);
|
||||
}}
|
||||
value={props.emails}
|
||||
onInputChange={setEmail}
|
||||
inputValue={email}
|
||||
emailInvitationsEnabled={true}
|
||||
autoFocus={true}
|
||||
validAddressMessageId={t('invitation_modal.members.users_emails_input.valid_email')}
|
||||
validAddressMessageDefault={'Invite **{email}** as a team member'}
|
||||
suppressNoOptionsMessage={suppressNoOptionsMessage}
|
||||
/>
|
||||
);
|
||||
|
||||
const inviteInteraction = <InviteMembersLink inviteURL={inviteURL}/>;
|
||||
const inviteLink = (
|
||||
<InviteMembersLink
|
||||
inviteURL={inviteURL}
|
||||
inputAndButtonStyle={props.isSelfHosted}
|
||||
/>
|
||||
);
|
||||
|
||||
const inviteMemberBodyContent = () => {
|
||||
if (props.isSelfHosted) {
|
||||
return (
|
||||
<>
|
||||
<Title>
|
||||
<FormattedMessage
|
||||
id={'onboarding_wizard.invite_members.title'}
|
||||
defaultMessage='Invite your team members'
|
||||
/>
|
||||
</Title>
|
||||
<Description>
|
||||
<FormattedMessage
|
||||
id={'onboarding_wizard.invite_members.description_link'}
|
||||
defaultMessage='Collaboration is tough by yourself. Invite a few team members using the invitation link below.'
|
||||
/>
|
||||
</Description>
|
||||
<PageBody>
|
||||
{inviteLink}
|
||||
</PageBody>
|
||||
<div className='InviteMembers__submit'>
|
||||
<button
|
||||
className='primary-button'
|
||||
disabled={props.disableEdits}
|
||||
onClick={props.next}
|
||||
>
|
||||
<FormattedMessage
|
||||
id={'onboarding_wizard.invite_members.next_link'}
|
||||
defaultMessage='Finish setup'
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<Title>
|
||||
<FormattedMessage
|
||||
id={'onboarding_wizard.invite_members_cloud.title'}
|
||||
defaultMessage='Who works with you?'
|
||||
/>
|
||||
</Title>
|
||||
<Description>
|
||||
<FormattedMessage
|
||||
id={'onboarding_wizard.invite_members.description'}
|
||||
defaultMessage='Collaboration is tough by yourself. Invite a few team members. Separate each email address with a space or comma.'
|
||||
/>
|
||||
</Description>
|
||||
<PageBody>
|
||||
{cloudInviteMembersInput}
|
||||
</PageBody>
|
||||
<div className='InviteMembers__submit'>
|
||||
<button
|
||||
className='primary-button'
|
||||
disabled={props.disableEdits || props.emails.length === 0}
|
||||
onClick={props.next}
|
||||
>
|
||||
<FormattedMessage
|
||||
id={'onboarding_wizard.invite_members.next'}
|
||||
defaultMessage='Send invites'
|
||||
/>
|
||||
|
||||
</button>
|
||||
{inviteLink}
|
||||
{showSkipButton &&
|
||||
<button
|
||||
className='link-style fade-in-skip-button'
|
||||
onClick={props.skip}
|
||||
>
|
||||
<FormattedMessage
|
||||
id={'onboarding_wizard.invite_members.skip'}
|
||||
defaultMessage='Skip'
|
||||
/>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<CSSTransition
|
||||
@@ -73,35 +228,12 @@ const InviteMembers = (props: Props) => {
|
||||
noLeft={true}
|
||||
/>
|
||||
{props.previous}
|
||||
<Title>
|
||||
<FormattedMessage
|
||||
id={'onboarding_wizard.invite_members.title'}
|
||||
defaultMessage='Invite your team members'
|
||||
/>
|
||||
</Title>
|
||||
<Description>
|
||||
{description}
|
||||
</Description>
|
||||
<PageBody>
|
||||
{inviteInteraction}
|
||||
</PageBody>
|
||||
<div className='InviteMembers__submit'>
|
||||
<button
|
||||
className='primary-button'
|
||||
disabled={props.disableEdits}
|
||||
onClick={props.next}
|
||||
>
|
||||
<FormattedMessage
|
||||
id={'onboarding_wizard.invite_members.next_link'}
|
||||
defaultMessage='Finish setup'
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
{inviteMemberBodyContent()}
|
||||
<PageLine
|
||||
style={{
|
||||
marginTop: '50px',
|
||||
marginLeft: '50px',
|
||||
height: 'calc(30vh)',
|
||||
height: 'calc(35vh)',
|
||||
}}
|
||||
noLeft={true}
|
||||
/>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@import 'utils/mixins';
|
||||
|
||||
.InviteMembersLink {
|
||||
display: flex;
|
||||
|
||||
@@ -9,35 +11,21 @@
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid rgba(var(--center-channel-color-rgb), 0.2);
|
||||
border-left: 1px solid rgba(var(--center-channel-color-rgb), 0.2);
|
||||
background: rgba(var(--center-channel-color-rgb), 0.04);
|
||||
background: var(--center-channel-color-rgb);
|
||||
border-radius: 4px 0 0 4px;
|
||||
color: rgba(var(--center-channel-color-rgb), 0.56);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
&__button {
|
||||
button {
|
||||
display: flex;
|
||||
width: 180px;
|
||||
max-width: 382px;
|
||||
height: 48px;
|
||||
flex-grow: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--button-bg);
|
||||
background: var(--center-channel-bg);
|
||||
border-radius: 0 4px 4px 0;
|
||||
color: var(--button-bg);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
|
||||
&:hover {
|
||||
background: rgba(var(--button-bg-rgb), 0.08);
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: rgba(var(--button-bg-rgb), 0.08);
|
||||
}
|
||||
|
||||
span {
|
||||
display: inline-block;
|
||||
height: 24px;
|
||||
@@ -48,4 +36,28 @@
|
||||
fill: var(--button-bg);
|
||||
}
|
||||
}
|
||||
|
||||
&__button {
|
||||
width: 180px;
|
||||
height: 48px;
|
||||
border: 1px solid var(--button-bg);
|
||||
background: var(--center-channel-bg);
|
||||
border-radius: 0 4px 4px 0;
|
||||
color: var(--button-bg);
|
||||
|
||||
&:hover {
|
||||
background: rgba(var(--button-bg-rgb), 0.08);
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: rgba(var(--button-bg-rgb), 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
&__button--single {
|
||||
@include tertiary-button;
|
||||
|
||||
height: 40px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
Некоторые файлы не были показаны из-за слишком большого количества измененных файлов Показать больше
Ссылка в новой задаче
Block a user