mirror of
https://github.com/AppFlowy-IO/AppFlowy-Web.git
synced 2025-11-29 02:38:00 +08:00
Fix namespace popover (#172)
* fix: open publish manage modal after closing share popover * chore: update log
This commit is contained in:
@@ -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') ||
|
||||
|
||||
@@ -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')),
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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'} />
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user