added publishing and saving events notes to appropriate buttons

This commit is contained in:
Dmitri 2026-04-30 12:47:22 +02:00
parent 166dafc217
commit f5f9d3d6fc
7 changed files with 226 additions and 22 deletions

View File

@ -28,6 +28,10 @@ class Publish_eventsDBApi extends GenericDBApi {
return ['from_environment', 'to_environment', 'status'];
}
static get UUID_FIELDS() {
return ['projectId'];
}
static get CSV_FIELDS() {
return [
'id',

View File

@ -7,6 +7,8 @@ import { useAppSelector } from '../stores/hooks';
type Props = {
label?: string;
/** Optional subtitle displayed below the label in smaller text */
subtitle?: string;
icon?: string;
iconSize?: string | number;
href?: string;
@ -26,6 +28,7 @@ type Props = {
export default function BaseButton({
label,
subtitle,
icon,
iconSize,
href,
@ -78,9 +81,17 @@ export default function BaseButton({
{icon && (
<BaseIcon path={icon} size={iconSize} className={iconClassName} />
)}
{label && (
{label && !subtitle && (
<span className={small && icon ? 'px-1' : 'px-2'}>{label}</span>
)}
{label && subtitle && (
<span
className={`flex flex-col items-center leading-tight ${small && icon ? 'px-1' : 'px-2'}`}
>
<span>{label}</span>
<span className='text-[10px] opacity-80'>{subtitle}</span>
</span>
)}
</>
);

View File

@ -15,10 +15,10 @@ import {
mdiSwapHorizontal,
mdiText,
mdiPlus,
mdiContentSave,
mdiExitToApp,
} from '@mdi/js';
import MenuActionButton from './MenuActionButton';
import dataFormatter from '../../helpers/dataFormatter';
import type {
Position,
CanvasElementType,
@ -41,6 +41,10 @@ interface ConstructorMenuProps {
onSave: () => void;
onSaveToStage: () => void;
onExit: () => void;
/** Page's last saved timestamp (updatedAt from tour_pages) */
lastSavedAt?: string | null;
/** Last save-to-stage timestamp */
lastSavedToStageAt?: string | null;
}
const ConstructorMenu = forwardRef<HTMLDivElement, ConstructorMenuProps>(
@ -60,6 +64,8 @@ const ConstructorMenu = forwardRef<HTMLDivElement, ConstructorMenuProps>(
onSave,
onSaveToStage,
onExit,
lastSavedAt,
lastSavedToStageAt,
},
ref,
) => {
@ -144,23 +150,32 @@ const ConstructorMenu = forwardRef<HTMLDivElement, ConstructorMenuProps>(
/>
<div className='pt-2 border-t border-gray-200 space-y-1'>
<div className='flex gap-1'>
<BaseButton
small
color='info'
label={isSaving ? 'Saving...' : 'Save'}
icon={mdiContentSave}
onClick={onSave}
disabled={isSaving}
/>
<BaseButton
small
color='success'
label={isSavingToStage ? 'Saving...' : 'Save to Stage'}
onClick={onSaveToStage}
disabled={isSavingToStage}
/>
</div>
<BaseButton
small
color='info'
label={isSaving ? 'Saving...' : 'Save'}
subtitle={
lastSavedAt
? dataFormatter.relativeTimestamp(lastSavedAt)
: undefined
}
onClick={onSave}
disabled={isSaving}
className='w-full'
/>
<BaseButton
small
color='success'
label={isSavingToStage ? 'Saving...' : 'Save to Stage'}
subtitle={
lastSavedToStageAt
? dataFormatter.relativeTimestamp(lastSavedToStageAt)
: undefined
}
onClick={onSaveToStage}
disabled={isSavingToStage}
className='w-full'
/>
<MenuActionButton
icon={mdiExitToApp}
label='Exit'

View File

@ -1,6 +1,9 @@
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import _ from 'lodash';
dayjs.extend(relativeTime);
const dataFormatter = {
filesFormatter(arr) {
if (!arr || !arr.length) return [];
@ -171,6 +174,39 @@ const dataFormatter = {
if (!val) return '';
return { label: val.name, id: val.id };
},
/**
* Format date as relative or absolute timestamp.
* Examples: "Just now", "5 min ago", "2 hours ago", "Today at 14:30", "Apr 28 at 16:45"
*/
relativeTimestamp(date) {
if (!date) return '';
const d = dayjs(date);
const now = dayjs();
const diffMinutes = now.diff(d, 'minute');
const diffHours = now.diff(d, 'hour');
// Within last hour
if (diffMinutes < 60) {
return diffMinutes <= 1 ? 'Just now' : `${diffMinutes} min ago`;
}
// Within last 24 hours
if (diffHours < 24) {
return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`;
}
// Format as date + time
const isToday = d.isSame(now, 'day');
const isYesterday = d.isSame(now.subtract(1, 'day'), 'day');
const timeStr = d.format('HH:mm');
if (isToday) return `Today at ${timeStr}`;
if (isYesterday) return `Yesterday at ${timeStr}`;
return `${d.format('MMM D')} at ${timeStr}`;
},
};
export default dataFormatter;

View File

@ -0,0 +1,112 @@
/**
* usePublishStatus Hook
*
* Fetches the last publish/save events for a project to display timestamps.
* Follows useDashboardCounts pattern with mountedRef and robust error handling.
*/
import { useState, useEffect, useCallback, useRef } from 'react';
import axios from 'axios';
import { logger } from '../lib/logger';
interface UsePublishStatusOptions {
/** Project ID to fetch status for */
projectId: string | null;
}
interface UsePublishStatusResult {
/** Last successful dev -> stage save timestamp */
lastSavedToStage: string | null;
/** Last successful stage -> production publish timestamp */
lastPublishedToProduction: string | null;
/** Whether data is loading */
isLoading: boolean;
/** Refresh the status */
refresh: () => Promise<void>;
}
export function usePublishStatus({
projectId,
}: UsePublishStatusOptions): UsePublishStatusResult {
const [lastSavedToStage, setLastSavedToStage] = useState<string | null>(null);
const [lastPublishedToProduction, setLastPublishedToProduction] = useState<
string | null
>(null);
const [isLoading, setIsLoading] = useState(false);
// Track mounted state to prevent updates after unmount
const mountedRef = useRef(true);
const fetchStatus = useCallback(async () => {
if (!projectId) {
setLastSavedToStage(null);
setLastPublishedToProduction(null);
return;
}
setIsLoading(true);
try {
// Fetch both in parallel for performance
const [stageResponse, prodResponse] = await Promise.all([
// Last successful dev -> stage event
axios.get('/publish_events', {
params: {
projectId,
from_environment: 'dev',
to_environment: 'stage',
status: 'success',
limit: 1,
field: 'finished_at',
sort: 'desc',
},
}),
// Last successful stage -> production event
axios.get('/publish_events', {
params: {
projectId,
from_environment: 'stage',
to_environment: 'production',
status: 'success',
limit: 1,
field: 'finished_at',
sort: 'desc',
},
}),
]);
if (!mountedRef.current) return;
setLastSavedToStage(stageResponse?.data?.rows?.[0]?.finished_at ?? null);
setLastPublishedToProduction(
prodResponse?.data?.rows?.[0]?.finished_at ?? null,
);
} catch (error) {
if (!mountedRef.current) return;
logger.error(
'Failed to fetch publish status:',
error instanceof Error ? error : { error },
);
} finally {
if (mountedRef.current) {
setIsLoading(false);
}
}
}, [projectId]);
useEffect(() => {
mountedRef.current = true;
fetchStatus();
return () => {
mountedRef.current = false;
};
}, [fetchStatus]);
return {
lastSavedToStage,
lastPublishedToProduction,
isLoading,
refresh: fetchStatus,
};
}
export default usePublishStatus;

View File

@ -82,6 +82,7 @@ import { usePageBackground } from '../hooks/usePageBackground';
import { useConstructorData } from '../hooks/useConstructorData';
import { useAssetOptions } from '../hooks/useAssetOptions';
import { useTransitionCreation } from '../hooks/useTransitionCreation';
import { usePublishStatus } from '../hooks/usePublishStatus';
import {
ConstructorProvider,
type ConstructorContextValue,
@ -692,6 +693,17 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
onSuccess: setSuccessMessage,
});
// Publish status for timestamp display
const { lastSavedToStage, refresh: refreshPublishStatus } = usePublishStatus({
projectId,
});
// Wrap saveToStage to refresh publish status after save
const handleSaveToStage = useCallback(async () => {
await saveToStage();
await refreshPublishStatus();
}, [saveToStage, refreshPublishStatus]);
useEffect(() => {
if (!router.isReady || typeof window === 'undefined') return;
@ -1703,7 +1715,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
onAddElement={addElement}
onCreatePage={createPage}
onSave={saveConstructor}
onSaveToStage={saveToStage}
onSaveToStage={handleSaveToStage}
onExit={() =>
router.push(
projectId
@ -1711,6 +1723,8 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
: '/projects/projects-list',
)
}
lastSavedAt={activePage?.updatedAt}
lastSavedToStageAt={lastSavedToStage}
/>
)}
</div>

View File

@ -4,7 +4,6 @@ import {
mdiFileDocumentMultiple,
mdiFolderMultipleImage,
mdiPencil,
mdiPublish,
mdiViewDashboard,
mdiWidgets,
} from '@mdi/js';
@ -23,6 +22,8 @@ import SectionMain from '../../components/SectionMain';
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton';
import { getPageTitle } from '../../config';
import { logger } from '../../lib/logger';
import { usePublishStatus } from '../../hooks/usePublishStatus';
import dataFormatter from '../../helpers/dataFormatter';
type ProjectData = {
id: string;
@ -49,6 +50,12 @@ const ProjectWorkspacePage = () => {
const [publishDescription, setPublishDescription] = useState('');
const [errorMessage, setErrorMessage] = useState('');
// Publish status for timestamp display
const { lastPublishedToProduction, refresh: refreshPublishStatus } =
usePublishStatus({
projectId,
});
useEffect(() => {
if (!projectId) return;
@ -116,6 +123,7 @@ const ProjectWorkspacePage = () => {
setIsPublishModalActive(false);
setPublishTitle('');
setPublishDescription('');
await refreshPublishStatus();
} catch (error: unknown) {
logger.error(
'Publish failed:',
@ -277,8 +285,12 @@ const ProjectWorkspacePage = () => {
</p>
<BaseButtons>
<BaseButton
icon={mdiPublish}
label={isPublishing ? 'Publishing...' : 'Publish to Production'}
subtitle={
lastPublishedToProduction
? `Last: ${dataFormatter.relativeTimestamp(lastPublishedToProduction)}`
: undefined
}
color='success'
onClick={() => setIsPublishModalActive(true)}
disabled={isPublishing || !projectId}