Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(notes+follow) - grant activity card #3470

Merged
merged 14 commits into from
Sep 11, 2024
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
5 changes: 1 addition & 4 deletions packages/client/src/components/BaseLayout.vue
Original file line number Diff line number Diff line change
Expand Up @@ -164,10 +164,7 @@
</b-col>

<div style="margin-top: 10px">
<section
class="container-fluid"
style="display: flex; justify-content: center;"
>
<section class="container-fluid d-flex flex-column align-items-center">
<AlertBox
v-for="(alert, alertId) in alerts"
v-bind="alert"
Expand Down
108 changes: 108 additions & 0 deletions packages/client/src/components/GrantActivity.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import {
describe, it, expect, vi, beforeEach,
} from 'vitest';
import { shallowMount, flushPromises } from '@vue/test-utils';
import { createStore } from 'vuex';
import GrantActivity from '@/components/GrantActivity.vue';

const mockFollowers = {
followers: Array.from(Array(20), (item, i) => ({
id: i,
user: {
id: i + 100,
name: `Follower ${i}`,
},
})),
};

describe('GrantActivity', () => {
const mockStore = {
getters: {
'users/loggedInUser': () => null,
'grants/currentGrant': () => ({
grant_id: 55,
}),
},
actions: {
'grants/getFollowerForGrant': vi.fn(),
'grants/getFollowersForGrant': vi.fn(),
'grants/getNotesForGrant': vi.fn(),
'grants/followGrantForCurrentUser': vi.fn(),
'grants/unfollowGrantForCurrentUser': vi.fn(),
},
};

const store = createStore(mockStore);

beforeEach(() => {
vi.resetAllMocks();
});

const buttonSelector = '[data-follow-btn]';
const followSummarySelector = '[data-follow-summary]';

it('renders', () => {
const wrapper = shallowMount(GrantActivity, {
global: {
plugins: [store],
},
});
expect(wrapper.exists()).toBe(true);
});

it('Correctly displays followers summary', async () => {
mockStore.actions['grants/getFollowersForGrant'].mockResolvedValue(mockFollowers);

const wrapper = shallowMount(GrantActivity, {
global: {
plugins: [store],
},
});

await flushPromises();

const summary = wrapper.find(followSummarySelector);

expect(summary.text()).equal('Followed by Follower 0 and 19 others');
});

it('Correctly follows grants when not followed by user', async () => {
mockStore.actions['grants/getFollowerForGrant'].mockResolvedValue(null);

const wrapper = shallowMount(GrantActivity, {
global: {
plugins: [store],
},
});

await flushPromises();

const btn = wrapper.find(buttonSelector);

expect(btn.text()).equal('Follow');

btn.trigger('click');
expect(mockStore.actions['grants/followGrantForCurrentUser']).toHaveBeenCalled();
});

it('Correctly unfollows grants when followed by user', async () => {
mockStore.actions['grants/getFollowerForGrant'].mockResolvedValue({
id: 1,
});

const wrapper = shallowMount(GrantActivity, {
global: {
plugins: [store],
},
});

await flushPromises();

const btn = wrapper.find(buttonSelector);

expect(btn.text()).equal('Following');

btn.trigger('click');
expect(mockStore.actions['grants/unfollowGrantForCurrentUser']).toHaveBeenCalled();
});
});
169 changes: 169 additions & 0 deletions packages/client/src/components/GrantActivity.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
<template>
<b-card
header-bg-variant="white"
footer-bg-variant="white"
>
<template #header>
<h3 class="my-2">
Grant Activity
</h3>
</template>
<div>
<div class="feature-text">
Stay up to date with this grant.
<b-icon
v-b-tooltip
class="ml-2"
title="Follow this grant to receive an email notification when others follow or leave a note."
icon="info-circle-fill"
/>
</div>
<b-button
block
size="lg"
:variant="followBtnVariant"
class="mb-4"
data-follow-btn
:disabled="!followStateLoaded"
@click="toggleFollowState"
>
<span class="h4">
<b-icon
icon="check-circle-fill"
class="mr-2"
/>
</span>
<span class="h5">
{{ followBtnLabel }}
</span>
</b-button>
<div>
<span
v-if="grantHasFollowers"
:class="followSummaryClass"
data-follow-summary
>{{ followSummaryText }}</span>
<span
v-if="showFollowSummary && showNotesSummary"
class="mx-1"
>&bull;</span>
<span v-if="showNotesSummary">{{ notesSummaryText }}</span>
</div>
</div>

<template #footer>
<!-- Feed -->
</template>
</b-card>
</template>

<script>
import { mapActions, mapGetters } from 'vuex';

export default {
components: {},
data() {
return {
userIsFollowing: false,
followStateLoaded: false,
followers: [],
notes: [],
};
},
computed: {
...mapGetters({
loggedInUser: 'users/loggedInUser',
currentGrant: 'grants/currentGrant',
}),
followBtnLabel() {
return this.userIsFollowing ? 'Following' : 'Follow';
},
followBtnVariant() {
return this.userIsFollowing ? 'success' : 'primary';
},
followSummaryClass() {
return this.followStateLoaded ? 'visible' : 'invisible';
},
followSummaryText() {
const userIsFollower = this.userIsFollowing;
const firstFollowerName = userIsFollower ? 'you' : this.followers[0].user.name;

let otherFollowerText = '';
if (this.followers.length > 1) {
const otherFollowersCount = this.followers.length - 1;

if (otherFollowersCount === 1) {
otherFollowerText += ' and 1 other';
} else if (otherFollowersCount < 50) {
otherFollowerText += ` and ${otherFollowersCount} others`;
} else if (otherFollowersCount > 50) {
otherFollowerText += ' and 50+ others';
}
}

return `Followed by ${firstFollowerName}${otherFollowerText}`;
},
grantHasFollowers() {
return this.followers.length > 0;
},
showNotesSummary() {
return this.notes.length > 0;
},
notesSummaryText() {
let textCount = `${this.notes.length}`;
if (this.notes.length > 50) {
textCount = '50+';
}

return `${textCount} notes`;
},
},
async beforeMount() {
this.fetchFollowState();
this.fetchAllNotes();
},
methods: {
...mapActions({
getFollowerForGrant: 'grants/getFollowerForGrant',
getFollowersForGrant: 'grants/getFollowersForGrant',
getNotesForGrant: 'grants/getNotesForGrant',
followGrantForCurrentUser: 'grants/followGrantForCurrentUser',
unfollowGrantForCurrentUser: 'grants/unfollowGrantForCurrentUser',
}),
async fetchFollowState() {
const followCalls = [
this.getFollowerForGrant({ grantId: this.currentGrant.grant_id }),
this.getFollowersForGrant({ grantId: this.currentGrant.grant_id, limit: 51 }),
];

const [userFollowsResult, followersResult] = await Promise.all(followCalls);

this.userIsFollowing = Boolean(userFollowsResult);
this.followers = followersResult ? followersResult.followers : [];
this.followStateLoaded = true;
},
async fetchAllNotes() {
const result = await this.getNotesForGrant({ grantId: this.currentGrant.grant_id, limit: 51 });

if (result) {
this.notes = result.notes;
}
},
async toggleFollowState() {
this.followStateLoaded = false;
if (this.userIsFollowing) {
await this.unfollowGrantForCurrentUser({ grantId: this.currentGrant.grant_id });
} else {
await this.followGrantForCurrentUser({ grantId: this.currentGrant.grant_id });
}
await this.fetchFollowState();
},
},
};
</script>

<style scoped>
.feature-text {
margin-bottom: .8rem;
}
</style>
41 changes: 41 additions & 0 deletions packages/client/src/components/ShareGrant.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import {
describe, it, expect, vi, beforeEach,
} from 'vitest';
import {
shallowMount,
} from '@vue/test-utils';
import { createStore } from 'vuex';

import ShareGrant from '@/components/ShareGrant.vue';

describe('ShareGrant', () => {
const mockStore = {
getters: {
'agencies/agencies': () => [],
'grants/currentGrant': () => ({
grant_id: 55,
}),
},
actions: {
'grants/getGrantAssignedAgencies': vi.fn(),
'grants/assignAgenciesToGrant': vi.fn(),
'grants/unassignAgenciesToGrant': vi.fn(),
'agencies/fetchAgencies': vi.fn(),
},
};

const store = createStore(mockStore);

beforeEach(() => {
vi.resetAllMocks();
});

it('renders', () => {
const wrapper = shallowMount(ShareGrant, {
global: {
plugins: [store],
},
});
expect(wrapper.exists()).toBe(true);
});
});
Loading
Loading