Skip to content

Commit

Permalink
feat: add publishing lock (#343)
Browse files Browse the repository at this point in the history
  • Loading branch information
stevenle authored Jun 4, 2024
1 parent 4b5b950 commit 2cfea11
Show file tree
Hide file tree
Showing 10 changed files with 420 additions and 35 deletions.
5 changes: 5 additions & 0 deletions .changeset/fresh-countries-brush.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@blinkk/root-cms': patch
---

feat: add publishing lock
29 changes: 28 additions & 1 deletion packages/root-cms/core/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import {
Query,
Timestamp,
WriteBatch,
getFirestore,
} from 'firebase-admin/firestore';
import {CMSPlugin} from './plugin.js';

Expand All @@ -27,6 +26,12 @@ export interface Doc<Fields = any> {
firstPublishedBy?: string;
publishedAt?: number;
publishedBy?: string;
publishingLocked: {
lockedAt: string;
lockedBy: string;
reason: string;
until?: Timestamp;
};
locales?: string[];
};
fields: Fields;
Expand Down Expand Up @@ -308,6 +313,13 @@ export class RootCMSClient {
return [];
}

// Verify there are no publishing locks on any docs.
for (const doc of docs) {
if (this.testPublishingLocked(doc)) {
throw new Error(`publishing is locked for doc: ${doc.id}`);
}
}

// // Each transaction or batch can write a max of 500 ops.
// // https://firebase.google.com/docs/firestore/manage-data/transactions
let batchCount = 0;
Expand Down Expand Up @@ -515,6 +527,21 @@ export class RootCMSClient {
}
}

/**
* Checks if a doc is currently "locked" for publishing.
*/
testPublishingLocked(doc: Doc) {
if (doc.sys?.publishingLocked) {
if (doc.sys.publishingLocked.until) {
const now = Timestamp.now().toMillis();
const until = doc.sys.publishingLocked.until.toMillis();
return now < until;
}
return true;
}
return false;
}

/**
* Loads translations saved in the translations collection, optionally
* filtered by tag.
Expand Down
40 changes: 27 additions & 13 deletions packages/root-cms/ui/components/DocActionsMenu/DocActionsMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,50 +8,46 @@ import {
IconCopy,
IconDotsVertical,
IconHistory,
IconLock,
IconLockOpen,
IconTrash,
} from '@tabler/icons-preact';

import {useModalTheme} from '../../hooks/useModalTheme.js';
import {
CMSDoc,
cmsDeleteDoc,
cmsRevertDraft,
cmsUnpublishDoc,
cmsUnscheduleDoc,
testIsScheduled,
testPublishingLocked,
} from '../../utils/doc.js';
import {useCopyDocModal} from '../CopyDocModal/CopyDocModal.js';
import {useLockPublishingModal} from '../LockPublishingModal/LockPublishingModal.js';
import {Text} from '../Text/Text.js';
import {useVersionHistoryModal} from '../VersionHistoryModal/VersionHistoryModal.js';

interface DocData {
sys?: {
modifiedAt?: number;
scheduledAt?: number;
firstPublishedAt?: number;
publishedAt?: number;
};
fields?: Record<string, any>;
}

export interface DocActionEvent {
action: 'copy' | 'delete' | 'revert-draft' | 'unpublish' | 'unschedule';
newDocId?: string;
}

export interface DocActionsMenuProps {
docId: string;
data?: DocData;
data?: CMSDoc;
onDelete?: () => void;
onAction?: (event: DocActionEvent) => void;
}

export function DocActionsMenu(props: DocActionsMenuProps) {
const docId = props.docId;
const data = props.data || {};
const data = (props.data || {}) as CMSDoc;
const sys = data.sys || {};
const modals = useModals();
const copyDocModal = useCopyDocModal({fromDocId: docId});
const modalTheme = useModalTheme();
const versionHistoryModal = useVersionHistoryModal({docId});
const lockPublishingModal = useLockPublishingModal({docId});

const onRevertDraft = () => {
const notificationId = `revert-draft-${docId}`;
Expand Down Expand Up @@ -270,6 +266,24 @@ export function DocActionsMenu(props: DocActionsMenuProps) {
Unschedule
</Menu.Item>
)}
{testPublishingLocked(data) ? (
<Menu.Item
icon={<IconLockOpen size={20} />}
onClick={() => lockPublishingModal.open({unlock: true})}
>
Unlock publishing
</Menu.Item>
) : (
<Menu.Item
icon={<IconLock size={20} />}
onClick={() => lockPublishingModal.open()}
// Prevent "publishing lock" if the doc has an existing scheduled
// publish.
disabled={testIsScheduled(data)}
>
Lock publishing
</Menu.Item>
)}
<Menu.Item icon={<IconTrash size={20} />} onClick={() => onDeleteDoc()}>
Delete
</Menu.Item>
Expand Down
70 changes: 61 additions & 9 deletions packages/root-cms/ui/components/DocEditor/DocEditor.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
import {ActionIcon, Button, LoadingOverlay, Menu, Select} from '@mantine/core';
import {
ActionIcon,
Button,
LoadingOverlay,
Menu,
Select,
Tooltip,
} from '@mantine/core';
import {
IconBraces,
IconCircleArrowDown,
IconCircleArrowUp,
IconCirclePlus,
IconCopy,
IconDotsVertical,
IconLock,
IconPlanet,
IconRocket,
IconRowInsertBottom,
Expand All @@ -23,10 +31,12 @@ import {
UseDraftHook,
} from '../../hooks/useDraft.js';
import {joinClassNames} from '../../utils/classes.js';
import {testIsScheduled, testPublishingLocked} from '../../utils/doc.js';
import {getDefaultFieldValue} from '../../utils/fields.js';
import {flattenNestedKeys} from '../../utils/objects.js';
import {autokey} from '../../utils/rand.js';
import {getPlaceholderKeys, strFormat} from '../../utils/str-format.js';
import {formatDateTime} from '../../utils/time.js';
import {
DocActionEvent,
DocActionsMenu,
Expand Down Expand Up @@ -110,14 +120,56 @@ export function DocEditor(props: DocEditorProps) {
</Button>
</div>
<div className="DocEditor__statusBar__publishButton">
<Button
color="dark"
size="xs"
leftIcon={<IconRocket size={16} />}
onClick={() => publishDocModal.open()}
>
Publish
</Button>
{loading ? (
<Button
color="dark"
size="xs"
onClick={() => publishDocModal.open()}
loading
disabled
>
Publish
</Button>
) : testIsScheduled(data) ? (
<Tooltip
label={`Scheduled ${formatDateTime(data.sys.scheduledAt)} by ${
data.sys.scheduledBy
}`}
transition="pop"
>
<Button
color="dark"
size="xs"
leftIcon={<IconRocket size={16} />}
disabled
>
Publish
</Button>
</Tooltip>
) : testPublishingLocked(data) ? (
<Tooltip
label={`Locked by ${data.sys.publishingLocked.lockedBy}: "${data.sys.publishingLocked.reason}"`}
transition="pop"
>
<Button
color="dark"
size="xs"
leftIcon={<IconLock size={16} />}
disabled
>
Publish
</Button>
</Tooltip>
) : (
<Button
color="dark"
size="xs"
leftIcon={<IconRocket size={16} />}
onClick={() => publishDocModal.open()}
>
Publish
</Button>
)}
</div>
<div className="DocEditor__statusBar__actionsMenu">
<DocActionsMenu
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {Badge, Tooltip} from '@mantine/core';
import {Timestamp} from 'firebase/firestore';

import {getTimeAgo} from '../../utils/time.js';
import {formatDateTime, getTimeAgo} from '../../utils/time.js';

interface DocStatusBadgesProps {
doc: {
Expand Down Expand Up @@ -86,14 +86,3 @@ function timeDiff(ts: Timestamp | null) {
}
return getTimeAgo(ts.toMillis());
}

function formatDateTime(ts: Timestamp) {
const date = new Date(ts.toMillis());
return date.toLocaleDateString('en', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
.LockPublishingModal__form__section {
margin-top: 16px;
}

.LockPublishingModal__buttons {
margin-top: 20px;
display: flex;
justify-content: flex-end;
gap: 12px;
}
Loading

0 comments on commit 2cfea11

Please sign in to comment.