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'];
|
return ['from_environment', 'to_environment', 'status'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static get UUID_FIELDS() {
|
||||||
|
return ['projectId'];
|
||||||
|
}
|
||||||
|
|
||||||
static get CSV_FIELDS() {
|
static get CSV_FIELDS() {
|
||||||
return [
|
return [
|
||||||
'id',
|
'id',
|
||||||
|
|||||||
@ -7,6 +7,8 @@ import { useAppSelector } from '../stores/hooks';
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
label?: string;
|
label?: string;
|
||||||
|
/** Optional subtitle displayed below the label in smaller text */
|
||||||
|
subtitle?: string;
|
||||||
icon?: string;
|
icon?: string;
|
||||||
iconSize?: string | number;
|
iconSize?: string | number;
|
||||||
href?: string;
|
href?: string;
|
||||||
@ -26,6 +28,7 @@ type Props = {
|
|||||||
|
|
||||||
export default function BaseButton({
|
export default function BaseButton({
|
||||||
label,
|
label,
|
||||||
|
subtitle,
|
||||||
icon,
|
icon,
|
||||||
iconSize,
|
iconSize,
|
||||||
href,
|
href,
|
||||||
@ -78,9 +81,17 @@ export default function BaseButton({
|
|||||||
{icon && (
|
{icon && (
|
||||||
<BaseIcon path={icon} size={iconSize} className={iconClassName} />
|
<BaseIcon path={icon} size={iconSize} className={iconClassName} />
|
||||||
)}
|
)}
|
||||||
{label && (
|
{label && !subtitle && (
|
||||||
<span className={small && icon ? 'px-1' : 'px-2'}>{label}</span>
|
<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,
|
mdiSwapHorizontal,
|
||||||
mdiText,
|
mdiText,
|
||||||
mdiPlus,
|
mdiPlus,
|
||||||
mdiContentSave,
|
|
||||||
mdiExitToApp,
|
mdiExitToApp,
|
||||||
} from '@mdi/js';
|
} from '@mdi/js';
|
||||||
import MenuActionButton from './MenuActionButton';
|
import MenuActionButton from './MenuActionButton';
|
||||||
|
import dataFormatter from '../../helpers/dataFormatter';
|
||||||
import type {
|
import type {
|
||||||
Position,
|
Position,
|
||||||
CanvasElementType,
|
CanvasElementType,
|
||||||
@ -41,6 +41,10 @@ interface ConstructorMenuProps {
|
|||||||
onSave: () => void;
|
onSave: () => void;
|
||||||
onSaveToStage: () => void;
|
onSaveToStage: () => void;
|
||||||
onExit: () => 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>(
|
const ConstructorMenu = forwardRef<HTMLDivElement, ConstructorMenuProps>(
|
||||||
@ -60,6 +64,8 @@ const ConstructorMenu = forwardRef<HTMLDivElement, ConstructorMenuProps>(
|
|||||||
onSave,
|
onSave,
|
||||||
onSaveToStage,
|
onSaveToStage,
|
||||||
onExit,
|
onExit,
|
||||||
|
lastSavedAt,
|
||||||
|
lastSavedToStageAt,
|
||||||
},
|
},
|
||||||
ref,
|
ref,
|
||||||
) => {
|
) => {
|
||||||
@ -144,23 +150,32 @@ const ConstructorMenu = forwardRef<HTMLDivElement, ConstructorMenuProps>(
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className='pt-2 border-t border-gray-200 space-y-1'>
|
<div className='pt-2 border-t border-gray-200 space-y-1'>
|
||||||
<div className='flex gap-1'>
|
<BaseButton
|
||||||
<BaseButton
|
small
|
||||||
small
|
color='info'
|
||||||
color='info'
|
label={isSaving ? 'Saving...' : 'Save'}
|
||||||
label={isSaving ? 'Saving...' : 'Save'}
|
subtitle={
|
||||||
icon={mdiContentSave}
|
lastSavedAt
|
||||||
onClick={onSave}
|
? dataFormatter.relativeTimestamp(lastSavedAt)
|
||||||
disabled={isSaving}
|
: undefined
|
||||||
/>
|
}
|
||||||
<BaseButton
|
onClick={onSave}
|
||||||
small
|
disabled={isSaving}
|
||||||
color='success'
|
className='w-full'
|
||||||
label={isSavingToStage ? 'Saving...' : 'Save to Stage'}
|
/>
|
||||||
onClick={onSaveToStage}
|
<BaseButton
|
||||||
disabled={isSavingToStage}
|
small
|
||||||
/>
|
color='success'
|
||||||
</div>
|
label={isSavingToStage ? 'Saving...' : 'Save to Stage'}
|
||||||
|
subtitle={
|
||||||
|
lastSavedToStageAt
|
||||||
|
? dataFormatter.relativeTimestamp(lastSavedToStageAt)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onClick={onSaveToStage}
|
||||||
|
disabled={isSavingToStage}
|
||||||
|
className='w-full'
|
||||||
|
/>
|
||||||
<MenuActionButton
|
<MenuActionButton
|
||||||
icon={mdiExitToApp}
|
icon={mdiExitToApp}
|
||||||
label='Exit'
|
label='Exit'
|
||||||
|
|||||||
@ -1,6 +1,9 @@
|
|||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
|
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
|
|
||||||
|
dayjs.extend(relativeTime);
|
||||||
|
|
||||||
const dataFormatter = {
|
const dataFormatter = {
|
||||||
filesFormatter(arr) {
|
filesFormatter(arr) {
|
||||||
if (!arr || !arr.length) return [];
|
if (!arr || !arr.length) return [];
|
||||||
@ -171,6 +174,39 @@ const dataFormatter = {
|
|||||||
if (!val) return '';
|
if (!val) return '';
|
||||||
return { label: val.name, id: val.id };
|
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;
|
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 { useConstructorData } from '../hooks/useConstructorData';
|
||||||
import { useAssetOptions } from '../hooks/useAssetOptions';
|
import { useAssetOptions } from '../hooks/useAssetOptions';
|
||||||
import { useTransitionCreation } from '../hooks/useTransitionCreation';
|
import { useTransitionCreation } from '../hooks/useTransitionCreation';
|
||||||
|
import { usePublishStatus } from '../hooks/usePublishStatus';
|
||||||
import {
|
import {
|
||||||
ConstructorProvider,
|
ConstructorProvider,
|
||||||
type ConstructorContextValue,
|
type ConstructorContextValue,
|
||||||
@ -692,6 +693,17 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
onSuccess: setSuccessMessage,
|
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(() => {
|
useEffect(() => {
|
||||||
if (!router.isReady || typeof window === 'undefined') return;
|
if (!router.isReady || typeof window === 'undefined') return;
|
||||||
|
|
||||||
@ -1703,7 +1715,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
onAddElement={addElement}
|
onAddElement={addElement}
|
||||||
onCreatePage={createPage}
|
onCreatePage={createPage}
|
||||||
onSave={saveConstructor}
|
onSave={saveConstructor}
|
||||||
onSaveToStage={saveToStage}
|
onSaveToStage={handleSaveToStage}
|
||||||
onExit={() =>
|
onExit={() =>
|
||||||
router.push(
|
router.push(
|
||||||
projectId
|
projectId
|
||||||
@ -1711,6 +1723,8 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
: '/projects/projects-list',
|
: '/projects/projects-list',
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
lastSavedAt={activePage?.updatedAt}
|
||||||
|
lastSavedToStageAt={lastSavedToStage}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -4,7 +4,6 @@ import {
|
|||||||
mdiFileDocumentMultiple,
|
mdiFileDocumentMultiple,
|
||||||
mdiFolderMultipleImage,
|
mdiFolderMultipleImage,
|
||||||
mdiPencil,
|
mdiPencil,
|
||||||
mdiPublish,
|
|
||||||
mdiViewDashboard,
|
mdiViewDashboard,
|
||||||
mdiWidgets,
|
mdiWidgets,
|
||||||
} from '@mdi/js';
|
} from '@mdi/js';
|
||||||
@ -23,6 +22,8 @@ import SectionMain from '../../components/SectionMain';
|
|||||||
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton';
|
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton';
|
||||||
import { getPageTitle } from '../../config';
|
import { getPageTitle } from '../../config';
|
||||||
import { logger } from '../../lib/logger';
|
import { logger } from '../../lib/logger';
|
||||||
|
import { usePublishStatus } from '../../hooks/usePublishStatus';
|
||||||
|
import dataFormatter from '../../helpers/dataFormatter';
|
||||||
|
|
||||||
type ProjectData = {
|
type ProjectData = {
|
||||||
id: string;
|
id: string;
|
||||||
@ -49,6 +50,12 @@ const ProjectWorkspacePage = () => {
|
|||||||
const [publishDescription, setPublishDescription] = useState('');
|
const [publishDescription, setPublishDescription] = useState('');
|
||||||
const [errorMessage, setErrorMessage] = useState('');
|
const [errorMessage, setErrorMessage] = useState('');
|
||||||
|
|
||||||
|
// Publish status for timestamp display
|
||||||
|
const { lastPublishedToProduction, refresh: refreshPublishStatus } =
|
||||||
|
usePublishStatus({
|
||||||
|
projectId,
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!projectId) return;
|
if (!projectId) return;
|
||||||
|
|
||||||
@ -116,6 +123,7 @@ const ProjectWorkspacePage = () => {
|
|||||||
setIsPublishModalActive(false);
|
setIsPublishModalActive(false);
|
||||||
setPublishTitle('');
|
setPublishTitle('');
|
||||||
setPublishDescription('');
|
setPublishDescription('');
|
||||||
|
await refreshPublishStatus();
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
logger.error(
|
logger.error(
|
||||||
'Publish failed:',
|
'Publish failed:',
|
||||||
@ -277,8 +285,12 @@ const ProjectWorkspacePage = () => {
|
|||||||
</p>
|
</p>
|
||||||
<BaseButtons>
|
<BaseButtons>
|
||||||
<BaseButton
|
<BaseButton
|
||||||
icon={mdiPublish}
|
|
||||||
label={isPublishing ? 'Publishing...' : 'Publish to Production'}
|
label={isPublishing ? 'Publishing...' : 'Publish to Production'}
|
||||||
|
subtitle={
|
||||||
|
lastPublishedToProduction
|
||||||
|
? `Last: ${dataFormatter.relativeTimestamp(lastPublishedToProduction)}`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
color='success'
|
color='success'
|
||||||
onClick={() => setIsPublishModalActive(true)}
|
onClick={() => setIsPublishModalActive(true)}
|
||||||
disabled={isPublishing || !projectId}
|
disabled={isPublishing || !projectId}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user