Publish new posts fix #2

Merged
crowetic merged 4 commits from :update into main 2025-09-16 20:18:59 +00:00
12 changed files with 226 additions and 17 deletions
+10
View File
@@ -0,0 +1,10 @@
# Q-Blog v0.3.2 Release Notes
## Fixes
- Resolved an issue where creating a brand-new post and confirming "Publish" would silently close the modal instead of requesting the transaction. The publish flow now correctly detects the active blog and surfaces clear errors if the context is missing.
- Updated the embedded content publishing panels (video, audio, file, image, poll) to use consistent "Publish" wording and to emit notifications when QDN publishes fail, making it easier to understand what went wrong.
## Testing
- Added end-to-end Vitest coverage for the new-post publish modal, exercising both the success path and the missing-blog error case so regressions are caught automatically before release.
+11
View File
@@ -0,0 +1,11 @@
# Q-Blog v0.3.2 User Announcement
Hi everyone,
We discovered that publishing brand-new posts from the "Create Post" button sometimes closed the modal without ever prompting for a transaction. That should never happen, and we're sorry both for the disruption and for not catching the bug earlier with better tests. The flow now confirms which blog you're publishing to and reports a clear error if anything is missing so you can retry immediately.
While we were in there we also cleaned up the embedded content publishers—video, audio, files, images, and polls now use consistent "Publish" terminology, and youll see explicit notifications whenever an upload to QDN fails.
To keep this from happening again, we added automated Vitest coverage that drives the new-post modal end to end, checking both the happy path and the missing-blog error path. These tests now run with the suite so we'll spot regressions before they reach you.
Thanks for your patience, and please let us know if anything still looks off.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "q-blog",
"version": "0.3.0",
"version": "0.3.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "q-blog",
"version": "0.3.0",
"version": "0.3.2",
"dependencies": {
"@emotion/react": "^11.10.6",
"@emotion/styled": "^11.10.6",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "q-blog",
"private": true,
"version": "0.3.1",
"version": "0.3.2",
"type": "module",
"scripts": {
"dev": "vite",
+9 -1
View File
@@ -20,6 +20,8 @@ import { toBase64 } from '../../utils/toBase64';
import AddIcon from '@mui/icons-material/Add';
import CloseIcon from '@mui/icons-material/Close';
import { usePublishAudio } from './PublishAudio';
import { useDispatch } from 'react-redux';
import { setNotification } from '../../state/features/notificationsSlice';
const StyledModal = styled(Modal)(({ theme }) => ({
display: 'flex',
@@ -141,6 +143,7 @@ export const AudioModal: React.FC<VideoModalProps> = ({
onPublish,
editVideoIdentifier,
}) => {
const dispatch = useDispatch();
const [file, setFile] = useState<File | null>(null);
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
@@ -189,7 +192,12 @@ export const AudioModal: React.FC<VideoModalProps> = ({
if (missingFields.length > 0) {
const missingFieldsString = missingFields.join(', ');
const errMsg = `Missing: ${missingFieldsString}`;
dispatch(
setNotification({
msg: errMsg,
alertType: 'error',
}),
);
return;
}
if (!file) return;
+17 -7
View File
@@ -86,20 +86,25 @@ export const GenericModal: React.FC<GenericModalProps> = ({
const { publishGeneric } = usePublishGeneric();
const dispatch = useDispatch();
let acceptedFile = {};
// Build accept prop for dropzone correctly
let accept: Record<string, string[]> | undefined = undefined;
if (acceptedFileType) {
acceptedFile = {
[acceptedFileType]: [],
};
accept = { [acceptedFileType]: [] } as any;
} else if (acceptedFileTypes && acceptedFileTypes.length > 0) {
accept = acceptedFileTypes.reduce((acc, t) => {
acc[t] = [] as any;
return acc;
}, {} as Record<string, string[]>);
}
const { getRootProps, getInputProps } = useDropzone({
...acceptedFile,
accept,
maxFiles: 1,
maxSize,
onDrop: (acceptedFiles) => {
setFile(acceptedFiles[0]);
},
onDropRejected: (rejectedFiles) => {
onDropRejected: () => {
dispatch(
setNotification({
msg: 'Your file is over the 500mb limit.',
@@ -137,7 +142,12 @@ export const GenericModal: React.FC<GenericModalProps> = ({
if (missingFields.length > 0) {
const missingFieldsString = missingFields.join(', ');
const errMsg = `Missing: ${missingFieldsString}`;
dispatch(
setNotification({
msg: errMsg,
alertType: 'error',
}),
);
return;
}
if (!file) return;
+1 -1
View File
@@ -200,7 +200,7 @@ export const ImagePanel: React.FC<ImagePanelProps> = ({ onSelect, height, width
setIsOpen(false);
}}
>
<Button variant="contained">Publish new image</Button>
<Button variant="contained">Embed new image</Button>
</ImageUploader>
</Box>
</Panel>
+9 -1
View File
@@ -20,6 +20,8 @@ import { usePublishVideo } from './PublishVideo';
import { toBase64 } from '../../utils/toBase64';
import AddIcon from '@mui/icons-material/Add';
import CloseIcon from '@mui/icons-material/Close';
import { useDispatch } from 'react-redux';
import { setNotification } from '../../state/features/notificationsSlice';
const StyledModal = styled(Modal)(({ theme }) => ({
display: 'flex',
alignItems: 'center',
@@ -62,6 +64,7 @@ const VideoModal: React.FC<VideoModalProps> = ({
onPublish,
editVideoIdentifier,
}) => {
const dispatch = useDispatch();
const [file, setFile] = useState<File | null>(null);
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
@@ -110,7 +113,12 @@ const VideoModal: React.FC<VideoModalProps> = ({
if (missingFields.length > 0) {
const missingFieldsString = missingFields.join(', ');
const errMsg = `Missing: ${missingFieldsString}`;
dispatch(
setNotification({
msg: errMsg,
alertType: 'error',
}),
);
return;
}
if (!file) return;
@@ -7,6 +7,7 @@ import { Typography, Box, Button, useTheme } from '@mui/material';
import EditIcon from '@mui/icons-material/Edit';
import BlogPostPreview from '../BlogList/PostPreview';
import {
setCurrentBlog,
setIsLoadingGlobal,
setVisitingBlog,
toggleEditBlogModal,
@@ -188,6 +189,24 @@ export const BlogIndividualProfile = () => {
}
const responseData = await response.json();
dispatch(setVisitingBlog({ ...responseData, name }));
const isOwner = user?.name && username && user.name === username;
if (isOwner) {
dispatch(
setCurrentBlog({
createdAt: responseData?.createdAt || Date.now(),
blogId: blog,
title: responseData?.title || '',
description: responseData?.description || '',
blogImage: responseData?.blogImage || '',
category: responseData?.category,
tags: responseData?.tags || [],
navbarConfig: responseData?.navbarConfig || null,
wikiEnabled: (responseData as any)?.wikiEnabled ?? false,
editorWhitelist: (responseData as any)?.editorWhitelist || [],
editorBlacklist: (responseData as any)?.editorBlacklist || [],
}),
);
}
setUserBlog(responseData);
setNotFound(false);
} catch (error) {
+4 -2
View File
@@ -421,6 +421,10 @@ export const CreatePostBuilder = ({
const errMsg = `Missing: ${missingFieldsString}`;
errorMsg = errMsg;
}
const blogIdForPublish = computeBlogIdForPublish(currentBlog, blogMetadataForEdit);
if (!blogIdForPublish && !errorMsg) {
errorMsg = 'Cannot determine which blog to publish to. Open your blog and try again.';
}
// In edit mode, allow updating even if content array is empty (legacy/minimal posts)
if (errorMsg) {
@@ -446,7 +450,6 @@ export const CreatePostBuilder = ({
layoutGeneralSettings,
};
try {
if (!currentBlog) return;
const id = uid();
let createTitleId = title
.replace(/[^a-zA-Z0-9\s-]/g, '')
@@ -468,7 +471,6 @@ export const CreatePostBuilder = ({
createTitleId = createTitleId.slice(1);
}
createTitleId = createTitleId.slice(0, 24);
const blogIdForPublish = computeBlogIdForPublish(currentBlog, blogMetadataForEdit);
const identifier = postIdForEdit
? `${blogIdForPublish ?? ''}-post-${postIdForEdit}`
: `${blogIdForPublish ?? currentBlog?.blogId ?? ''}-post-${createTitleId}-${id}`;
+4 -2
View File
@@ -309,6 +309,10 @@ export const CreatePostMinimal = ({
const errMsg = `Missing: ${missingFieldsString}`;
errorMsg = errMsg;
}
const blogIdForPublish = computeBlogIdForPublish(currentBlog, blogMetadataForEdit);
if (!blogIdForPublish && !errorMsg) {
errorMsg = 'Cannot determine which blog to publish to. Open your blog and try again.';
}
// In edit mode, allow updating even if content array is empty (legacy/minimal posts)
if (errorMsg) {
@@ -334,7 +338,6 @@ export const CreatePostMinimal = ({
layoutGeneralSettings,
};
try {
if (!currentBlog) return;
const id = uid();
let createTitleId = title
.replace(/[^a-zA-Z0-9\s-]/g, '')
@@ -356,7 +359,6 @@ export const CreatePostMinimal = ({
createTitleId = createTitleId.slice(1);
}
createTitleId = createTitleId.slice(0, 24);
const blogIdForPublish = computeBlogIdForPublish(currentBlog, blogMetadataForEdit);
const identifier = postIdForEdit
? `${blogIdForPublish ?? ''}-post-${postIdForEdit}`
: `${blogIdForPublish ?? currentBlog?.blogId ?? ''}-post-${createTitleId}-${id}`;
+139
View File
@@ -0,0 +1,139 @@
import React from 'react';
import { describe, it, expect, vi } from 'vitest';
import { configureStore } from '@reduxjs/toolkit';
import { Provider } from 'react-redux';
import { ThemeProvider, createTheme } from '@mui/material/styles';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { CreatePost } from '@/pages/CreatePost/CreatePost';
import notificationsReducer from '@/state/features/notificationsSlice';
import authReducer, { addUser } from '@/state/features/authSlice';
import globalReducer, { setCurrentBlog } from '@/state/features/globalSlice';
import blogReducer from '@/state/features/blogSlice';
import mailReducer from '@/state/features/mailSlice';
import bookmarksReducer from '@/state/features/bookmarksSlice';
import { server } from '../msw/server';
const createTestStore = () =>
configureStore({
reducer: {
notifications: notificationsReducer,
auth: authReducer,
global: globalReducer,
blog: blogReducer,
mail: mailReducer,
bookmarks: bookmarksReducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
serializableCheck: false,
}),
});
type TestStore = ReturnType<typeof createTestStore>;
const renderCreatePost = (store: TestStore) =>
render(
<Provider store={store}>
<ThemeProvider theme={createTheme()}>
<MemoryRouter initialEntries={[`/post/new`]}>
<Routes>
<Route path="/post/new" element={<CreatePost />} />
</Routes>
</MemoryRouter>
</ThemeProvider>
</Provider>,
);
const selectMinimalEditor = async () => {
const user = userEvent.setup();
const option = await screen.findByText('Minimal Editor');
await user.click(option);
await screen.findByPlaceholderText('Title');
return user;
};
describe('CreatePost (new publish flow)', () => {
const categoriesHandler = http.get('/arbitrary/categories', () => HttpResponse.json([]));
it('surfaces an error when no blog is selected', async () => {
server.use(categoriesHandler);
const store = createTestStore();
globalThis.qortalRequest = vi.fn().mockResolvedValue({ ok: true });
store.dispatch(addUser({ address: 'QADDR', publicKey: 'PUB', name: 'alice' }));
renderCreatePost(store);
const user = await selectMinimalEditor();
const titleField = await screen.findByPlaceholderText('Title');
await user.type(titleField, 'Unsaved Draft');
const publishTrigger = await screen.findByRole('button', { name: /^Publish$/i });
await user.click(publishTrigger);
const submitButton = await screen.findByRole('button', { name: /Submit/i });
await user.click(submitButton);
await waitFor(() => {
expect(store.getState().notifications.alertTypes.alertError).toBe(
'Cannot determine which blog to publish to. Open your blog and try again.',
);
});
expect(globalThis.qortalRequest).not.toHaveBeenCalled();
}, 10000);
it('publishes a new post when currentBlog is available', async () => {
server.use(categoriesHandler);
const store = createTestStore();
globalThis.qortalRequest = vi.fn().mockResolvedValue({ ok: true });
store.dispatch(addUser({ address: 'QADDR', publicKey: 'PUB', name: 'alice' }));
store.dispatch(
setCurrentBlog({
createdAt: Date.now(),
blogId: 'q-blog-myblog',
title: 'My Blog',
description: '',
blogImage: '',
category: '',
tags: [],
wikiEnabled: false,
editorWhitelist: [],
editorBlacklist: [],
}),
);
renderCreatePost(store);
const user = await selectMinimalEditor();
const titleField = await screen.findByPlaceholderText('Title');
await user.type(titleField, 'My New Post');
const publishTrigger = await screen.findByRole('button', { name: /^Publish$/i });
await user.click(publishTrigger);
const submitButton = await screen.findByRole('button', { name: /Submit/i });
await user.click(submitButton);
await waitFor(() => {
expect(globalThis.qortalRequest).toHaveBeenCalled();
});
const publishCall = (globalThis.qortalRequest as any).mock.calls.find(
(call: any[]) => call[0]?.action === 'PUBLISH_QDN_RESOURCE',
);
expect(publishCall).toBeTruthy();
expect(publishCall?.[0]).toMatchObject({
action: 'PUBLISH_QDN_RESOURCE',
service: 'BLOG_POST',
});
expect(publishCall?.[0]?.identifier.startsWith('q-blog-myblog-post-')).toBe(true);
await waitFor(() => {
expect(store.getState().notifications.alertTypes.alertSuccess).toBe(
'Blog post successfully published',
);
});
}, 10000);
});