Add Export/import board (#403)

* Resolve GH-261

* fix eslint

* Address comments

* update mocks

* add test
This commit is contained in:
Hossein 2021-05-13 17:04:49 -04:00 committed by GitHub
parent 8a3b4cacb2
commit e2dd9a978a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 135 additions and 15 deletions

View file

@ -597,16 +597,19 @@ func (a *API) handleExport(w http.ResponseWriter, r *http.Request) {
// schema:
// "$ref": "#/definitions/ErrorResponse"
query := r.URL.Query()
rootID := query.Get("root_id")
container, err := a.getContainer(r)
if err != nil {
noContainerErrorResponse(w, err)
return
}
blocks, err := a.app().GetAllBlocks(*container)
if err != nil {
errorResponse(w, http.StatusInternalServerError, "", err)
return
blocks := []model.Block{}
if rootID == "" {
blocks, err = a.app().GetAllBlocks(*container)
} else {
blocks, err = a.app().GetBlocksWithRootID(*container, rootID)
}
log.Printf("%d raw block(s)", len(blocks))

View file

@ -17,6 +17,10 @@ func (a *App) GetBlocks(c store.Container, parentID string, blockType string) ([
return a.store.GetBlocksWithParent(c, parentID)
}
func (a *App) GetBlocksWithRootID(c store.Container, rootID string) ([]model.Block, error) {
return a.store.GetBlocksWithRootID(c, rootID)
}
func (a *App) GetRootID(c store.Container, blockID string) (string, error) {
return a.store.GetRootID(c, blockID)
}

View file

@ -165,6 +165,21 @@ func (mr *MockStoreMockRecorder) GetBlocksWithParentAndType(arg0, arg1, arg2 int
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetBlocksWithParentAndType", reflect.TypeOf((*MockStore)(nil).GetBlocksWithParentAndType), arg0, arg1, arg2)
}
// GetBlocksWithRootID mocks base method.
func (m *MockStore) GetBlocksWithRootID(arg0 store.Container, arg1 string) ([]model.Block, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetBlocksWithRootID", arg0, arg1)
ret0, _ := ret[0].([]model.Block)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetBlocksWithRootID indicates an expected call of GetBlocksWithRootID.
func (mr *MockStoreMockRecorder) GetBlocksWithRootID(arg0, arg1 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetBlocksWithRootID", reflect.TypeOf((*MockStore)(nil).GetBlocksWithRootID), arg0, arg1)
}
// GetBlocksWithType mocks base method.
func (m *MockStore) GetBlocksWithType(arg0 store.Container, arg1 string) ([]model.Block, error) {
m.ctrl.T.Helper()

View file

@ -74,6 +74,35 @@ func (s *SQLStore) GetBlocksWithParent(c store.Container, parentID string) ([]mo
return blocksFromRows(rows)
}
func (s *SQLStore) GetBlocksWithRootID(c store.Container, rootID string) ([]model.Block, error) {
query := s.getQueryBuilder().
Select(
"id",
"parent_id",
"root_id",
"modified_by",
s.escapeField("schema"),
"type",
"title",
"COALESCE(fields, '{}')",
"create_at",
"update_at",
"delete_at",
).
From(s.tablePrefix + "blocks").
Where(sq.Eq{"root_id": rootID}).
Where(sq.Eq{"coalesce(workspace_id, '0')": c.WorkspaceID})
rows, err := query.Query()
if err != nil {
log.Printf(`GetBlocksWithRootID ERROR: %v`, err)
return nil, err
}
return blocksFromRows(rows)
}
func (s *SQLStore) GetBlocksWithType(c store.Container, blockType string) ([]model.Block, error) {
query := s.getQueryBuilder().
Select(

View file

@ -13,6 +13,7 @@ type Container struct {
type Store interface {
GetBlocksWithParentAndType(c Container, parentID string, blockType string) ([]model.Block, error)
GetBlocksWithParent(c Container, parentID string) ([]model.Block, error)
GetBlocksWithRootID(c Container, rootID string) ([]model.Block, error)
GetBlocksWithType(c Container, blockType string) ([]model.Block, error)
GetSubTree2(c Container, blockID string) ([]model.Block, error)
GetSubTree3(c Container, blockID string) ([]model.Block, error)

View file

@ -59,6 +59,11 @@ func StoreTestBlocksStore(t *testing.T, setup func(t *testing.T) (store.Store, f
defer tearDown()
testGetBlocksWithType(t, store, container)
})
t.Run("GetBlocksWithRootID", func(t *testing.T) {
store, tearDown := setup(t)
defer tearDown()
testGetBlocksWithRootID(t, store, container)
})
}
func testInsertBlock(t *testing.T, store store.Store, container store.Container) {
@ -656,3 +661,64 @@ func testGetBlocksWithType(t *testing.T, store store.Store, container store.Cont
require.Len(t, blocks, 4)
})
}
func testGetBlocksWithRootID(t *testing.T, store store.Store, container store.Container) {
userID := "user-id"
blocks, err := store.GetAllBlocks(container)
require.NoError(t, err)
blocksToInsert := []model.Block{
{
ID: "block1",
ParentID: "",
RootID: "block1",
ModifiedBy: userID,
Type: "test",
},
{
ID: "block2",
ParentID: "block1",
RootID: "block1",
ModifiedBy: userID,
Type: "test",
},
{
ID: "block3",
ParentID: "block1",
RootID: "block1",
ModifiedBy: userID,
Type: "test",
},
{
ID: "block4",
ParentID: "block1",
RootID: "block1",
ModifiedBy: userID,
Type: "test2",
},
{
ID: "block5",
ParentID: "block2",
RootID: "block2",
ModifiedBy: userID,
Type: "test",
},
}
InsertBlocks(t, store, container, blocksToInsert)
defer DeleteBlocks(t, store, container, blocksToInsert, "test")
t.Run("not existing parent", func(t *testing.T) {
time.Sleep(1 * time.Millisecond)
blocks, err = store.GetBlocksWithRootID(container, "not-exists")
require.NoError(t, err)
require.Len(t, blocks, 0)
})
t.Run("valid parent", func(t *testing.T) {
time.Sleep(1 * time.Millisecond)
blocks, err = store.GetBlocksWithRootID(container, "block1")
require.NoError(t, err)
require.Len(t, blocks, 4)
})
}

View file

@ -8,13 +8,13 @@ import {Utils} from './utils'
import {BoardTree} from './viewModel/boardTree'
class Archiver {
static async exportBoardTree(boardTree: BoardTree): Promise<void> {
const blocks = boardTree.allBlocks
static async exportBoardArchive(boardTree: BoardTree): Promise<void> {
const blocks = await mutator.exportArchive(boardTree.board.id)
this.exportArchive(blocks)
}
static async exportFullArchive(): Promise<void> {
const blocks = await mutator.exportFullArchive()
const blocks = await mutator.exportArchive()
this.exportArchive(blocks)
}

View file

@ -4,6 +4,7 @@ import React, {useState} from 'react'
import {useIntl, IntlShape} from 'react-intl'
import {CsvExporter} from '../../csvExporter'
import {Archiver} from '../../archiver'
import {UserContext} from '../../user'
import {BoardTree} from '../../viewModel/boardTree'
import IconButton from '../../widgets/buttons/iconButton'
@ -104,11 +105,11 @@ const ViewHeaderActionsMenu = React.memo((props: Props) => {
name={intl.formatMessage({id: 'ViewHeader.export-csv', defaultMessage: 'Export to CSV'})}
onClick={() => onExportCsvTrigger(boardTree, intl)}
/>
{/* <Menu.Text
<Menu.Text
id='exportBoardArchive'
name={intl.formatMessage({id: 'ViewHeader.export-board-archive', defaultMessage: 'Export board archive'})}
onClick={() => Archiver.exportBoardTree(boardTree)}
/> */}
onClick={() => Archiver.exportBoardArchive(boardTree)}
/>
<UserContext.Consumer>
{(user) => (user && user.id !== 'single-user' &&
<Menu.Text

View file

@ -615,8 +615,8 @@ class Mutator {
// Other methods
// Not a mutator, but convenient to put here since Mutator wraps OctoClient
async exportFullArchive(): Promise<IBlock[]> {
return octoClient.exportFullArchive()
async exportArchive(boardID?: string): Promise<IBlock[]> {
return octoClient.exportArchive(boardID)
}
// Not a mutator, but convenient to put here since Mutator wraps OctoClient

View file

@ -28,7 +28,7 @@ test('OctoClient: get blocks', async () => {
expect(boards.length).toBe(blocks.length)
FetchMock.fn.mockReturnValueOnce(FetchMock.jsonResponse(JSON.stringify(blocks)))
boards = await octoClient.exportFullArchive()
boards = await octoClient.exportArchive()
expect(boards.length).toBe(blocks.length)
FetchMock.fn.mockReturnValueOnce(FetchMock.jsonResponse(JSON.stringify(blocks)))

View file

@ -135,8 +135,9 @@ class OctoClient {
return blocks
}
async exportFullArchive(): Promise<IBlock[]> {
const path = this.workspacePath() + '/blocks/export'
// If no boardID is provided, it will export the entire archive
async exportArchive(boardID = ''): Promise<IBlock[]> {
const path = `${this.workspacePath()}/blocks/export?root_id=${boardID}`
const response = await fetch(this.serverUrl + path, {headers: this.headers()})
if (response.status !== 200) {
return []