diff --git a/.changeset/ten-tables-rhyme.md b/.changeset/ten-tables-rhyme.md new file mode 100644 index 000000000..b086404c9 --- /dev/null +++ b/.changeset/ten-tables-rhyme.md @@ -0,0 +1,5 @@ +--- +'@blinkk/root-cms': patch +--- + +feat: add release archiving and status filters diff --git a/docs/scripts/fix_release_timestamps.mjs b/docs/scripts/fix_release_timestamps.mjs new file mode 100644 index 000000000..25269f96c --- /dev/null +++ b/docs/scripts/fix_release_timestamps.mjs @@ -0,0 +1,45 @@ +import {loadRootConfig} from '@blinkk/root/node'; +import {RootCMSClient} from '@blinkk/root-cms'; +import {Timestamp} from 'firebase-admin/firestore'; + +async function main() { + const rootConfig = await loadRootConfig(process.cwd()); + const client = new RootCMSClient(rootConfig); + const db = client.db; + const releases = await db.collection('Projects/www/Releases').get(); + + for (const doc of releases.docs) { + const data = doc.data(); + let changed = false; + + // Check createdAt field + if (data.createdAt && !(data.createdAt instanceof Timestamp)) { + if (data.createdAt._seconds && data.createdAt._nanoseconds) { + data.createdAt = new Timestamp( + data.createdAt._seconds, + data.createdAt._nanoseconds + ); + changed = true; + } + } + + // Check publishedAt field + if (data.publishedAt && !(data.publishedAt instanceof Timestamp)) { + if (data.publishedAt._seconds && data.publishedAt._nanoseconds) { + data.publishedAt = new Timestamp( + data.publishedAt._seconds, + data.publishedAt._nanoseconds + ); + changed = true; + } + } + + if (changed) { + console.log(`Updating release ${doc.id}`); + await doc.ref.set(data); + } + } + console.log('Done'); +} + +main(); diff --git a/packages/root-cms/ui/components/ReleaseStatusBadge/ReleaseStatusBadge.tsx b/packages/root-cms/ui/components/ReleaseStatusBadge/ReleaseStatusBadge.tsx index 3ff22559c..9d04171dc 100644 --- a/packages/root-cms/ui/components/ReleaseStatusBadge/ReleaseStatusBadge.tsx +++ b/packages/root-cms/ui/components/ReleaseStatusBadge/ReleaseStatusBadge.tsx @@ -13,7 +13,21 @@ export interface ReleaseStatusBadgeProps { export function ReleaseStatusBadge(props: ReleaseStatusBadgeProps) { const release = props.release; - if (release.scheduledAt) { + if (testIsValidTimestamp(release.archivedAt)) { + return ( + + + Archived + + + ); + } + if (testIsValidTimestamp(release.scheduledAt)) { return ( ); } - if (release.publishedAt) { + if (testIsValidTimestamp(release.publishedAt)) { return ( { @@ -55,6 +68,48 @@ export function ReleasePage(props: {id: string}) { init(); } + function onArchiveClicked() { + modals.openConfirmModal({ + ...modalTheme, + title: `Archive release: ${id}`, + children: ( + + Are you sure you want to archive this release? + + ), + labels: {confirm: 'Archive', cancel: 'Cancel'}, + cancelProps: {size: 'xs'}, + confirmProps: {color: 'dark', size: 'xs'}, + closeOnConfirm: true, + onConfirm: async () => { + await notifyErrors(async () => { + await archiveRelease(id); + }); + }, + }); + } + + function onUnarchiveClicked() { + modals.openConfirmModal({ + ...modalTheme, + title: `Unarchive release: ${id}`, + children: ( + + Are you sure you want to unarchive this release? + + ), + labels: {confirm: 'Unarchive', cancel: 'Cancel'}, + cancelProps: {size: 'xs'}, + confirmProps: {color: 'dark', size: 'xs'}, + closeOnConfirm: true, + onConfirm: async () => { + await notifyErrors(async () => { + await unarchiveRelease(id); + }); + }, + }); + } + return (
@@ -103,6 +158,34 @@ export function ReleasePage(props: {id: string}) { key={`data-sources-${updated}`} /> )} + {release && canPublish && ( +
+ {release.archivedAt ? ( + + ) : ( + + )} +
+ )} )}
@@ -220,7 +303,7 @@ ReleasePage.PublishStatus = (props: {
- {!release.scheduledAt && ( + {!release.archivedAt && !release.scheduledAt && ( )} - {release.scheduledAt ? ( - - - - - - ) : ( - - onCancelScheduleClicked()} + disabled={!canPublish} + > + Cancel Schedule + + + + ) : ( + - - - - )} + + + + ))}
diff --git a/packages/root-cms/ui/pages/ReleasesPage/ReleasesPage.css b/packages/root-cms/ui/pages/ReleasesPage/ReleasesPage.css index f85c6708c..c66432886 100644 --- a/packages/root-cms/ui/pages/ReleasesPage/ReleasesPage.css +++ b/packages/root-cms/ui/pages/ReleasesPage/ReleasesPage.css @@ -28,3 +28,9 @@ .ReleasesPage__ReleasesTable__publishStatus__icon { color: #12b886; } + +.ReleasesPage__ReleasesTable__filters { + display: flex; + justify-content: flex-end; + margin-bottom: 20px; +} diff --git a/packages/root-cms/ui/pages/ReleasesPage/ReleasesPage.tsx b/packages/root-cms/ui/pages/ReleasesPage/ReleasesPage.tsx index 332ea3e69..bd329957f 100644 --- a/packages/root-cms/ui/pages/ReleasesPage/ReleasesPage.tsx +++ b/packages/root-cms/ui/pages/ReleasesPage/ReleasesPage.tsx @@ -1,7 +1,7 @@ import './ReleasesPage.css'; -import {Button, Loader, Table} from '@mantine/core'; -import {useEffect, useState} from 'preact/hooks'; +import {Button, Loader, SegmentedControl, Table} from '@mantine/core'; +import {useEffect, useMemo, useState} from 'preact/hooks'; import {ConditionalTooltip} from '../../components/ConditionalTooltip/ConditionalTooltip.js'; import {Heading} from '../../components/Heading/Heading.js'; import {ReleaseStatusBadge} from '../../components/ReleaseStatusBadge/ReleaseStatusBadge.js'; @@ -11,6 +11,8 @@ import {Layout} from '../../layout/Layout.js'; import {testCanPublish} from '../../utils/permissions.js'; import {Release, listReleases} from '../../utils/release.js'; +type ReleaseListFilter = 'active' | 'unpublished' | 'published' | 'archived'; + export function ReleasesPage() { const {roles} = useProjectRoles(); const currentUserEmail = window.firebase.user.email || ''; @@ -51,6 +53,7 @@ export function ReleasesPage() { ReleasesPage.ReleasesTable = () => { const [loading, setLoading] = useState(true); const [tableData, setTableData] = useState([]); + const [filter, setFilter] = useState('active'); async function init() { const releases = await listReleases(); @@ -58,6 +61,23 @@ ReleasesPage.ReleasesTable = () => { setLoading(false); } + const filteredReleases = useMemo(() => { + return tableData.filter((release) => { + const isArchived = Boolean(release.archivedAt); + const isPublished = Boolean(release.publishedAt); + if (filter === 'active') { + return !isArchived; + } + if (filter === 'unpublished') { + return !isArchived && !isPublished; + } + if (filter === 'published') { + return !isArchived && isPublished; + } + return isArchived; + }); + }, [tableData, filter]); + useEffect(() => { init(); }, []); @@ -65,39 +85,59 @@ ReleasesPage.ReleasesTable = () => { return (
{loading && } - {tableData.length > 0 && ( - - - - - - - - - - - {tableData.map((release) => ( - - - - - - - ))} - -
iddescriptioncontentstatus
- {release.id} - {release.description || ''} - {(release.docIds || []).map((docId) => ( -
- {docId} -
- ))} -
-
- -
-
+ {!loading && ( + <> +
+ setFilter(value)} + data={[ + {label: 'Active', value: 'active'}, + {label: 'Unpublished', value: 'unpublished'}, + {label: 'Published', value: 'published'}, + {label: 'Archived', value: 'archived'}, + ]} + /> +
+ {filteredReleases.length > 0 && ( + + + + + + + + + + + {filteredReleases.map((release) => ( + + + + + + + ))} + +
iddescriptioncontentstatus
+ {release.id} + {release.description || ''} + {(release.docIds || []).map((docId) => ( +
+ {docId} +
+ ))} +
+
+ +
+
+ )} + {filteredReleases.length === 0 && ( + No releases found for this filter. + )} + )}
); diff --git a/packages/root-cms/ui/utils/release.ts b/packages/root-cms/ui/utils/release.ts index 441f53524..4bb8ca545 100644 --- a/packages/root-cms/ui/utils/release.ts +++ b/packages/root-cms/ui/utils/release.ts @@ -28,6 +28,8 @@ export interface Release { scheduledBy?: string; publishedAt?: Timestamp; publishedBy?: string; + archivedAt?: Timestamp; + archivedBy?: string; } const COLLECTION_ID = 'Releases'; @@ -95,11 +97,36 @@ export async function deleteRelease(id: string) { logAction('release.delete', {metadata: {releaseId: id}}); } +export async function archiveRelease(id: string) { + const projectId = window.__ROOT_CTX.rootConfig.projectId; + const db = window.firebase.db; + const docRef = doc(db, 'Projects', projectId, COLLECTION_ID, id); + await updateDoc(docRef, { + archivedAt: serverTimestamp(), + archivedBy: window.firebase.user.email, + }); + logAction('release.archive', {metadata: {releaseId: id}}); +} + +export async function unarchiveRelease(id: string) { + const projectId = window.__ROOT_CTX.rootConfig.projectId; + const db = window.firebase.db; + const docRef = doc(db, 'Projects', projectId, COLLECTION_ID, id); + await updateDoc(docRef, { + archivedAt: deleteField(), + archivedBy: deleteField(), + }); + logAction('release.unarchive', {metadata: {releaseId: id}}); +} + export async function publishRelease(id: string) { const release = await getRelease(id); if (!release) { throw new Error(`release not found: ${id}`); } + if (release.archivedAt) { + throw new Error(`release is archived: ${id}`); + } const docIds = release.docIds || []; const dataSourceIds = release.dataSourceIds || []; if (docIds.length === 0 && dataSourceIds.length === 0) { @@ -129,7 +156,11 @@ export async function publishRelease(id: string) { publishMessage: release.description, }); console.log(`published release: ${id}`); - const metadata: Record = {releaseId: id, docIds, dataSourceIds}; + const metadata: Record = { + releaseId: id, + docIds, + dataSourceIds, + }; if (release.description) { metadata.publishMessage = release.description; } @@ -147,6 +178,10 @@ export async function scheduleRelease( throw new Error(`release not found: ${id}`); } + if (release.archivedAt) { + throw new Error(`release is archived: ${id}`); + } + if (typeof timestamp === 'number') { timestamp = Timestamp.fromMillis(timestamp); }