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 5 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
4 changes: 4 additions & 0 deletions packages/client/src/helpers/dates.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,7 @@ export function daysUntil(dateString) {
const daysDiff = DateTime.fromISO(dateString).endOf('day').diffNow('days').days;
return daysDiff < 0 ? 0 : Math.floor(daysDiff);
}

export const formatDate = (dateString) => DateTime.fromISO(dateString).toLocaleString(DateTime.DATE_MED);

export const formatDateTime = (dateString) => DateTime.fromISO(dateString).toLocaleString(DateTime.DATETIME_MED);
Comment on lines +11 to +14
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

35 changes: 35 additions & 0 deletions packages/client/src/store/modules/grants.js
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,41 @@ export default {
fetchApi.get(`/api/organizations/${rootGetters['users/selectedAgencyId']}/interested-codes`)
.then((data) => commit('SET_INTERESTED_CODES', data));
},
async getFollowerForGrant({ rootGetters }, { grantId }) {
try {
return await fetchApi.get(`/api/organizations/${rootGetters['users/selectedAgencyId']}/grants/${grantId}/follow`);
} catch (e) {
return null;
}
TylerHendrickson marked this conversation as resolved.
Show resolved Hide resolved
},
async getFollowersForGrant({ rootGetters }, { grantId }) {
try {
return await fetchApi.get(`/api/organizations/${rootGetters['users/selectedAgencyId']}/grants/${grantId}/followers?limit=51`);
} catch (e) {
return null;
}
},
async getNotesForGrant({ rootGetters }, { grantId }) {
try {
return await fetchApi.get(`/api/organizations/${rootGetters['users/selectedAgencyId']}/grants/${grantId}/notes?limit=51`);
} catch (e) {
return null;
}
},
async followGrantForCurrentUser({ rootGetters }, { grantId }) {
try {
await fetchApi.put(`/api/organizations/${rootGetters['users/selectedAgencyId']}/grants/${grantId}/follow`);
} catch (e) {
// fail silently
}
},
async unfollowGrantForCurrentUser({ rootGetters }, { grantId }) {
try {
await fetchApi.deleteRequest(`/api/organizations/${rootGetters['users/selectedAgencyId']}/grants/${grantId}/follow`);
} catch (e) {
// fail silently
}
},
async setEligibilityCodeEnabled({ rootGetters }, { code, enabled }) {
await fetchApi.put(`/api/organizations/${rootGetters['users/selectedAgencyId']}/eligibility-codes/${code}/enable/${enabled}`);
},
Expand Down
108 changes: 108 additions & 0 deletions packages/client/src/views/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 '@/views/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();
});
});
167 changes: 167 additions & 0 deletions packages/client/src/views/GrantActivity.vue
TylerHendrickson marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
<template>
<b-card
header-bg-variant="white"
footer-bg-variant="white"
>
<template #header>
<h3 class="m-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="loadingFollowState"
@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="showFollowSummary"
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,
loadingFollowState: false,
TylerHendrickson marked this conversation as resolved.
Show resolved Hide resolved
followers: [],
notes: [],
};
},
computed: {
...mapGetters({
loggedInUser: 'users/loggedInUser',
currentGrant: 'grants/currentGrant',
}),
followBtnLabel() {
return this.userIsFollowing ? 'Following' : 'Follow';
},
followBtnVariant() {
return this.userIsFollowing ? 'success' : 'primary';
},
followSummaryText() {
const userIsFollower = this.followers.some((follower) => follower.user.id === this.loggedInUser?.id);
TylerHendrickson marked this conversation as resolved.
Show resolved Hide resolved
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}`;
},
showFollowSummary() {
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.fetchFollowersState();
this.fetchNotes();
},
methods: {
...mapActions({
getFollowerForGrant: 'grants/getFollowerForGrant',
getFollowersForGrant: 'grants/getFollowersForGrant',
getNotesForGrant: 'grants/getNotesForGrant',
followGrantForCurrentUser: 'grants/followGrantForCurrentUser',
unfollowGrantForCurrentUser: 'grants/unfollowGrantForCurrentUser',
}),
async fetchFollowState() {
const result = await this.getFollowerForGrant({ grantId: this.currentGrant.grant_id });
this.userIsFollowing = Boolean(result);
},
async fetchFollowersState() {
const result = await this.getFollowersForGrant({ grantId: this.currentGrant.grant_id });

if (result) {
this.followers = result.followers;
}
},
async fetchNotes() {
const result = await this.getNotesForGrant({ grantId: this.currentGrant.grant_id });

if (result) {
this.notes = result.notes;
}
},
TylerHendrickson marked this conversation as resolved.
Show resolved Hide resolved
async toggleFollowState() {
this.loadingFollowState = true;
if (this.userIsFollowing) {
await this.unfollowGrantForCurrentUser({ grantId: this.currentGrant.grant_id });
} else {
await this.followGrantForCurrentUser({ grantId: this.currentGrant.grant_id });
}
this.fetchFollowersState();
await this.fetchFollowState();
this.loadingFollowState = false;
},
},
};
</script>

<style scoped>
.feature-text {
margin-bottom: .8rem;
}
</style>
Loading
Loading