Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/odd-parrots-kiss.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@blinkk/root-cms': patch
---

feat: add release archiving (#872)
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,20 @@ export interface ReleaseStatusBadgeProps {

export function ReleaseStatusBadge(props: ReleaseStatusBadgeProps) {
const release = props.release;
if (release.archivedAt) {
return (
<Tooltip
{...TOOLTIP_PROPS}
label={`Archived ${formatDateTime(release.archivedAt)} by ${
release.archivedBy
}`}
>
<Badge size="xs" variant="gradient" gradient={{from: 'gray', to: 'dark'}}>
Archived
</Badge>
</Tooltip>
);
}
if (release.scheduledAt) {
return (
<Tooltip
Expand Down
147 changes: 112 additions & 35 deletions packages/root-cms/ui/pages/ReleasePage/ReleasePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,11 @@ import {Layout} from '../../layout/Layout.js';
import {notifyErrors} from '../../utils/notifications.js';
import {
Release,
archiveRelease,
cancelScheduledRelease,
getRelease,
publishRelease,
unarchiveRelease,
} from '../../utils/release.js';
import {timestamp} from '../../utils/time.js';
import './ReleasePage.css';
Expand Down Expand Up @@ -113,6 +115,7 @@ ReleasePage.PublishStatus = (props: {
}) => {
const release = props.release;
const [publishLoading, setPublishLoading] = useState(false);
const [archiveLoading, setArchiveLoading] = useState(false);

const modals = useModals();
const modalTheme = useModalTheme();
Expand Down Expand Up @@ -196,6 +199,40 @@ ReleasePage.PublishStatus = (props: {
props.onAction('cancel-schedule');
}

function onArchiveClicked() {
const modalId = modals.openConfirmModal({
...modalTheme,
title: `Archive release: ${release.id}`,
children: (
<Text size="body-sm" weight="semi-bold">
Archived releases will be hidden from the active list and cannot be
published or scheduled until they are unarchived.
</Text>
),
labels: {confirm: 'Archive', cancel: 'Cancel'},
cancelProps: {size: 'xs'},
confirmProps: {color: 'red', size: 'xs'},
onConfirm: async () => {
setArchiveLoading(true);
await notifyErrors(async () => {
await archiveRelease(release.id);
props.onAction('archive');
});
setArchiveLoading(false);
modals.closeModal(modalId);
},
});
}

async function onUnarchiveClicked() {
setArchiveLoading(true);
await notifyErrors(async () => {
await unarchiveRelease(release.id);
props.onAction('unarchive');
});
setArchiveLoading(false);
}

return (
<div className="ReleasePage__PublishStatus">
<Heading size="h2">Status</Heading>
Expand All @@ -213,53 +250,93 @@ ReleasePage.PublishStatus = (props: {
</td>
<td>
<div className="ReleasePage__PublishStatus__actions">
{!release.scheduledAt && (
<Tooltip
label="Publish the release immediately"
position="bottom"
withArrow
>
<Button
variant="default"
size="xs"
compact
onClick={() => onPublishClicked()}
loading={publishLoading}
{!release.archivedAt && (
<>
{!release.scheduledAt && (
<Tooltip
label="Publish the release immediately"
position="bottom"
withArrow
>
<Button
variant="default"
size="xs"
compact
onClick={() => onPublishClicked()}
loading={publishLoading}
>
{release.publishedAt ? 'Re-publish' : 'Publish'}
</Button>
</Tooltip>
)}
{release.scheduledAt ? (
<Tooltip
label="Cancel the scheduled release"
position="bottom"
withArrow
>
<Button
variant="default"
size="xs"
compact
onClick={() => onCancelScheduleClicked()}
>
Cancel Schedule
</Button>
</Tooltip>
) : (
<Tooltip
label="Schedule the release to be published at a future date"
position="bottom"
withArrow
wrapLines
width={180}
>
<Button
variant="default"
size="xs"
compact
onClick={() => onScheduleClicked()}
>
Schedule
</Button>
</Tooltip>
)}
<Tooltip
label="Archive the release to hide it from the active list"
position="bottom"
withArrow
wrapLines
width={200}
>
{release.publishedAt ? 'Re-publish' : 'Publish'}
</Button>
</Tooltip>
<Button
variant="outline"
size="xs"
compact
onClick={() => onArchiveClicked()}
loading={archiveLoading}
>
Archive
</Button>
</Tooltip>
</>
)}
{release.scheduledAt ? (
<Tooltip
label="Cancel the scheduled release"
position="bottom"
withArrow
>
<Button
variant="default"
size="xs"
compact
onClick={() => onCancelScheduleClicked()}
>
Cancel Schedule
</Button>
</Tooltip>
) : (
{release.archivedAt && (
<Tooltip
label="Schedule the release to be published at a future date"
label="Unarchive the release to enable publishing and scheduling"
position="bottom"
withArrow
wrapLines
width={180}
width={200}
>
<Button
variant="default"
size="xs"
compact
onClick={() => onScheduleClicked()}
onClick={() => onUnarchiveClicked()}
loading={archiveLoading}
>
Schedule
Unarchive
</Button>
</Tooltip>
)}
Expand Down
8 changes: 8 additions & 0 deletions packages/root-cms/ui/pages/ReleasesPage/ReleasesPage.css
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,11 @@
.ReleasesPage__ReleasesTable__publishStatus__icon {
color: #12b886;
}

.ReleasesPage__ReleasesTable__controls {
margin-top: 16px;
}

.ReleasesPage__row--archived {
opacity: 0.6;
}
21 changes: 17 additions & 4 deletions packages/root-cms/ui/pages/ReleasesPage/ReleasesPage.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {Button, Loader, Table} from '@mantine/core';
import {Button, Loader, Switch, Table} from '@mantine/core';
import {useEffect, useState} from 'preact/hooks';
import {Heading} from '../../components/Heading/Heading.js';
import {ReleaseStatusBadge} from '../../components/ReleaseStatusBadge/ReleaseStatusBadge.js';
Expand Down Expand Up @@ -36,16 +36,18 @@ export function ReleasesPage() {
ReleasesPage.ReleasesTable = () => {
const [loading, setLoading] = useState(true);
const [tableData, setTableData] = useState<Release[]>([]);
const [includeArchived, setIncludeArchived] = useState(false);

async function init() {
const releases = await listReleases();
setLoading(true);
const releases = await listReleases({includeArchived});
setTableData(releases);
setLoading(false);
}

useEffect(() => {
init();
}, []);
}, [includeArchived]);

return (
<div className="ReleasesPage__ReleasesTable">
Expand All @@ -62,7 +64,10 @@ ReleasesPage.ReleasesTable = () => {
</thead>
<tbody>
{tableData.map((release) => (
<tr key={release.id}>
<tr
key={release.id}
className={release.archivedAt ? 'ReleasesPage__row--archived' : ''}
>
<td>
<a href={`/cms/releases/${release.id}`}>{release.id}</a>
</td>
Expand All @@ -84,6 +89,14 @@ ReleasesPage.ReleasesTable = () => {
</tbody>
</Table>
)}
<div className="ReleasesPage__ReleasesTable__controls">
<Switch
size="md"
checked={includeArchived}
onChange={(event) => setIncludeArchived(event.currentTarget.checked)}
label="Show archived"
/>
</div>
</div>
);
};
53 changes: 51 additions & 2 deletions packages/root-cms/ui/utils/release.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ export interface Release {
scheduledBy?: string;
publishedAt?: Timestamp;
publishedBy?: string;
archivedAt?: Timestamp;
archivedBy?: string;
}

const COLLECTION_ID = 'Releases';
Expand All @@ -54,7 +56,9 @@ export async function addRelease(id: string, release: Partial<Release>) {
logAction('release.create', {metadata: {releaseId: id}});
}

export async function listReleases(): Promise<Release[]> {
export async function listReleases(
options: {includeArchived?: boolean} = {}
): Promise<Release[]> {
const projectId = window.__ROOT_CTX.rootConfig.projectId;
const db = window.firebase.db;
const colRef = collection(db, 'Projects', projectId, COLLECTION_ID);
Expand All @@ -64,7 +68,10 @@ export async function listReleases(): Promise<Release[]> {
querySnapshot.forEach((doc) => {
res.push(doc.data() as Release);
});
return res;
if (options.includeArchived) {
return res;
}
return res.filter((release) => !release.archivedAt);
}

export async function getRelease(id: string) {
Expand Down Expand Up @@ -100,6 +107,9 @@ export async function publishRelease(id: string) {
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) {
Expand Down Expand Up @@ -138,6 +148,9 @@ export async function scheduleRelease(
if (!release) {
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);
Expand Down Expand Up @@ -172,3 +185,39 @@ export async function cancelScheduledRelease(id: string) {
});
logAction('release.unschedule', {metadata: {releaseId: id}});
}

export async function archiveRelease(id: string) {
const release = await getRelease(id);
if (!release) {
throw new Error(`release not found: ${id}`);
}

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,
scheduledAt: deleteField(),
scheduledBy: deleteField(),
});
logAction('release.archive', {metadata: {releaseId: id}});
}

export async function unarchiveRelease(id: string) {
const release = await getRelease(id);
if (!release) {
throw new Error(`release not found: ${id}`);
}

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}});
}