Skip to content
Closed
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
2 changes: 1 addition & 1 deletion apps/modeling-commons-frontend/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<HTMLAnchorElement>('a[href*="reset-password"]');

expect(link?.getAttribute("href")).toBe(getResetPasswordLink("/models/foo bar"));
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<template>
<UModal
:open="open"
class="lg:max-w-lg"
title="Your old account is still here"
@update:open="onOpenChange"
>
<template #content>
<div class="p-6 space-y-5">
<div class="flex gap-4">
<div class="shrink-0 flex items-center justify-center size-10 rounded-full bg-primary/10">
<UIcon name="i-lucide-key-round" class="size-5 text-primary" />
</div>
<div class="space-y-1">
<h6 class="font-semibold text-highlighted">Old accounts are not lost</h6>
<p class="text-sm text-muted">
Accounts from the previous modelingcommons.org site came across to this new version.
Passwords did not, but we can email you a link to set up a new one.
</p>

<p class="text-xs text-muted mt-5">
Not sure if you had one? Try reclaiming it. We will email a link if we find an account
for your address.
</p>
</div>
</div>

<div class="flex flex-col justify-end gap-4">
<UButton variant="solid" color="primary" :to="resetPasswordLink" icon="lucide:user-round-search" @click="dismiss">
Reclaim account
</UButton>
<UButton variant="link" size="xs" class="mx-auto" color="neutral" @click="dismiss">
I didn't have an old account
</UButton>
</div>
</div>
</template>
</UModal>
</template>

<script setup lang="ts">
const props = defineProps<{ next?: string }>();

const { open, dismiss } = useLegacyAccountNotice();

const resetPasswordLink = computed(() => getResetPasswordLink(props.next));

function onOpenChange(value: boolean) {
if (!value) {
dismiss();
}
}
</script>
Original file line number Diff line number Diff line change
Expand Up @@ -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));
</script>
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {}) {
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<ReturnType<typeof mountSuspended>>) {
return wrapper
.findAll("button")
.find((b: DOMWrapper<HTMLButtonElement>) => b.text().includes("Publish"));
function primaryAction(wrapper: Awaited<ReturnType<typeof mountSuspended>>) {
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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,6 @@
>
Delete
</UButton>
<UButton
v-if="isEdit"
variant="outline"
color="neutral"
:disabled="publishing || reverting || !isDirty"
:loading="reverting"
@click="emit('revert')"
>
Revert
</UButton>
<UButton
variant="outline"
color="neutral"
Expand All @@ -37,7 +27,8 @@
:disabled="publishing || hydrating"
variant="solid"
color="primary"
@click="emit('submit')"
data-testid="draft-primary-action"
@click="onPrimaryAction"
>
{{ submitLabel }}
</UButton>
Expand All @@ -46,26 +37,33 @@
</template>

<script setup lang="ts">
withDefaults(
const props = withDefaults(
defineProps<{
isEdit: boolean;
publishing: boolean;
hydrating?: boolean;
reverting: boolean;
deletingModel: boolean;
isDirty: boolean;
draftId: string | null | undefined;
saveStatusLabel: string;
submitLabel: string;
discardLabel: string;
isLastStep?: boolean;
}>(),
{ hydrating: false },
{ hydrating: false, isLastStep: true },
);

const emit = defineEmits<{
delete: [];
revert: [];
discard: [];
next: [];
submit: [];
}>();

function onPrimaryAction(): void {
if (props.isLastStep) {
emit("submit");
return;
}
emit("next");
}
</script>
Loading