Fix namespace popover (#172)

* fix: open publish manage modal after closing share popover

* chore: update log
This commit is contained in:
Nathan.fooo
2025-11-26 15:50:40 +08:00
committed by GitHub
parent ed24f05183
commit 95a596e651
9 changed files with 220 additions and 65 deletions

View File

@@ -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') ||

View File

@@ -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')),
};
/**

View File

@@ -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);

View File

@@ -215,7 +215,7 @@ export function PublishManage({ onClose }: { onClose?: () => void }) {
const url = `${window.location.origin}/${namespace}`;
return (
<div className={'flex flex-col gap-2'}>
<div className={'flex flex-col gap-2'} data-testid='publish-manage-panel'>
<div className={'px-1 text-base font-medium'}>{t('namespace')}</div>
<div className={'px-1 text-xs text-text-secondary'}>{t('manageNamespaceDescription')}</div>
<Divider className={'mb-2'} />

View File

@@ -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<boolean>(false);
const [renameOpen, setRenameOpen] = React.useState<boolean>(false);
const { t } = useTranslation();
const [publishName, setPublishName] = React.useState<string>(publishInfo.publishName);
@@ -78,8 +77,8 @@ function PublishLinkPreview({
<IconButton
size={'small'}
onClick={() => {
setSiteOpen(true);
onClose?.();
onOpenPublishManage?.();
}}
data-testid={'open-publish-settings'}
>
@@ -161,32 +160,6 @@ function PublishLinkPreview({
url={url}
/>
)}
<NormalModal
okButtonProps={{
className: 'hidden',
}}
cancelButtonProps={{
className: 'hidden',
}}
classes={{
paper: 'w-[700px] appflowy-scroller max-w-[90vw] max-h-[90vh] h-[600px] overflow-hidden',
}}
overflowHidden
onClose={() => {
setSiteOpen(false);
}}
scroll={'paper'}
open={siteOpen}
title={<div className={'flex items-center justify-start'}>{t('settings.sites.title')}</div>}
>
<div className={'h-full w-full overflow-y-auto overflow-x-hidden'}>
<PublishManage
onClose={() => {
setSiteOpen(false);
}}
/>
</div>
</NormalModal>
</div>
</>
);

View File

@@ -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}
/>
<div className={'flex w-full items-center justify-end gap-4'}>
<Button
@@ -157,6 +168,7 @@ function PublishPanel({ viewId, opened, onClose }: { viewId: string; onClose: ()
duplicateEnabled,
updatePublishConfig,
viewId,
onOpenPublishManage,
]);
const layout = view?.layout;

View File

@@ -4,8 +4,10 @@ import { useTranslation } from 'react-i18next';
import { ViewLayout } from '@/application/types';
import { useAppView } from '@/components/app/app.hooks';
import ShareTabs from '@/components/app/share/ShareTabs';
import { PublishManage } from '@/components/app/publish-manage';
import { Button } from '@/components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { NormalModal } from '@/components/_shared/modal';
export function ShareButton({ viewId }: { viewId: string }) {
const { t } = useTranslation();
@@ -13,10 +15,12 @@ export function ShareButton({ viewId }: { viewId: string }) {
const view = useAppView(viewId);
const layout = view?.layout;
const [opened, setOpened] = React.useState(false);
const [publishManageOpen, setPublishManageOpen] = React.useState(false);
if (layout === ViewLayout.AIChat) return null;
return (
<>
<Popover open={opened} onOpenChange={setOpened}>
<PopoverTrigger asChild>
<Button className={'mx-2'} data-testid={'share-button'} size={'sm'} variant={'default'}>
@@ -30,9 +34,39 @@ export function ShareButton({ viewId }: { viewId: string }) {
className={'h-fit min-w-[480px] max-w-[480px]'}
data-testid={'share-popover'}
>
<ShareTabs opened={opened} viewId={viewId} onClose={() => setOpened(false)} />
<ShareTabs
opened={opened}
viewId={viewId}
onClose={() => setOpened(false)}
onOpenPublishManage={() => {
setOpened(false);
setPublishManageOpen(true);
}}
/>
</PopoverContent>
</Popover>
<NormalModal
data-testid='publish-manage-modal'
open={publishManageOpen}
onClose={() => 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={<div className={'flex items-center justify-start'}>{t('settings.sites.title')}</div>}
>
<div className={'h-full w-full overflow-y-auto overflow-x-hidden'}>
<PublishManage onClose={() => setPublishManageOpen(false)} />
</div>
</NormalModal>
</>
);
}

View File

@@ -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>(TabKey.SHARE);
@@ -43,12 +53,19 @@ function ShareTabs({ opened, viewId, onClose }: { opened: boolean; viewId: strin
icon: <Templates className={'mb-0 h-5 w-5'} />,
Panel: TemplatePanel,
},
].filter(Boolean) as {
].filter(Boolean) as Array<
{
value: TabKey;
label: string;
icon?: React.JSX.Element;
Panel: React.FC<{ viewId: string; onClose: () => void; opened: boolean }>;
}[];
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
<Separator className='my-0' />
{options.map((option) => (
<TabsContent key={option.value} value={option.value}>
<option.Panel viewId={viewId} onClose={onClose} opened={opened} />
<option.Panel
viewId={viewId}
onClose={onClose}
opened={opened}
onOpenPublishManage={onOpenPublishManage}
/>
</TabsContent>
))}
</Tabs>

View File

@@ -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({