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;