Support for PWA "Share To " on Android #1789
Conversation
Changed page selection logic
content/url and multiple combinations
WalkthroughAdds Web Share Target: manifest share_target entry, service worker to accept/cached POST shares and redirect, a new ShareTarget React page and route to import cached content, client service worker registration with update handling, and server import changes to accept and persist an optional parentPageId. Changes
Sequence DiagramsequenceDiagram
autonumber
participant ExtApp as External App
participant Browser as Browser
participant SW as Service Worker
participant Cache as Cache API
participant SPA as ShareTarget Page
participant Server as Server
ExtApp->>Browser: Share (POST FormData: title,text,url)
Browser->>SW: POST /share-target
SW->>Cache: put('shared-content', JSON)
SW-->>Browser: 303 Redirect -> /share-target
Browser->>SPA: Load /share-target
SPA->>Cache: get('shared-content')
SPA->>User: render import UI (space, parent, title, body)
User->>SPA: submit import (multipart with parentPageId?)
SPA->>Server: POST /pages/import (spaceId, parentPageId?, file, title?)
Server->>Server: importPage(..., parentPageId?, title?) — validate parent, compute position, insert page with parentPageId
Server-->>SPA: respond with created page id/slug
SPA->>Browser: navigate to new page
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes
Possibly related PRs
Poem
Pre-merge checks and finishing touches❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✨ Finishing touches
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 5
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (4)
apps/client/src/main.tsx (1)
49-97: Prevent SW update reload loops + fix suspicious</BrowserRouter >closing tag.Current logic can trigger multiple reloads (and in worst cases loops) because you reload both on
installedand oncontrollerchangewithout a guard.root.render( <BrowserRouter> @@ - </BrowserRouter >, + </BrowserRouter>, ); -if ('serviceWorker' in navigator) { +if ("serviceWorker" in navigator) { window.addEventListener('load', () => { + let refreshing = false; navigator.serviceWorker.register('/sw.js').then( (registration) => { @@ - newWorker.addEventListener('statechange', () => { - if (newWorker.state === 'installed' && navigator.serviceWorker.controller) { + newWorker.addEventListener('statechange', () => { + if ( + newWorker.state === 'installed' && + navigator.serviceWorker.controller && + !refreshing + ) { // New update available and installed @@ - window.location.reload(); + refreshing = true; + window.location.reload(); } }); } }); }, @@ - navigator.serviceWorker.addEventListener('controllerchange', () => { - window.location.reload(); + navigator.serviceWorker.addEventListener('controllerchange', () => { + if (refreshing) return; + refreshing = true; + window.location.reload(); }); }); }apps/server/src/integrations/import/import.controller.ts (1)
75-93: Add validation forparentPageId— ensure parent page exists and belongs to the same space.The
importService.importPage()method acceptsparentPageIdwithout validating that it exists or belongs to the specifiedspaceId. This differs from the standard page creation flow (page.service.ts), which checks both conditions and throws aNotFoundExceptionif the parent doesn't match. An attacker could pass aparentPageIdfrom a different space or a non-existent ID.Additionally, normalize empty strings to
undefined:- const parentPageId = file.fields?.parentPageId?.value; + const rawParentPageId = file.fields?.parentPageId?.value; + const parentPageId = + typeof rawParentPageId === 'string' && rawParentPageId.trim() + ? rawParentPageId.trim() + : undefined;Then in
importService.importPage(), add validation before callinggetNewPagePosition():if (parentPageId) { + const parentPage = await this.pageRepo.findById(parentPageId); + if (!parentPage || parentPage.spaceId !== spaceId) { + throw new NotFoundException('Parent page not found'); + } }apps/server/src/integrations/import/services/import.service.ts (2)
84-98: ValidateparentPageId(existence + same space/workspace + access) before inserting.
As-is, a client can supply an arbitraryparentPageId; if DB constraints don’t fully prevent it, this risks cross-space linkage and/or unauthorized hierarchy creation.@@ - const pagePosition = await this.getNewPagePosition(spaceId, parentPageId); + if (parentPageId) { + const parent = await this.db + .selectFrom('pages') + .select(['id', 'spaceId', 'workspaceId']) + .where('id', '=', parentPageId) + .executeTakeFirst(); + + if (!parent || parent.spaceId !== spaceId || parent.workspaceId !== workspaceId) { + throw new BadRequestException('Invalid parentPageId'); + } + } + + const pagePosition = await this.getNewPagePosition(spaceId, parentPageId); @@ - parentPageId: parentPageId || null, + parentPageId: parentPageId ?? null, });(Also consider an explicit authorization check if “can create under parent page” isn’t implied by workspace membership.)
100-102: Log message should usepageTitle(orfileName) instead of possibly-nulltitle.
Current log prints"null.md"when the document has no extracted H1.- this.logger.debug( - `Successfully imported "${title}${fileExtension}. ID: ${createdPage.id} - SlugId: ${createdPage.slugId}"`, - ); + this.logger.debug( + `Successfully imported "${pageTitle}${fileExtension}". ID: ${createdPage.id} - SlugId: ${createdPage.slugId}`, + );
🧹 Nitpick comments (1)
apps/server/src/integrations/import/services/import.service.ts (1)
33-40: Constructor brace spacing is non-idiomatic; consider normalizing.
This is purely style, but) { }sticks out vs typical Nest/TS formatting.- ) { } + ) {}
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (8)
apps/client/public/manifest.json(1 hunks)apps/client/public/sw.js(1 hunks)apps/client/src/App.tsx(2 hunks)apps/client/src/main.tsx(1 hunks)apps/client/src/pages/share-target/share-target.tsx(1 hunks)apps/server/src/core/search/search.service.ts(2 hunks)apps/server/src/integrations/import/import.controller.ts(3 hunks)apps/server/src/integrations/import/services/import.service.ts(4 hunks)
🧰 Additional context used
🧬 Code graph analysis (3)
apps/client/src/pages/share-target/share-target.tsx (4)
apps/client/src/features/search/types/search.types.ts (1)
IPageSearch(6-18)apps/client/src/features/space/queries/space-query.ts (1)
useGetSpacesQuery(34-43)apps/client/src/features/page/queries/page-query.ts (2)
useGetRootSidebarPagesQuery(265-277)useGetSidebarPagesQuery(250-263)apps/server/src/core/search/search.service.ts (1)
searchPage(23-134)
apps/client/public/sw.js (1)
apps/server/src/common/interceptors/http-response.interceptor.ts (1)
Response(10-12)
apps/client/src/App.tsx (1)
apps/client/src/pages/share-target/share-target.tsx (1)
ShareTarget(33-484)
🔇 Additional comments (4)
apps/server/src/core/search/search.service.ts (1)
65-65: The current code on line 65 correctly uses the logical OR operator (||) to default the limit to 25. This ensures that custom limit values are returned as-is (e.g.,limit=10returns10), while null or undefined values default to25. No verification of a prior bitwise OR usage was possible due to unavailable git history, and no other similar bitwise OR misuses were found elsewhere in the codebase.apps/client/src/App.tsx (1)
41-64: Verify/share-targetunauthenticated flow (SW may open it before login).Since the share-target flow can land users on
/share-targetwithout an active session, confirm there’s a clean 401 handling path (e.g., redirect to/loginand then back). If you intend it to require the app shell, consider nesting under<Layout />(or whatever enforces auth) rather than a top-level route.apps/client/public/manifest.json (1)
20-39: Manifestshare_targetwiring matches the route/SW design.The
share_targetblock aligns with/share-targetand the SW’s POST handling, and limiting params totitle/text/urlmatches the “no media yet” scope.apps/server/src/integrations/import/services/import.service.ts (1)
178-203: Query logic is correct; Kysely reorders WHERE clauses properly regardless of builder call order.Verified: Kysely generates SQL with proper clause ordering (SELECT, WHERE, ORDER BY, LIMIT) regardless of the sequence of
.where(),.orderBy(), and.limit()method calls. Multiple.where()calls are combined with AND in a single WHERE clause. The code correctly filters byspaceIdfirst, then conditionally adds theparentPageIdfilter (orIS NULL), ensuring positioned pages are selected only within the same parent context.
| // Fetch spaces | ||
| const { data: spacesData, isLoading: isLoadingSpaces } = useGetSpacesQuery({ | ||
| limit: 100, | ||
| }); | ||
|
|
||
| // Determine effective nav parent for queries | ||
| // If we are searching, we don't use these queries for options (we use search results). | ||
|
|
||
| // Query for ROOT pages (used when currentNavParentId is null) | ||
| const { data: rootPagesData, isLoading: isLoadingRoot } = useGetRootSidebarPagesQuery({ | ||
| spaceId: selectedSpace || "", | ||
| }); | ||
|
|
||
| // Query for SUB pages (used when currentNavParentId is set) | ||
| const { data: subPagesData, isLoading: isLoadingSub } = useGetSidebarPagesQuery({ | ||
| spaceId: selectedSpace || "", | ||
| pageId: currentNavParentId || undefined, | ||
| }); | ||
|
|
There was a problem hiding this comment.
Avoid firing root-pages query with spaceId: "".
useGetRootSidebarPagesQuery({ spaceId: selectedSpace || "" }) can execute with an empty string before the user chooses a space. If that hook is not enabled-guarded internally, it will call the API with an invalid spaceId.
Practical fixes:
- Add an
enabled: !!spaceIdoption insideuseGetRootSidebarPagesQuery(recommended), or - Refactor to reuse
useGetSidebarPagesQueryfor root listings (since it already supports anenabledguard based onspaceId).
🤖 Prompt for AI Agents
In apps/client/src/pages/share-target/share-target.tsx around lines 80 to 98,
the useGetRootSidebarPagesQuery is being called with spaceId: selectedSpace ||
"" which can fire the query with an empty string before a space is chosen;
change the call to include an enabled guard (e.g. enabled: !!selectedSpace) so
the hook only runs when a valid spaceId exists, or alternatively refactor to
call useGetSidebarPagesQuery for root listings (passing pageId undefined) since
that hook already supports an enabled guard based on spaceId.
Fixed coderabbit recommended TS promise
There was a problem hiding this comment.
Actionable comments posted: 2
♻️ Duplicate comments (1)
apps/client/src/pages/share-target/share-target.tsx (1)
89-97: Queries still fire with empty or invalidspaceId.Both
useGetRootSidebarPagesQueryanduseGetSidebarPagesQueryare called withspaceId: selectedSpace || "", which triggers API calls with an empty string before a space is selected.
🧹 Nitpick comments (2)
apps/server/src/integrations/import/services/import.service.ts (2)
90-118: Parent‑aware positioning and return value wiring look good; consider usingpageTitlein logWith the scoping bug above fixed, the rest of
importPagelooks coherent:
getNewPagePosition(spaceId, parentPageId)correctly passes through the parent ID so children get their own position sequence.insertPagenow persistsparentPageId: parentPageId || null, aligning the DB hierarchy with the new positioning logic.- The method returns
{ id, slugId } | null, matching the earlier type mismatch feedback and current usage ofcreatedPage.Minor tweak you may want:
- The success log uses the raw
titleparam ("${title}${fileExtension}..."), which may be empty/undefined when the title is derived from content or fromfileName. LoggingpageTitleinstead would better reflect what the user actually sees.Example adjustment:
- this.logger.debug( - `Successfully imported "${title}${fileExtension}. ID: ${createdPage.id} - SlugId: ${createdPage.slugId}"`, - ); + this.logger.debug( + `Successfully imported "${pageTitle}${fileExtension}". ID: ${createdPage.id} - SlugId: ${createdPage.slugId}`, + );
186-205:getNewPagePositioncorrectly scopes by parent; watch DB indexingThe updated
getNewPagePosition:
- Filters by
spaceIdplusparentPageIdwhen provided, orparentPageId IS NULLfor root pages.- Still orders by
position(collation'C') and takes the last row to generate a new jittered key.This matches the hierarchical behavior you want for nested pages.
Operationally, ensure there’s an index that supports this query shape (e.g., on
pages(spaceId, parentPageId, position)or similar) so that per‑parent inserts remain cheap as the table grows.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (3)
apps/client/public/sw.js(1 hunks)apps/client/src/pages/share-target/share-target.tsx(1 hunks)apps/server/src/integrations/import/services/import.service.ts(5 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
- apps/client/public/sw.js
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2025-08-07T21:44:49.482Z
Learnt from: lukasbach
Repo: docmost/docmost PR: 1457
File: apps/client/src/features/page/tree/components/space-tree.tsx:520-525
Timestamp: 2025-08-07T21:44:49.482Z
Learning: In the Docmost headless tree implementation (apps/client/src/features/page/tree/components/space-tree.tsx), the useEffect in the PageArrow component intentionally omits the dependency array. This allows isFolder() to run on subsequent renders as needed, since the item reference is stable and wouldn't trigger re-runs when required. This pattern is intentional for performance reasons in the tree component.
Applied to files:
apps/client/src/pages/share-target/share-target.tsx
🧬 Code graph analysis (1)
apps/client/src/pages/share-target/share-target.tsx (4)
apps/client/src/features/search/types/search.types.ts (1)
IPageSearch(6-18)apps/client/src/features/space/queries/space-query.ts (1)
useGetSpacesQuery(34-43)apps/client/src/features/page/queries/page-query.ts (2)
useGetRootSidebarPagesQuery(265-277)useGetSidebarPagesQuery(250-263)apps/server/src/core/search/search.service.ts (1)
searchPage(23-134)
🔇 Additional comments (4)
apps/client/src/pages/share-target/share-target.tsx (3)
1-78: Well-structured component with persistent user preferences.Good use of
useLocalStoragefor persisting space and parent page selections across sessions. The combobox configuration and state management are properly organized.
123-210: Shared content loading logic handles edge cases well.The URL-only fallback (lines 186-193), title deduplication from content (lines 153-158), and cache cleanup are all properly implemented. Error notification is appropriately shown to users.
334-492: Clean UI with proper accessibility patterns.The use of
onMouseDown(line 441) for folder navigation correctly prevents the combobox blur event from interfering. The sticky "go up" option and scroll area provide good UX for deep hierarchies.apps/server/src/integrations/import/services/import.service.ts (1)
33-48: Constructor DI and updatedimportPagesignature look consistentConstructor DI remains unchanged logically, and the updated
importPagesignature (addingparentPageId?,title?and returning{ id, slugId } | null) is consistent with the laterreturnand with the new parent‑page support.
There was a problem hiding this comment.
Actionable comments posted: 0
♻️ Duplicate comments (1)
apps/server/src/integrations/import/services/import.service.ts (1)
78-86: Critical bug: Variable shadowing prevents page creation when title is empty.The
constdestructuring on lines 81-82 creates block-scoped variables that shadow the outerletdeclarations. Whentitleis null/empty, the outercontentTitleandprosemirrorJsonremainundefined, causingif (prosemirrorJson)on line 90 to be false and silently skipping page creation.Apply this diff to fix the shadowing:
let contentTitle; let prosemirrorJson; if (title == null || title === "") { - const { title: contentTitle, prosemirrorJson } = + const extracted = this.extractTitleAndRemoveHeading(prosemirrorState); + contentTitle = extracted.title; + prosemirrorJson = extracted.prosemirrorJson; } else { contentTitle = title; prosemirrorJson = prosemirrorState; }
🧹 Nitpick comments (1)
apps/server/src/integrations/import/services/import.service.ts (1)
114-116: Consider usingpageTitlein debug log.The log uses
title(the optional parameter) which may beundefined. UsingpageTitle(line 88) would provide more meaningful output.this.logger.debug( - `Successfully imported "${title}${fileExtension}. ID: ${createdPage.id} - SlugId: ${createdPage.slugId}"`, + `Successfully imported "${pageTitle}${fileExtension}". ID: ${createdPage.id} - SlugId: ${createdPage.slugId}`, );Also note there's a missing closing quote before the period.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (2)
apps/server/src/integrations/import/import.controller.ts(3 hunks)apps/server/src/integrations/import/services/import.service.ts(6 hunks)
🔇 Additional comments (3)
apps/server/src/integrations/import/import.controller.ts (1)
77-98: LGTM!The
parentPageIdextraction is well-implemented with proper type checking, trimming, and fallback toundefined. Passing it to the service aligns correctly with the updatedimportPagesignature.apps/server/src/integrations/import/services/import.service.ts (2)
92-97: LGTM!Good validation ensuring the parent page exists and belongs to the same space before creating a child page. This prevents orphaned pages and cross-space hierarchy issues.
192-209: LGTM!The
getNewPagePositionextension correctly handles both root pages and child pages by conditionally filtering onparentPageId. The query construction with Kysely is sound.
There was a problem hiding this comment.
Actionable comments posted: 2
♻️ Duplicate comments (1)
apps/client/src/pages/share-target/share-target.tsx (1)
88-98: Guard root pages query so it doesn’t fire with an emptyspaceId
useGetRootSidebarPagesQuery({ spaceId: selectedSpace || "" })will run withspaceId: ""before any space is chosen, and the hook itself has noenabledguard. That can trigger an API call with an invalidspaceId.Consider either:
- Changing this call to only run when
selectedSpaceexists (e.g. passundefined/nulland add anenabledoption insideuseGetRootSidebarPagesQuery), or- Reusing
useGetSidebarPagesQueryfor root listings, which already has anenabledguard onspaceId.
🧹 Nitpick comments (3)
apps/client/src/pages/share-target/share-target.tsx (1)
359-455: UseisSearchingfor better search UX in the parent page pickerYou maintain
isSearchingstate but only show loaders based onisLoadingPages. During a search, the dropdown can briefly show “No pages found” while results are loading.You can improve this by:
- Showing a loader when
isSearchingis true.- Suppressing the “No pages found” state while a search is in progress.
For example:
- {isLoadingPages && ( + {(isLoadingPages || isSearching) && ( @@ - {!isLoadingPages && pageItems.length === 0 && ( + {!isLoadingPages && !isSearching && pageItems.length === 0 && ( <Combobox.Empty>{t("No pages found")}</Combobox.Empty> )}apps/server/src/integrations/import/services/import.service.ts (2)
92-101: Parent page validation + position lookup are sound; consider also validating workspaceValidating
parentPageIdviapageRepo.findByIdand checkingparentPage.spaceId === spaceIdprevents linking to a parent in another space, and passingparentPageIdintogetNewPagePositionensures children are positioned within their parent’s sequence.Optionally, you might also assert that the parent’s
workspaceIdmatches the providedworkspaceId(if not already guaranteed by data model) to harden cross-workspace isolation further.
102-114: Parent-child linkage ininsertPageand logging look good, with a tiny log string nitSetting
parentPageId: parentPageId ?? nullcorrectly normalizes the root vs child cases, and the rest of the payload (slugId, title, ydoc, textContent, etc.) is consistent with existing behavior.Minor nit: the debug string has an extra quote after the filename:
`Successfully imported "${file.filename}. ID: ${createdPage.id} - SlugId: ${createdPage.slugId}"` ^ extra quote before the dotYou probably meant:
`Successfully imported "${file.filename}". ID: ${createdPage.id} - SlugId: ${createdPage.slugId}`
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (2)
apps/client/src/pages/share-target/share-target.tsx(1 hunks)apps/server/src/integrations/import/services/import.service.ts(6 hunks)
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2025-08-07T21:44:49.482Z
Learnt from: lukasbach
Repo: docmost/docmost PR: 1457
File: apps/client/src/features/page/tree/components/space-tree.tsx:520-525
Timestamp: 2025-08-07T21:44:49.482Z
Learning: In the Docmost headless tree implementation (apps/client/src/features/page/tree/components/space-tree.tsx), the useEffect in the PageArrow component intentionally omits the dependency array. This allows isFolder() to run on subsequent renders as needed, since the item reference is stable and wouldn't trigger re-runs when required. This pattern is intentional for performance reasons in the tree component.
Applied to files:
apps/client/src/pages/share-target/share-target.tsx
🧬 Code graph analysis (1)
apps/client/src/pages/share-target/share-target.tsx (4)
apps/client/src/features/search/types/search.types.ts (1)
IPageSearch(6-18)apps/client/src/features/space/queries/space-query.ts (1)
useGetSpacesQuery(34-43)apps/client/src/features/page/queries/page-query.ts (2)
useGetRootSidebarPagesQuery(265-277)useGetSidebarPagesQuery(250-263)apps/server/src/core/search/search.service.ts (1)
searchPage(23-134)
🔇 Additional comments (5)
apps/client/src/pages/share-target/share-target.tsx (2)
123-210: Content extraction and URL-only handling look solidThe
loadSharedContentflow correctly prefers cached share payloads, falls back to URL params, normalizes the title vs first-line duplication, and now derives a sensible title fromurlwhen no title/text are present. The early aborts on fully empty input and the finalfinallyblock updatingisProcessingall look correct.
267-289: Good handling of empty/failed share casesThe early-return branches for
(isProcessing || isLoadingSpaces)and for!sharedData && !isProcessinggive clear feedback (“Processing…” vs “No content to share found”) and avoid rendering the full form without data. That guards against weird partial states when cache/URL parsing fails.apps/server/src/integrations/import/services/import.service.ts (3)
78-90: Title extraction vs provided title is now correct and avoids shadowingThe
contentTitle/prosemirrorJsonhandling now cleanly distinguishes between:
- No/empty
title→ useextractTitleAndRemoveHeadingresult.- Provided
title→ keep full ProseMirror state.This fixes the previous shadowing issue where outer variables stayed
undefined.pageTitlefalling back tofileNameis also a sensible default.
126-127: Return type now matches actual behavior ofimportPageReturning
{ id, slugId } | nullaligns the signature with what callers actually use and avoids the previousPromise<void>vs return-value mismatch.
194-218: Positioning logic for root vs child pages is correctUpdating
getNewPagePositionto:
- Always filter by
spaceId, and- Add
parentPageIdfilter when provided, orIS NULLfor root pages,ensures new pages are appended at the end of the appropriate sibling list. Using
generateJitteredKeyBetween(lastPage.position, null)is consistent with the existing fractional indexing approach.
| // Import Logic | ||
| const handleImport = async () => { | ||
| if (!selectedSpace || !sharedData) return; | ||
|
|
||
| setIsImporting(true); | ||
| try { | ||
| const { title, text } = sharedData; | ||
|
|
||
| const safeTitle = (title || "Shared page") | ||
| .replace(/[\\\/:*?"<>|]+/g, "-") | ||
| .trim() | ||
| .slice(0, 120); | ||
| const blob = new Blob([text], { type: "text/markdown" }); | ||
| const formData = new FormData(); | ||
| // Page import uses the filename as the title | ||
| formData.append("spaceId", selectedSpace); | ||
| formData.append("title", title); | ||
| formData.append("file", blob, `${safeTitle}.md`); | ||
|
|
||
| if (selectedParentPage) { | ||
| formData.append("parentPageId", selectedParentPage); | ||
| } | ||
|
|
||
| const response = await api.post("/pages/import", formData); | ||
|
|
||
| const newPage = response.data; | ||
|
|
||
| notifications.show({ | ||
| message: t("Page created successfully"), | ||
| }); | ||
|
|
||
| if (newPage?.slugId) { | ||
| navigate(`/s/${selectedSpace}/p/${newPage.slugId}`, { replace: true }); | ||
| } else { | ||
| navigate("/home", { replace: true }); | ||
| } | ||
|
|
||
| } catch (error: any) { | ||
| console.error("Import failed:", error); | ||
| notifications.show({ | ||
| message: error?.response?.data?.message || t("Failed to create page"), | ||
| color: "red", | ||
| }); | ||
| } finally { | ||
| setIsImporting(false); | ||
| } | ||
| }; |
There was a problem hiding this comment.
Avoid double-submits and keep navigation space consistent in handleImport
Two small robustness tweaks here:
- The “Save Page” button is still clickable while
isImportingistrue, so fast taps can create duplicate pages. navigateuses the currentselectedSpace, which might change while the import request is in flight, potentially building a URL with a different space than the one used to create the page.
Suggested adjustments:
- const handleImport = async () => {
- if (!selectedSpace || !sharedData) return;
+ const handleImport = async () => {
+ if (!selectedSpace || !sharedData || isImporting) return;
+ const spaceId = selectedSpace;
@@
- formData.append("spaceId", selectedSpace);
+ formData.append("spaceId", spaceId);
@@
- if (newPage?.slugId) {
- navigate(`/s/${selectedSpace}/p/${newPage.slugId}`, { replace: true });
+ if (newPage?.slugId) {
+ navigate(`/s/${spaceId}/p/${newPage.slugId}`, { replace: true });
} else {
navigate("/home", { replace: true });
}And optionally also disable the button while importing:
- <Button
- onClick={handleImport}
- loading={isImporting}
- disabled={!selectedSpace}
- >
+ <Button
+ onClick={handleImport}
+ loading={isImporting}
+ disabled={!selectedSpace || isImporting}
+ >There was a problem hiding this comment.
Actionable comments posted: 0
♻️ Duplicate comments (1)
apps/client/src/pages/share-target/share-target.tsx (1)
89-91: Still firing root-pages query with emptyspaceId.This issue was flagged in a previous review but remains unresolved.
useGetRootSidebarPagesQuery({ spaceId: selectedSpace || "" })will execute withspaceId: ""when no space is selected, potentially causing an invalid API call.The recommended fix is to add an
enabledguard:- const { data: rootPagesData, isLoading: isLoadingRoot } = useGetRootSidebarPagesQuery({ - spaceId: selectedSpace || "", - }); + const { data: rootPagesData, isLoading: isLoadingRoot } = useGetRootSidebarPagesQuery( + { spaceId: selectedSpace || "" }, + { enabled: !!selectedSpace } + );Alternatively, modify the
useGetRootSidebarPagesQueryhook definition to include an internalenabled: !!data.spaceIdguard.
🧹 Nitpick comments (3)
apps/client/src/pages/share-target/share-target.tsx (3)
100-124: Minor: search results not cleared when search becomes empty.When
debouncedSearchValuebecomes empty or falsy, the effect doesn't execute, leaving oldsearchResultsin state. While line 305 prevents these stale results from being displayed, clearing them would improve state hygiene.Optional refactor:
useEffect(() => { + if (!debouncedSearchValue || !selectedSpace) { + setSearchResults([]); + return; + } + const fetchSearchResults = async () => { - if (debouncedSearchValue && selectedSpace) { const trimmed = debouncedSearchValue.trim(); if (trimmed && selectedSpace) { setIsSearching(true); try { const results = await searchPage({ query: trimmed, spaceId: selectedSpace, }); setSearchResults(results); } catch (error) { console.error("Search failed", error); } finally { setIsSearching(false); } } else { setSearchResults([]); } - } }; fetchSearchResults(); }, [debouncedSearchValue, selectedSpace]);Also note: the
selectedSpacecheck on line 104 is redundant (already checked on line 102).
174-178: Use strict equality operators.Lines 174 and 177 use loose equality (
==) instead of strict equality (===). While functionally equivalent here, strict equality is a best practice in JavaScript/TypeScript.- } else if (url == null || url.trim() == '') { + } else if (url == null || url.trim() === '') { return; // Abort } - } else if (url == null || url.trim() == '') { + } else if (url == null || url.trim() === '') { return; // Abort }
223-268: Optional: captureselectedSpacebefore async operations for consistency.While double-submit prevention is correctly implemented, capturing
selectedSpacein a local variable at the start ofhandleImportwould guard against the theoretical race where the user changes the selected space while the import request is in flight.const handleImport = async () => { if (!selectedSpace || !sharedData || isImporting) return; + const spaceId = selectedSpace; setIsImporting(true); try { const { title, text } = sharedData; const safeTitle = (title || "Shared page") .replace(/[\\\/:*?"<>|]+/g, "-") .trim() .slice(0, 120); const blob = new Blob([text], { type: "text/markdown" }); const formData = new FormData(); // Page import uses the filename as the title - formData.append("spaceId", selectedSpace); + formData.append("spaceId", spaceId); formData.append("title", title); formData.append("file", blob, `${safeTitle}.md`); if (selectedParentPage) { formData.append("parentPageId", selectedParentPage); } const response = await api.post("/pages/import", formData); const newPage = response.data; notifications.show({ message: t("Page created successfully"), }); if (newPage?.slugId) { - navigate(`/s/${selectedSpace}/p/${newPage.slugId}`, { replace: true }); + navigate(`/s/${spaceId}/p/${newPage.slugId}`, { replace: true }); } else { navigate("/home", { replace: true }); }
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (3)
apps/client/src/pages/share-target/share-target.tsx(1 hunks)apps/server/src/integrations/import/import.controller.ts(3 hunks)apps/server/src/integrations/import/services/import.service.ts(6 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
- apps/server/src/integrations/import/import.controller.ts
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2025-08-07T21:44:49.482Z
Learnt from: lukasbach
Repo: docmost/docmost PR: 1457
File: apps/client/src/features/page/tree/components/space-tree.tsx:520-525
Timestamp: 2025-08-07T21:44:49.482Z
Learning: In the Docmost headless tree implementation (apps/client/src/features/page/tree/components/space-tree.tsx), the useEffect in the PageArrow component intentionally omits the dependency array. This allows isFolder() to run on subsequent renders as needed, since the item reference is stable and wouldn't trigger re-runs when required. This pattern is intentional for performance reasons in the tree component.
Applied to files:
apps/client/src/pages/share-target/share-target.tsx
🧬 Code graph analysis (1)
apps/client/src/pages/share-target/share-target.tsx (4)
apps/client/src/features/search/types/search.types.ts (1)
IPageSearch(6-18)apps/client/src/features/space/queries/space-query.ts (1)
useGetSpacesQuery(34-43)apps/client/src/features/page/queries/page-query.ts (2)
useGetRootSidebarPagesQuery(265-277)useGetSidebarPagesQuery(250-263)apps/server/src/core/search/search.service.ts (1)
searchPage(23-134)
🔇 Additional comments (8)
apps/server/src/integrations/import/services/import.service.ts (6)
1-1: LGTM! Import added for parent page validation.The
NotFoundExceptionimport is properly used in the parent page validation logic below.
41-48: LGTM! Method signature properly updated.The signature changes correctly support hierarchical page creation and return the created page identifiers. The optional parameters maintain backward compatibility.
78-90: LGTM! Variable shadowing issue resolved.The title extraction logic now correctly assigns to the outer variables, fixing the shadowing issue flagged in previous reviews. The conditional logic properly handles both explicit title provision and content-derived titles.
194-218: LGTM! Query logic correctly handles hierarchical positioning.The conditional filtering properly distinguishes between:
- Subpage creation: finds the last page with the specified parent
- Top-level page creation: finds the last page where parentPageId is null
The use of
IS NULLfor null comparison is correct SQL, and the fractional indexing handles both existing and empty cases.
113-126: LGTM! Page creation and return value properly implemented.The changes correctly:
- Persist the parent-child relationship using
parentPageId ?? null- Log the actual filename for traceability
- Return the created page identifiers, enabling callers to navigate to the newly created page
94-99: The parent page validation logic is correct.PageRepo.findByIdreturnsPage | undefined, and the code properly handles both cases by checking for existence (!parentPage) and verifying space membership (parentPage.spaceId !== spaceId). ThespaceIdproperty is non-optional in the Pages schema and always present. The validation prevents orphaned pages and cross-space references as intended.apps/client/src/pages/share-target/share-target.tsx (2)
1-78: LGTM: Clean imports and well-organized state setup.The component structure is solid. Persistent state via localStorage for space/parent selection is a good UX choice, and the separation of concerns between processing, importing, and search states is clear.
337-495: LGTM: Well-structured UI with good UX patterns.The hierarchical page selector with breadcrumb navigation, search, and visual indicators (folder/file icons) provides an intuitive experience. State management correctly resets dependent fields when the space changes, and loading/disabled states are properly handled throughout.
Very minor: the inline styles on line 412 for sticky positioning could be extracted to a constant or style object for consistency, but this is a nitpick.
Added option to create a new subpage in a selected workspace when using Share To Docmost on Android.
Tested on Android and self-hosted instance, but uses standard PWA capabilities so, should work for other setups.
Summary by CodeRabbit
New Features
Bug Fixes
Style
✏️ Tip: You can customize this high-level summary in your review settings.