Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const mockState = vi.hoisted(() => ({
store: {
lang: "en",
isPublished: false,
currentDraftVersionId: null as string | null,
},
editLockContext: {
takeover: vi.fn().mockResolvedValue(undefined),
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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(
<EditLockClient formId="test-form-id">
<div>Child content</div>
</EditLockClient>
);

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");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 7 additions & 2 deletions lib/editLocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
Expand Down Expand Up @@ -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;

Expand Down
19 changes: 19 additions & 0 deletions lib/tests/editLocks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
acquireEditLock,
clearEditLockTakeoverSaveAcknowledgement,
getEditLockAssignedPendingUsersCountCacheKey,
getEditLockAssignedUsersCacheKey,
getEditLockAssignedUsersCountCacheKey,
getEditLockStatus,
getTemplateCollaboratorCount,
Expand Down Expand Up @@ -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<typeof vi.fn>;

Expand Down
Loading