diff --git a/app/(gcforms)/[locale]/(form administration)/form-builder/components/shared/edit-lock/EditLockClient.test.tsx b/app/(gcforms)/[locale]/(form administration)/form-builder/components/shared/edit-lock/EditLockClient.test.tsx
index 998d49f4ea..712a2eaa5f 100644
--- a/app/(gcforms)/[locale]/(form administration)/form-builder/components/shared/edit-lock/EditLockClient.test.tsx
+++ b/app/(gcforms)/[locale]/(form administration)/form-builder/components/shared/edit-lock/EditLockClient.test.tsx
@@ -9,6 +9,7 @@ const mockState = vi.hoisted(() => ({
store: {
lang: "en",
isPublished: false,
+ currentDraftVersionId: null as string | null,
},
editLockContext: {
takeover: vi.fn().mockResolvedValue(undefined),
@@ -69,6 +70,7 @@ vi.mock("@formBuilder/components/shared/Toast", () => ({
describe("EditLockClient", () => {
beforeEach(() => {
mockState.store.isPublished = false;
+ mockState.store.currentDraftVersionId = null;
mockState.editLockContext.hasSessionExpired = false;
mockState.editLockContext.isEnabled = true;
mockState.toast.success.mockClear();
@@ -100,6 +102,20 @@ describe("EditLockClient", () => {
expect(screen.getByText("Child content")).toBeInTheDocument();
});
+ it("keeps the lock banner visible for published templates with a current draft version", () => {
+ mockState.store.isPublished = true;
+ mockState.store.currentDraftVersionId = "draft-version-1";
+
+ render(
+
+ Child content
+
+ );
+
+ expect(screen.getByText("Take over editing")).toBeInTheDocument();
+ expect(screen.getByText("Child content")).toBeInTheDocument();
+ });
+
describe("takeover toast persistence", () => {
it("shows toast and clears sessionStorage flag when editLockTakeoverSuccess is present on mount", () => {
sessionStorage.setItem("showToast", "editLockTakeoverSuccess");
diff --git a/app/(gcforms)/[locale]/(form administration)/form-builder/components/shared/edit-lock/EditLockClient.tsx b/app/(gcforms)/[locale]/(form administration)/form-builder/components/shared/edit-lock/EditLockClient.tsx
index c9c4dd2cfb..999fe431ac 100644
--- a/app/(gcforms)/[locale]/(form administration)/form-builder/components/shared/edit-lock/EditLockClient.tsx
+++ b/app/(gcforms)/[locale]/(form administration)/form-builder/components/shared/edit-lock/EditLockClient.tsx
@@ -23,8 +23,9 @@ export const EditLockClient = ({
}) => {
const pathname = usePathname();
const { t } = useTranslation("form-builder");
- const { isPublished } = useTemplateStore((s) => ({
+ const { isPublished, currentDraftVersionId } = useTemplateStore((s) => ({
isPublished: s.isPublished,
+ currentDraftVersionId: s.currentDraftVersionId,
}));
const { takeover, getIsActiveTab, hasSessionExpired, isEnabled } = useEditLockContext();
const { headlessTree } = useTreeRef();
@@ -44,7 +45,9 @@ export const EditLockClient = ({
}, [toastString]);
const showLockedEdit =
- isEnabled && !isPublished && (!restrictToEditPaths || isEditPath(pathname));
+ isEnabled &&
+ (!isPublished || currentDraftVersionId) &&
+ (!restrictToEditPaths || isEditPath(pathname));
const handleTakeover = async () => {
await takeover();
diff --git a/app/(gcforms)/[locale]/(form administration)/forms/components/client/Card.tsx b/app/(gcforms)/[locale]/(form administration)/forms/components/client/Card.tsx
index c19bae917e..ce0faff813 100644
--- a/app/(gcforms)/[locale]/(form administration)/forms/components/client/Card.tsx
+++ b/app/(gcforms)/[locale]/(form administration)/forms/components/client/Card.tsx
@@ -19,8 +19,8 @@ import { formatDateToEstYYYYMMDD } from "@root/lib/utils/date/utcToEst";
const getCardState = (card: FormsTemplateWithLockInfo): CardState => {
if (card.closingDate && dateHasPast(card.closingDate?.getTime())) return CARD_STATE.CLOSED;
if (card.ttl) return CARD_STATE.ARCHIVED;
+ if (card.editLockInfo && (!card.isPublished || card.hasDraft)) return CARD_STATE.DRAFT_EDITING;
if (card.isPublished) return CARD_STATE.PUBLISHED;
- if (card.editLockInfo) return CARD_STATE.DRAFT_EDITING;
return CARD_STATE.DRAFT_READONLY;
};
diff --git a/app/(gcforms)/[locale]/(form administration)/forms/components/client/Cards.tsx b/app/(gcforms)/[locale]/(form administration)/forms/components/client/Cards.tsx
index 5773a3e355..e7d2d9fafd 100644
--- a/app/(gcforms)/[locale]/(form administration)/forms/components/client/Cards.tsx
+++ b/app/(gcforms)/[locale]/(form administration)/forms/components/client/Cards.tsx
@@ -50,23 +50,37 @@ export const Cards = ({
});
}, []);
- // Setup edit lock polling - only poll for draft forms in the recentlyEdited and draft tabs
+ // Stable callback for the polling hook — must be memoized so the hook's
+ // useEffect doesn't tear down and restart on every poll-driven render.
+ const handlePollUpdate = useCallback(
+ (updater: (prev: FormsTemplateWithLockInfo[]) => FormsTemplateWithLockInfo[]) => {
+ startTransition(() => {
+ setTemplates(updater);
+ });
+ },
+ []
+ );
+
+ // Poll any template that can currently be edited from the cards view.
+ // With template versioning, published templates can still have an editable draft version.
+ // Also include templates currently shown as locked (hasEditLock=true) so a lock release
+ // is always detected even when hasDraft is stale in local state.
const shouldPoll =
- !tabStatus || tabStatus === TAB_STATUS.RECENTLY_EDITED || tabStatus === TAB_STATUS.DRAFT;
- const draftTemplates = useMemo(
- () => templates.filter((t) => t.isPublished === false && t.ttl === null),
+ !tabStatus ||
+ tabStatus === TAB_STATUS.RECENTLY_EDITED ||
+ tabStatus === TAB_STATUS.DRAFT ||
+ tabStatus === TAB_STATUS.PUBLISHED;
+ const editableTemplates = useMemo(
+ () =>
+ templates.filter((t) => t.ttl === null && (!t.isPublished || t.hasDraft || t.hasEditLock)),
[templates]
);
useEditLockPolling({
- templates: draftTemplates,
+ templates: editableTemplates,
displayedCount,
pollIntervalMs,
enabled: shouldPoll,
- onUpdate: (nextTemplates) => {
- startTransition(() => {
- setTemplates(nextTemplates);
- });
- },
+ onUpdate: handlePollUpdate,
});
// Get the appropriate message when there are no forms to display
diff --git a/lib/editLocks.ts b/lib/editLocks.ts
index 12f7332387..4605071189 100644
--- a/lib/editLocks.ts
+++ b/lib/editLocks.ts
@@ -114,7 +114,7 @@ const getEditLockKey = (templateId: string) => `${EDIT_LOCK_KEY_PREFIX}:${templa
const getEditLockStreamKey = (templateId: string) => `${EDIT_LOCK_STREAM_PREFIX}:${templateId}`;
const getEditLockTakeoverSaveAckKey = (templateId: string, sessionId: string) =>
`${EDIT_LOCK_TAKEOVER_SAVE_ACK_PREFIX}:${templateId}:${sessionId}`;
-const getEditLockAssignedUsersCacheKey = (templateId: string) =>
+export const getEditLockAssignedUsersCacheKey = (templateId: string) =>
`${EDIT_LOCK_ASSIGNED_USERS_CACHE_PREFIX}:${templateId}`;
export const getEditLockAssignedUsersCountCacheKey = (templateId: string) =>
`${EDIT_LOCK_ASSIGNED_USERS_COUNT_CACHE_PREFIX}:${templateId}`;
@@ -617,7 +617,12 @@ const shouldEnforceTemplateEditLockInternal = async (
const cachedTemplate = formCache.cacheAvailable
? await formCache.check(templateId).catch(() => null)
: null;
- const cachedIsPublished = cachedTemplate?.isPublished ?? null;
+ const cachedIsPublished = cachedTemplate
+ ? getEffectiveTemplatePublishState({
+ isPublished: cachedTemplate.isPublished,
+ currentDraftVersionId: cachedTemplate.currentDraftVersionId,
+ })
+ : null;
let cachedHasEnoughUsers: boolean | null = null;
diff --git a/lib/tests/editLocks.test.ts b/lib/tests/editLocks.test.ts
index ec166a7d9e..fe57843fbf 100644
--- a/lib/tests/editLocks.test.ts
+++ b/lib/tests/editLocks.test.ts
@@ -22,6 +22,7 @@ import {
acquireEditLock,
clearEditLockTakeoverSaveAcknowledgement,
getEditLockAssignedPendingUsersCountCacheKey,
+ getEditLockAssignedUsersCacheKey,
getEditLockAssignedUsersCountCacheKey,
getEditLockStatus,
getTemplateCollaboratorCount,
@@ -281,6 +282,24 @@ describe("editLocks with redis", () => {
await expect(shouldEnforceTemplateEditLock("form-9")).resolves.toBe(true);
});
+ it("enforces edit locking for cached versioned draft forms even when cached isPublished is true", async () => {
+ const cachedTemplate: PublicFormRecord = {
+ id: "form-9-cached",
+ form: {} as FormProperties,
+ isPublished: true,
+ currentDraftVersionId: "draft-version-3",
+ securityAttribute: "Unclassified",
+ };
+
+ vi.mocked(formCache.check).mockResolvedValue(cachedTemplate);
+
+ const redisInstance = await getRedisInstance();
+ await redisInstance.set(getEditLockAssignedUsersCacheKey("form-9-cached"), "1");
+
+ await expect(shouldEnforceTemplateEditLock("form-9-cached")).resolves.toBe(true);
+ expect(prisma.template.findUnique).not.toHaveBeenCalled();
+ });
+
it("revalidates a cached multi-user threshold when fresh assigned-user enforcement is required", async () => {
const findUniqueMock = prisma.template.findUnique as unknown as ReturnType;