added publishing and saving events notes to appropriate buttons
This commit is contained in:
parent
166dafc217
commit
f5f9d3d6fc
@ -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',
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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;
|
||||
|
||||
112
frontend/src/hooks/usePublishStatus.ts
Normal file
112
frontend/src/hooks/usePublishStatus.ts
Normal 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;
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user