From 6297a262ef8664b5bee0aeaf29c9a8a7be0be8d6 Mon Sep 17 00:00:00 2001 From: Steven Le <387282+stevenle@users.noreply.github.com> Date: Fri, 20 Feb 2026 14:12:55 -0800 Subject: [PATCH 1/3] feat(root-cms): add release archiving and status filters --- .../ReleaseStatusBadge/ReleaseStatusBadge.tsx | 12 ++ .../ui/pages/ReleasePage/ReleasePage.tsx | 110 ++++++++++++------ .../ui/pages/ReleasesPage/ReleasesPage.tsx | 108 +++++++++++------ packages/root-cms/ui/utils/release.ts | 20 ++++ 4 files changed, 182 insertions(+), 68 deletions(-) diff --git a/packages/root-cms/ui/components/ReleaseStatusBadge/ReleaseStatusBadge.tsx b/packages/root-cms/ui/components/ReleaseStatusBadge/ReleaseStatusBadge.tsx index 3ff22559c..fc008cd79 100644 --- a/packages/root-cms/ui/components/ReleaseStatusBadge/ReleaseStatusBadge.tsx +++ b/packages/root-cms/ui/components/ReleaseStatusBadge/ReleaseStatusBadge.tsx @@ -13,6 +13,18 @@ export interface ReleaseStatusBadgeProps { export function ReleaseStatusBadge(props: ReleaseStatusBadgeProps) { const release = props.release; + if (release.archivedAt) { + return ( + + + Archived + + + ); + } if (release.scheduledAt) { return ( + Are you sure you want to archive this release? Archived releases are + hidden from active lists. + + ), + labels: {confirm: 'Archive', cancel: 'Cancel'}, + cancelProps: {size: 'xs'}, + confirmProps: {color: 'dark', size: 'xs'}, + closeOnConfirm: true, + onConfirm: async () => { + await notifyErrors(async () => { + await archiveRelease(release.id); + props.onAction('archive'); + }); + }, + }); + } + return (
Status @@ -220,7 +244,7 @@ ReleasePage.PublishStatus = (props: {
- {!release.scheduledAt && ( + {!release.archivedAt && !release.scheduledAt && ( )} - {release.scheduledAt ? ( - - - - - - ) : ( + + + + ) : ( + + + + + + ))} + {!release.archivedAt && ( - + diff --git a/packages/root-cms/ui/pages/ReleasesPage/ReleasesPage.tsx b/packages/root-cms/ui/pages/ReleasesPage/ReleasesPage.tsx index 332ea3e69..51ab18284 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,57 @@ ReleasesPage.ReleasesTable = () => { return (
{loading && } - {tableData.length > 0 && ( - - - - - - - - - - - {tableData.map((release) => ( - - - - - - - ))} - -
iddescriptioncontentstatus
- {release.id} - {release.description || ''} - {(release.docIds || []).map((docId) => ( -
- {docId} -
- ))} -
-
- -
-
+ {!loading && ( + <> + setFilter(value as ReleaseListFilter)} + data={[ + {label: 'Active', value: 'active'}, + {label: 'Unpublished', value: 'unpublished'}, + {label: 'Published', value: 'published'}, + {label: 'Archived', value: 'archived'}, + ]} + mb="md" + /> + {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..7a325f90e 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,25 @@ 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 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) { @@ -147,6 +163,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); } From bb36372fa4fd30c570a3f177d9edcca61fe3697d Mon Sep 17 00:00:00 2001 From: Steven Le Date: Fri, 20 Feb 2026 15:07:31 -0800 Subject: [PATCH 2/3] chore: fix timestamps on releases page --- docs/scripts/fix_release_timestamps.mjs | 45 +++++++++++++++++++ .../ReleaseStatusBadge/ReleaseStatusBadge.tsx | 16 ++++--- 2 files changed, 56 insertions(+), 5 deletions(-) create mode 100644 docs/scripts/fix_release_timestamps.mjs 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 fc008cd79..9d04171dc 100644 --- a/packages/root-cms/ui/components/ReleaseStatusBadge/ReleaseStatusBadge.tsx +++ b/packages/root-cms/ui/components/ReleaseStatusBadge/ReleaseStatusBadge.tsx @@ -13,11 +13,13 @@ export interface ReleaseStatusBadgeProps { export function ReleaseStatusBadge(props: ReleaseStatusBadgeProps) { const release = props.release; - if (release.archivedAt) { + if (testIsValidTimestamp(release.archivedAt)) { return ( Archived @@ -25,7 +27,7 @@ export function ReleaseStatusBadge(props: ReleaseStatusBadgeProps) { ); } - if (release.scheduledAt) { + if (testIsValidTimestamp(release.scheduledAt)) { return ( ); } - if (release.publishedAt) { + if (testIsValidTimestamp(release.publishedAt)) { return ( Date: Fri, 20 Feb 2026 15:53:07 -0800 Subject: [PATCH 3/3] chore: move archive buttons to bottom of page --- .changeset/ten-tables-rhyme.md | 5 + .../ui/pages/ReleasePage/ReleasePage.css | 4 + .../ui/pages/ReleasePage/ReleasePage.tsx | 126 ++++++++++++------ .../ui/pages/ReleasesPage/ReleasesPage.css | 6 + .../ui/pages/ReleasesPage/ReleasesPage.tsx | 24 ++-- packages/root-cms/ui/utils/release.ts | 17 ++- 6 files changed, 127 insertions(+), 55 deletions(-) create mode 100644 .changeset/ten-tables-rhyme.md 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/packages/root-cms/ui/pages/ReleasePage/ReleasePage.css b/packages/root-cms/ui/pages/ReleasePage/ReleasePage.css index 481498e76..a058696e1 100644 --- a/packages/root-cms/ui/pages/ReleasePage/ReleasePage.css +++ b/packages/root-cms/ui/pages/ReleasePage/ReleasePage.css @@ -100,3 +100,7 @@ border-top: 1px solid var(--color-border); padding: 4px 8px; } + +.ReleasePage__footer { + margin-top: 40px; +} diff --git a/packages/root-cms/ui/pages/ReleasePage/ReleasePage.tsx b/packages/root-cms/ui/pages/ReleasePage/ReleasePage.tsx index c4625479a..f3f2d5cc0 100644 --- a/packages/root-cms/ui/pages/ReleasePage/ReleasePage.tsx +++ b/packages/root-cms/ui/pages/ReleasePage/ReleasePage.tsx @@ -8,7 +8,12 @@ import { } from '@mantine/core'; import {useModals} from '@mantine/modals'; import {showNotification, updateNotification} from '@mantine/notifications'; -import {IconArchive, IconSettings} from '@tabler/icons-preact'; +import { + IconArchive, + IconArchiveOff, + IconRestore, + IconSettings, +} from '@tabler/icons-preact'; import {useEffect, useState} from 'preact/hooks'; import {ConditionalTooltip} from '../../components/ConditionalTooltip/ConditionalTooltip.js'; import {DocPreviewCard} from '../../components/DocPreviewCard/DocPreviewCard.js'; @@ -27,6 +32,7 @@ import { getRelease, publishRelease, archiveRelease, + unarchiveRelease, } from '../../utils/release.js'; import {timestamp} from '../../utils/time.js'; import './ReleasePage.css'; @@ -37,6 +43,12 @@ export function ReleasePage(props: {id: string}) { const [updated, setUpdated] = useState(0); const id = props.id; + const modals = useModals(); + const modalTheme = useModalTheme(); + const {roles} = useProjectRoles(); + const currentUserEmail = window.firebase.user.email || ''; + const canPublish = testCanPublish(roles, currentUserEmail); + async function init() { setLoading(true); await notifyErrors(async () => { @@ -56,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 (
@@ -104,6 +158,34 @@ export function ReleasePage(props: {id: string}) { key={`data-sources-${updated}`} /> )} + {release && canPublish && ( +
+ {release.archivedAt ? ( + + ) : ( + + )} +
+ )} )}
@@ -204,29 +286,6 @@ ReleasePage.PublishStatus = (props: { props.onAction('cancel-schedule'); } - function onArchiveClicked() { - modals.openConfirmModal({ - ...modalTheme, - title: `Archive release: ${release.id}`, - children: ( - - Are you sure you want to archive this release? Archived releases are - hidden from active lists. - - ), - labels: {confirm: 'Archive', cancel: 'Cancel'}, - cancelProps: {size: 'xs'}, - confirmProps: {color: 'dark', size: 'xs'}, - closeOnConfirm: true, - onConfirm: async () => { - await notifyErrors(async () => { - await archiveRelease(release.id); - props.onAction('archive'); - }); - }, - }); - } - return (
Status @@ -316,25 +375,6 @@ ReleasePage.PublishStatus = (props: { ))} - {!release.archivedAt && ( - - - - - - )}
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 51ab18284..bd329957f 100644 --- a/packages/root-cms/ui/pages/ReleasesPage/ReleasesPage.tsx +++ b/packages/root-cms/ui/pages/ReleasesPage/ReleasesPage.tsx @@ -87,17 +87,19 @@ ReleasesPage.ReleasesTable = () => { {loading && } {!loading && ( <> - setFilter(value as ReleaseListFilter)} - data={[ - {label: 'Active', value: 'active'}, - {label: 'Unpublished', value: 'unpublished'}, - {label: 'Published', value: 'published'}, - {label: 'Archived', value: 'archived'}, - ]} - mb="md" - /> +
+ setFilter(value)} + data={[ + {label: 'Active', value: 'active'}, + {label: 'Unpublished', value: 'unpublished'}, + {label: 'Published', value: 'published'}, + {label: 'Archived', value: 'archived'}, + ]} + /> +
{filteredReleases.length > 0 && ( diff --git a/packages/root-cms/ui/utils/release.ts b/packages/root-cms/ui/utils/release.ts index 7a325f90e..4bb8ca545 100644 --- a/packages/root-cms/ui/utils/release.ts +++ b/packages/root-cms/ui/utils/release.ts @@ -108,6 +108,17 @@ export async function archiveRelease(id: string) { 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) { @@ -145,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; }