From 487e0f327d7e010abb8b5bf0daeec9c4d647f341 Mon Sep 17 00:00:00 2001 From: Agents Agent Date: Tue, 30 Jun 2026 15:15:39 +0000 Subject: [PATCH 1/2] feat(frontend): show period membership on the contribution manager The Member/User chip on the contribution manager read the user's current MEMBER role, so a row's membership state did not reflect the contribution period being viewed: someone who was a member during an older period but is not one now showed as User, and vice versa. The contribution manager now fetches the memberships overlapping the selected period (findMemberships with the period's from/to range, the same query the member manager uses) alongside the period's contributions, and derives the set of user ids that held a membership during it. That set is threaded through the contribution list to each row, which derives wasMemberDuringPeriod from it; the chip and its colour come from that instead of the user's roles. The membership set is cleared when no period is selected and is subject to the same stale-response guard as the contributions, so a slow fetch for a deselected period cannot overwrite the current one. --- .../common/lists/ContributionUserList.vue | 14 +++++++++++++- .../common/rows/ContributionUserRow.vue | 9 ++++++--- .../pages/management/ContributionManager.vue | 19 +++++++++++++++++-- 3 files changed, 36 insertions(+), 6 deletions(-) diff --git a/services/frontend/src/components/common/lists/ContributionUserList.vue b/services/frontend/src/components/common/lists/ContributionUserList.vue index bdd4151bd..708f96d8c 100644 --- a/services/frontend/src/components/common/lists/ContributionUserList.vue +++ b/services/frontend/src/components/common/lists/ContributionUserList.vue @@ -55,6 +55,7 @@ disabled?: boolean startOpen?: boolean }>(), { panelKey: "", contributions: () => [], + periodMemberUserIds: () => new Set(), disabled: false, startOpen: false, }) -const {title, panelKey, users, contributions, disabled, contributionPeriodId, startOpen} = toRefs(props) +const { + title, + panelKey, + users, + contributions, + periodMemberUserIds, + disabled, + contributionPeriodId, + startOpen, +} = toRefs(props) const emit = defineEmits<{ (e: "delete:contribution", userId: number): void diff --git a/services/frontend/src/components/common/rows/ContributionUserRow.vue b/services/frontend/src/components/common/rows/ContributionUserRow.vue index 58fbd491b..24ad34138 100644 --- a/services/frontend/src/components/common/rows/ContributionUserRow.vue +++ b/services/frontend/src/components/common/rows/ContributionUserRow.vue @@ -17,14 +17,13 @@ >
- {{ user.roles.includes("MEMBER") ? "Member" : "User" }} + {{ wasMemberDuringPeriod ? "Member" : "User" }} + periodMemberUserIds?: Set disabled?: boolean }>(), { contributions: () => [], + periodMemberUserIds: () => new Set(), disabled: false, }) @@ -66,6 +67,8 @@ const emit = defineEmits<{ const saving = ref(false) +const wasMemberDuringPeriod = computed(() => props.periodMemberUserIds.has(props.user.id)) + const contribution = computed(() => props.contributions.find( (c) => c.userId === props.user.id && c.contributionPeriodId === props.contributionPeriodId, diff --git a/services/frontend/src/pages/management/ContributionManager.vue b/services/frontend/src/pages/management/ContributionManager.vue index 318b79fa3..594681013 100644 --- a/services/frontend/src/pages/management/ContributionManager.vue +++ b/services/frontend/src/pages/management/ContributionManager.vue @@ -12,6 +12,7 @@ ([]) const contributions = ref([]) +const periodMemberUserIds = ref>(new Set()) const selectedPeriodId = ref(0) @@ -106,14 +110,25 @@ const contributionPeriodChanged = async (newPeriod?: ContributionPeriodResponse) if (!periodId) { selectedPeriodId.value = 0 contributions.value = [] + periodMemberUserIds.value = new Set() return } selectedPeriodId.value = periodId contributions.value = [] + periodMemberUserIds.value = new Set() - const resp = await findContributionsByPeriodId({path: {periodId}}) - if (selectedPeriodId.value === periodId) contributions.value = resp.data ?? [] + const [contributionsResp, membershipsResp] = await Promise.all([ + findContributionsByPeriodId({path: {periodId}}), + findMemberships({query: {from: newPeriod.startDate, to: newPeriod.endDate}}), + ]) + + if (selectedPeriodId.value !== periodId) return + + contributions.value = contributionsResp.data ?? [] + periodMemberUserIds.value = new Set( + (membershipsResp.data ?? []).map((membership) => membership.userId), + ) } onMounted(async () => { From 27fb681b430f83bbfa983bbdd67edaf6c7e8613d Mon Sep 17 00:00:00 2001 From: Agents Agent Date: Tue, 30 Jun 2026 15:15:47 +0000 Subject: [PATCH 2/2] test(frontend): cover period membership on the contribution manager Add coverage for the period-membership behaviour: the contribution manager calls findMemberships with the selected period's range and forwards the resulting user-id set to both lists, clears it on deselection and when a period has no memberships, and ignores a stale response from a previously selected period. The list forwards membership state per row, and the row chip is driven by the period membership set rather than the user's roles (a current member absent from the period shows User; a non-member present in the period shows Member; an empty roles array still renders User). --- .../common/lists/ContributionUserList.test.ts | 27 ++++- .../common/rows/ContributionUserRow.test.ts | 52 ++++++-- .../management/ContributionManager.test.ts | 112 +++++++++++++++++- 3 files changed, 179 insertions(+), 12 deletions(-) diff --git a/services/frontend/tests/unit/components/common/lists/ContributionUserList.test.ts b/services/frontend/tests/unit/components/common/lists/ContributionUserList.test.ts index 26d455501..8b0e55a0a 100644 --- a/services/frontend/tests/unit/components/common/lists/ContributionUserList.test.ts +++ b/services/frontend/tests/unit/components/common/lists/ContributionUserList.test.ts @@ -38,27 +38,46 @@ const vuetifyStubs = { } describe("ContributionUserList", () => { - it("renders list and forwards contribution update event", async () => { + it("renders list and forwards contribution update event and period membership set", async () => { const wrapper = mount(ContributionUserList, { props: { title: "Contribution paid", users, contributionPeriodId: 2, contributions: [], + periodMemberUserIds: new Set([1]), startOpen: true, }, global: { stubs: { ...vuetifyStubs, ContributionUserRow: { - template: "", + props: ["user", "periodMemberUserIds"], + template: ` + + `, }, }, }, }) expect(wrapper.text()).toContain("Contribution paid") - await wrapper.find("button").trigger("click") + + const rows = wrapper.findAll("button.row") + expect(rows).toHaveLength(2) + expect(rows[0].attributes("data-username")).toBe("lyndisluna") + expect(rows[0].attributes("data-is-period-member")).toBe("true") + expect(rows[1].attributes("data-username")).toBe("ariosfury") + expect(rows[1].attributes("data-is-period-member")).toBe("false") + + await rows[0].trigger("click") expect(wrapper.emitted("update:contribution")?.length).toBe(1) }) @@ -75,7 +94,7 @@ describe("ContributionUserList", () => { stubs: { ...vuetifyStubs, ContributionUserRow: { - props: ["user"], + props: ["user", "periodMemberUserIds"], template: "
{{ user.username }}
", }, }, diff --git a/services/frontend/tests/unit/components/common/rows/ContributionUserRow.test.ts b/services/frontend/tests/unit/components/common/rows/ContributionUserRow.test.ts index a0b671b07..9103080e2 100644 --- a/services/frontend/tests/unit/components/common/rows/ContributionUserRow.test.ts +++ b/services/frontend/tests/unit/components/common/rows/ContributionUserRow.test.ts @@ -12,6 +12,24 @@ vi.mock("@/services/api", () => ({ deleteContribution: mockDeleteContribution, })) +const mountRow = (props: Record) => + mount(ContributionUserRow, { + props: { + user: {id: 1, fullName: "Emma", username: "emma", roles: ["MEMBER"]}, + contributionPeriodId: 9, + contributions: [], + ...props, + }, + global: { + stubs: { + VChip: { + props: ["color"], + template: "", + }, + }, + }, + }) + describe("ContributionUserRow", () => { beforeEach(() => { vi.clearAllMocks() @@ -20,13 +38,7 @@ describe("ContributionUserRow", () => { }) it("marks and unmarks contribution", async () => { - const wrapper = mount(ContributionUserRow, { - props: { - user: {id: 1, fullName: "Emma", username: "emma", roles: ["MEMBER"]}, - contributionPeriodId: 9, - contributions: [], - }, - }) + const wrapper = mountRow({}) await (wrapper.vm as any).markPaid() expect(mockCreateContribution).toHaveBeenCalledWith({ @@ -48,4 +60,30 @@ describe("ContributionUserRow", () => { }, }) }) + + it("renders membership chip from selected-period membership set instead of current roles", () => { + const periodMember = mountRow({ + user: {id: 1, fullName: "Emma", username: "emma", roles: ["USER"]}, + periodMemberUserIds: new Set([1]), + }) + + expect(periodMember.get("[data-testid='membership-chip']").text()).toBe("Member") + expect(periodMember.get("[data-testid='membership-chip']").attributes("data-color")).toBe("primary") + + const currentMemberOnly = mountRow({ + user: {id: 1, fullName: "Emma", username: "emma", roles: ["MEMBER"]}, + periodMemberUserIds: new Set(), + }) + + expect(currentMemberOnly.get("[data-testid='membership-chip']").text()).toBe("User") + expect(currentMemberOnly.get("[data-testid='membership-chip']").attributes("data-color")).toBe("grey") + + const noRoles = mountRow({ + user: {id: 1, fullName: "Emma", username: "emma", roles: []}, + periodMemberUserIds: new Set(), + }) + + expect(noRoles.get("[data-testid='membership-chip']").text()).toBe("User") + expect(noRoles.get("[data-testid='membership-chip']").attributes("data-color")).toBe("grey") + }) }) diff --git a/services/frontend/tests/unit/pages/management/ContributionManager.test.ts b/services/frontend/tests/unit/pages/management/ContributionManager.test.ts index 5e8d5dbc0..8408f1706 100644 --- a/services/frontend/tests/unit/pages/management/ContributionManager.test.ts +++ b/services/frontend/tests/unit/pages/management/ContributionManager.test.ts @@ -5,14 +5,17 @@ import {settle} from "../helpers" const { mockFindUsers, + mockFindMemberships, mockFindContributionsByPeriodId, } = vi.hoisted(() => ({ mockFindUsers: vi.fn(), + mockFindMemberships: vi.fn(), mockFindContributionsByPeriodId: vi.fn(), })) vi.mock("@/services/api", () => ({ findUsers: mockFindUsers, + findMemberships: mockFindMemberships, findContributionsByPeriodId: mockFindContributionsByPeriodId, })) @@ -24,6 +27,33 @@ const contribution = (userId: number, contributionPeriodId: number, version = 1) version, }) +const membership = (userId: number, id = userId) => ({ + createdAt: "2026-01-01T00:00:00Z", + id, + incasso: false, + memberType: "REGULAR", + startDate: "2026-01-01", + updatedAt: "2026-01-01T00:00:00Z", + userId, + version: 1, +}) + +type ContributionsResponse = { + data: Array> +} + +type MembershipsResponse = { + data: Array> +} + +const deferred = () => { + let resolve: (value: T) => void = () => undefined + const promise = new Promise((res) => { + resolve = res + }) + return {promise, resolve} +} + const period = (id: number) => ({ alumniFee: 15, createdAt: "2026-01-01T00:00:00Z", @@ -39,6 +69,9 @@ const period = (id: number) => ({ const usernames = (users: unknown) => (users as Array<{username: string}>).map((user) => user.username) +const memberUserIds = (list: VueWrapper) => + [...(list.props("periodMemberUserIds") as Set)].sort((a, b) => a - b) + const mountPage = () => shallowMount(ContributionManager, { global: { @@ -50,6 +83,7 @@ const mountPage = () => props: [ "contributionPeriodId", "contributions", + "periodMemberUserIds", "disabled", "panelKey", "users", @@ -84,6 +118,12 @@ describe("ContributionManager page", () => { mockFindContributionsByPeriodId.mockResolvedValue({ data: [contribution(1, 8)], }) + mockFindMemberships.mockResolvedValue({ + data: [ + membership(1, 90), + membership(3, 91), + ], + }) }) it("shows all users and splits them by selected-period contribution", async () => { @@ -104,6 +144,8 @@ describe("ContributionManager page", () => { expect(initialUnpaid.props("panelKey")).toBe("unpaid") expect(usernames(initialPaid.props("users"))).toEqual([]) expect(usernames(initialUnpaid.props("users"))).toEqual(["alice", "bob", "carol"]) + expect(memberUserIds(initialPaid)).toEqual([]) + expect(memberUserIds(initialUnpaid)).toEqual([]) expect(initialPaid.props("disabled")).toBe(true) expect(initialUnpaid.props("disabled")).toBe(true) @@ -114,13 +156,16 @@ describe("ContributionManager page", () => { const unpaid = contributionList(wrapper, "unpaid") expect(mockFindContributionsByPeriodId).toHaveBeenCalledWith({path: {periodId: 8}}) + expect(mockFindMemberships).toHaveBeenCalledWith({query: {from: "2026-01-01", to: "2026-12-31"}}) expect(usernames(paid.props("users"))).toEqual(["alice"]) expect(usernames(unpaid.props("users"))).toEqual(["bob", "carol"]) + expect(memberUserIds(paid)).toEqual([1, 3]) + expect(memberUserIds(unpaid)).toEqual([1, 3]) expect(paid.props("disabled")).toBe(false) expect(unpaid.props("disabled")).toBe(false) }) - it("clears contribution state and disables lists when selected period is undefined", async () => { + it("clears contribution and membership state and disables lists when selected period is undefined", async () => { const wrapper = mountPage() await settle() @@ -129,6 +174,7 @@ describe("ContributionManager page", () => { expect((wrapper.vm as any).selectedPeriodId).toBe(8) expect((wrapper.vm as any).contributions).toHaveLength(1) + expect(memberUserIds(contributionList(wrapper, "paid"))).toEqual([1, 3]) await (wrapper.vm as any).contributionPeriodChanged(undefined) await settle() @@ -140,10 +186,74 @@ describe("ContributionManager page", () => { expect((wrapper.vm as any).contributions).toEqual([]) expect(usernames(paid.props("users"))).toEqual([]) expect(usernames(unpaid.props("users"))).toEqual(["alice", "bob", "carol"]) + expect(memberUserIds(paid)).toEqual([]) + expect(memberUserIds(unpaid)).toEqual([]) expect(paid.props("disabled")).toBe(true) expect(unpaid.props("disabled")).toBe(true) }) + it("passes an empty period membership set when selected period has no memberships", async () => { + mockFindMemberships.mockResolvedValueOnce({data: []}) + + const wrapper = mountPage() + await settle() + + await (wrapper.vm as any).contributionPeriodChanged(period(8)) + await settle() + + expect(memberUserIds(contributionList(wrapper, "paid"))).toEqual([]) + expect(memberUserIds(contributionList(wrapper, "unpaid"))).toEqual([]) + }) + + it("does not let stale period membership or contribution responses overwrite the current period", async () => { + const period8Contributions = deferred() + const period8Memberships = deferred() + const period9Contributions = deferred() + const period9Memberships = deferred() + const newPeriod = { + ...period(9), + startDate: "2027-01-01", + endDate: "2027-12-31", + } + + mockFindContributionsByPeriodId + .mockReturnValueOnce(period8Contributions.promise) + .mockReturnValueOnce(period9Contributions.promise) + mockFindMemberships + .mockReturnValueOnce(period8Memberships.promise) + .mockReturnValueOnce(period9Memberships.promise) + + const wrapper = mountPage() + await settle() + + const request8 = (wrapper.vm as any).contributionPeriodChanged(period(8)) + const request9 = (wrapper.vm as any).contributionPeriodChanged(newPeriod) + + period9Contributions.resolve({data: [contribution(2, 9)]}) + period9Memberships.resolve({data: [membership(2, 92)]}) + await request9 + await settle() + + expect(mockFindMemberships).toHaveBeenNthCalledWith(1, {query: {from: "2026-01-01", to: "2026-12-31"}}) + expect(mockFindMemberships).toHaveBeenNthCalledWith(2, {query: {from: "2027-01-01", to: "2027-12-31"}}) + expect((wrapper.vm as any).selectedPeriodId).toBe(9) + expect((wrapper.vm as any).contributions).toEqual([contribution(2, 9)]) + expect(usernames((wrapper.vm as any).usersPaid)).toEqual(["bob"]) + expect(memberUserIds(contributionList(wrapper, "paid"))).toEqual([2]) + expect(memberUserIds(contributionList(wrapper, "unpaid"))).toEqual([2]) + + period8Contributions.resolve({data: [contribution(1, 8)]}) + period8Memberships.resolve({data: [membership(1, 91)]}) + await request8 + await settle() + + expect((wrapper.vm as any).selectedPeriodId).toBe(9) + expect((wrapper.vm as any).contributions).toEqual([contribution(2, 9)]) + expect(usernames((wrapper.vm as any).usersPaid)).toEqual(["bob"]) + expect(memberUserIds(contributionList(wrapper, "paid"))).toEqual([2]) + expect(memberUserIds(contributionList(wrapper, "unpaid"))).toEqual([2]) + }) + it("upserts contributions by user and selected period", async () => { const wrapper = mountPage() await settle()