diff --git a/apps/modeling-commons-frontend/CLAUDE.md b/apps/modeling-commons-frontend/CLAUDE.md index 7c71204f..6eb84c39 100644 --- a/apps/modeling-commons-frontend/CLAUDE.md +++ b/apps/modeling-commons-frontend/CLAUDE.md @@ -60,7 +60,7 @@ When a parent passes server data and the child mutates a derived view of it (e.g ## Testing -- Upload/edit interaction logic lives in `tests/nuxt/composables/*` (`vitest --project=nuxt`) with `useApi` mocked via `tests/helpers/mockApi` (`makeApiClientMock`/`apiResult`). Don't mount the editor stepper in component tests — the `UStepper` recursive-update flake under `@nuxt/test-utils` makes it unreliable. +- Upload/edit interaction logic lives in `tests/nuxt/composables/*` (`vitest --project=nuxt`) with `useApi` mocked via `tests/helpers/mockApi` (`makeApiClientMock`/`apiResult`). Mounting the editor stepper in component tests requires replacing `UStepper` via `mockComponent` with a flat slot renderer; mounting the real one hits a recursive-update flake under `@nuxt/test-utils`. - Full journeys go in `tests/e2e` (real backend + Chromium): they need the backend + Mailpit running and use `signUpAndVerify` for a verified session. ## Dev servers diff --git a/apps/modeling-commons-frontend/app/components/auth/LegacyAccountNoticeDialog.test.ts b/apps/modeling-commons-frontend/app/components/auth/LegacyAccountNoticeDialog.test.ts new file mode 100644 index 00000000..db48029b --- /dev/null +++ b/apps/modeling-commons-frontend/app/components/auth/LegacyAccountNoticeDialog.test.ts @@ -0,0 +1,97 @@ +import { mockNuxtImport, mountSuspended } from "@nuxt/test-utils/runtime"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { computed } from "vue"; +import LegacyAccountNoticeDialog from "./LegacyAccountNoticeDialog.vue"; +import { + legacyAccountNoticeDismissalKey, + legacyAccountNoticeSunsetDate, +} from "~/composables/auth/useLegacyAccountNotice"; + +const { userState } = vi.hoisted(() => ({ + userState: { current: { isLoggedIn: false } as { isLoggedIn: boolean } }, +})); + +mockNuxtImport("useUser", () => () => computed(() => userState.current)); + +const noticeTitle = "Old accounts are not lost"; +const dismissLabel = "I didn't have an old account"; + +function renderedText() { + return document.body.textContent ?? ""; +} + +beforeEach(() => { + userState.current = { isLoggedIn: false }; + window.localStorage.clear(); + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date("2026-08-11T10:00:00")); +}); + +afterEach(() => { + vi.useRealTimers(); + document.body.innerHTML = ""; +}); + +describe("LegacyAccountNoticeDialog", () => { + it("opens for a signed-out visitor before the sunset date", async () => { + await mountSuspended(LegacyAccountNoticeDialog); + + expect(renderedText()).toContain(noticeTitle); + }); + + it("stays closed on the sunset date", async () => { + vi.setSystemTime(new Date(`${legacyAccountNoticeSunsetDate}T00:00:00`)); + + await mountSuspended(LegacyAccountNoticeDialog); + + expect(renderedText()).not.toContain(noticeTitle); + }); + + it("stays closed after the sunset date", async () => { + vi.setSystemTime(new Date("2027-01-05T10:00:00")); + + await mountSuspended(LegacyAccountNoticeDialog); + + expect(renderedText()).not.toContain(noticeTitle); + }); + + it("stays closed once it has been dismissed", async () => { + window.localStorage.setItem(legacyAccountNoticeDismissalKey, "1"); + + await mountSuspended(LegacyAccountNoticeDialog); + + expect(renderedText()).not.toContain(noticeTitle); + }); + + it("stays closed for a signed-in user", async () => { + userState.current = { isLoggedIn: true }; + + await mountSuspended(LegacyAccountNoticeDialog); + + expect(renderedText()).not.toContain(noticeTitle); + }); + + it("persists the dismissal and closes when the visitor dismisses it", async () => { + const wrapper = await mountSuspended(LegacyAccountNoticeDialog); + + const dismissButton = document + .querySelectorAll("button") + .values() + .find((button) => button.textContent?.includes(dismissLabel)); + + expect(dismissButton).toBeDefined(); + dismissButton!.click(); + await wrapper.vm.$nextTick(); + + expect(window.localStorage.getItem(legacyAccountNoticeDismissalKey)).toBe("1"); + expect(document.querySelector('[role="dialog"][data-state="open"]')).toBeNull(); + }); + + it("points the primary action at the reset-password route with the next path", async () => { + await mountSuspended(LegacyAccountNoticeDialog, { props: { next: "/models/foo bar" } }); + + const link = document.querySelector('a[href*="reset-password"]'); + + expect(link?.getAttribute("href")).toBe(getResetPasswordLink("/models/foo bar")); + }); +}); diff --git a/apps/modeling-commons-frontend/app/components/auth/LegacyAccountNoticeDialog.vue b/apps/modeling-commons-frontend/app/components/auth/LegacyAccountNoticeDialog.vue new file mode 100644 index 00000000..093e038e --- /dev/null +++ b/apps/modeling-commons-frontend/app/components/auth/LegacyAccountNoticeDialog.vue @@ -0,0 +1,53 @@ + + + diff --git a/apps/modeling-commons-frontend/app/components/auth/ReclaimAccount.vue b/apps/modeling-commons-frontend/app/components/auth/ReclaimAccount.vue index c033c8af..b0477d16 100644 --- a/apps/modeling-commons-frontend/app/components/auth/ReclaimAccount.vue +++ b/apps/modeling-commons-frontend/app/components/auth/ReclaimAccount.vue @@ -22,8 +22,5 @@ const props = defineProps<{ next?: string; }>(); -const resetPasswordLink = computed(() => { - const nextPath = getSafeNextPath(props.next); - return `${authRoutes.resetPassword}?next=${encodeURIComponent(nextPath)}`; -}); +const resetPasswordLink = computed(() => getResetPasswordLink(props.next)); diff --git a/apps/modeling-commons-frontend/app/components/upload/ModelDraftActionBar.test.ts b/apps/modeling-commons-frontend/app/components/upload/ModelDraftActionBar.test.ts index 28e11660..9aed1cec 100644 --- a/apps/modeling-commons-frontend/app/components/upload/ModelDraftActionBar.test.ts +++ b/apps/modeling-commons-frontend/app/components/upload/ModelDraftActionBar.test.ts @@ -1,54 +1,83 @@ import { describe, expect, it } from "vitest"; import { mountSuspended } from "@nuxt/test-utils/runtime"; import ModelDraftActionBar from "./ModelDraftActionBar.vue"; -import type { DOMWrapper } from "@vue/test-utils"; function makeProps(overrides: Record = {}) { return { isEdit: true, publishing: false, hydrating: false, - reverting: false, deletingModel: false, - isDirty: false, draftId: "draft-1", saveStatusLabel: "Saved", submitLabel: "Publish", discardLabel: "Discard edits", + isLastStep: true, ...overrides, }; } -function publishButton(wrapper: Awaited>) { - return wrapper - .findAll("button") - .find((b: DOMWrapper) => b.text().includes("Publish")); +function primaryAction(wrapper: Awaited>) { + return wrapper.get('[data-testid="draft-primary-action"]'); } describe("ModelDraftActionBar", () => { - it("disables the Publish action while the draft is hydrating", async () => { + it("disables the primary action while the draft is hydrating", async () => { const wrapper = await mountSuspended(ModelDraftActionBar, { props: makeProps({ hydrating: true }), }); - const button = publishButton(wrapper); - expect(button).toBeTruthy(); - expect(button!.attributes("disabled")).toBeDefined(); + expect(primaryAction(wrapper).attributes("disabled")).toBeDefined(); }); - it("enables the Publish action once hydration completes", async () => { + it("enables the primary action once hydration completes", async () => { const wrapper = await mountSuspended(ModelDraftActionBar, { props: makeProps({ hydrating: false }), }); - const button = publishButton(wrapper); - expect(button).toBeTruthy(); - expect(button!.attributes("disabled")).toBeUndefined(); + expect(primaryAction(wrapper).attributes("disabled")).toBeUndefined(); }); - it("does not emit submit while hydrating even if the Publish button is clicked", async () => { + it("does not emit submit while hydrating even if the primary action is clicked", async () => { const wrapper = await mountSuspended(ModelDraftActionBar, { props: makeProps({ hydrating: true }), }); - await publishButton(wrapper)!.trigger("click"); + await primaryAction(wrapper).trigger("click"); expect(wrapper.emitted("submit")).toBeFalsy(); }); + + it("renders Publish and emits submit on the last step", async () => { + const wrapper = await mountSuspended(ModelDraftActionBar, { + props: makeProps({ isLastStep: true, submitLabel: "Publish" }), + }); + const button = primaryAction(wrapper); + expect(button.text()).toContain("Publish"); + await button.trigger("click"); + expect(wrapper.emitted("submit")).toHaveLength(1); + expect(wrapper.emitted("next")).toBeFalsy(); + }); + + it("renders Next and emits next on a non-final step", async () => { + const wrapper = await mountSuspended(ModelDraftActionBar, { + props: makeProps({ isLastStep: false, submitLabel: "Next" }), + }); + const button = primaryAction(wrapper); + expect(button.text()).toContain("Next"); + await button.trigger("click"); + expect(wrapper.emitted("next")).toHaveLength(1); + expect(wrapper.emitted("submit")).toBeFalsy(); + }); + + it("keeps the same disabled gating for Next as for Publish", async () => { + const wrapper = await mountSuspended(ModelDraftActionBar, { + props: makeProps({ isLastStep: false, submitLabel: "Next", hydrating: true }), + }); + await primaryAction(wrapper).trigger("click"); + expect(primaryAction(wrapper).attributes("disabled")).toBeDefined(); + expect(wrapper.emitted("next")).toBeFalsy(); + }); + + it("no longer renders a revert action in the action bar", async () => { + const wrapper = await mountSuspended(ModelDraftActionBar, { props: makeProps() }); + expect(wrapper.text()).not.toContain("Revert"); + expect(wrapper.find('[data-testid="revert-changes"]').exists()).toBe(false); + }); }); diff --git a/apps/modeling-commons-frontend/app/components/upload/ModelDraftActionBar.vue b/apps/modeling-commons-frontend/app/components/upload/ModelDraftActionBar.vue index 6c75e67f..f9c2355b 100644 --- a/apps/modeling-commons-frontend/app/components/upload/ModelDraftActionBar.vue +++ b/apps/modeling-commons-frontend/app/components/upload/ModelDraftActionBar.vue @@ -14,16 +14,6 @@ > Delete - - Revert - {{ submitLabel }} @@ -46,26 +37,33 @@ diff --git a/apps/modeling-commons-frontend/app/components/upload/ModelDraftEditor.test.ts b/apps/modeling-commons-frontend/app/components/upload/ModelDraftEditor.test.ts index a42d8e93..6f67c46b 100644 --- a/apps/modeling-commons-frontend/app/components/upload/ModelDraftEditor.test.ts +++ b/apps/modeling-commons-frontend/app/components/upload/ModelDraftEditor.test.ts @@ -1,10 +1,10 @@ -import { computed } from "vue"; +import { computed, h } from "vue"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { mountSuspended, mockNuxtImport } from "@nuxt/test-utils/runtime"; +import { mountSuspended, mockComponent, mockNuxtImport } from "@nuxt/test-utils/runtime"; import ModelDraftEditor from "./ModelDraftEditor.vue"; import { apiResult, makeApiClientMock } from "~~/tests/helpers/mockApi"; -const { apiState, userState, navigateToMock } = vi.hoisted(() => ({ +const { apiState, userState, navigateToMock, toastAdd } = vi.hoisted(() => ({ apiState: { current: null as ReturnType | null }, userState: { current: { @@ -14,23 +14,72 @@ const { apiState, userState, navigateToMock } = vi.hoisted(() => ({ } as unknown, }, navigateToMock: vi.fn(), + toastAdd: vi.fn(), })); mockNuxtImport("useApi", () => () => apiState.current!.client); mockNuxtImport("useUser", () => () => computed(() => userState.current)); mockNuxtImport("navigateTo", () => navigateToMock); +mockNuxtImport("useToast", () => () => ({ add: toastAdd })); +mockNuxtImport("useModelAdditionalFiles", () => () => ({ data: computed(() => []) })); + +// The real UStepper hits a recursive-update bug under @nuxt/test-utils, so the +// step panels are rendered flat here; step navigation is driven by the editor's +// own stepIndex, not by the stepper widget. +mockComponent("UStepper", { + props: { modelValue: { type: Number, default: 0 }, items: { type: Array, default: () => [] } }, + setup(_props, { slots }) { + return () => [slots.details?.(), slots.files?.(), slots.permissions?.()]; + }, +}); + +mockComponent("UTooltip", { + props: { text: { type: String, default: "" } }, + setup(props, { slots }) { + return () => h("span", { "data-tooltip-text": props.text }, slots.default?.()); + }, +}); + +const draftData = { + title: "Wolf Sheep", + description: "predator-prey", + visibility: "public", + tags: ["agents"], + primaryFile: { + s3Key: "staging/u/d/abc-model.nlogox", + filename: "model.nlogox", + sizeBytes: 1024, + mimeType: "application/xml", + }, + attachments: [], +}; + +const draftDto = { + id: "draft-1", + userId: "user-1", + modelId: "model-1", + schemaVersion: 1, + data: draftData, + previewImageUrl: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", +}; beforeEach(() => { + toastAdd.mockClear(); apiState.current = makeApiClientMock(); + apiState.current.GET.mockImplementation(async (path: string) => + path === "/api/v1/model-drafts/{id}" + ? apiResult.ok(draftDto) + : apiResult.ok({ data: [], count: 0, limit: 20, page: 1 }), + ); apiState.current.PATCH.mockResolvedValue(apiResult.ok(undefined as never)); apiState.current.POST.mockResolvedValue(apiResult.ok({ id: "draft-1" })); apiState.current.DELETE.mockResolvedValue(apiResult.ok(undefined as never)); }); // In create mode without a picked or primary file, the component renders only -// the picker modal — no UStepper. Tests on edit/stepper UI are covered by the -// composable + e2e layers; the UStepper recursive-update bug under -// @nuxt/test-utils makes mounting that path flaky here. +// the picker modal, no stepper. describe("ModelDraftEditor (picker view)", () => { it("renders the title prop in the picker modal", async () => { @@ -47,9 +96,8 @@ describe("ModelDraftEditor (picker view)", () => { it("does not show Delete or Revert affordances before a primary file is present", async () => { const wrapper = await mountSuspended(ModelDraftEditor, { props: {} }); - const text = wrapper.text(); - expect(text).not.toContain("Delete model"); - expect(text).not.toContain("Revert"); + expect(wrapper.text()).not.toContain("Delete model"); + expect(wrapper.find('[data-testid="revert-changes"]').exists()).toBe(false); }); it("shows the picker copy that requires a .nlogox file", async () => { @@ -57,3 +105,123 @@ describe("ModelDraftEditor (picker view)", () => { expect(wrapper.text()).toContain(".nlogox"); }); }); + +type Wrapper = Awaited>; + +async function mountHydrated(props: Record) { + const wrapper = await mountSuspended(ModelDraftEditor, { props }); + await vi.waitFor(() => { + expect(wrapper.find('[data-testid="draft-primary-action"]').exists()).toBe(true); + }); + return wrapper; +} + +function mountResumedDraft() { + return mountHydrated({ title: "Upload", initialDraftId: "draft-1" }); +} + +function mountEditMode() { + return mountHydrated({ mode: "edit" as const, title: "Edit Model", seedModelId: "model-1" }); +} + +function primaryAction(wrapper: Wrapper) { + return wrapper.get('[data-testid="draft-primary-action"]'); +} + +function published() { + return apiState.current!.POST.mock.calls.some((call) => String(call[0]).includes("/publish")); +} + +describe("ModelDraftEditor (steps)", () => { + it("labels the primary action Next until the final step, then Publish", async () => { + const wrapper = await mountResumedDraft(); + + expect(primaryAction(wrapper).text()).toContain("Next"); + + await primaryAction(wrapper).trigger("click"); + await wrapper.vm.$nextTick(); + expect(primaryAction(wrapper).text()).toContain("Next"); + + await primaryAction(wrapper).trigger("click"); + await wrapper.vm.$nextTick(); + expect(primaryAction(wrapper).text()).toContain("Publish"); + }); + + it("advances instead of publishing while a later step remains", async () => { + const wrapper = await mountResumedDraft(); + + await primaryAction(wrapper).trigger("click"); + await wrapper.vm.$nextTick(); + + expect(published()).toBe(false); + }); + + it("publishes instead of advancing once the final step is reached", async () => { + const wrapper = await mountResumedDraft(); + + await primaryAction(wrapper).trigger("click"); + await wrapper.vm.$nextTick(); + await primaryAction(wrapper).trigger("click"); + await wrapper.vm.$nextTick(); + await primaryAction(wrapper).trigger("click"); + + await vi.waitFor(() => { + expect(published()).toBe(true); + }); + }); + + it("publishes from the first step in edit mode, which is not a wizard", async () => { + const wrapper = await mountEditMode(); + + expect(primaryAction(wrapper).text()).toContain("Publish"); + + await primaryAction(wrapper).trigger("click"); + + await vi.waitFor(() => { + expect(published()).toBe(true); + }); + }); +}); + +describe("ModelDraftEditor (revert trigger)", () => { + it("renders revert as a labelled icon button at the top of the wizard", async () => { + const wrapper = await mountEditMode(); + const revert = wrapper.get('[data-testid="revert-changes"]'); + + expect(revert.attributes("aria-label")).toBe("Revert changes"); + expect(revert.text()).not.toContain("Revert changes"); + expect(wrapper.get("[data-tooltip-text]").attributes("data-tooltip-text")).toBe( + "Revert changes", + ); + expect(revert.attributes("disabled")).toBeDefined(); + }); + + it("does not render the revert icon in create mode", async () => { + const wrapper = await mountResumedDraft(); + expect(wrapper.find('[data-testid="revert-changes"]').exists()).toBe(false); + }); + + it("reverts edits back to the loaded draft when the icon is clicked", async () => { + const wrapper = await mountEditMode(); + const titleInput = wrapper.get('input[type="text"]'); + await titleInput.setValue("Changed title"); + + const revert = wrapper.get('[data-testid="revert-changes"]'); + await vi.waitFor(() => { + expect(revert.attributes("disabled")).toBeUndefined(); + }); + + await revert.trigger("click"); + + await vi.waitFor(() => { + expect((wrapper.get('input[type="text"]').element as HTMLInputElement).value).toBe( + "Wolf Sheep", + ); + }); + await vi.waitFor(() => { + expect(toastAdd).toHaveBeenCalledWith( + expect.objectContaining({ title: "Reverted to original" }), + ); + }); + }); +}); diff --git a/apps/modeling-commons-frontend/app/components/upload/ModelDraftEditor.vue b/apps/modeling-commons-frontend/app/components/upload/ModelDraftEditor.vue index c58c1753..18721f81 100644 --- a/apps/modeling-commons-frontend/app/components/upload/ModelDraftEditor.vue +++ b/apps/modeling-commons-frontend/app/components/upload/ModelDraftEditor.vue @@ -30,6 +30,18 @@

{{ title }}

+ + +
@@ -75,16 +87,15 @@ :is-edit="isEdit" :publishing="publishing" :hydrating="hydrating" - :reverting="reverting" :deleting-model="deletingModel" - :is-dirty="isDirty" :draft-id="draftId" :save-status-label="saveStatusLabel" :submit-label="submitLabel" :discard-label="discardLabel" + :is-last-step="isLastStep" @delete="confirmDelete = true" - @revert="onRevert" @discard="onDiscard" + @next="goToNextStep" @submit="onSubmit" /> @@ -185,21 +196,31 @@ const reverting = ref(false); const confirmDelete = ref(false); const confirmDiscard = ref(false); +const stepperItems = [ + { slot: "details", icon: "i-lucide-file-text", title: "Add Details" }, + { slot: "files", icon: "i-lucide-file-up", title: "Add Files" }, + { slot: "permissions", icon: "i-lucide-lock", title: "Set Permissions" }, +] satisfies Array; + const isEdit = computed(() => props.mode === "edit"); const isSeedingDraft = computed(() => Boolean(props.initialDraftId || props.seedModelId)); const loadingDraft = computed( () => isSeedingDraft.value && (initializing.value || hydrating.value), ); const showPicker = computed(() => !isEdit.value && !hasPrimaryFile.value); -const submitLabel = computed(() => "Publish"); +// Editing an existing model is a save surface, not a wizard: its steps are +// freely navigable sections, so the primary action always publishes there. +const isLastStep = computed( + () => isEdit.value || stepIndex.value >= stepperItems.length - 1, +); +const submitLabel = computed(() => (isLastStep.value ? "Publish" : "Next")); const discardLabel = computed(() => (isEdit.value ? "Discard edits" : "Discard draft")); const willCreateNewVersion = computed(() => primaryFileChanged.value || modelFilesAdded.value); -const stepperItems = [ - { slot: "details", icon: "i-lucide-file-text", title: "Add Details" }, - { slot: "files", icon: "i-lucide-file-up", title: "Add Files" }, - { slot: "permissions", icon: "i-lucide-lock", title: "Set Permissions" }, -] satisfies Array; +function goToNextStep(): void { + if (isLastStep.value) return; + stepIndex.value += 1; +} onMounted(() => { void init(); diff --git a/apps/modeling-commons-frontend/app/composables/auth/useLegacyAccountNotice.ts b/apps/modeling-commons-frontend/app/composables/auth/useLegacyAccountNotice.ts new file mode 100644 index 00000000..c82bf0f8 --- /dev/null +++ b/apps/modeling-commons-frontend/app/composables/auth/useLegacyAccountNotice.ts @@ -0,0 +1,34 @@ +/** + * The legacy modelingcommons.org notice stops showing on this date (viewer local time). + * Edit this single constant to move or extend the sunset. + */ +export const legacyAccountNoticeSunsetDate = "2026-09-18"; + +export const legacyAccountNoticeDismissalKey = "auth:legacy-account-notice:dismissed"; + +export function isLegacyAccountNoticeExpired(now: Date = new Date()) { + return now.getTime() >= new Date(`${legacyAccountNoticeSunsetDate}T00:00:00`).getTime(); +} + +export default function useLegacyAccountNotice() { + const user = useUser(); + const open = ref(false); + + function dismiss() { + if (typeof window !== "undefined") { + window.localStorage.setItem(legacyAccountNoticeDismissalKey, "1"); + } + + open.value = false; + } + + onMounted(() => { + if (user.value.isLoggedIn || isLegacyAccountNoticeExpired()) { + return; + } + + open.value = window.localStorage.getItem(legacyAccountNoticeDismissalKey) !== "1"; + }); + + return { open, dismiss }; +} diff --git a/apps/modeling-commons-frontend/app/pages/(auth)/login.vue b/apps/modeling-commons-frontend/app/pages/(auth)/login.vue index a87024b2..89d5a26d 100644 --- a/apps/modeling-commons-frontend/app/pages/(auth)/login.vue +++ b/apps/modeling-commons-frontend/app/pages/(auth)/login.vue @@ -1,5 +1,6 @@