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: // schema:
// "$ref": "#/definitions/ErrorResponse" // "$ref": "#/definitions/ErrorResponse"
query := r.URL.Query()
rootID := query.Get("root_id")
container, err := a.getContainer(r) container, err := a.getContainer(r)
if err != nil { if err != nil {
noContainerErrorResponse(w, err) noContainerErrorResponse(w, err)
return return
} }
blocks, err := a.app().GetAllBlocks(*container) blocks := []model.Block{}
if err != nil { if rootID == "" {
errorResponse(w, http.StatusInternalServerError, "", err) blocks, err = a.app().GetAllBlocks(*container)
return } else {
blocks, err = a.app().GetBlocksWithRootID(*container, rootID)
} }
log.Printf("%d raw block(s)", len(blocks)) 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) 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) { func (a *App) GetRootID(c store.Container, blockID string) (string, error) {
return a.store.GetRootID(c, blockID) 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) 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. // GetBlocksWithType mocks base method.
func (m *MockStore) GetBlocksWithType(arg0 store.Container, arg1 string) ([]model.Block, error) { func (m *MockStore) GetBlocksWithType(arg0 store.Container, arg1 string) ([]model.Block, error) {
m.ctrl.T.Helper() m.ctrl.T.Helper()

View file

@ -74,6 +74,35 @@ func (s *SQLStore) GetBlocksWithParent(c store.Container, parentID string) ([]mo
return blocksFromRows(rows) 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) { func (s *SQLStore) GetBlocksWithType(c store.Container, blockType string) ([]model.Block, error) {
query := s.getQueryBuilder(). query := s.getQueryBuilder().
Select( Select(

View file

@ -13,6 +13,7 @@ type Container struct {
type Store interface { type Store interface {
GetBlocksWithParentAndType(c Container, parentID string, blockType string) ([]model.Block, error) GetBlocksWithParentAndType(c Container, parentID string, blockType string) ([]model.Block, error)
GetBlocksWithParent(c Container, parentID 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) GetBlocksWithType(c Container, blockType string) ([]model.Block, error)
GetSubTree2(c Container, blockID string) ([]model.Block, error) GetSubTree2(c Container, blockID string) ([]model.Block, error)
GetSubTree3(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() defer tearDown()
testGetBlocksWithType(t, store, container) 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) { 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) 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' import {BoardTree} from './viewModel/boardTree'
class Archiver { class Archiver {
static async exportBoardTree(boardTree: BoardTree): Promise<void> { static async exportBoardArchive(boardTree: BoardTree): Promise<void> {
const blocks = boardTree.allBlocks const blocks = await mutator.exportArchive(boardTree.board.id)
this.exportArchive(blocks) this.exportArchive(blocks)
} }
static async exportFullArchive(): Promise<void> { static async exportFullArchive(): Promise<void> {
const blocks = await mutator.exportFullArchive() const blocks = await mutator.exportArchive()
this.exportArchive(blocks) this.exportArchive(blocks)
} }

View file

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

View file

@ -615,8 +615,8 @@ class Mutator {
// Other methods // Other methods
// Not a mutator, but convenient to put here since Mutator wraps OctoClient // Not a mutator, but convenient to put here since Mutator wraps OctoClient
async exportFullArchive(): Promise<IBlock[]> { async exportArchive(boardID?: string): Promise<IBlock[]> {
return octoClient.exportFullArchive() return octoClient.exportArchive(boardID)
} }
// Not a mutator, but convenient to put here since Mutator wraps OctoClient // 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) expect(boards.length).toBe(blocks.length)
FetchMock.fn.mockReturnValueOnce(FetchMock.jsonResponse(JSON.stringify(blocks))) FetchMock.fn.mockReturnValueOnce(FetchMock.jsonResponse(JSON.stringify(blocks)))
boards = await octoClient.exportFullArchive() boards = await octoClient.exportArchive()
expect(boards.length).toBe(blocks.length) expect(boards.length).toBe(blocks.length)
FetchMock.fn.mockReturnValueOnce(FetchMock.jsonResponse(JSON.stringify(blocks))) FetchMock.fn.mockReturnValueOnce(FetchMock.jsonResponse(JSON.stringify(blocks)))

View file

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