From 95a596e6511a845b9bd07afcf3423e24305609c6 Mon Sep 17 00:00:00 2001 From: "Nathan.fooo" <86001920+appflowy@users.noreply.github.com> Date: Wed, 26 Nov 2025 15:50:40 +0800 Subject: [PATCH] Fix namespace popover (#172) * fix: open publish manage modal after closing share popover * chore: update log --- cypress/e2e/page/publish-page.cy.ts | 39 +++++++++++ cypress/support/selectors.ts | 3 + deploy/server.ts | 28 +++++--- .../app/publish-manage/PublishManage.tsx | 2 +- .../app/share/PublishLinkPreview.tsx | 33 +--------- src/components/app/share/PublishPanel.tsx | 14 +++- src/components/app/share/ShareButton.tsx | 64 ++++++++++++++----- src/components/app/share/ShareTabs.tsx | 38 ++++++++--- vite.config.ts | 64 ++++++++++++++++++- 9 files changed, 220 insertions(+), 65 deletions(-) diff --git a/cypress/e2e/page/publish-page.cy.ts b/cypress/e2e/page/publish-page.cy.ts index 972255e1..6102d8b8 100644 --- a/cypress/e2e/page/publish-page.cy.ts +++ b/cypress/e2e/page/publish-page.cy.ts @@ -693,6 +693,45 @@ describe('Publish Page Test', () => { }); }); + it('opens publish manage modal from namespace caret and closes share popover first', () => { + cy.on('uncaught:exception', (err: Error) => { + if (err.message.includes('No workspace or service found') || + err.message.includes('createThemeNoVars_default is not a function') || + err.message.includes('View not found')) { + return false; + } + return true; + }); + + cy.visit('/login', { failOnStatusCode: false }); + cy.wait(1000); + const authUtils = new AuthTestUtils(); + authUtils.signInWithTestUrl(testEmail).then(() => { + cy.url().should('include', '/app'); + SidebarSelectors.pageHeader().should('be.visible', { timeout: 30000 }); + PageSelectors.names().should('exist', { timeout: 30000 }); + cy.wait(2000); + + TestTool.openSharePopover(); + cy.contains('Publish').should('exist').click({ force: true }); + cy.wait(1000); + + ShareSelectors.publishConfirmButton().should('be.visible').click({ force: true }); + cy.wait(5000); + ShareSelectors.publishNamespace().should('be.visible', { timeout: 10000 }); + + ShareSelectors.sharePopover().should('exist'); + ShareSelectors.openPublishSettingsButton().should('be.visible').click({ force: true }); + + ShareSelectors.sharePopover().should('not.exist'); + ShareSelectors.publishManageModal().should('be.visible'); + ShareSelectors.publishManagePanel().should('be.visible').contains('Namespace'); + + cy.get('body').type('{esc}'); + ShareSelectors.publishManageModal().should('not.exist'); + }); + }); + it('publish database (To-dos) and visit published link', () => { cy.on('uncaught:exception', (err: Error) => { if (err.message.includes('No workspace or service found') || diff --git a/cypress/support/selectors.ts b/cypress/support/selectors.ts index f8a1aab5..37fb4832 100644 --- a/cypress/support/selectors.ts +++ b/cypress/support/selectors.ts @@ -177,6 +177,7 @@ export const ShareSelectors = { // Publish namespace and name inputs publishNamespace: () => cy.get(byTestId('publish-namespace')), publishNameInput: () => cy.get(byTestId('publish-name-input')), + openPublishSettingsButton: () => cy.get(byTestId('open-publish-settings')), // Page settings button pageSettingsButton: () => cy.get(byTestId('page-settings-button')), @@ -195,6 +196,8 @@ export const ShareSelectors = { // Visit Site button visitSiteButton: () => cy.get(byTestId('visit-site-button')), + publishManageModal: () => cy.get(byTestId('publish-manage-modal')), + publishManagePanel: () => cy.get(byTestId('publish-manage-panel')), }; /** diff --git a/deploy/server.ts b/deploy/server.ts index b6195826..36485c85 100644 --- a/deploy/server.ts +++ b/deploy/server.ts @@ -26,10 +26,9 @@ const logger = pino({ options: { colorize: true, translateTime: 'SYS:standard', - destination: `${__dirname}/pino-logger.log`, }, }, - level: 'info', + level: process.env.LOG_LEVEL || 'info', }); const logRequestTimer = (req: Request) => { @@ -51,7 +50,7 @@ const fetchMetaData = async (namespace: string, publishName?: string) => { url = `${baseURL}/api/workspace/v1/published/${namespace}/${publishName}`; } - logger.info(`Fetching meta data from ${url}`); + logger.debug(`Fetching meta data from ${url}`); try { const response = await fetch(url, { verbose: true, @@ -63,11 +62,11 @@ const fetchMetaData = async (namespace: string, publishName?: string) => { const data = await response.json(); - logger.info(`Fetched meta data from ${url}: ${JSON.stringify(data)}`); + logger.debug(`Fetched meta data from ${url}: ${JSON.stringify(data)}`); return data; } catch (error) { - logger.error(`Error fetching meta data ${error}`); + logger.error(`Failed to fetch meta data from ${url}: ${error}`); return null; } }; @@ -130,24 +129,27 @@ const createServer = async (req: Request) => { } let metaData; + let redirectAttempted = false; try { const data = await fetchMetaData(namespace, publishName); if (publishName) { - if (data.code === 0) { + if (data && data.code === 0) { metaData = data.data; } else { - logger.error(`Error fetching meta data: ${JSON.stringify(data)}`); + logger.error( + `Publish view lookup failed for namespace="${namespace}" publishName="${publishName}" response=${JSON.stringify(data)}` + ); } } else { - const publishInfo = data?.data?.info; - if (publishInfo) { + if (publishInfo?.namespace && publishInfo?.publish_name) { const newURL = `/${encodeURIComponent(publishInfo.namespace)}/${encodeURIComponent(publishInfo.publish_name)}`; logger.info(`Redirecting to default page in: ${JSON.stringify(publishInfo)}`); + redirectAttempted = true; timer(); return new Response(null, { status: 302, @@ -155,6 +157,8 @@ const createServer = async (req: Request) => { Location: newURL, }, }); + } else { + logger.warn(`Namespace "${namespace}" has no default publish page. response=${JSON.stringify(data)}`); } } } catch (error) { @@ -219,6 +223,12 @@ const createServer = async (req: Request) => { logger.error(`Error injecting meta data: ${error}`); } + if (!metaData) { + logger.warn( + `Serving fallback landing page for namespace="${namespace}" publishName="${publishName ?? ''}". redirectAttempted=${redirectAttempted}` + ); + } + $('title').text(title); $('link[rel="icon"]').attr('href', favicon); $('link[rel="canonical"]').attr('href', url); diff --git a/src/components/app/publish-manage/PublishManage.tsx b/src/components/app/publish-manage/PublishManage.tsx index b07442f5..e0092ec0 100644 --- a/src/components/app/publish-manage/PublishManage.tsx +++ b/src/components/app/publish-manage/PublishManage.tsx @@ -215,7 +215,7 @@ export function PublishManage({ onClose }: { onClose?: () => void }) { const url = `${window.location.origin}/${namespace}`; return ( -
+
{t('namespace')}
{t('manageNamespaceDescription')}
diff --git a/src/components/app/share/PublishLinkPreview.tsx b/src/components/app/share/PublishLinkPreview.tsx index b410c7ce..3d6fc1df 100644 --- a/src/components/app/share/PublishLinkPreview.tsx +++ b/src/components/app/share/PublishLinkPreview.tsx @@ -5,9 +5,7 @@ import { useTranslation } from 'react-i18next'; import { UpdatePublishConfigPayload } from '@/application/types'; import { ReactComponent as LinkIcon } from '@/assets/icons/link.svg'; import { ReactComponent as DownIcon } from '@/assets/icons/toggle_list.svg'; -import { NormalModal } from '@/components/_shared/modal'; import { notify } from '@/components/_shared/notify'; -import { PublishManage } from '@/components/app/publish-manage'; import { PublishNameSetting } from '@/components/app/publish-manage/PublishNameSetting'; import { copyTextToClipboard } from '@/utils/copy'; @@ -20,6 +18,7 @@ function PublishLinkPreview({ isOwner, isPublisher, onClose, + onOpenPublishManage, }: { viewId: string; publishInfo: { namespace: string; publishName: string }; @@ -29,8 +28,8 @@ function PublishLinkPreview({ isOwner: boolean; isPublisher: boolean; onClose?: () => void; + onOpenPublishManage?: () => void; }) { - const [siteOpen, setSiteOpen] = React.useState(false); const [renameOpen, setRenameOpen] = React.useState(false); const { t } = useTranslation(); const [publishName, setPublishName] = React.useState(publishInfo.publishName); @@ -78,8 +77,8 @@ function PublishLinkPreview({ { - setSiteOpen(true); onClose?.(); + onOpenPublishManage?.(); }} data-testid={'open-publish-settings'} > @@ -161,32 +160,6 @@ function PublishLinkPreview({ url={url} /> )} - { - setSiteOpen(false); - }} - scroll={'paper'} - open={siteOpen} - title={
{t('settings.sites.title')}
} - > -
- { - setSiteOpen(false); - }} - /> -
-
); diff --git a/src/components/app/share/PublishPanel.tsx b/src/components/app/share/PublishPanel.tsx index 90aa088f..1bfaaccd 100644 --- a/src/components/app/share/PublishPanel.tsx +++ b/src/components/app/share/PublishPanel.tsx @@ -13,7 +13,17 @@ import { useAppHandlers } from '@/components/app/app.hooks'; import { useLoadPublishInfo } from '@/components/app/share/publish.hooks'; import PublishLinkPreview from '@/components/app/share/PublishLinkPreview'; -function PublishPanel({ viewId, opened, onClose }: { viewId: string; onClose: () => void; opened: boolean }) { +function PublishPanel({ + viewId, + opened, + onClose, + onOpenPublishManage, +}: { + viewId: string; + onClose: () => void; + opened: boolean; + onOpenPublishManage?: () => void; +}) { const { t } = useTranslation(); const { publish, unpublish } = useAppHandlers(); const { url, loadPublishInfo, view, publishInfo, loading, isOwner, isPublisher, updatePublishConfig } = @@ -92,6 +102,7 @@ function PublishPanel({ viewId, opened, onClose }: { viewId: string; onClose: () isOwner={isOwner} isPublisher={isPublisher} onClose={onClose} + onOpenPublishManage={onOpenPublishManage} />
- - + + + + + + setOpened(false)} + onOpenPublishManage={() => { + setOpened(false); + setPublishManageOpen(true); + }} + /> + + + setPublishManageOpen(false)} + scroll='paper' + overflowHidden + okButtonProps={{ + className: 'hidden', + }} + cancelButtonProps={{ + className: 'hidden', + }} + classes={{ + paper: 'w-[700px] appflowy-scroller max-w-[90vw] max-h-[90vh] h-[600px] overflow-hidden', + }} + title={
{t('settings.sites.title')}
} > - setOpened(false)} /> -
- +
+ setPublishManageOpen(false)} /> +
+ + ); } diff --git a/src/components/app/share/ShareTabs.tsx b/src/components/app/share/ShareTabs.tsx index 87f306e4..ba76be0e 100644 --- a/src/components/app/share/ShareTabs.tsx +++ b/src/components/app/share/ShareTabs.tsx @@ -17,7 +17,17 @@ enum TabKey { TEMPLATE = 'template', } -function ShareTabs({ opened, viewId, onClose }: { opened: boolean; viewId: string; onClose: () => void }) { +function ShareTabs({ + opened, + viewId, + onClose, + onOpenPublishManage, +}: { + opened: boolean; + viewId: string; + onClose: () => void; + onOpenPublishManage?: () => void; +}) { const { t } = useTranslation(); const view = useAppView(viewId); const [value, setValue] = React.useState(TabKey.SHARE); @@ -43,12 +53,19 @@ function ShareTabs({ opened, viewId, onClose }: { opened: boolean; viewId: strin icon: , Panel: TemplatePanel, }, - ].filter(Boolean) as { - value: TabKey; - label: string; - icon?: React.JSX.Element; - Panel: React.FC<{ viewId: string; onClose: () => void; opened: boolean }>; - }[]; + ].filter(Boolean) as Array< + { + value: TabKey; + label: string; + icon?: React.JSX.Element; + Panel: React.FC<{ + viewId: string; + onClose: () => void; + opened: boolean; + onOpenPublishManage?: () => void; + }>; + } + >; }, [currentUser?.email, t, view?.is_published]); useEffect(() => { @@ -76,7 +93,12 @@ function ShareTabs({ opened, viewId, onClose }: { opened: boolean; viewId: strin {options.map((option) => ( - + ))} diff --git a/vite.config.ts b/vite.config.ts index 9f7efe61..58084b2b 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -10,14 +10,76 @@ import { totalBundleSize } from 'vite-plugin-total-bundle-size'; import { stripTestIdPlugin } from './vite-plugin-strip-testid'; const resourcesPath = path.resolve(__dirname, '../resources'); -const isDev = process.env.NODE_ENV === 'development'; +const isDev = process.env.NODE_ENV ? process.env.NODE_ENV === 'development' : true; const isProd = process.env.NODE_ENV === 'production'; const isTest = process.env.NODE_ENV === 'test' || process.env.COVERAGE === 'true'; +// Namespace redirect plugin for dev mode - mirrors deploy/server.ts behavior +function namespaceRedirectPlugin() { + const baseURL = process.env.APPFLOWY_BASE_URL || 'http://localhost:8000'; + + return { + name: 'namespace-redirect', + apply: 'serve' as const, + configureServer(server: { middlewares: { use: (fn: (req: { url?: string; method?: string }, res: { statusCode: number; setHeader: (name: string, value: string) => void; end: () => void }, next: () => void) => void) => void } }) { + const ignoredPrefixes = ['/app', '/login', '/import', '/after-payment', '/as-template', '/accept-invitation', '/404']; + + server.middlewares.use(async (req, res, next) => { + if (!req.url || req.method !== 'GET') { + return next(); + } + + const url = new URL(req.url, 'http://localhost'); + const pathname = url.pathname; + + // Skip ignored prefixes and root + if (pathname === '/' || ignoredPrefixes.some((prefix) => pathname.startsWith(prefix))) { + return next(); + } + + const parts = pathname.split('/').filter(Boolean); + + // Skip if not a single-segment path (namespace only) or if it's a static asset/dev file + const isStaticAsset = /\.(js|css|html|map|json|png|jpg|jpeg|gif|svg|woff2?|ttf)$/i.test(pathname); + if (parts.length !== 1 || isStaticAsset || pathname.includes('@') || pathname.includes('node_modules') || pathname.startsWith('/src/')) { + return next(); + } + + try { + // Fetch publish info for this namespace (same API as deploy/server.ts) + const apiUrl = `${baseURL}/api/workspace/published/${parts[0]}`; + const response = await fetch(apiUrl); + + if (!response.ok) { + return next(); + } + + const data = await response.json(); + const publishInfo = data?.data?.info; + + if (publishInfo?.namespace && publishInfo?.publish_name) { + const redirectUrl = `/${encodeURIComponent(publishInfo.namespace)}/${encodeURIComponent(publishInfo.publish_name)}`; + res.statusCode = 302; + res.setHeader('Location', redirectUrl); + res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); + res.end(); + return; + } + } catch { + // Silently fail and let the request continue + } + + next(); + }); + }, + }; +} + // https://vitejs.dev/config/ export default defineConfig({ plugins: [ react(), + isDev ? namespaceRedirectPlugin() : undefined, // Strip data-testid attributes in production builds isProd ? stripTestIdPlugin() : undefined, createHtmlPlugin({