diff --git a/server/api/api.go b/server/api/api.go index e6260c4dc..b24748ae7 100644 --- a/server/api/api.go +++ b/server/api/api.go @@ -140,7 +140,7 @@ func (a *API) getContainerAllowingReadTokenForBlock(r *http.Request, blockID str if a.WorkspaceAuthenticator == nil { // Native auth: always use root workspace container := store.Container{ - WorkspaceID: "", + WorkspaceID: "0", } // Has session @@ -160,11 +160,6 @@ func (a *API) getContainerAllowingReadTokenForBlock(r *http.Request, blockID str vars := mux.Vars(r) workspaceID := vars["workspaceID"] - if workspaceID == "" || workspaceID == "0" { - // When authenticating, a workspace is required - return nil, errors.New("No workspace specified") - } - container := store.Container{ WorkspaceID: workspaceID, } diff --git a/server/app/blocks_test.go b/server/app/blocks_test.go index 24b624d87..43f2cdb0d 100644 --- a/server/app/blocks_test.go +++ b/server/app/blocks_test.go @@ -27,7 +27,7 @@ func TestGetParentID(t *testing.T) { app := New(&cfg, store, auth, wsserver, &mocks.FileBackend{}, webhook) container := st.Container{ - WorkspaceID: "", + WorkspaceID: "0", } t.Run("success query", func(t *testing.T) { store.EXPECT().GetParentID(gomock.Eq(container), gomock.Eq("test-id")).Return("test-parent-id", nil) diff --git a/server/services/store/sqlstore/blocks.go b/server/services/store/sqlstore/blocks.go index 63bc30716..2c47a019d 100644 --- a/server/services/store/sqlstore/blocks.go +++ b/server/services/store/sqlstore/blocks.go @@ -21,7 +21,7 @@ func (s *SQLStore) latestsBlocksSubquery(c store.Container) sq.SelectBuilder { FromSelect(internalQuery, "a"). Where(sq.Eq{"rn": 1}). Where(sq.Eq{"delete_at": 0}). - Where(sq.Eq{"coalesce(workspace_id, '')": c.WorkspaceID}) + Where(sq.Eq{"coalesce(workspace_id, '0')": c.WorkspaceID}) } func (s *SQLStore) GetBlocksWithParentAndType(c store.Container, parentID string, blockType string) ([]model.Block, error) { diff --git a/server/services/store/sqlstore/initialize.go b/server/services/store/sqlstore/initialize.go index 730ec34b4..9aa80f4d9 100644 --- a/server/services/store/sqlstore/initialize.go +++ b/server/services/store/sqlstore/initialize.go @@ -34,7 +34,7 @@ func (s *SQLStore) importInitialTemplates() error { } globalContainer := store.Container{ - WorkspaceID: "", + WorkspaceID: "0", } log.Printf("Inserting %d blocks", len(archive.Blocks)) diff --git a/server/services/store/sqlstore/migrations/postgres/bindata.go b/server/services/store/sqlstore/migrations/postgres/bindata.go index f92019110..04bf50f65 100644 --- a/server/services/store/sqlstore/migrations/postgres/bindata.go +++ b/server/services/store/sqlstore/migrations/postgres/bindata.go @@ -376,12 +376,12 @@ func _000008_teamsDownSql() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "000008_teams.down.sql", size: 140, mode: os.FileMode(420), modTime: time.Unix(1616709037, 0)} + info := bindataFileInfo{name: "000008_teams.down.sql", size: 140, mode: os.FileMode(420), modTime: time.Unix(1616780070, 0)} a := &asset{bytes: bytes, info: info} return a, nil } -var __000008_teamsUpSql = []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff\x72\xf4\x09\x71\x0d\x52\x08\x71\x74\xf2\x71\x55\x48\xca\xc9\x4f\xce\x2e\xe6\x72\x74\x71\x51\x70\xf6\xf7\x09\xf5\xf5\x53\x28\xcf\x2f\xca\x2e\x2e\x48\x4c\x4e\x8d\xcf\x4c\x51\x08\x73\x0c\x72\xf6\x70\x0c\xd2\x30\x36\xd3\xb4\xe6\xe2\x42\xd6\x58\x9c\x91\x58\x94\x99\x97\x4e\x8e\xce\xd4\xe2\xe2\xcc\xfc\x3c\x14\x4b\x13\x4b\x4b\x32\xe2\x8b\x53\x8b\xca\x32\x93\x53\xe1\x5a\x8d\x0c\x34\xad\xb9\x00\x01\x00\x00\xff\xff\xba\x55\x30\xd8\xad\x00\x00\x00") +var __000008_teamsUpSql = []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff\x72\xf4\x09\x71\x0d\x52\x08\x71\x74\xf2\x71\x55\x48\xca\xc9\x4f\xce\x2e\xe6\x72\x74\x71\x51\x70\xf6\xf7\x09\xf5\xf5\x53\x28\xcf\x2f\xca\x2e\x2e\x48\x4c\x4e\x8d\xcf\x4c\x51\x08\x73\x0c\x72\xf6\x70\x0c\xd2\x30\x36\xd3\xb4\xe6\xe2\x42\xd6\x58\x9c\x91\x58\x94\x99\x97\x4e\x8e\xce\xd4\xe2\xe2\xcc\xfc\x3c\x14\x4b\x13\x4b\x4b\x32\xe2\x8b\x53\x8b\xca\x32\x93\x53\xe1\x5a\x8d\x0c\x40\x5a\x43\x03\x5c\x1c\x43\x60\x0e\x55\x08\x76\x0d\x41\xb5\xc7\x56\x41\xdd\x40\x5d\x21\xdc\xc3\x35\xc8\x15\x43\x42\x5d\xc1\x3f\x08\x55\xd0\x33\x58\xc1\x2f\xd4\xc7\xc7\x9a\x0b\x10\x00\x00\xff\xff\x0e\xd0\xa2\xd3\x04\x01\x00\x00") func _000008_teamsUpSqlBytes() ([]byte, error) { return bindataRead( @@ -396,7 +396,7 @@ func _000008_teamsUpSql() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "000008_teams.up.sql", size: 173, mode: os.FileMode(420), modTime: time.Unix(1616709035, 0)} + info := bindataFileInfo{name: "000008_teams.up.sql", size: 260, mode: os.FileMode(420), modTime: time.Unix(1617137666, 0)} a := &asset{bytes: bytes, info: info} return a, nil } diff --git a/server/services/store/sqlstore/migrations/postgres_files/000008_teams.up.sql b/server/services/store/sqlstore/migrations/postgres_files/000008_teams.up.sql index 409b9cf7f..017d8b0dc 100644 --- a/server/services/store/sqlstore/migrations/postgres_files/000008_teams.up.sql +++ b/server/services/store/sqlstore/migrations/postgres_files/000008_teams.up.sql @@ -6,3 +6,5 @@ ADD COLUMN workspace_id VARCHAR(36); ALTER TABLE sessions ADD COLUMN auth_service VARCHAR(20); + +UPDATE blocks SET workspace_id = '0' WHERE workspace_id = '' OR workspace_id IS NULL; diff --git a/server/services/store/sqlstore/migrations/sqlite/bindata.go b/server/services/store/sqlstore/migrations/sqlite/bindata.go index 4151b7696..b0485571f 100644 --- a/server/services/store/sqlstore/migrations/sqlite/bindata.go +++ b/server/services/store/sqlstore/migrations/sqlite/bindata.go @@ -376,12 +376,12 @@ func _000008_teamsDownSql() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "000008_teams.down.sql", size: 140, mode: os.FileMode(420), modTime: time.Unix(1616709044, 0)} + info := bindataFileInfo{name: "000008_teams.down.sql", size: 140, mode: os.FileMode(420), modTime: time.Unix(1616780070, 0)} a := &asset{bytes: bytes, info: info} return a, nil } -var __000008_teamsUpSql = []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff\x72\xf4\x09\x71\x0d\x52\x08\x71\x74\xf2\x71\x55\x48\xca\xc9\x4f\xce\x2e\xe6\x72\x74\x71\x51\x70\xf6\xf7\x09\xf5\xf5\x53\x28\xcf\x2f\xca\x2e\x2e\x48\x4c\x4e\x8d\xcf\x4c\x51\x08\x73\x0c\x72\xf6\x70\x0c\xd2\x30\x36\xd3\xb4\xe6\xe2\x42\xd6\x58\x9c\x91\x58\x94\x99\x97\x4e\x8e\xce\xd4\xe2\xe2\xcc\xfc\x3c\x14\x4b\x13\x4b\x4b\x32\xe2\x8b\x53\x8b\xca\x32\x93\x53\xe1\x5a\x8d\x0c\x34\xad\xb9\x00\x01\x00\x00\xff\xff\xba\x55\x30\xd8\xad\x00\x00\x00") +var __000008_teamsUpSql = []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff\x72\xf4\x09\x71\x0d\x52\x08\x71\x74\xf2\x71\x55\x48\xca\xc9\x4f\xce\x2e\xe6\x72\x74\x71\x51\x70\xf6\xf7\x09\xf5\xf5\x53\x28\xcf\x2f\xca\x2e\x2e\x48\x4c\x4e\x8d\xcf\x4c\x51\x08\x73\x0c\x72\xf6\x70\x0c\xd2\x30\x36\xd3\xb4\xe6\xe2\x42\xd6\x58\x9c\x91\x58\x94\x99\x97\x4e\x8e\xce\xd4\xe2\xe2\xcc\xfc\x3c\x14\x4b\x13\x4b\x4b\x32\xe2\x8b\x53\x8b\xca\x32\x93\x53\xe1\x5a\x8d\x0c\x40\x5a\x43\x03\x5c\x1c\x43\x60\x0e\x55\x08\x76\x0d\x41\xb5\xc7\x56\x41\xdd\x40\x5d\x21\xdc\xc3\x35\xc8\x15\x43\x42\x5d\xc1\x3f\x08\x55\xd0\x33\x58\xc1\x2f\xd4\xc7\xc7\x9a\x0b\x10\x00\x00\xff\xff\x0e\xd0\xa2\xd3\x04\x01\x00\x00") func _000008_teamsUpSqlBytes() ([]byte, error) { return bindataRead( @@ -396,7 +396,7 @@ func _000008_teamsUpSql() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "000008_teams.up.sql", size: 173, mode: os.FileMode(420), modTime: time.Unix(1616709052, 0)} + info := bindataFileInfo{name: "000008_teams.up.sql", size: 260, mode: os.FileMode(420), modTime: time.Unix(1617137662, 0)} a := &asset{bytes: bytes, info: info} return a, nil } diff --git a/server/services/store/sqlstore/migrations/sqlite_files/000008_teams.up.sql b/server/services/store/sqlstore/migrations/sqlite_files/000008_teams.up.sql index 409b9cf7f..017d8b0dc 100644 --- a/server/services/store/sqlstore/migrations/sqlite_files/000008_teams.up.sql +++ b/server/services/store/sqlstore/migrations/sqlite_files/000008_teams.up.sql @@ -6,3 +6,5 @@ ADD COLUMN workspace_id VARCHAR(36); ALTER TABLE sessions ADD COLUMN auth_service VARCHAR(20); + +UPDATE blocks SET workspace_id = '0' WHERE workspace_id = '' OR workspace_id IS NULL; diff --git a/server/services/store/storetests/blocks.go b/server/services/store/storetests/blocks.go index 42916c2b2..35a8da8bb 100644 --- a/server/services/store/storetests/blocks.go +++ b/server/services/store/storetests/blocks.go @@ -11,7 +11,7 @@ import ( func StoreTestBlocksStore(t *testing.T, setup func(t *testing.T) (store.Store, func())) { container := store.Container{ - WorkspaceID: "", + WorkspaceID: "0", } t.Run("InsertBlock", func(t *testing.T) { diff --git a/server/services/store/storetests/sharing.go b/server/services/store/storetests/sharing.go index a161cd6b1..abf5cd8fd 100644 --- a/server/services/store/storetests/sharing.go +++ b/server/services/store/storetests/sharing.go @@ -10,7 +10,7 @@ import ( func StoreTestSharingStore(t *testing.T, setup func(t *testing.T) (store.Store, func())) { container := store.Container{ - WorkspaceID: "", + WorkspaceID: "0", } t.Run("UpsertSharingAndGetSharing", func(t *testing.T) { diff --git a/server/ws/websockets.go b/server/ws/websockets.go index 6ed562057..ec8aafd99 100644 --- a/server/ws/websockets.go +++ b/server/ws/websockets.go @@ -179,11 +179,6 @@ func (ws *Server) authenticateListener(wsSession *websocketSession, workspaceID, // Authenticated - // Special case: Default workspace is blank - if workspaceID == "0" { - workspaceID = "" - } - wsSession.workspaceID = workspaceID wsSession.isAuthenticated = true log.Printf("authenticateListener: Authenticated, workspaceID: %s", workspaceID) @@ -201,11 +196,6 @@ func (ws *Server) getAuthenticatedWorkspaceID(wsSession *websocketSession, comma return "", errors.New("No workspace") } - // Special case: Default workspace is blank - if workspaceID == "0" { - workspaceID = "" - } - container := store.Container{ WorkspaceID: workspaceID, } diff --git a/webapp/src/components/sidebar/sidebarAddBoardMenu.tsx b/webapp/src/components/sidebar/sidebarAddBoardMenu.tsx index 6076a43bd..c14d1edf2 100644 --- a/webapp/src/components/sidebar/sidebarAddBoardMenu.tsx +++ b/webapp/src/components/sidebar/sidebarAddBoardMenu.tsx @@ -3,9 +3,11 @@ import React from 'react' import {FormattedMessage, injectIntl, IntlShape} from 'react-intl' -import {MutableBoard} from '../../blocks/board' +import {Board, MutableBoard} from '../../blocks/board' import {MutableBoardView} from '../../blocks/boardView' import mutator from '../../mutator' +import octoClient from '../../octoClient' +import {GlobalTemplateTree, MutableGlobalTemplateTree} from '../../viewModel/globalTemplateTree' import {WorkspaceTree} from '../../viewModel/workspaceTree' import IconButton from '../../widgets/buttons/iconButton' import BoardIcon from '../../widgets/icons/board' @@ -24,13 +26,32 @@ type Props = { intl: IntlShape } -class SidebarAddBoardMenu extends React.Component { +type State = { + globalTemplateTree?: GlobalTemplateTree, +} + +class SidebarAddBoardMenu extends React.Component { + state: State = {} + shouldComponentUpdate(): boolean { return true } + componentDidMount(): void { + this.syncGlobalTemplates() + } + + private async syncGlobalTemplates() { + if (octoClient.workspaceId !== '0' && !this.state.globalTemplateTree) { + const globalTemplateTree = await MutableGlobalTemplateTree.sync() + this.setState({globalTemplateTree}) + } + } + render(): JSX.Element { const {workspaceTree, intl} = this.props + const {globalTemplateTree} = this.state + if (!workspaceTree) { return
} @@ -58,44 +79,9 @@ class SidebarAddBoardMenu extends React.Component { } - {workspaceTree.boardTemplates.map((boardTemplate) => { - const displayName = boardTemplate.title || intl.formatMessage({id: 'Sidebar.untitled', defaultMessage: 'Untitled'}) + {workspaceTree.boardTemplates.map((boardTemplate) => this.renderBoardTemplate(boardTemplate))} - return ( - {boardTemplate.icon}
} - onClick={() => { - this.addBoardFromTemplate(boardTemplate.id) - }} - rightIcon={ - - }/> - - } - id='edit' - name={intl.formatMessage({id: 'Sidebar.edit-template', defaultMessage: 'Edit'})} - onClick={() => { - this.props.showBoard(boardTemplate.id) - }} - /> - } - id='delete' - name={intl.formatMessage({id: 'Sidebar.delete-template', defaultMessage: 'Delete'})} - onClick={async () => { - await mutator.deleteBlock(boardTemplate, 'delete board template') - }} - /> - - - } - /> - ) - })} + {globalTemplateTree && globalTemplateTree.boardTemplates.map((boardTemplate) => this.renderBoardTemplate(boardTemplate, true))} { ) } + private renderBoardTemplate(boardTemplate: Board, isGlobal = false): JSX.Element { + const {intl} = this.props + + const displayName = boardTemplate.title || intl.formatMessage({id: 'Sidebar.untitled', defaultMessage: 'Untitled'}) + + return ( + {boardTemplate.icon}} + onClick={() => { + if (isGlobal) { + this.addBoardFromGlobalTemplate(boardTemplate.id) + } else { + this.addBoardFromTemplate(boardTemplate.id) + } + }} + rightIcon={!isGlobal && + + }/> + + } + id='edit' + name={intl.formatMessage({id: 'Sidebar.edit-template', defaultMessage: 'Edit'})} + onClick={() => { + this.props.showBoard(boardTemplate.id) + }} + /> + } + id='delete' + name={intl.formatMessage({id: 'Sidebar.delete-template', defaultMessage: 'Delete'})} + onClick={async () => { + await mutator.deleteBlock(boardTemplate, 'delete board template') + }} + /> + + + } + /> + ) + } + private addBoardClicked = async () => { const {showBoard, intl} = this.props @@ -143,6 +174,24 @@ class SidebarAddBoardMenu extends React.Component { ) } + private async addBoardFromGlobalTemplate(boardTemplateId: string) { + const oldBoardId = this.props.activeBoardId + + await mutator.duplicateFromRootBoard( + boardTemplateId, + this.props.intl.formatMessage({id: 'Mutator.new-board-from-template', defaultMessage: 'new board from template'}), + false, + async (newBoardId) => { + this.props.showBoard(newBoardId) + }, + async () => { + if (oldBoardId) { + this.props.showBoard(oldBoardId) + } + }, + ) + } + private async addBoardFromTemplate(boardTemplateId: string) { const oldBoardId = this.props.activeBoardId diff --git a/webapp/src/mutator.ts b/webapp/src/mutator.ts index d18ed6f01..5bf7382f4 100644 --- a/webapp/src/mutator.ts +++ b/webapp/src/mutator.ts @@ -6,7 +6,7 @@ import {Board, IPropertyOption, IPropertyTemplate, MutableBoard, PropertyType} f import {BoardView, ISortOption, MutableBoardView} from './blocks/boardView' import {Card, MutableCard} from './blocks/card' import {FilterGroup} from './blocks/filterGroup' -import octoClient from './octoClient' +import octoClient, {OctoClient} from './octoClient' import {OctoUtils} from './octoUtils' import undoManager from './undomanager' import {Utils} from './utils' @@ -579,6 +579,39 @@ class Mutator { return [newBlocks, newBoard.id] } + async duplicateFromRootBoard( + boardId: string, + description = 'duplicate board', + asTemplate = false, + afterRedo?: (newBoardId: string) => Promise, + beforeUndo?: () => Promise, + ): Promise<[IBlock[], string]> { + const rootClient = new OctoClient(octoClient.serverUrl, '0') + const blocks = await rootClient.getSubtree(boardId, 3) + const [newBlocks1, newBoard] = OctoUtils.duplicateBlockTree(blocks, boardId) as [IBlock[], MutableBoard, Record] + const newBlocks = newBlocks1.filter((o) => o.type !== 'comment') + Utils.log(`duplicateBoard: duplicating ${newBlocks.length} blocks`) + + if (asTemplate === newBoard.isTemplate) { + newBoard.title = `${newBoard.title} copy` + } else if (asTemplate) { + // Template from board + newBoard.title = 'New board template' + } else { + // Board from template + } + newBoard.isTemplate = asTemplate + await this.insertBlocks( + newBlocks, + description, + async () => { + await afterRedo?.(newBoard.id) + }, + beforeUndo, + ) + return [newBlocks, newBoard.id] + } + // Other methods // Not a mutator, but convenient to put here since Mutator wraps OctoClient diff --git a/webapp/src/octoClient.ts b/webapp/src/octoClient.ts index e89c9dbd7..f5c6bcbd1 100644 --- a/webapp/src/octoClient.ts +++ b/webapp/src/octoClient.ts @@ -24,9 +24,7 @@ class OctoClient { return readToken } - workspaceId = '0' - - constructor(serverUrl?: string) { + constructor(serverUrl?: string, public workspaceId = '0') { this.serverUrl = serverUrl || window.location.origin Utils.log(`OctoClient serverUrl: ${this.serverUrl}`) } @@ -362,6 +360,7 @@ class OctoClient { } } -const client = new OctoClient() +const octoClient = new OctoClient() -export default client +export {OctoClient} +export default octoClient diff --git a/webapp/src/viewModel/globalTemplateTree.ts b/webapp/src/viewModel/globalTemplateTree.ts new file mode 100644 index 000000000..f42323bce --- /dev/null +++ b/webapp/src/viewModel/globalTemplateTree.ts @@ -0,0 +1,54 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import {IBlock} from '../blocks/block' +import {Board} from '../blocks/board' +import octoClient, {OctoClient} from '../octoClient' +import {OctoUtils} from '../octoUtils' + +interface GlobalTemplateTree { + readonly boardTemplates: readonly Board[] + readonly allBlocks: readonly IBlock[] +} + +class MutableGlobalTemplateTree implements GlobalTemplateTree { + boardTemplates: Board[] = [] + get allBlocks(): IBlock[] { + return [...this.boardTemplates] + } + + // Factory methods + + static async sync(): Promise { + const rootClient = new OctoClient(octoClient.serverUrl, '0') + const rawBlocks = await rootClient.getBlocksWithType('board') + + return this.buildTree(rawBlocks) + } + + static incrementalUpdate(originalTree: GlobalTemplateTree, updatedBlocks: IBlock[]): GlobalTemplateTree { + const relevantBlocks = updatedBlocks.filter((block) => block.deleteAt !== 0 || block.type === 'board' || block.type === 'view') + if (relevantBlocks.length < 1) { + // No change + return originalTree + } + const rawBlocks = OctoUtils.mergeBlocks(originalTree.allBlocks, relevantBlocks) + return this.buildTree(rawBlocks) + } + + private static buildTree(sourceBlocks: readonly IBlock[]): MutableGlobalTemplateTree { + const blocks = OctoUtils.hydrateBlocks(sourceBlocks) + + const workspaceTree = new MutableGlobalTemplateTree() + const allBoards = blocks.filter((block) => block.type === 'board') as Board[] + workspaceTree.boardTemplates = allBoards.filter((block) => block.isTemplate). + sort((a, b) => a.title.localeCompare(b.title)) as Board[] + + return workspaceTree + } + + // private mutableCopy(): MutableWorkspaceTree { + // return MutableWorkspaceTree.buildTree(this.allBlocks)! + // } +} + +export {MutableGlobalTemplateTree, GlobalTemplateTree}