Add Export/import board (#403)
* Resolve GH-261 * fix eslint * Address comments * update mocks * add test
This commit is contained in:
parent
8a3b4cacb2
commit
e2dd9a978a
11 changed files with 135 additions and 15 deletions
|
@ -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))
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)))
|
||||||
|
|
|
@ -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 []
|
||||||
|
|
Loading…
Reference in a new issue