@@ -434,6 +437,10 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) {
const { openDeleteModal } = useDeletePageModal();
const [exportOpened, { open: openExportModal, close: closeExportModal }] =
useDisclosure(false);
+ const [
+ movePageModalOpened,
+ { open: openMovePageModal, close: closeMoveSpaceModal },
+ ] = useDisclosure(false);
const handleCopyLink = () => {
const pageUrl =
@@ -486,8 +493,18 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) {
{!(treeApi.props.disableEdit as boolean) && (
<>
-
+
}
+ onClick={(e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ openMovePageModal();
+ }}
+ >
+ {t("Move")}
+
+
}
@@ -504,6 +521,14 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) {
+
+
void;
+ onChange: (value: ISpace) => void;
value?: string;
label?: string;
+ width?: number;
+ opened?: boolean;
+ clearable?: boolean;
}
const renderSelectOption: SelectProps["renderOption"] = ({ option }) => (
- {option.label}
+
+ {option.label}
+
);
-export function SpaceSelect({ onChange, label, value }: SpaceSelectProps) {
+export function SpaceSelect({
+ onChange,
+ label,
+ value,
+ width,
+ opened,
+ clearable,
+}: SpaceSelectProps) {
const { t } = useTranslation();
const [searchValue, setSearchValue] = useState("");
const [debouncedQuery] = useDebouncedValue(searchValue, 500);
@@ -42,8 +54,8 @@ export function SpaceSelect({ onChange, label, value }: SpaceSelectProps) {
});
const filteredSpaceData = spaceData.filter(
- (user) =>
- !data.find((existingUser) => existingUser.value === user.value),
+ (space) =>
+ !data.find((existingSpace) => existingSpace.value === space.value),
);
setData((prevData) => [...prevData, ...filteredSpaceData]);
}
@@ -59,14 +71,18 @@ export function SpaceSelect({ onChange, label, value }: SpaceSelectProps) {
searchable
searchValue={searchValue}
onSearchChange={setSearchValue}
- clearable
+ clearable={clearable}
variant="filled"
- onChange={onChange}
+ onChange={(slug) =>
+ onChange(spaces.items?.find((item) => item.slug === slug))
+ }
+ // duct tape
+ onClick={(e) => e.stopPropagation()}
nothingFoundMessage={t("No space found")}
limit={50}
checkIconPosition="right"
- comboboxProps={{ width: 300, withinPortal: false }}
- dropdownOpened
+ comboboxProps={{ width, withinPortal: false }}
+ dropdownOpened={opened}
/>
);
}
diff --git a/apps/client/src/features/space/components/sidebar/switch-space.tsx b/apps/client/src/features/space/components/sidebar/switch-space.tsx
index dfc92530..dc47b778 100644
--- a/apps/client/src/features/space/components/sidebar/switch-space.tsx
+++ b/apps/client/src/features/space/components/sidebar/switch-space.tsx
@@ -55,7 +55,9 @@ export function SwitchSpace({ spaceName, spaceSlug }: SwitchSpaceProps) {
handleSelect(space.slug)}
+ width={300}
+ opened={true}
/>
diff --git a/apps/client/src/features/user/components/change-email.tsx b/apps/client/src/features/user/components/change-email.tsx
index f5ad36e8..873d0744 100644
--- a/apps/client/src/features/user/components/change-email.tsx
+++ b/apps/client/src/features/user/components/change-email.tsx
@@ -70,7 +70,6 @@ function ChangeEmailForm() {
function handleSubmit(data: FormValues) {
setIsLoading(true);
- console.log(data);
}
return (
diff --git a/apps/server/src/core/page/dto/move-page.dto.ts b/apps/server/src/core/page/dto/move-page.dto.ts
index 83ff080c..236a4044 100644
--- a/apps/server/src/core/page/dto/move-page.dto.ts
+++ b/apps/server/src/core/page/dto/move-page.dto.ts
@@ -13,3 +13,11 @@ export class MovePageDto {
@IsString()
parentPageId?: string | null;
}
+
+export class MovePageToSpaceDto {
+ @IsString()
+ pageId: string;
+
+ @IsString()
+ spaceId: string;
+}
diff --git a/apps/server/src/core/page/page.controller.ts b/apps/server/src/core/page/page.controller.ts
index ec2a086d..79424509 100644
--- a/apps/server/src/core/page/page.controller.ts
+++ b/apps/server/src/core/page/page.controller.ts
@@ -7,11 +7,12 @@ import {
UseGuards,
ForbiddenException,
NotFoundException,
+ BadRequestException,
} from '@nestjs/common';
import { PageService } from './services/page.service';
import { CreatePageDto } from './dto/create-page.dto';
import { UpdatePageDto } from './dto/update-page.dto';
-import { MovePageDto } from './dto/move-page.dto';
+import { MovePageDto, MovePageToSpaceDto } from './dto/move-page.dto';
import { PageHistoryIdDto, PageIdDto, PageInfoDto } from './dto/page.dto';
import { PageHistoryService } from './services/page-history.service';
import { AuthUser } from '../../common/decorators/auth-user.decorator';
@@ -93,11 +94,7 @@ export class PageController {
throw new ForbiddenException();
}
- return this.pageService.update(
- page,
- updatePageDto,
- user.id,
- );
+ return this.pageService.update(page, updatePageDto, user.id);
}
@HttpCode(HttpStatus.OK)
@@ -210,6 +207,36 @@ export class PageController {
return this.pageService.getSidebarPages(dto.spaceId, pagination, pageId);
}
+ @HttpCode(HttpStatus.OK)
+ @Post('move-to-space')
+ async movePageToSpace(
+ @Body() dto: MovePageToSpaceDto,
+ @AuthUser() user: User,
+ ) {
+ const movedPage = await this.pageRepo.findById(dto.pageId);
+ if (!movedPage) {
+ throw new NotFoundException('Page to move not found');
+ }
+ if (movedPage.spaceId === dto.spaceId) {
+ throw new BadRequestException('Page is already in this space');
+ }
+
+ const abilities = await Promise.all([
+ this.spaceAbility.createForUser(user, movedPage.spaceId),
+ this.spaceAbility.createForUser(user, dto.spaceId),
+ ]);
+
+ if (
+ abilities.some((ability) =>
+ ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page),
+ )
+ ) {
+ throw new ForbiddenException();
+ }
+
+ return this.pageService.movePageToSpace(movedPage, dto.spaceId);
+ }
+
@HttpCode(HttpStatus.OK)
@Post('move')
async movePage(@Body() dto: MovePageDto, @AuthUser() user: User) {
diff --git a/apps/server/src/core/page/services/page.service.ts b/apps/server/src/core/page/services/page.service.ts
index 7ca54197..43d8f1d2 100644
--- a/apps/server/src/core/page/services/page.service.ts
+++ b/apps/server/src/core/page/services/page.service.ts
@@ -19,11 +19,14 @@ import { MovePageDto } from '../dto/move-page.dto';
import { ExpressionBuilder } from 'kysely';
import { DB } from '@docmost/db/types/db';
import { generateSlugId } from '../../../common/helpers';
+import { executeTx } from '@docmost/db/utils';
+import { AttachmentRepo } from '@docmost/db/repos/attachment/attachment.repo';
@Injectable()
export class PageService {
constructor(
private pageRepo: PageRepo,
+ private attachmentRepo: AttachmentRepo,
@InjectKysely() private readonly db: KyselyDB,
) {}
@@ -60,12 +63,31 @@ export class PageService {
parentPageId = parentPage.id;
}
+ const createdPage = await this.pageRepo.insertPage({
+ slugId: generateSlugId(),
+ title: createPageDto.title,
+ position: await this.nextPagePosition(
+ createPageDto.spaceId,
+ parentPageId,
+ ),
+ icon: createPageDto.icon,
+ parentPageId: parentPageId,
+ spaceId: createPageDto.spaceId,
+ creatorId: userId,
+ workspaceId: workspaceId,
+ lastUpdatedById: userId,
+ });
+
+ return createdPage;
+ }
+
+ async nextPagePosition(spaceId: string, parentPageId?: string) {
let pagePosition: string;
const lastPageQuery = this.db
.selectFrom('pages')
- .select(['id', 'position'])
- .where('spaceId', '=', createPageDto.spaceId)
+ .select(['position'])
+ .where('spaceId', '=', spaceId)
.orderBy('position', 'desc')
.limit(1);
@@ -96,19 +118,7 @@ export class PageService {
}
}
- const createdPage = await this.pageRepo.insertPage({
- slugId: generateSlugId(),
- title: createPageDto.title,
- position: pagePosition,
- icon: createPageDto.icon,
- parentPageId: parentPageId,
- spaceId: createPageDto.spaceId,
- creatorId: userId,
- workspaceId: workspaceId,
- lastUpdatedById: userId,
- });
-
- return createdPage;
+ return pagePosition;
}
async update(
@@ -192,6 +202,36 @@ export class PageService {
return result;
}
+ async movePageToSpace(rootPage: Page, spaceId: string) {
+ await executeTx(this.db, async (trx) => {
+ // Update root page
+ const nextPosition = await this.nextPagePosition(spaceId);
+ await this.pageRepo.updatePage(
+ { spaceId, parentPageId: null, position: nextPosition },
+ rootPage.id,
+ trx,
+ );
+ const pageIds = await this.pageRepo
+ .getPageAndDescendants(rootPage.id)
+ .then((pages) => pages.map((page) => page.id));
+ // The first id is the root page id
+ if (pageIds.length > 1) {
+ // Update sub pages
+ await this.pageRepo.updatePages(
+ { spaceId },
+ pageIds.filter((id) => id !== rootPage.id),
+ trx,
+ );
+ }
+ // Update attachments
+ await this.attachmentRepo.updateAttachmentsByPageId(
+ { spaceId },
+ pageIds,
+ trx,
+ );
+ });
+ }
+
async movePage(dto: MovePageDto, movedPage: Page) {
// validate position value by attempting to generate a key
try {
diff --git a/apps/server/src/database/repos/attachment/attachment.repo.ts b/apps/server/src/database/repos/attachment/attachment.repo.ts
index 59df7fe2..784e6c84 100644
--- a/apps/server/src/database/repos/attachment/attachment.repo.ts
+++ b/apps/server/src/database/repos/attachment/attachment.repo.ts
@@ -55,6 +55,18 @@ export class AttachmentRepo {
.execute();
}
+ updateAttachmentsByPageId(
+ updatableAttachment: UpdatableAttachment,
+ pageIds: string[],
+ trx?: KyselyTransaction,
+ ) {
+ return dbOrTx(this.db, trx)
+ .updateTable('attachments')
+ .set(updatableAttachment)
+ .where('pageId', 'in', pageIds)
+ .executeTakeFirst();
+ }
+
async updateAttachment(
updatableAttachment: UpdatableAttachment,
attachmentId: string,
diff --git a/apps/server/src/database/repos/page/page.repo.ts b/apps/server/src/database/repos/page/page.repo.ts
index 317c3e07..d6fff24d 100644
--- a/apps/server/src/database/repos/page/page.repo.ts
+++ b/apps/server/src/database/repos/page/page.repo.ts
@@ -96,18 +96,19 @@ export class PageRepo {
pageId: string,
trx?: KyselyTransaction,
) {
- const db = dbOrTx(this.db, trx);
- let query = db
+ return this.updatePages(updatablePage, [pageId], trx);
+ }
+
+ async updatePages(
+ updatePageData: UpdatablePage,
+ pageIds: string[],
+ trx?: KyselyTransaction,
+ ) {
+ return dbOrTx(this.db, trx)
.updateTable('pages')
- .set({ ...updatablePage, updatedAt: new Date() });
-
- if (isValidUUID(pageId)) {
- query = query.where('id', '=', pageId);
- } else {
- query = query.where('slugId', '=', pageId);
- }
-
- return query.executeTakeFirst();
+ .set({ ...updatePageData, updatedAt: new Date() })
+ .where(pageIds.some(pageId => !isValidUUID(pageId)) ? "slugId" : "id", "in", pageIds)
+ .executeTakeFirst();
}
async insertPage(