diff --git a/.docker/init.sql b/.docker/init.sql new file mode 100644 index 00000000..ae4eea2b --- /dev/null +++ b/.docker/init.sql @@ -0,0 +1,2 @@ +create database if not exists involvemint; + diff --git a/.gitignore b/.gitignore index 487de16e..2e69ffa2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ # See http://help.github.com/ignore-files/ for more about ignoring files. +# environment file +environment.ts # compiled output /dist diff --git a/README.md b/README.md index 8991a047..a02bf1b8 100644 --- a/README.md +++ b/README.md @@ -1,41 +1,115 @@ # Official involveMINT Web App Repository -## Setting Up Environment +## Build Prerequisites Prerequisites: -- Nodejs -- Postgres database named `involvemint` running on localhost port 5432 with username `postgres` and password `1Qazxsw2` -- Visual Studio Code +- Node.js +- Docker -### Steps +## Steps to Set Up Environment -1. Clone repository +1. Fork and clone this repository. Ensure that you uncheck the "copy main branch only" checkbox. Once you forked the repository, clone it to a directory on your local machine and `cd` into it. -```sh -git clone git@ssh.dev.azure.com:v3/involvemint/involveMINT/involvemint2.0 -``` +2. Checkout the "develop" branch: `git checkout develop`. +3. Ensure you have Node.js installed. If you don't, please look at the section on [installing node.js](#installing-nodejs). +4. Ensure you have Docker installed. If you don't, please navigate to the [official website](https://docs.docker.com/get-docker/) and follow the instructions. Run `docker --version` in your terminal after it's installed to ensure you have installed everything correctly. -2. Change directory into repository +### Installing Node.js -```sh -cd involvemint2.0 -``` +Run the following commands. -3. Install dependencies +1. `curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.1/install.sh | bash` +2. `export NVM_DIR="$([ -z "${XDG_CONFIG_HOME-}" ] && printf %s "${HOME}/.nvm" || printf %s "${XDG_CONFIG_HOME}/nvm")"` +3. `[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"` +4. Refresh your shell (close and reopen). If you're using zsh, here's a shortcut: `source ~/.zshrc` +5. If the following command does not throw an error, you're good so far: `nvm -v` +6. `nvm install --lts` +7. `nvm use --lts` -```sh -npm install -``` +### Configuring Firebase -4. Start client +In order to run this code, you will need to navigate to [firebase](https://console.firebase.google.com/). You will see a screen that looks like ![firebase-landing](/assets/firebase-landing.png) and click "Create a project" (if you don't already have a GCP account and an existing project where you want to use Firebase). Once you have a project, you should see a screen like ![this](assets/firebase-dashboard.png) Once here, click the little gear and you'll see a screen that looks like ![this](assets/firebase-settings.png) Select the "Service accounts" tab and you'll see a screen that looks like ![this](assets/firebase-service-accounts.png) Hit the "Manage service account permissions" hyperlink which will take you to your GCP project. You will see a screen that looks like ![this](/assets/googlecloud-service-accounts.png). Click the account and then hit the "Keys" tab. You should see a screen that looks like ![this](/assets/service-account-keys.png). Click "Add Key" and then choose the "JSON" option to download it as a JSON file. -```sh -npx ng s -``` +Ensure you are in the project directory (the directory that this file is in). Run the following command: `cp libs/shared/domain/src/lib/environments/environment.ts libs/shared/domain/src/lib/environments/environment.prod.ts` and open the new file (environment.prod.ts) in your editor. It should look like this at first: + +```typescript +import { Env } from './environment.interface'; -5. In another terminal, start server +const host = 'localhost'; -```sh -npx ng s api +/** Develop environment variables. */ +export const environment: Env = { + production: false, + test: false, + host, + apiUrl: `http://${host}:3335`, + appUrl: `http://${host}:4202`, + storageBucket: 'your storage bucket', + adminPasswordHash: + 'sZfCJx5X3sGSwkokIs9IVFxDfxWd2lEKsAhkOSDfEK8u2YS98y5rJAmXmtrJs7AQ29xkHMmz0bDfLkXCKS9/+A==', + gcpApiKey: 'insert your key here', + typeOrmConfig: { + type: 'postgres', + host: '127.0.0.1', + port: 5432, + username: 'postgres', + password: '1Qazxsw2', + database: 'involvemint', + synchronize: true, + autoLoadEntities: true, + ssl: false, + }, + firebaseEnv: { + apiKey: 'insert your key here', + authDomain: 'firebase auth domain', + databaseURL: '', + projectId: 'your project id', + storageBucket: 'Your project storage bucket', //your-something.appspot.com + messagingSenderId: '', + appId: 'your app id', + measurementId: 'Your measurementId', + }, + // mailgun and twilio are optional + mailgun: { + apiKey: '', + domain: '', + }, + twilio: { + accountSid: '', + authToken: '', + sendingPhone: '', + }, + gcp: { + type: 'service_account', + project_id: 'project-id', + private_key_id: 'Your Private Key', + private_key: '-----BEGIN PRIVATE KEY-----\nYour Private Key\n-----END PRIVATE KEY-----\n', + client_email: 'Your client email', + client_id: 'client id', + auth_uri: 'https://accounts.google.com/o/oauth2/auth', + token_uri: 'https://oauth2.googleapis.com/token', + auth_provider_x509_cert_url: 'https://www.googleapis.com/oauth2/v1/certs', + client_x509_cert_url: 'cert url', + }, + // I don't think this is used + scrypt: { + memCost: 14, + rounds: 8, + saltSeparator: 'Bw==', + signerKey: 'de/PQ/Gy53mgslvUgDUKDCgHJPArYqbFnGILLQZNe5My/CvqIThVL/CsndU8oudZ9lc4B7PT8w3sAar2/luQxA==', + }, +}; ``` + +Under the key "typeOrmConfig", please change the password field to "postgres". Under the key "gcp", please change the fields to match the fields in your service account JSON file that you just downloaded in the previous step. + +### Starting the Containers + +Run `docker compose up` in the root directory, which will spin up a PostgreSQL database on port 5432 and a PgAdmin UI on port 8889. + +### Starting the Apps + +Open a terminal and run `npm i` to install all the required packages. Once done, run `export NODE_OPTIONS=--openssl-legacy-provider` because otherwise there will be an error with OpenSSL. To start the client, run `npm run start:client`. To start the server, open a new terminal, export the same environment variable as before (`export NODE_OPTIONS=--openssl-legacy-provider`), run `npm run build`, and then `npm run start`. + +For any issues, or to suggest improvements to this documentation, please contact Anish Sinha <> diff --git a/angular.json b/angular.json index 5f397eba..a6c7401e 100644 --- a/angular.json +++ b/angular.json @@ -99,6 +99,14 @@ "maximumError": "10kb" } ] + }, + "org": { + "fileReplacements":[ + { + "replace": "libs/shared/domain/src/lib/environments/environment.ts", + "with": "libs/shared/domain/src/lib/environments/environment.org.ts" + } + ] } } }, @@ -115,6 +123,9 @@ }, "test": { "browserTarget": "involvemint:build:test" + }, + "org": { + "browserTarget": "involvemint:build:org" } } }, @@ -158,6 +169,9 @@ }, "test": { "devServerTarget": "involvemint:serve:test" + }, + "org": { + "devServerTarget": "involvemint:serve" } } }, @@ -205,6 +219,14 @@ "with": "libs/shared/domain/src/lib/environments/environment.test.ts" } ] + }, + "org": { + "fileReplacements":[ + { + "replace": "libs/shared/domain/src/lib/environments/environment.ts", + "with": "libs/shared/domain/src/lib/environments/environment.org.ts" + } + ] } } }, @@ -213,6 +235,11 @@ "options": { "buildTarget": "api:build", "port": 2324 + }, + "configurations": { + "org":{ + "buildTarget": "api:build:org" + } } }, "lint": { diff --git a/app.yaml b/app.yaml index cfb9573b..4135ecbf 100644 --- a/app.yaml +++ b/app.yaml @@ -1,3 +1,6 @@ -runtime: nodejs14 +runtime: nodejs16 instance_class: F4 + +build_env_variables: + GOOGLE_NODE_RUN_SCRIPTS: '' \ No newline at end of file diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 533ecd82..62f33eec 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -19,7 +19,7 @@ async function bootstrap() { // app.useWebSocketAdapter(new IoAdapter(app)); - if (environment.production) { + if (environment.production || environment.test) { const port = Number(process.env.PORT) || 8080; await app.listen(port); } else { diff --git a/assets/firebase-dashboard.png b/assets/firebase-dashboard.png new file mode 100644 index 00000000..c5bf88ba Binary files /dev/null and b/assets/firebase-dashboard.png differ diff --git a/assets/firebase-landing.png b/assets/firebase-landing.png new file mode 100644 index 00000000..96ef544c Binary files /dev/null and b/assets/firebase-landing.png differ diff --git a/assets/firebase-service-accounts.png b/assets/firebase-service-accounts.png new file mode 100644 index 00000000..19467c13 Binary files /dev/null and b/assets/firebase-service-accounts.png differ diff --git a/assets/firebase-settings.png b/assets/firebase-settings.png new file mode 100644 index 00000000..a771b120 Binary files /dev/null and b/assets/firebase-settings.png differ diff --git a/assets/googlecloud-service-accounts.png b/assets/googlecloud-service-accounts.png new file mode 100644 index 00000000..feff6a1c Binary files /dev/null and b/assets/googlecloud-service-accounts.png differ diff --git a/assets/service-account-keys.png b/assets/service-account-keys.png new file mode 100644 index 00000000..4e34df8d Binary files /dev/null and b/assets/service-account-keys.png differ diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..18ffe58d --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,27 @@ +version: '3.8' + +services: + postgres: + hostname: postgres + container_name: postgres + image: postgres + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: postgres + POSTGRES_INITDB_ARGS: '-A md5' + ports: + - '5432:5432' + volumes: + - ./.docker/init.sql:/docker-entrypoint-initdb.d/init.sql + pgadmin: + container_name: pgadmin + image: dpage/pgadmin4 + environment: + PGADMIN_DEFAULT_EMAIL: anish@involvemint.io + PGADMIN_DEFAULT_PASSWORD: involvemint + ports: + - '8889:80' + hostname: pgadmin + depends_on: + - postgres diff --git a/libs/client/admin/data-access/src/index.ts b/libs/client/admin/data-access/src/index.ts index 62066e48..894a0648 100644 --- a/libs/client/admin/data-access/src/index.ts +++ b/libs/client/admin/data-access/src/index.ts @@ -1,4 +1,4 @@ export * from './lib/admin.facade'; export { EpApplicationStoreModel, SpApplicationStoreModel } from './lib/applications/applications.reducer'; export * from './lib/client-admin-data-access.module'; -export { BaPrivilegeStoreModel } from './lib/privileges/privileges.reducer'; +export { BaPrivilegeStoreModel } from './lib/privileges/privileges.reducer'; \ No newline at end of file diff --git a/libs/client/admin/data-access/src/lib/admin.facade.ts b/libs/client/admin/data-access/src/lib/admin.facade.ts index 857d7e82..f996b6c5 100644 --- a/libs/client/admin/data-access/src/lib/admin.facade.ts +++ b/libs/client/admin/data-access/src/lib/admin.facade.ts @@ -1,14 +1,16 @@ import { Injectable } from '@angular/core'; import { GrantBaPrivilegesDto, + HideCommentDto, MintDto, ProcessEpApplicationDto, ProcessSpApplicationDto, RevokeBaPrivilegesDto, + UnhideCommentDto, } from '@involvemint/shared/domain'; import { Actions, ofType } from '@ngrx/effects'; import { select, Store } from '@ngrx/store'; -import { tap } from 'rxjs/operators'; +import { take, tap } from 'rxjs/operators'; import * as ApplicationsActions from './applications/applications.actions'; import * as ApplicationsSelectors from './applications/applications.selectors'; import * as CreditsActions from './credits/credits.actions'; @@ -17,6 +19,7 @@ import * as PrivilegesSelectors from './privileges/privileges.selectors'; @Injectable() export class AdminFacade { + readonly applications = { selectors: { state$: this.store.pipe(select(ApplicationsSelectors.getState)).pipe( diff --git a/libs/client/admin/shell/src/lib/client-admin-shell.module.ts b/libs/client/admin/shell/src/lib/client-admin-shell.module.ts index cd8c58d5..d72a2626 100644 --- a/libs/client/admin/shell/src/lib/client-admin-shell.module.ts +++ b/libs/client/admin/shell/src/lib/client-admin-shell.module.ts @@ -24,6 +24,10 @@ import { ImRoutes } from '@involvemint/shared/domain'; path: ImRoutes.admin.users.ROOT, loadChildren: () => import('./users/users.module').then((m) => m.UsersModule), }, + { + path: ImRoutes.admin.moderation.ROOT, + loadChildren: () => import('./moderation/moderation.module').then((m) => m.AdminModerationModule), + }, ]), ], }) diff --git a/libs/client/admin/shell/src/lib/moderation/comments/modal-comments.component.html b/libs/client/admin/shell/src/lib/moderation/comments/modal-comments.component.html new file mode 100644 index 00000000..30b38ba4 --- /dev/null +++ b/libs/client/admin/shell/src/lib/moderation/comments/modal-comments.component.html @@ -0,0 +1,49 @@ + + + + + + Comments + + + + + + + + + + + + +
+
+
+
+ + @{{ comment.handleId }} + {{ comment.name }} + {{ comment.text }} + +
+
+ +
+
+

{{comment.flagCount}}

+
+
+ Hide +
+
+ Unhide +
+
+
+
+ +
\ No newline at end of file diff --git a/libs/client/admin/shell/src/lib/moderation/comments/modal-comments.component.scss b/libs/client/admin/shell/src/lib/moderation/comments/modal-comments.component.scss new file mode 100644 index 00000000..23afeae6 --- /dev/null +++ b/libs/client/admin/shell/src/lib/moderation/comments/modal-comments.component.scss @@ -0,0 +1,49 @@ +.comment-container { + display: flex; + flex-direction: row; + align-items: flex-start; + flex: 1; + } + + .flag-container { + display: flex; + flex-direction: row; + justify-content: flex-end; + align-items: flex-start; + padding-right: 12px; + color: black; + } + +/* Other CSS rules remain the same */ +.comment { + flex-direction: column; + padding: 12px; + margin-top: 12px; + margin-bottom: 12px; + background: #f4f5f8; + color: #000; + border-color: #fff; + border-style: solid; + border-radius: 25px; + overflow-wrap: break-word; + word-break: break-all; + hyphens: auto; + } + + .member-handle { + align-self: flex-start; + padding: 8px; + } + + .pointer { + cursor: pointer; + } + + .spaced { + padding-bottom: 4px; + } + + .ion-text-wrap.removed { + color: #ababab; + font-style: italic; + } \ No newline at end of file diff --git a/libs/client/admin/shell/src/lib/moderation/comments/modal-comments.component.spec.ts b/libs/client/admin/shell/src/lib/moderation/comments/modal-comments.component.spec.ts new file mode 100644 index 00000000..e69de29b diff --git a/libs/client/admin/shell/src/lib/moderation/comments/modal-comments.component.ts b/libs/client/admin/shell/src/lib/moderation/comments/modal-comments.component.ts new file mode 100644 index 00000000..bea682b3 --- /dev/null +++ b/libs/client/admin/shell/src/lib/moderation/comments/modal-comments.component.ts @@ -0,0 +1,112 @@ +import { ChangeDetectionStrategy, Component, Input, OnInit, ViewChild } from '@angular/core'; +import { ImViewProfileModalService, PostStoreModel, UserFacade } from '@involvemint/client/shared/data-access'; +import { map, tap } from 'rxjs/operators'; + +import { IonContent, ModalController } from '@ionic/angular'; +import { Observable } from 'rxjs'; +import { CommentStoreModel } from 'libs/client/shared/data-access/src/lib/+state/comments/comments.reducer'; +import { StatefulComponent } from '@involvemint/client/shared/util'; + +interface State { + comments: Array; + loaded: boolean; +} + +@Component({ + selector: 'app-modal-comments', + templateUrl: 'modal-comments.component.html', + styleUrls: ['./modal-comments.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ModalCommentComponent extends StatefulComponent implements OnInit { + @ViewChild(IonContent) + content!: IonContent; + @Input() post!: PostStoreModel; + msg!: string; + profilePicFilePath!: string; + name$!: Observable; + handleID!: string; + +constructor( + private modalCtrl: ModalController, + private user: UserFacade, + private readonly viewProfileModal: ImViewProfileModalService, +) { + super({ comments: [], loaded: true }); +} + +ngOnInit() { + this.user.comments.dispatchers.initComments(this.post.comments); + this.effect(() => + this.user.comments.selectors.comments$.pipe( + tap(({ comments }) => + this.updateState({ + comments: comments + })) + ) + ); + + this.handleID = this.getHandleID(); + this.name$ = this.getName(); + this.profilePicFilePath = this.getProfilePic(); +} + + hide(id: string) { + this.user.comments.dispatchers.hideComment({ + commentId: id, + }) + } + + unhide(id: string) { + this.user.comments.dispatchers.unhideComment({ + commentId: id, + }) + } + + checkCommentHidden(comment: CommentStoreModel) { + let userId = ""; + this.user.session.selectors.email$.subscribe(s => userId = s); + return comment.hidden + } + + viewProfile(handle: string) { + this.viewProfileModal.open({ handle }); + } + + getProfilePic() { + const profilePicObservable: Observable = this.user.session.selectors.changeMaker$.pipe( + map(changeMaker => changeMaker?.profilePicFilePath || '') + ); + let profilePic: string = ''; + profilePicObservable.subscribe( + (url: string) => { + profilePic = url; + } + ); + return profilePic; + } + + getName(): Observable { + return this.user.session.selectors.changeMaker$.pipe( + map(changeMaker => `${changeMaker?.firstName || ''} ${changeMaker?.lastName || ''}` || '') + ); + } + + getHandleID() { + const handleIDObservable: Observable = this.user.session.selectors.activeProfile$.pipe( + map(activeProfile => activeProfile.handle.id) + ) + let handleID: string = ''; + handleIDObservable.subscribe( + (url: string) => { + handleID = url; + } + ); + return handleID; + } + + cancel() { + return this.modalCtrl.dismiss(this.state.comments, 'cancel'); + } + +} diff --git a/libs/client/admin/shell/src/lib/moderation/moderation.component.html b/libs/client/admin/shell/src/lib/moderation/moderation.component.html new file mode 100644 index 00000000..f03cb5c8 --- /dev/null +++ b/libs/client/admin/shell/src/lib/moderation/moderation.component.html @@ -0,0 +1,182 @@ + + + + + + + Moderation + +
+
Showing flagged
+
+
+
Showing all
+
+ +
+
+ +
+ + +
+
+
There are no activity posts.
+
+ + + + + +
+
+
+ +
+
{{ post.poi.enrollment.project.description }}
+
+ + +
+ + + +
+
+ +
+ +

{{ post.poi.enrollment.project.title }}

+
+
+

Time Worked

+

{{calculateTimeWorked(post.poi)}}

+
+
+
+ + + +
+
+ + + +
+
+
+
+ + + + + +
+
+ +
+ +

{{ post.poi.enrollment.project.title }}

+
+
+

Time Worked

+

{{calculateTimeWorked(post.poi)}}

+
+
+
+ +
+
+ +
+ +

{{ post.poi.enrollment.project.title }}

+
+
+

Time Worked

+

{{calculateTimeWorked(post.poi)}}

+
+
+
+
+
+
+
+ +
+
+ +
+ + + +
+
+ + + +
+ + + +
+
+ +
+
+ + + +
+
+ + + +
+ +
+ +

Comments

+ +
+
+
+ +

Comments

+ +
+
+
+
+
+ + + + +
+
+
\ No newline at end of file diff --git a/libs/client/admin/shell/src/lib/moderation/moderation.component.scss b/libs/client/admin/shell/src/lib/moderation/moderation.component.scss new file mode 100644 index 00000000..0b111175 --- /dev/null +++ b/libs/client/admin/shell/src/lib/moderation/moderation.component.scss @@ -0,0 +1,654 @@ +// Mobile styles +@media screen and (max-width: 480px) { + .wrap { + inline-size: 75px; + overflow: wrap; + padding-left: 15px; + text-align: left; + } + .overflow { + inline-size: 150px; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + } + + .iconSize { + margin-top: 9px; + padding: -20px; + margin-left: -8%; + } + + .infoButton { + color: #fff !important; + margin: -10%; + width: 17px; + height: 17px; + } + + .projectText { + background: none; + color:white !important; + position: absolute; + bottom: 3%; + padding: 3%; + display: flex; + font-family: var(--ion-font-family) !important; + font-size: 18px; + } + + .statDescription { + margin-bottom: 0px !important; + } + + .stat { + margin-top: 0px; + } + + .statText { + color:white !important; + position: absolute; + bottom: 3%; + left: 50%; + font-family: var(--ion-font-family) !important; + margin-bottom: +7px; + width: 50%; + } + + .notification-item { + max-width: 200%; + text-align: right !important; + } + + .timeline-details { + display: grid; + } + + .timeline-detail-item { + align-items: flex-start; + display: flex; + flex-direction: column; + grid-template-columns: auto 1fr; + + .comment-button { + color: var(--ion-text-color) !important; + margin-left: -7px; + width: 22px; + height: 20.2px; + --ionicon-stroke-width: 40; + } + + .like-button { + color: var(--ion-text-color) !important; + width: 24px; + height: 22px; + --ionicon-stroke-width: 40; + } + + .like-text { + color: var(--ion-text-color) !important; + font-family: 'Inter'; + font-style: normal; + font-weight: 400; + font-size: 12px; + line-height: 138%; + margin-top: -18px; + padding-left: 8px; + } + + .button-spacing { + margin-top: -50px !important; + padding-bottom: 2px; + padding-top: 2px; + position: relative; + } + + ion-buttons { + grid-column-gap: -20px; + } + } + + .activity-post-carousel { + position: relative; + padding: 15px 0; + } + + .scroll-left { + position: absolute; + top: 50%; + transform: translateY(-50%); + left: 10px; + width: 40px; + height: 40px; + display: flex; + justify-content: center; + align-items: center; + } + + .scroll-right { + position: absolute; + top: 50%; + transform: translateY(-50%); + right: 10px; + width: 40px; + height: 40px; + display: flex; + justify-content: center; + align-items: center; + } + + .scroll-left ion-icon, + .scroll-right ion-icon { + font-size: 30px; + opacity: 0.7; + --color: white !important; + } + + .comment-dropdown { + color: var(--ion-text-color) !important; + font-family: 'Inter'; + font-style: normal; + font-weight: 400; + font-size: 12px; + line-height: 138%; + left: 0px !important; + margin-top: -13px; + padding-top: 0px; + padding-left: 8px; + --padding-start: 0; + --margin-start: 0; + } + + .comment-dropdown-nolikes { + color: var(--ion-text-color) !important; + font-family: 'Inter'; + font-style: normal; + font-weight: 400; + font-size: 12px; + line-height: 138%; + left: 0px !important; + margin-top: -40px; + padding-top: 0px; + padding-left: 8px; + --padding-start: 0; + --margin-start: 0; + } + + .date { + font-family: 'Inter'; + font-style: normal; + font-weight: 400; + font-size: 12px; + margin-top: 0px !important; + padding: 4px; + right: 0px; + position: absolute + } + + .handle { + font-family: 'Inter'; + font-style: normal; + font-weight: 400; + font-size: 12px; + line-height: 134%; + padding-left: 4px; + text-decoration-line: underline; + } + + .information-circle { + display: grid; + width: 13.68px !important; + height: 13.68px !important; + grid-template-columns: auto 1fr; + cursor: pointer; + } + + .name-color { + align-items: center; + color: var(--im-green); + display: flex; + font-family: 'Manrope'; + font-style: normal; + font-weight: 700; + font-size: 16px; + line-height: 22px; + padding: 4px; + text-align: center; + } + + .name-info { + grid-row: 1; + } + + .name-item { + display: grid; + grid-template-columns: auto; + } + + .name-handle { + align-items: center; + display: flex; + font-size: small; + padding: 0px; + margin-bottom: -4.5px; + --ion-item-background: var(--ion-toolbar-background); + } + + .project-description { + font-family: 'Inter'; + font-style: normal; + font-weight: 400; + font-size: 12px; + line-height: 138%; + overflow-wrap: break-word; + margin-bottom: -12px; + padding-left: 8px; + word-break: break-all; + } + + .project-title { + align-items: center; + display: flex; + font-family: 'Manrope'; + font-style: normal; + font-weight: 700; + font-size: 18px; + line-height: 25px; + padding-left: 8px; + --ion-item-background: var(--ion-toolbar-background); + } + + .separator { + margin: 4px 0 4px 0 !important; + } + + .swiper-pagination { + position: relative; + padding-bottom: 34px; + --bullet-background: var(--ion-text-color) !important; + --bullet-background-active: var(--ion-text-color) !important; + + ion-button { + -webkit-tap-highlight-color: transparent !important; + user-select: none !important; + } + + ion-slide { + pointer-events: none; + user-select: none; + } + } + + .user-info { + align-items: center; + display: grid; + flex-direction: column; + grid-template-columns: auto 1fr; + padding: 2px; + } + + .user-info { + align-items: center; + display: grid; + flex-direction: column; + grid-template-columns: auto 1fr; + padding: 2px; + } + + ::ng-deep .swiper-wrapper { + align-items: center !important; + } + + .shadow { + display: inline-block; + } + + .shadow:after { + content: ''; + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 300px; + background-image: linear-gradient(180deg, rgba(217, 217, 217, 0) 0%, rgba(63, 61, 86, 0.39) 65.1%, #3F3D56 100%); + } + + .relative { + position: relative; + } + + #post-user-handle { + cursor: pointer; + } + +} + +// Desktop styles +@media screen and (min-width: 480px) { + .overflow { + inline-size: 65%; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + font-size: 2em; + } + + .projectText { + background: none; + color:white !important; + position: absolute; + bottom: 3%; + padding: 3%; + margin-bottom: -25px; + display: flex; + font-family: var(--ion-font-family) !important; + } + + .overflow { + inline-size: 100%; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + font-size: 4em; + } + + .infoButton { + color: #fff !important; + margin: -10%; + } + + .iconSize { + margin-top: 15px; + padding: -30%; + margin-left: -15px; + } + + .statDescription { + margin-bottom: 0px !important; + font-size: 2em; + } + + .stat { + margin-top: 0px; + font-size: 4em; + } + + .statText { + color:white !important; + position: absolute; + bottom: 3%; + left: 60%; + font-family: var(--ion-font-family) !important; + width: 50%; + } + + .notification-item { + max-width: 200%; + text-align: right !important; + } + + .timeline-details { + display: grid; + } + + .timeline-detail-item { + align-items: flex-start; + display: flex; + flex-direction: column; + grid-template-columns: auto 1fr; + + .comment-button { + color: var(--ion-text-color) !important; + margin-left: -7px; + width: 22px; + height: 20.2px; + --ionicon-stroke-width: 40; + } + + .like-button { + color: var(--ion-text-color) !important; + width: 24px; + height: 22px; + --ionicon-stroke-width: 40; + } + + .like-text { + color: var(--ion-text-color) !important; + font-family: 'Inter'; + font-style: normal; + font-weight: 400; + font-size: 12px; + line-height: 138%; + margin-top: -17px; + padding-left: 8px; + } + + .button-spacing { + margin-top: -50px !important; + padding-bottom: 2px; + padding-top: 2px; + position: relative; + } + + ion-buttons { + grid-column-gap: -20px; + } + } + + .activity-post-carousel { + position: relative; + padding: 15px 0; + } + + .scroll-left { + position: absolute; + top: 50%; + transform: translateY(-50%); + width: 40px; + height: 40px; + display: flex; + left: 10px; + justify-content: center; + align-items: center; + } + + .scroll-right { + position: absolute; + top: 50%; + transform: translateY(-50%); + width: 40px; + height: 40px; + display: flex; + right: 10px; + justify-content: center; + align-items: center; + } + + .scroll-left ion-icon, + .scroll-right ion-icon { + font-size: 30px; + opacity: 0.7; + --color: white !important; + } + + .comment-dropdown { + color: var(--ion-text-color) !important; + font-family: 'Inter'; + font-style: normal; + font-weight: 400; + font-size: 12px; + line-height: 138%; + left: 0px !important; + margin-top: -7px; + padding-top: 0px; + padding-left: 8px; + --padding-start: 0; + --margin-start: 0; + } + + .comment-dropdown-nolikes { + color: var(--ion-text-color) !important; + font-family: 'Inter'; + font-style: normal; + font-weight: 400; + font-size: 12px; + line-height: 138%; + left: 0px !important; + margin-top: -40px; + padding-top: 0px; + padding-left: 8px; + --padding-start: 0; + --margin-start: 0; + } + + .date { + font-family: 'Inter'; + font-style: normal; + font-weight: 400; + font-size: 12px; + margin-top: +4px !important; + padding: 4px; + right: 10px; + position: absolute + } + + .handle { + font-family: 'Inter'; + font-style: normal; + font-weight: 400; + font-size: 12px; + line-height: 134%; + padding-left: 4px; + text-decoration-line: underline; + } + + .information-circle { + display: grid; + width: 13.68px !important; + height: 13.68px !important; + grid-template-columns: auto 1fr; + cursor: pointer; + } + + .name-color { + align-items: center; + color: var(--im-green); + display: flex; + font-family: 'Manrope'; + font-style: normal; + font-weight: 700; + font-size: 16px; + line-height: 22px; + padding: 4px; + text-align: center; + } + + .name-info { + grid-row: 1; + } + + .name-item { + display: grid; + grid-template-columns: auto; + } + + .name-handle { + align-items: center; + display: flex; + font-size: small; + padding: 0px; + margin-bottom: -4.5px; + --ion-item-background: var(--ion-toolbar-background); + } + + .project-description { + font-family: 'Inter'; + font-style: normal; + font-weight: 400; + font-size: 12px; + line-height: 138%; + overflow-wrap: break-word; + margin-bottom: -12px; + padding-left: 8px; + word-break: break-all; + } + + .project-title { + align-items: center; + display: flex; + font-family: 'Manrope'; + font-style: normal; + font-weight: 700; + font-size: 18px; + line-height: 25px; + padding-left: 8px; + --ion-item-background: var(--ion-toolbar-background); + } + + .separator { + margin: 4px 0 4px 0 !important; + } + + .swiper-pagination { + position: relative; + padding-bottom: 20px; + --bullet-background: var(--ion-text-color) !important; + --bullet-background-active: var(--ion-text-color) !important; + + ion-button { + -webkit-tap-highlight-color: transparent !important; + user-select: none !important; + } + + ion-slide { + pointer-events: none; + user-select: none; + } + } + + .user-info { + align-items: center; + display: grid; + flex-direction: column; + grid-template-columns: auto 1fr; + padding: 2px; + } + + .user-info { + align-items: center; + display: grid; + flex-direction: column; + grid-template-columns: auto 1fr; + padding: 2px; + } + + ::ng-deep .swiper-wrapper { + align-items: center !important; + } + + .shadow { + display: inline-block; + } + + .shadow:after { + content: ''; + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 300px; + background-image: linear-gradient(180deg, rgba(217, 217, 217, 0) 0%, rgba(63, 61, 86, 0.39) 65.1%, #3F3D56 100%); + } + + .relative { + position: relative; + } + + #post-user-handle { + cursor: pointer; + } + + .spaced { + padding-left: 8px !important; + } +} \ No newline at end of file diff --git a/libs/client/admin/shell/src/lib/moderation/moderation.component.spec.ts b/libs/client/admin/shell/src/lib/moderation/moderation.component.spec.ts new file mode 100644 index 00000000..768b4595 --- /dev/null +++ b/libs/client/admin/shell/src/lib/moderation/moderation.component.spec.ts @@ -0,0 +1,184 @@ +/** + * This test suite is for the ModerationComponent using the ngneat/spectator testing framework. + * The test suite includes a beforeEach() function that creates an instance of the ModerationComponent + * using the createComponentFactory() method from ngneat/spectator. The createComponentFactory() + * method creates a factory function that generates a ModerationComponent instance with the specified + * dependencies. The providers property is used to provide UserFacade service and other necessary services. + * The it() function tests whether the component is created or not by checking the toBeTruthy() function. + * The test suite also includes various test cases to ensure that component methods are working correctly. + * Note that the describe() function is used to define the test suite for the ModerationComponent. + +Note that the fdescribe() function is used to run only this test suite while excluding others. + */ +import { createComponentFactory, Spectator } from '@ngneat/spectator'; +import { ModerationComponent } from './moderation.component'; +import { IonicModule } from '@ionic/angular'; +import { UserFacade, ImStorageUrlPipeModule, ImViewProfileModalService, PostStoreModel } from '@involvemint/client/shared/data-access'; +import { FormsModule } from '@angular/forms'; +import { ImBlockModule } from '@involvemint/client/shared/data-access'; +import { StorageOrchestration } from '../../../../../shared/data-access/src/lib/orchestrations/storage.orchestration'; + +import { of } from 'rxjs'; +import { fakeAsync, tick } from '@angular/core/testing'; +import { CommentStoreModel } from 'libs/client/shared/data-access/src/lib/+state/comments/comments.reducer'; + +describe('Moderation Component', () => { + let userFacade: UserFacade; + let spectator: Spectator; + + const createComponent = createComponentFactory({ + component: ModerationComponent, + imports: [IonicModule.forRoot(), ImStorageUrlPipeModule, FormsModule, ImBlockModule], + providers: [ImViewProfileModalService, StorageOrchestration], + mocks: [ + UserFacade, + ], + }); + + beforeEach(() => { + userFacade = { + posts: { + selectors: { + posts$: of({ posts: [], loaded: false, allPagesLoaded: false }), + }, + dispatchers: { + loadPosts: () => {}, + like: () => {}, + unlike: () => {}, + }, + }, + session: { + selectors: { + email$: of('test@test.com'), + }, + }, + } as any; + spectator = createComponent({ + providers: [{ provide: UserFacade, useValue: userFacade }], + }); + }); + + it('should create', () => { + expect(spectator.component).toBeTruthy(); + }); + + it('should call loadMore method on scroll', () => { + const loadMoreSpy = spyOn(spectator.component, 'loadMore'); + const event = new CustomEvent('test'); + spectator.component.loadMore(event); + expect(loadMoreSpy).toHaveBeenCalled(); + }); + + it('should call viewComments method', fakeAsync(() => { + const viewCommentsSpy = spyOn(spectator.component, 'viewComments').and.callThrough(); + const post: PostStoreModel = { + id: '1', + user: {} as any, + dateCreated: new Date(), + likes: [], + comments: [], + poi: {} as any, + likeCount: 0, + enabled: false + }; + + spectator.component.viewComments(post); + tick(); + expect(viewCommentsSpy).toHaveBeenCalled(); + })); + + it('should call updatePostsDisplayed method', () => { + const updatePostsDisplayedSpy = spyOn(spectator.component, 'updatePostsDisplayed').and.callThrough(); + spectator.component.updatePostsDisplayed(); + expect(updatePostsDisplayedSpy).toHaveBeenCalled(); + }); + + it('should call viewProfile method', () => { + const viewProfileSpy = spyOn(spectator.component, 'viewProfile'); + const handle = 'test_handle'; + spectator.component.viewProfile(handle); + expect(viewProfileSpy).toHaveBeenCalled(); + }); + + it('should call trackPost method', () => { + const trackPostSpy = spyOn(spectator.component, 'trackPost'); + const index = 1; + const post: PostStoreModel = { + id: '1', + user: {} as any, + dateCreated: new Date(), + likes: [], + comments: [], + poi: {} as any, + likeCount: 0, + enabled: false + }; + + spectator.component.trackPost(index, post); + expect(trackPostSpy).toHaveBeenCalled(); + }); + + it('should call checkUserLiked method', () => { + const checkUserLikedSpy = spyOn(spectator.component, 'checkUserLiked').and.callThrough(); + const post: PostStoreModel = { + id: '1', + user: {} as any, + dateCreated: new Date(), + likes: [], + comments: [], + poi: {} as any, + likeCount: 0, + enabled: false + }; + spectator.component.checkUserLiked(post); + expect(checkUserLikedSpy).toHaveBeenCalled(); + }); + + it('should call getUserAvatar, getUserFirstName, getUserLastName, and getUserHandle methods', () => { + const getUserAvatarSpy = spyOn(spectator.component, 'getUserAvatar').and.callThrough(); + const getUserFirstNameSpy = spyOn(spectator.component, 'getUserFirstName').and.callThrough(); + const getUserLastNameSpy = spyOn(spectator.component, 'getUserLastName').and.callThrough(); + const getUserHandleSpy = spyOn(spectator.component, 'getUserHandle').and.callThrough(); + + const post: PostStoreModel = { + id: '1', + user: { + changeMaker: { + profilePicFilePath: 'path/to/profile/pic', + firstName: 'John', + lastName: 'Doe', + handle: { + id: 'john_doe', + }, + }, + } as any, + dateCreated: new Date(), + likes: [], + comments: [], + poi: {} as any, + likeCount: 0, + enabled: false + }; + + spectator.component.getUserAvatar(post); + spectator.component.getUserFirstName(post); + spectator.component.getUserLastName(post); + spectator.component.getUserHandle(post); + + expect(getUserAvatarSpy).toHaveBeenCalled(); + expect(getUserFirstNameSpy).toHaveBeenCalled(); + expect(getUserLastNameSpy).toHaveBeenCalled(); + expect(getUserHandleSpy).toHaveBeenCalled(); + }); + + it('should call completeLoad method', () => { + const completeLoadSpy = spyOn(spectator.component, 'completeLoad').and.callThrough(); + const allPagesLoaded = true; + + spectator.component.completeLoad(allPagesLoaded); + expect(completeLoadSpy).toHaveBeenCalled(); + }); + + // Add more test cases as needed +}); + diff --git a/libs/client/admin/shell/src/lib/moderation/moderation.component.ts b/libs/client/admin/shell/src/lib/moderation/moderation.component.ts new file mode 100644 index 00000000..2caf5e7b --- /dev/null +++ b/libs/client/admin/shell/src/lib/moderation/moderation.component.ts @@ -0,0 +1,240 @@ +import { ChangeDetectionStrategy, Component, OnInit, ViewChild } from '@angular/core'; +import { + ChangeMakerFacade, + PoiCmStoreModel, +} from '@involvemint/client/cm/data-access'; +import { UserFacade, PostStoreModel, ImViewProfileModalService, ChatService } from '@involvemint/client/shared/data-access'; +import { StatefulComponent } from '@involvemint/client/shared/util'; +import { calculatePoiStatus, calculatePoiTimeWorked, PoiStatus } from '@involvemint/shared/domain'; +import { parseDate } from '@involvemint/shared/util'; +import { IonButton, IonInfiniteScroll, IonSlides } from '@ionic/angular'; +import { ModalController } from '@ionic/angular'; +import { ModalCommentComponent } from './comments/modal-comments.component'; +import { compareDesc } from 'date-fns'; +import { tap } from 'rxjs/operators'; +import { CommentStoreModel, commentsAdapter } from 'libs/client/shared/data-access/src/lib/+state/comments/comments.reducer'; + +interface State { + posts: Array; + loaded: boolean; + onlyPostsWithFlaggedComments: boolean; +} + +@Component({ + selector: 'involvemint-moderation', + templateUrl: './moderation.component.html', + styleUrls: ['./moderation.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ModerationComponent extends StatefulComponent implements OnInit { + @ViewChild(IonInfiniteScroll) infiniteScroll!: IonInfiniteScroll; + @ViewChild('likeButton', { read: IonButton }) likeButton!: IonButton; + @ViewChild('unlikeButton', { read: IonButton }) unlikeButton!: IonButton; + @ViewChild('slides', { read: IonSlides }) slides!: IonSlides; + loading = false; + event: any = null; + allPagesLoaded = false; + + get PoiStatus() { + return PoiStatus; + } + + constructor( + private readonly user: UserFacade, + private modalCtrl: ModalController, + private readonly viewProfileModal: ImViewProfileModalService, + private readonly chat: ChatService + ) { + super({ posts: [], loaded: false, onlyPostsWithFlaggedComments: false }); + } + + ngOnInit(): void { + + this.effect(() => + this.user.posts.selectors.posts$.pipe( + tap(({ posts, loaded, allPagesLoaded }) => { + this.completeLoad(allPagesLoaded); + this.updateState({ + posts: posts + .sort((a, b) => + compareDesc(parseDate(a.dateCreated ?? new Date()), parseDate(b.dateCreated ?? new Date())) + ) + .map((post) => ({ + ...post, + status: calculatePoiStatus(post.poi), + timeWorked: calculatePoiTimeWorked(post.poi) + })), + loaded: loaded + }) + }) + ) + ); + + } + + /** Used on updates to state data to check if infiniteScroll reached end */ + completeLoad(allPagesLoaded: boolean): void { + this.allPagesLoaded = allPagesLoaded; + if (this.loading && this.event) { + this.event.target.complete(); + this.loading = false; + } + if (this.infiniteScroll) { + this.infiniteScroll.disabled = allPagesLoaded; + } + } + + /** Used by infiniteScroll to issue request to load more posts */ + loadMore(event: Event) { + if (!this.allPagesLoaded) { + this.user.posts.dispatchers.loadPosts(); + this.loading = true; + this.event = event; + } + } + + /** + * Dispatches a 'like' request for a post using NgRx state management. + * The changes resulting from the request can be tracked/re-rendered using post selectors. + */ + like(id: string) { + if (!this.likeButton.disabled) { + this.likeButton.disabled = true; // prevent click spam + this.user.posts.dispatchers.like({ + postId: id, + }); + } + } + + /** + * Dispatches a 'unlike' request for a post using NgRx state management. + * The changes resulting from the request can be tracked/re-rendered using post selectors. + */ + unlike(id: string) { + if (!this.unlikeButton.disabled) { + this.unlikeButton.disabled = true; // prevent click spam + this.user.posts.dispatchers.unlike({ + postId: id, + }); + } + } + + message(handle: string) { + this.chat.upsert([{ handleId: handle }]); + } + + /** Used to check which like button to display */ + checkUserLiked(post: PostStoreModel) { + let userId = ""; + this.user.session.selectors.email$.subscribe(s => userId = s); + const filteredObj = post.likes.filter(obj => obj.user.id === userId); + return filteredObj.length != 0 + } + + /** Used to track posts and prevent excessive re-rendering */ + trackPost(_index: number, post: PostStoreModel) { + return post.id; + } + + /** + * Opens the comment modal (modal-comments.component.ts) and waits. + */ + async viewComments(post: PostStoreModel) { + const modal = await this.modalCtrl.create({ + component: ModalCommentComponent, + componentProps: { + 'post': post, + 'user': this.user, + } + }); + modal.present(); + + const { data } = await modal.onWillDismiss(); + if (data) { + this.updatePostComments(post.id, data); + } + + } + + /** + * Updates state of comments within post given by postId (necessary for changes + * to be saved after modal is closed) + */ + private updatePostComments(postId: string, updatedComments: Array) { + this.updateState((state => { + const updatedPosts = state.posts.map((post) => { + if (post.id === postId) { + return { ...post, comments: updatedComments }; + } + return post; + }); + return { ...state, posts: updatedPosts }; + })(this.state)); + } + + /** + * Called when admin toggles between all posts and only posts with flagged comments + */ + updatePostsDisplayed() { + this.updateState({ onlyPostsWithFlaggedComments: !this.state.onlyPostsWithFlaggedComments }); + // Only show posts with flagged comments + if (this.state.onlyPostsWithFlaggedComments) { + this.updateState((state => { + const updatedPosts = state.posts.filter(post => + post.comments.filter(comment => comment.flagCount > 0).length > 0); + return { ...state, posts: updatedPosts }; + })(this.state)); + } else { + const flaggedState = this.state; + this.ngOnInit(); + // Need this to update state of comments when toggling back to displaying all posts + this.updateState((state => { + var updatedPosts = this.state.posts; + for (let i = 0; i < flaggedState.posts.length; i++) { + const flaggedPost = flaggedState.posts[i]; + updatedPosts = updatedPosts.map((post) => { + if (post.id === flaggedPost.id) { + return { ...post, comments: flaggedPost.comments }; + } + return post; + }); + } + return { ...state, posts: updatedPosts }; + })(this.state)) + } + } + + /** Functions to compute/provide UI values */ + getUserAvatar(post: PostStoreModel) { + return post.user.changeMaker?.profilePicFilePath + } + + getUserFirstName(post: PostStoreModel) { + return post.user.changeMaker?.firstName + } + + getUserLastName(post: PostStoreModel) { + return post.user.changeMaker?.lastName + } + + calculateTimeWorked(poi: any) { + let tempString = calculatePoiTimeWorked(poi); + tempString = tempString.replace("seconds", "sec"); + tempString = tempString.replace("minutes", "min"); + tempString = tempString.replace("hours", "hrs"); + return tempString; + } + + // Convert user handle to string in order to pass into profile viewer + getUserHandle(post: PostStoreModel) { + if (post.user.changeMaker?.handle.id != undefined) { + return post.user.changeMaker?.handle.id + } else { + return "" + } + } + + viewProfile(handle: string) { + this.viewProfileModal.open({ handle }); + } +} diff --git a/libs/client/admin/shell/src/lib/moderation/moderation.module.ts b/libs/client/admin/shell/src/lib/moderation/moderation.module.ts new file mode 100644 index 00000000..7011833b --- /dev/null +++ b/libs/client/admin/shell/src/lib/moderation/moderation.module.ts @@ -0,0 +1,32 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { RouterModule } from '@angular/router'; +import { ImBlockModule, ImStorageUrlPipeModule, ImagesViewerModalModule } from '@involvemint/client/shared/data-access'; +import { ImFormsModule, ImImageModule, ImTabsModule } from '@involvemint/client/shared/ui'; +import { IonicModule } from '@ionic/angular'; +import { ModerationComponent } from './moderation.component'; +import { ModalCommentComponent } from './comments/modal-comments.component'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; + +@NgModule({ + declarations: [ModerationComponent, ModalCommentComponent], + imports: [ + CommonModule, + FormsModule, + IonicModule, + ImBlockModule, + ImTabsModule, + ImFormsModule, + ReactiveFormsModule, + ImImageModule, + ImStorageUrlPipeModule, + ImagesViewerModalModule, + RouterModule.forChild([ + { + path: '', + component: ModerationComponent, + }, + ]), + ], +}) +export class AdminModerationModule {} diff --git a/libs/client/cm/data-access/src/lib/change-maker.facade.ts b/libs/client/cm/data-access/src/lib/change-maker.facade.ts index 5156bb32..eb34be9e 100644 --- a/libs/client/cm/data-access/src/lib/change-maker.facade.ts +++ b/libs/client/cm/data-access/src/lib/change-maker.facade.ts @@ -161,5 +161,8 @@ export class ChangeMakerFacade { }, }; - constructor(private readonly store: Store, private readonly actions$: Actions) {} + constructor( + private readonly store: Store, + private readonly actions$: Actions, + ) {} } diff --git a/libs/client/cm/data-access/src/lib/pois/pois.effects.ts b/libs/client/cm/data-access/src/lib/pois/pois.effects.ts index 44215cee..85291721 100644 --- a/libs/client/cm/data-access/src/lib/pois/pois.effects.ts +++ b/libs/client/cm/data-access/src/lib/pois/pois.effects.ts @@ -244,6 +244,7 @@ export class PoiEffects { filter((body) => !!body), map((poi) => { if (!poi) throw new Error('No POI Emitted!'); + this.user.posts.dispatchers.create({poiId: poi.id}); return PoisActions.submitPoiSuccess({ poi }); }) ), diff --git a/libs/client/shared/data-access/src/index.ts b/libs/client/shared/data-access/src/index.ts index 67d4dc3e..e51af2a2 100644 --- a/libs/client/shared/data-access/src/index.ts +++ b/libs/client/shared/data-access/src/index.ts @@ -33,3 +33,6 @@ export * from './lib/orchestrations'; export * from './lib/pipes'; export * from './lib/shared-routes'; export * from './lib/smart-components'; +export * from './lib/+state/activity-posts'; +export * from './lib/comment.service'; + diff --git a/libs/client/shared/data-access/src/lib/+state/activity-posts/activity-posts.actions.spec.ts b/libs/client/shared/data-access/src/lib/+state/activity-posts/activity-posts.actions.spec.ts new file mode 100644 index 00000000..e69fcb7c --- /dev/null +++ b/libs/client/shared/data-access/src/lib/+state/activity-posts/activity-posts.actions.spec.ts @@ -0,0 +1,201 @@ +import * as PostsActions from './activity-posts.actions'; + +describe('Activity-Post Actions', () => { + + it('load posts', () => { + const action = PostsActions.loadPosts({ page: 1, limit: 10 }) + expect(action.type).toEqual(PostsActions.LOAD_POSTS); + expect(action.page).toEqual(1); + expect(action.limit).toEqual(10); + }); + + it('load posts success', () => { + const action = PostsActions.loadPostsSuccess({ posts: [{ id: 5 } as any], page: 2, limit: 10 }); + expect(action.type).toEqual(PostsActions.LOAD_POSTS_SUCCESS); + expect(action.page).toEqual(2); + expect(action.posts).toEqual([{ id: 5 }]) + expect(action.limit).toEqual(10); + }); + + it('load post error', () => { + const action = PostsActions.loadPostsError({ error: { + statusCode: 24, + timestamp: "123", + operation: "Activity Posts Load", + message: "Server Failure", + response: "" + }}); + expect(action.type).toEqual(PostsActions.LOAD_POSTS_ERROR); + expect(action.error).toEqual({ + statusCode: 24, + timestamp: "123", + operation: "Activity Posts Load", + message: "Server Failure", + response: "" + }); + }); + + it('get post', () => { + const action = PostsActions.getPost({ dto: { postId: "1"}}); + expect(action.type).toEqual(PostsActions.GET_POST); + expect(action.dto).toEqual({ postId: "1" }); + }); + + it('get post success', () => { + const action = PostsActions.getPostSuccess({ + post: { id: "1" } as any + }); + expect(action.type).toEqual(PostsActions.GET_POST_SUCCESS); + expect(action.post).toEqual({ + id: "1" + }); + }); + + it('get post error', () => { + const action = PostsActions.getPostError({ + error: { + statusCode: 24, + timestamp: "123", + operation: "Get Post", + message: "Server Failure", + response: "" + } + }); + expect(action.type).toEqual(PostsActions.GET_POST_ERROR); + expect(action.error).toEqual({ + statusCode: 24, + timestamp: "123", + operation: "Get Post", + message: "Server Failure", + response: "" + }); + }); + + it('create post', () => { + const action = PostsActions.createPost({ dto: { + poiId: "3" + }}); + expect(action.type).toEqual(PostsActions.CREATE_POST); + expect(action.dto).toEqual({ poiId: "3" }); + }); + + it('create post success', () => { + const action = PostsActions.createPostSuccess({ + post: { id: "1" } as any + }); + expect(action.type).toEqual(PostsActions.CREATE_POST_SUCCESS); + expect(action.post).toEqual({ + id: "1" + }); + }); + + it('create post error', () => { + const action = PostsActions.createPostError({ + error: { + statusCode: 24, + timestamp: "123", + operation: "Create Post", + message: "Server Failure", + response: "" + } + }); + expect(action.type).toEqual(PostsActions.CREATE_POST_ERROR); + expect(action.error).toEqual({ + statusCode: 24, + timestamp: "123", + operation: "Create Post", + message: "Server Failure", + response: "" + }); + }); + + it('like post', () => { + const action = PostsActions.like({ + dto: { + postId: "1" + } + }) + expect(action.type).toEqual(PostsActions.LIKE_POST); + expect(action.dto).toEqual({ + postId: "1" + }); + }); + + it('like post success', () => { + const action = PostsActions.likeSuccess({ + post: { id: "1", likeCount: 2 } as any + }); + expect(action.type).toEqual(PostsActions.LIKE_POST_SUCCESS); + expect(action.post).toEqual({ + id: "1", + likeCount: 2 + }); + }); + + it('like post error', () => { + const action = PostsActions.likeError({ + error: { + statusCode: 24, + timestamp: "now", + operation: "Like Post", + message: "Server Failure", + response: "" + } + }); + expect(action.type).toEqual(PostsActions.LIKE_POST_ERROR); + expect(action.error).toEqual({ + statusCode: 24, + timestamp: "now", + operation: "Like Post", + message: "Server Failure", + response: "" + }); + }); + + it('unlike post', () => { + const action = PostsActions.unlike({ + dto: { + postId: "1" + } + }); + expect(action.type).toEqual(PostsActions.UNLIKE_POST); + expect(action.dto).toEqual({ + postId: "1" + }); + }); + + it('unlike post success', () => { + const action = PostsActions.unlikeSuccess({ + post: { + id: "1", + likeCount: 1 + } as any + }); + expect(action.type).toEqual(PostsActions.UNLIKE_POST_SUCCESS); + expect(action.post).toEqual({ + id: "1", + likeCount: 1 + }); + }); + + it('unlike post error', () => { + const action = PostsActions.unlikeError({ + error: { + statusCode: 24, + timestamp: "123", + operation: "Unlike Post", + message: "Server Failure", + response: "" + } + }); + expect(action.type).toEqual(PostsActions.UNLIKE_POST_ERROR); + expect(action.error).toEqual({ + statusCode: 24, + timestamp: "123", + operation: "Unlike Post", + message: "Server Failure", + response: "" + }); + }); + +}); diff --git a/libs/client/shared/data-access/src/lib/+state/activity-posts/activity-posts.actions.ts b/libs/client/shared/data-access/src/lib/+state/activity-posts/activity-posts.actions.ts new file mode 100644 index 00000000..073d3942 --- /dev/null +++ b/libs/client/shared/data-access/src/lib/+state/activity-posts/activity-posts.actions.ts @@ -0,0 +1,123 @@ +import { createAction, props } from '@ngrx/store'; +import { OrchaOperationError } from '@orcha/common'; +import { CreateActivityPostDto, GetActivityPostDto, LikeActivityPostDto, UnlikeActivityPostDto } from '@involvemint/shared/domain'; +import { PostStoreModel } from './activity-posts.reducer'; + + +/** + * Activity Post Actions. + * + * An 'action' is a component of Angular state management that sends + * out a signal that something happened or needs to be done. They have + * similarities to functions in that releasing/invoking an action on the + * state management lifecycle will invoke some background action in the system. + * With actions you specify the name of the action (so others can listen for it) and + * the values that are passed along with that action. + * + * Ex: + * loadPosts => signals that Activity Posts need to be loaded + presents params. + * loadPostsSuccess => signals that Activity Posts have been fetched successfully + presents outputs. + */ + + +/** + * Constant Action Types. + */ +export const LOAD_POSTS = '[Activity Posts] Activity Posts Load'; +export const LOAD_POSTS_SUCCESS = '[Activity Posts] Activity Posts Load Success'; +export const LOAD_POSTS_ERROR = '[Activity Posts] Activity Posts Load Error'; + +export const GET_POST = '[Activity Posts] Activity Posts Get'; +export const GET_POST_SUCCESS = '[Activity Posts] Activity Posts Get Success'; +export const GET_POST_ERROR = '[Activity Posts] Activity Posts Get Error'; + +export const CREATE_POST = '[Activity Posts] Activity Posts Create'; +export const CREATE_POST_SUCCESS = '[Activity Posts] Activity Posts Create Success'; +export const CREATE_POST_ERROR = '[Activity Posts] Activity Posts Create Error'; + +export const LIKE_POST = '[Activity Posts] Like Activity Post'; +export const LIKE_POST_SUCCESS = '[Activity Posts] Like Activity Post Success'; +export const LIKE_POST_ERROR = '[Activity Posts] Like Activity Post Error'; + +export const UNLIKE_POST = '[Activity Posts] unlike Activity Post'; +export const UNLIKE_POST_SUCCESS = '[Activity Posts] unlike Activity Post Success'; +export const UNLIKE_POST_ERROR = '[Activity Posts] unlike Activity Post Error'; + + +/** + * Action Specifications. + */ +export const loadPosts = createAction( + LOAD_POSTS, + props<{ page: number; limit: number }>() +); + +export const loadPostsSuccess = createAction( + LOAD_POSTS_SUCCESS, + props<{ posts: PostStoreModel[]; page: number; limit: number }>() +); + +export const loadPostsError = createAction( + LOAD_POSTS_ERROR, + props<{ error: OrchaOperationError }>() +); + +export const createPost = createAction( + CREATE_POST, + props<{ dto: CreateActivityPostDto }>() +); + +export const createPostSuccess = createAction( + CREATE_POST_SUCCESS, + props<{ post: PostStoreModel }>() +); + +export const createPostError = createAction( + CREATE_POST_ERROR, + props<{ error: OrchaOperationError }>() +); + +export const getPost = createAction( + GET_POST, + props<{ dto: GetActivityPostDto }>() +); + +export const getPostSuccess = createAction( + GET_POST_SUCCESS, + props<{ post: PostStoreModel }>() +) + +export const getPostError = createAction( + GET_POST_ERROR, + props<{ error: OrchaOperationError }>() +); + +export const like = createAction( + LIKE_POST, + props<{ dto: LikeActivityPostDto }>() +); + +export const likeSuccess = createAction( + LIKE_POST_SUCCESS, + props<{ post: PostStoreModel }>() +); + +export const likeError = createAction( + LIKE_POST_ERROR, + props<{ error: OrchaOperationError }>() +); + +export const unlike = createAction( + UNLIKE_POST, + props<{ dto: UnlikeActivityPostDto }>() +); + +export const unlikeSuccess = createAction( + UNLIKE_POST_SUCCESS, + props<{ post: PostStoreModel }>() +); + +export const unlikeError = createAction( + UNLIKE_POST_ERROR, + props<{ error: OrchaOperationError }>() +); diff --git a/libs/client/shared/data-access/src/lib/+state/activity-posts/activity-posts.effects.spec.ts b/libs/client/shared/data-access/src/lib/+state/activity-posts/activity-posts.effects.spec.ts new file mode 100644 index 00000000..0f4b2f04 --- /dev/null +++ b/libs/client/shared/data-access/src/lib/+state/activity-posts/activity-posts.effects.spec.ts @@ -0,0 +1,261 @@ +import { StatusService } from "@involvemint/client/shared/util"; +import { asyncScheduler, Observable, of, throwError } from "rxjs"; +import { ActivityPostOrchestration } from "../../orchestrations"; +import { PostEffects } from "./activity-posts.effects"; +import { Action } from "@ngrx/store"; +import { provideMockActions } from '@ngrx/effects/testing'; +import { TestBed } from "@angular/core/testing"; +import { hot, cold } from 'jest-marbles'; + +import * as PostsActions from './activity-posts.actions'; + + +describe('Activity-Post Effects', () => { + + let actions: Observable; + let effects: PostEffects; + let status: StatusService; + let posts: ActivityPostOrchestration; + + beforeEach(() => { + const moduleRef = TestBed.configureTestingModule({ + providers: [ + PostEffects, + provideMockActions(() => actions), + { + provide: StatusService, + useValue: { + showLoader: jest.fn(), + dismissLoader: jest.fn(), + presentNgRxActionAlert: jest.fn(), + } + }, + { + provide: ActivityPostOrchestration, + useValue: { + list: jest.fn(), + create: jest.fn(), + like: jest.fn(), + unlike: jest.fn(), + get: jest.fn(), + } + } + ] + }); + + effects = moduleRef.get(PostEffects); + + status = moduleRef.get(StatusService); + posts = moduleRef.get(ActivityPostOrchestration); + + // mock generic functions for all tests + jest.spyOn(status, 'presentNgRxActionAlert') + .mockImplementation(async (_action: any, error: any) => { + return; + }); + jest.spyOn(status, 'showLoader') + .mockImplementation(async () => { return }); + jest.spyOn(status, 'dismissLoader') + .mockImplementation(async () => { return }); + }); + + it('should be defined', () => { + expect(effects).toBeTruthy(); + expect(status).toBeTruthy(); + expect(posts).toBeTruthy(); + }); + + it('should return load posts success on happy path', () => { + // provide fake data that will be used in effect + const fakePosts = { + items: [ + { id: "1"} as any, {id: "2"} as any + ] + } + + const action = PostsActions.loadPosts({ page: 1, limit: 10 }); + const completion = PostsActions.loadPostsSuccess({ + posts: fakePosts.items, + page: 1, + limit: 10, + }); + + // mock `this.posts.list` call and return OBSERVABLE data! + jest.spyOn(posts, 'list') + .mockImplementation((_query: any) => { + return of(fakePosts) + }); + + // mock the observable action passed to effects + actions = hot('--a-', { a: action }); + + // specify expected output and run test + const expected = cold('--(b)', { b: completion }); + expect(effects.loadPosts$).toBeObservable(expected); + }); + + it('should return load posts error on unhappy path', () => { + // provide fake data that will be used in effect + const action = PostsActions.loadPosts({ page: 1, limit: 10 }); + const completion = PostsActions.loadPostsError({ error: undefined as any }); + + // mock `this.posts.list` call and return OBSERVABLE data! + jest.spyOn(posts, 'list') + .mockImplementation((_query: any) => { + throw throwError("error"); + }); + + // mock the observable action passed to effects + actions = hot('--a-', { a: action }); + + // specify expected output and run test + const expected = cold('--(b)', { b: completion }); + expect(effects.loadPosts$).toBeObservable(expected); + }); + + it('should return get post success on happy path', () => { + const fakePost = { id: "1" } as any; + const action = PostsActions.getPost({ dto: { postId: "1" }}); + const completion = PostsActions.getPostSuccess({ post: fakePost }); + + jest.spyOn(posts, 'get') + .mockImplementation((_query: any, _dto: any) => { + return of(fakePost); + }); + + actions = hot('--a-', { a: action }); + + const expected = cold('--(b)', { b: completion }); + expect(effects.getPost$).toBeObservable(expected); + }); + + it('should return get post error on unhappy path', () => { + const action = PostsActions.getPost({ dto: { postId: "1" }}); + const completion = PostsActions.getPostError({ error: undefined as any }); + + jest.spyOn(posts, 'get') + .mockImplementation((_query: any, _dto: any) => { + throw throwError("error"); + }); + + actions = hot('--a-', { a: action }); + + const expected = cold('--(b)', { b: completion }); + expect(effects.getPost$).toBeObservable(expected); + }); + + it('should return like post success on happy path', () => { + // provide fake data that will be used in effect + const fakePost = { id: "1" } as any + const action = PostsActions.like({ dto: { postId: "1" }}); + const completion = PostsActions.likeSuccess({ post: fakePost }) + + // mock `this.posts.list` call and return OBSERVABLE data! + jest.spyOn(posts, 'like') + .mockImplementation((_query: any, _dto: any) => { + return of(fakePost); + }); + + // specify expected output and run test + actions = hot('--a-', { a: action }); + + // specify expected output and run test + const expected = cold('--(b)', { b: completion }); + expect(effects.likePost$).toBeObservable(expected); + }); + + it('should return like post error on unhappy path', () => { + const action = PostsActions.like({ dto: { postId: "1" }}); + const completion = PostsActions.likeError({ error: undefined as any }); + + jest.spyOn(posts, 'like') + .mockImplementation((_query: any, _dto: any) => { + throw throwError("error"); + }); + + // specify expected output and run test + actions = hot('--a-', { a: action }); + + // specify expected output and run test + const expected = cold('--(b)', { b: completion }); + expect(effects.likePost$).toBeObservable(expected); + }); + + it('should return unlike post success on happy path', () => { + // provide fake data that will be used in effect + const fakePost = { id: "1" } as any + const action = PostsActions.unlike({ dto: { postId: "1" }}); + const completion = PostsActions.unlikeSuccess({ post: fakePost }) + + // mock `this.posts.list` call and return OBSERVABLE data! + jest.spyOn(posts, 'unlike') + .mockImplementation((_query: any, _dto: any) => { + return of(fakePost); + }); + + // specify expected output and run test + actions = hot('--a-', { a: action }); + + // specify expected output and run test + const expected = cold('--(b)', { b: completion }); + expect(effects.unlikePost$).toBeObservable(expected); + }); + + it('should return unlike post error on unhappy path', () => { + const action = PostsActions.unlike({ dto: {postId: "1" }}); + const completion = PostsActions.unlikeError({ error: undefined as any }); + + jest.spyOn(posts, 'unlike') + .mockImplementation((_query: any, _dto: any) => { + throw throwError("error"); + }); + + // specify expected output and run test + actions = hot('--a-', { a: action }); + + // specify expected output and run test + const expected = cold('--(b)', { b: completion }); + expect(effects.unlikePost$).toBeObservable(expected); + }); + + // it('should return create post success on happy path', () => { + // // provide fake data that will be used in effect + // const fakePost = { id: "1"} as any; + // const action = PostsActions.createPost({ dto: { poiId: "21"}}) + // const completion = PostsActions.createPostSuccess({ + // post: fakePost + // }); + + // // mock `this.posts.list` call and return OBSERVABLE data! + // jest.spyOn(posts, 'create') + // .mockImplementation((_query: any, _dto: any) => { + // return of(fakePost); + // }); + + // actions = hot('--a-', { a: action }); + + // // specify expected output and run test + // const expected = cold('--(b)', { b: completion }); + // const actual = effects.createPost$; + // expect(actual).toBeObservable(expected); + // }); + + // it('should return create post error on unhappy path', () => { + // const action = PostsActions.createPost({ dto: { poiId: "21"}}) + // const completion = PostsActions.createPostError({ error: undefined as any }); + + // // mock `this.posts.list` call and return OBSERVABLE data! + // jest.spyOn(posts, 'create') + // .mockImplementation((_query: any, _dto: any) => { + // throw throwError("error"); + // }); + + // actions = hot('--a-', { a: action }); + + // // specify expected output and run test + // const expected = cold('--(b)', { b: completion }); + // const actual = effects.createPost$; + // expect(actual).toBeObservable(expected); + // }); + +}); \ No newline at end of file diff --git a/libs/client/shared/data-access/src/lib/+state/activity-posts/activity-posts.effects.ts b/libs/client/shared/data-access/src/lib/+state/activity-posts/activity-posts.effects.ts new file mode 100644 index 00000000..cfaa92b8 --- /dev/null +++ b/libs/client/shared/data-access/src/lib/+state/activity-posts/activity-posts.effects.ts @@ -0,0 +1,137 @@ +import { Injectable } from "@angular/core"; +import { StatusService } from "@involvemint/client/shared/util"; +import { ActivityFeedQuery, ActivityPostQuery } from "@involvemint/shared/domain"; +import { Actions, createEffect, ofType } from "@ngrx/effects"; +import * as PostsActions from './activity-posts.actions'; +import { map, delayWhen, tap } from 'rxjs/operators'; +import { fetch, pessimisticUpdate } from "@nrwl/angular"; +import { from } from 'rxjs'; +import { ActivityPostOrchestration } from '../../orchestrations/activity-post.orchestration'; + + +/** + * Activity Post Effects. + * + * An 'effect' is a component of Angular state management that listens for + * actions and perform corresponding effects on the system. + * It is where you define the _logic_ for actions. For the most part, we use + * effects as a way to fetch data from the backend and then prepare it to be + * presented to the user interface. + * + * Ex: + * loadPosts$ => listens for 'loadPosts' action via ofType(..) and then fetches the next page of Activity Posts + * and passes on a either a 'loadPostsSuccess' or 'loadPostsError' action with appropriate values + * into the state management lifecycle. + */ +@Injectable() +export class PostEffects { + + readonly loadPosts$ = createEffect(() => + this.actions$.pipe( + ofType(PostsActions.loadPosts), + fetch({ + run: ({ page, limit }) => + this.posts.list( + { + ...ActivityFeedQuery, + __paginate: { + ...ActivityFeedQuery.__paginate, + page, + limit, + } + } + ).pipe( + map((posts) => PostsActions.loadPostsSuccess({ posts: posts.items, page, limit})) + ), + onError: (action, { error }) => { + this.status.presentNgRxActionAlert(action, error); + return PostsActions.loadPostsError({ error }); + } + }) + ) + ); + + readonly getPost$ = createEffect(() => + this.actions$.pipe( + ofType(PostsActions.getPost), + fetch({ + run: ({ dto }) => + this.posts.get( + ActivityPostQuery, + dto + ).pipe( + map((post) => PostsActions.getPostSuccess({ post })) + ), + onError: (action, { error }) => { + this.status.presentNgRxActionAlert(action, error); + return PostsActions.getPostError({ error }) + } + }) + ) + ); + + readonly createPost$ = createEffect(() => + this.actions$.pipe( + ofType(PostsActions.createPost), + delayWhen(() => from(this.status.showLoader('Creating...'))), + pessimisticUpdate({ + run: ({ dto }) => + this.posts.create( + ActivityPostQuery, + dto + ).pipe( + map((post) => PostsActions.createPostSuccess({ post })) + ), + onError: (action, { error }) => { + this.status.presentNgRxActionAlert(action, error) + return PostsActions.createPostError({ error }) + } + }), + tap(() => this.status.dismissLoader()) + ) + ); + + readonly likePost$ = createEffect(() => + this.actions$.pipe( + ofType(PostsActions.like), + pessimisticUpdate({ + run: ({ dto }) => + this.posts.like( + ActivityPostQuery, + dto + ).pipe( + map((post) => PostsActions.likeSuccess({ post })) + ), + onError: (action, { error }) => { + this.status.presentNgRxActionAlert(action, error) + return PostsActions.likeError({ error }) + } + }) + ) + ); + + readonly unlikePost$ = createEffect(() => + this.actions$.pipe( + ofType(PostsActions.unlike), + pessimisticUpdate({ + run: ({ dto }) => + this.posts.unlike( + ActivityPostQuery, + dto + ).pipe( + map((post) => PostsActions.unlikeSuccess({ post })) + ), + onError: (action, { error }) => { + this.status.presentNgRxActionAlert(action, error) + return PostsActions.unlikeError({ error }) + } + }) + ) + ); + + constructor( + private readonly actions$: Actions, + private readonly status: StatusService, + private readonly posts: ActivityPostOrchestration, + ) {} +} diff --git a/libs/client/shared/data-access/src/lib/+state/activity-posts/activity-posts.reducer.spec.ts b/libs/client/shared/data-access/src/lib/+state/activity-posts/activity-posts.reducer.spec.ts new file mode 100644 index 00000000..b0481e49 --- /dev/null +++ b/libs/client/shared/data-access/src/lib/+state/activity-posts/activity-posts.reducer.spec.ts @@ -0,0 +1,160 @@ +import { initialState, postsAdapter, PostsReducer } from "./activity-posts.reducer"; +import * as PostsActions from './activity-posts.actions'; + + +describe('Activity-Post Reducer', () => { + + beforeEach(() => { + + // reset the state to init + initialState.pagesLoaded = 0; + initialState.posts = { + entities: {}, + ids: [], + }; + initialState.allPagesLoaded = false; + + // spy methods used + jest.spyOn(postsAdapter, 'upsertMany') + .mockImplementation((posts: any, existing: any) => { + posts.forEach((post: any) => { + existing.ids.push(post.id); + }); + return existing; + }); + + jest.spyOn(postsAdapter, 'upsertOne') + .mockImplementation((post: any, existing: any) => { + existing.ids.push(post.id); + return existing; + }); + }); + + afterEach(() => { jest.clearAllMocks(); }) + + it('should return initial state when undefined start', () => { + const newState = PostsReducer(undefined, PostsActions.loadPosts); + expect(newState).toEqual(initialState); + }); + + it('should return initial state when undefined start & action', () => { + const newState = PostsReducer(undefined, { type: 'NOOP' } as any); + expect(newState).toEqual(initialState); + }); + + it('should return initial state on unknown action', () => { + const newState = PostsReducer(initialState, { type: 'NOOP' } as any); + expect(newState).toEqual(initialState); + }); + + it('should return initial state on unregistered action', () => { + const newState = PostsReducer( + initialState, + PostsActions.like({ dto: { postId: "1"}}) + ); + expect(newState).toEqual(initialState); + }); + + it('should update posts & page on load posts success w/ pagination not complete', () => { + const action = PostsActions.loadPostsSuccess({ + posts: [{ id: 1 }, { id: 2 },{ id: 3 },{ id: 4 },{ id: 5 },{ id: 6 },{ id: 7 },{ id: 8 },{ id: 9 },{ id: 10 }], + page: 1, + limit: initialState.limit, + } as any); + const newState = PostsReducer(initialState, action); + expect(postsAdapter.upsertMany).toBeCalledTimes(1); + expect(newState.posts.ids.sort()).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10].sort()); + expect(newState.posts.entities).toEqual({}); + expect(newState.pagesLoaded).toEqual(1); + expect(newState.allPagesLoaded).toEqual(false); + }); + + it('should update posts & page on load posts success w/ pagination complete returning partial', () => { + const action = PostsActions.loadPostsSuccess({ + posts: [{ id: 1 }, { id: 2 }, { id: 3 }], + page: 1, + limit: initialState.limit, + } as any); + const newState = PostsReducer(initialState, action); + expect(postsAdapter.upsertMany).toBeCalledTimes(1); + expect(newState.posts.ids.sort()).toEqual([1, 2, 3].sort()); + expect(newState.posts.entities).toEqual({}); + expect(newState.pagesLoaded).toEqual(1); + expect(newState.allPagesLoaded).toEqual(true); + }); + + it('should update posts & page on load posts success w/ pagination complete returning empty', () => { + const action = PostsActions.loadPostsSuccess({ + posts: [], + page: 1, + limit: initialState.limit, + } as any); + const newState = PostsReducer(initialState, action); + expect(postsAdapter.upsertMany).toBeCalledTimes(1); + expect(newState.posts.ids).toEqual([].sort()); + expect(newState.posts.entities).toEqual({}); + expect(newState.pagesLoaded).toEqual(1); + expect(newState.allPagesLoaded).toEqual(true); + }); + + it('should add post on get post success', () => { + const action = PostsActions.getPostSuccess({ post: { id: 1 }} as any); + const newState = PostsReducer(initialState, action); + expect(postsAdapter.upsertOne).toBeCalledTimes(1); + expect(newState).toEqual({ + posts: { + entities: {}, + ids: [ 1 ] + }, + pagesLoaded: 0, + allPagesLoaded: false, + limit: 10, + }); + }); + + it('should add post on create post success', () => { + const action = PostsActions.createPostSuccess({ post: { id: 1 } as any }); + const newState = PostsReducer(initialState, action); + expect(postsAdapter.upsertOne).toBeCalledTimes(1); + expect(newState).toEqual({ + posts: { + entities: {}, + ids: [ 1 ] + }, + pagesLoaded: 0, + allPagesLoaded: false, + limit: 10, + }); + }); + + it('should update post on like post success', () => { + const action = PostsActions.likeSuccess({ post: { id: 1 } as any}); + const newState = PostsReducer(initialState, action); + expect(postsAdapter.upsertOne).toBeCalledTimes(1); + expect(newState).toEqual({ + posts: { + entities: {}, + ids: [ 1 ] + }, + pagesLoaded: 0, + allPagesLoaded: false, + limit: 10, + }); + }); + + it('should update post on unlike post success', () => { + const action = PostsActions.unlikeSuccess({ post: { id: 1 } as any}); + const newState = PostsReducer(initialState, action); + expect(postsAdapter.upsertOne).toBeCalledTimes(1); + expect(newState).toEqual({ + posts: { + entities: {}, + ids: [ 1 ] + }, + pagesLoaded: 0, + allPagesLoaded: false, + limit: 10, + }); + }); + +}) \ No newline at end of file diff --git a/libs/client/shared/data-access/src/lib/+state/activity-posts/activity-posts.reducer.ts b/libs/client/shared/data-access/src/lib/+state/activity-posts/activity-posts.reducer.ts new file mode 100644 index 00000000..50a24e61 --- /dev/null +++ b/libs/client/shared/data-access/src/lib/+state/activity-posts/activity-posts.reducer.ts @@ -0,0 +1,96 @@ +import { ActivityPost, ActivityPostQuery } from '@involvemint/shared/domain'; +import { createEntityAdapter, EntityState } from '@ngrx/entity'; +import { IParser } from '@orcha/common'; +import { createReducer, on } from '@ngrx/store'; +import * as PostsActions from './activity-posts.actions'; + + +/** + * Activity Post Reducer. + * + * A 'reducer' is a component of Angular state management that listens for + * actions and performs state change to reduce the incoming data from the action + * into the existing state. There is a 'PostState' which defines the structure of + * the state for the reducer, and an 'initialState' which defines the initial values + * for the state (which is inputted to reduce new state when actions passed). + * + * Ex: + * loadPostsSuccess => listens for the 'loadPostsSuccess' action (outputted by PostEffects) and + * creates a new PostState by taking the existing state and the values passed + * by the 'loadPostsSuccess' (posts, page, limit) and merging them. This specific + * reducer does so by adding the new posts to the existing state posts, updating page + * to be the value of the action passed page, and checking the length of posts list + * returned. + */ + +export const POSTS_KEY = 'posts'; + +export type PostStoreModel = IParser; + +export interface PostsState { + posts: EntityState; + pagesLoaded: number; + limit: number; + allPagesLoaded: boolean; +} + +export const postsAdapter = createEntityAdapter(); + +export const initialState: PostsState = { + posts: postsAdapter.getInitialState(), + pagesLoaded: 0, + limit: 10, + allPagesLoaded: false, +} + +export const PostsReducer = createReducer( + initialState, + on( + PostsActions.loadPostsSuccess, + (state, { posts, page, limit }): PostsState => { + return { + ...state, + posts: postsAdapter.upsertMany(posts, state.posts), + pagesLoaded: page, + allPagesLoaded: (posts.length % limit !== 0) || (posts.length === 0), + } + } + ), + on( + PostsActions.getPostSuccess, + (state, { post }): PostsState => { + return { + ...state, + posts: postsAdapter.upsertOne(post, state.posts) + } + } + ), + on( + PostsActions.createPostSuccess, + (state, { post }): PostsState => { + return { + ...state, + posts: postsAdapter.upsertOne(post, state.posts) + } + } + ), + on( + PostsActions.likeSuccess, + (state, { post }): PostsState => { + return { + ...state, + posts: postsAdapter.upsertOne(post, state.posts) + } + } + ), + on( + PostsActions.unlikeSuccess, + (state, { post }): PostsState => { + return { + ...state, + posts: postsAdapter.upsertOne(post, state.posts) + } + } + ), + +) diff --git a/libs/client/shared/data-access/src/lib/+state/activity-posts/activity-posts.selectors.ts b/libs/client/shared/data-access/src/lib/+state/activity-posts/activity-posts.selectors.ts new file mode 100644 index 00000000..c0e30fe1 --- /dev/null +++ b/libs/client/shared/data-access/src/lib/+state/activity-posts/activity-posts.selectors.ts @@ -0,0 +1,35 @@ + +import { createFeatureSelector, createSelector } from '@ngrx/store'; +import { PostsState, postsAdapter, POSTS_KEY } from './activity-posts.reducer'; + +const { selectAll, selectEntities } = postsAdapter.getSelectors(); +const getPostsState = createFeatureSelector(POSTS_KEY); + +/** + * Activity Post Selectors. + * + * A 'selector' provides a memoized view of the Post State. It allows Angular + * components to pull from a memoized view of the state that can be updated as + * the data morphs. + * + * Ex: + * getPosts => provides a memoized view of the Post State by revealing all the + * current posts, pages loaded, limit, and if all pages are loaded. + */ + +export const getPosts = createSelector(getPostsState, (state: PostsState) => ({ + posts: selectAll(state.posts), + pagesLoaded: state.pagesLoaded, + loaded: state.pagesLoaded > 0, + limit: state.limit, + allPagesLoaded: state.allPagesLoaded, +})); + +export const selectPost = (id: string) => + createSelector(getPostsState, (state: PostsState) => ({ + post: selectEntities(state.posts)[id], + pagesLoaded: state.pagesLoaded, + loaded: state.pagesLoaded > 0, + limit: state.limit, + allPagesLoaded: state.allPagesLoaded, + })); diff --git a/libs/client/shared/data-access/src/lib/+state/activity-posts/index.ts b/libs/client/shared/data-access/src/lib/+state/activity-posts/index.ts new file mode 100644 index 00000000..bf59498d --- /dev/null +++ b/libs/client/shared/data-access/src/lib/+state/activity-posts/index.ts @@ -0,0 +1,4 @@ +export * from './activity-posts.actions'; +export * from './activity-posts.effects'; +export * from './activity-posts.reducer'; +export * from './activity-posts.selectors'; \ No newline at end of file diff --git a/libs/client/shared/data-access/src/lib/+state/comments/comments.actions.spec.ts b/libs/client/shared/data-access/src/lib/+state/comments/comments.actions.spec.ts new file mode 100644 index 00000000..56c5e377 --- /dev/null +++ b/libs/client/shared/data-access/src/lib/+state/comments/comments.actions.spec.ts @@ -0,0 +1,272 @@ +import * as CommentsActions from './comments.actions'; + +describe('Comment Actions', () => { + + it('load comments', () => { + const action = CommentsActions.loadComments({ page: 1 }) + expect(action.type).toEqual(CommentsActions.LOAD_COMMENTS); + expect(action.page).toEqual(1); + }); + + it('load comments success', () => { + const action = CommentsActions.loadCommentsSuccess({ comments: [{ id: 5 } as any], page: 2}); + expect(action.type).toEqual(CommentsActions.LOAD_COMMENTS_SUCCESS); + expect(action.page).toEqual(2); + expect(action.comments).toEqual([{ id: 5 }]) + }); + + it('load comments error', () => { + const action = CommentsActions.loadCommentsError({ error: { + statusCode: 24, + timestamp: "123", + operation: "Comments Load", + message: "Server Failure", + response: "" + }}); + expect(action.type).toEqual(CommentsActions.LOAD_COMMENTS_ERROR); + expect(action.error).toEqual({ + statusCode: 24, + timestamp: "123", + operation: "Comments Load", + message: "Server Failure", + response: "" + }); + }); + + it('create comment', () => { + const action = CommentsActions.createComment({ dto: { + postId: "3", + text: "test", + commentsId: "4", + handleId: "2", + profilePicFilePath: "testing/123/picture", + name: "john smith" + }}); + expect(action.type).toEqual(CommentsActions.CREATE_COMMENT); + expect(action.dto).toEqual({ + postId: "3", + text: "test", + commentsId: "4", + handleId: "2", + profilePicFilePath: "testing/123/picture", + name: "john smith" + }); + }); + + it('create comment success', () => { + const action = CommentsActions.createCommentSuccess({ + comment: { id: "1" } as any + }); + expect(action.type).toEqual(CommentsActions.CREATE_COMMENT_SUCCESS); + expect(action.comment).toEqual({ + id: "1" + }); + }); + + it('create comment error', () => { + const action = CommentsActions.createCommentError({ + error: { + statusCode: 24, + timestamp: "123", + operation: "Create Comment", + message: "Server Failure", + response: "" + } + }); + expect(action.type).toEqual(CommentsActions.CREATE_COMMENT_ERROR); + expect(action.error).toEqual({ + statusCode: 24, + timestamp: "123", + operation: "Create Comment", + message: "Server Failure", + response: "" + }); + }); + + it('flag comment', () => { + const action = CommentsActions.flagComment({ + dto: { + commentId: "1", + } + }) + expect(action.type).toEqual(CommentsActions.FLAG_COMMENT); + expect(action.dto).toEqual({ + commentId: "1", + }); + }); + + it('flag comment success', () => { + const action = CommentsActions.flagCommentSuccess({ + comment: { id: "1" } as any + }); + expect(action.type).toEqual(CommentsActions.FLAG_COMMENT_SUCCESS); + expect(action.comment).toEqual({ + id: "1" + }); + }); + + it('flag comment error', () => { + const action = CommentsActions.flagCommentError({ + error: { + statusCode: 24, + timestamp: "123", + operation: "Flag Comment", + message: "Server Failure", + response: "" + } + }); + expect(action.type).toEqual(CommentsActions.FLAG_COMMENT_ERROR); + expect(action.error).toEqual({ + statusCode: 24, + timestamp: "123", + operation: "Flag Comment", + message: "Server Failure", + response: "" + }); + }); + + it('init comments', () => { + const action = CommentsActions.initComments({ comments: [{ id: 5 } as any] }); + expect(action.type).toEqual(CommentsActions.INIT_COMMENTS); + expect(action.comments).toEqual([{ id: 5 }]); + }); + + it('unflag comment', () => { + const action = CommentsActions.unflagComment({ + dto: { + commentId: "1", + } + }) + expect(action.type).toEqual(CommentsActions.UNFLAG_COMMENT); + expect(action.dto).toEqual({ + commentId: "1", + }); + }); + + it('unflag comment success', () => { + const action = CommentsActions.unflagCommentSuccess({ + comment: { + id: "1", + flagCount: 1 + } as any + }); + expect(action.type).toEqual(CommentsActions.UNFLAG_COMMENT_SUCCESS); + expect(action.comment).toEqual({ + id: "1", + flagCount: 1 + }); + }); + + it('unflag comment error', () => { + const action = CommentsActions.unflagCommentError({ + error: { + statusCode: 24, + timestamp: "123", + operation: "Unflag Comment", + message: "Server Failure", + response: "" + } + }); + expect(action.type).toEqual(CommentsActions.UNFLAG_COMMENT_ERROR); + expect(action.error).toEqual({ + statusCode: 24, + timestamp: "123", + operation: "Unflag Comment", + message: "Server Failure", + response: "" + }); + }); + + it('hide comment', () => { + const action = CommentsActions.hideComment({ + dto: { + commentId: "1", + } + }) + expect(action.type).toEqual(CommentsActions.HIDE_COMMENT); + expect(action.dto).toEqual({ + commentId: "1", + }); + }); + + it('hide comment success', () => { + const action = CommentsActions.hideCommentSuccess({ + comment: { + id: "1", + isHidden: true + } as any + }); + expect(action.type).toEqual(CommentsActions.HIDE_COMMENT_SUCCESS); + expect(action.comment).toEqual({ + id: "1", + isHidden: true + }); + }); + + it('hide comment error', () => { + const action = CommentsActions.hideCommentError({ + error: { + statusCode: 24, + timestamp: "123", + operation: "Hide Comment", + message: "Server Failure", + response: "" + } + }); + expect(action.type).toEqual(CommentsActions.HIDE_COMMENT_ERROR); + expect(action.error).toEqual({ + statusCode: 24, + timestamp: "123", + operation: "Hide Comment", + message: "Server Failure", + response: "" + }); + }); + + it('unhide comment', () => { + const action = CommentsActions.unhideComment({ + dto: { + commentId: "1", + } + }) + expect(action.type).toEqual(CommentsActions.UNHIDE_COMMENT); + expect(action.dto).toEqual({ + commentId: "1", + }); + }); + + it('unhide comment success', () => { + const action = CommentsActions.unhideCommentSuccess({ + comment: { + id: "1", + isHidden: false + } as any + }); + expect(action.type).toEqual(CommentsActions.UNHIDE_COMMENT_SUCCESS); + expect(action.comment).toEqual({ + id: "1", + isHidden: false + }); + }); + + it('unhide comment error', () => { + const action = CommentsActions.unhideCommentError({ + error: { + statusCode: 24, + timestamp: "123", + operation: "Unhide Comment", + message: "Server Failure", + response: "" + } + }); + expect(action.type).toEqual(CommentsActions.UNHIDE_COMMENT_ERROR); + expect(action.error).toEqual({ + statusCode: 24, + timestamp: "123", + operation: "Unhide Comment", + message: "Server Failure", + response: "" + }); + }); + +}); \ No newline at end of file diff --git a/libs/client/shared/data-access/src/lib/+state/comments/comments.actions.ts b/libs/client/shared/data-access/src/lib/+state/comments/comments.actions.ts new file mode 100644 index 00000000..f275ad96 --- /dev/null +++ b/libs/client/shared/data-access/src/lib/+state/comments/comments.actions.ts @@ -0,0 +1,149 @@ +import { CreateCommentDto, FlagCommentDto, HideCommentDto, UnflagCommentDto, UnhideCommentDto } from "@involvemint/shared/domain"; +import { createAction, props } from "@ngrx/store"; +import { OrchaOperationError } from "@orcha/common"; +import { CommentStoreModel } from "./comments.reducer"; + + +export const LOAD_COMMENTS = '[Comments] Comments Load'; +export const LOAD_COMMENTS_SUCCESS = '[Comments] Comments Load Success'; +export const LOAD_COMMENTS_ERROR = '[Comments] Comments Load Error'; + +export const CREATE_COMMENT = '[Comments] Comments Create'; +export const CREATE_COMMENT_SUCCESS = '[Comments] Comments Create Success'; +export const CREATE_COMMENT_ERROR = '[Comments] Comments Create Error'; + +export const INIT_COMMENTS = '[Comments] Comments Init'; + +export const FLAG_COMMENT = '[Comments] Flag Comment'; +export const FLAG_COMMENT_SUCCESS = '[Comments] Flag Comment Success'; +export const FLAG_COMMENT_ERROR = '[Comments] Flag Comment Error'; + +export const UNFLAG_COMMENT = '[Comments] Unflag Comment'; +export const UNFLAG_COMMENT_SUCCESS = '[Comments] Unflag Comment Success'; +export const UNFLAG_COMMENT_ERROR = '[Comments] Unflag Comment Error'; + +export const HIDE_COMMENT = '[Comment] Hide Comment'; +export const HIDE_COMMENT_SUCCESS = '[Comment] Hide Comment Success'; +export const HIDE_COMMENT_ERROR = '[Comment] Hide Comment Error'; + +export const UNHIDE_COMMENT = '[Comment] Unhide Comment'; +export const UNHIDE_COMMENT_SUCCESS = '[Comment] Unhide Comment Success'; +export const UNHIDE_COMMENT_ERROR = '[Comment] Unhide Comment Error'; + +/** + * Actions for loading comments + */ + export const loadComments = createAction( + LOAD_COMMENTS, + props<{ page: number }>() +); + +export const loadCommentsSuccess = createAction( + LOAD_COMMENTS_SUCCESS, + props<{ comments: CommentStoreModel[]; page: number }>() +); + +export const loadCommentsError = createAction( + LOAD_COMMENTS_ERROR, + props<{ error: OrchaOperationError }>() +); + + +/** + * Actions for creating comment + */ + +export const createComment = createAction( + CREATE_COMMENT, + props<{ dto: CreateCommentDto }>() +); + +export const createCommentSuccess = createAction( + CREATE_COMMENT_SUCCESS, + props<{ comment: CommentStoreModel }>() +); + +export const createCommentError = createAction( + CREATE_COMMENT_ERROR, + props<{ error: OrchaOperationError }>() +); + +/** + * Action to load comment modal with comments from a post + */ +export const initComments = createAction( + INIT_COMMENTS, + props<{ comments: CommentStoreModel[] }>() +); + +/** + * Actions for flagging a comment + */ + +export const flagComment = createAction( + FLAG_COMMENT, + props<{ dto: FlagCommentDto }>() +); + +export const flagCommentSuccess = createAction( + FLAG_COMMENT_SUCCESS, + props<{ comment: CommentStoreModel }>() +); + +export const flagCommentError = createAction( + FLAG_COMMENT_ERROR, + props<{ error: OrchaOperationError }>() +); + +export const unflagComment = createAction( + UNFLAG_COMMENT, + props<{ dto: UnflagCommentDto }>() +); + +export const unflagCommentSuccess = createAction( + UNFLAG_COMMENT_SUCCESS, + props<{ comment: CommentStoreModel }>() +); + +export const unflagCommentError = createAction( + UNFLAG_COMMENT_ERROR, + props<{ error: OrchaOperationError }>() +); + +/** + * Actions for hiding a comment + */ + + export const hideComment = createAction( + HIDE_COMMENT, + props<{ dto: HideCommentDto }>() +); + +export const hideCommentSuccess = createAction( + HIDE_COMMENT_SUCCESS, + props<{ comment: CommentStoreModel }>() +); + +export const hideCommentError = createAction( + HIDE_COMMENT_ERROR, + props<{ error: OrchaOperationError }>() +); + +/** + * Actions for unhiding a comment + */ + + export const unhideComment = createAction( + UNHIDE_COMMENT, + props<{ dto: UnhideCommentDto }>() +); + +export const unhideCommentSuccess = createAction( + UNHIDE_COMMENT_SUCCESS, + props<{ comment: CommentStoreModel }>() +); + +export const unhideCommentError = createAction( + UNHIDE_COMMENT_ERROR, + props<{ error: OrchaOperationError }>() +); \ No newline at end of file diff --git a/libs/client/shared/data-access/src/lib/+state/comments/comments.effects.spec.ts b/libs/client/shared/data-access/src/lib/+state/comments/comments.effects.spec.ts new file mode 100644 index 00000000..c8d2172f --- /dev/null +++ b/libs/client/shared/data-access/src/lib/+state/comments/comments.effects.spec.ts @@ -0,0 +1,263 @@ +import { StatusService } from "@involvemint/client/shared/util"; +import { asyncScheduler, Observable, of, throwError } from "rxjs"; +import { CommentOrchestration } from "../../orchestrations"; +import { CommentEffects } from "./comments.effects"; +import { Action } from "@ngrx/store"; +import { provideMockActions } from '@ngrx/effects/testing'; +import { TestBed } from "@angular/core/testing"; +import { hot, cold } from 'jest-marbles'; + +import * as CommentActions from './comments.actions'; + +describe('Comments Effects', () => { + + let actions: Observable; + let effects: CommentEffects; + let status: StatusService; + let comments: CommentOrchestration; + + beforeEach(() => { + const module = TestBed.configureTestingModule({ + providers: [ + CommentEffects, + provideMockActions(() => actions), + { + provide: StatusService, + useValue: { + showLoader: jest.fn(), + dismissLoader: jest.fn(), + presentNgRxActionAlert: jest.fn(), + } + }, + { + provide: CommentOrchestration, + useValue: { + list: jest.fn(), + create: jest.fn(), + flag: jest.fn(), + unflag: jest.fn(), + hide: jest.fn(), + unhide: jest.fn(), + } + } + ] + }); + + effects = module.get(CommentEffects); + + status = module.get(StatusService); + comments = module.get(CommentOrchestration); + + // mock generic functions for all tests + jest.spyOn(status, 'presentNgRxActionAlert') + .mockImplementation(async (_action: any, error: any) => { + return; + }); + jest.spyOn(status, 'showLoader') + .mockImplementation(async () => { return }); + jest.spyOn(status, 'dismissLoader') + .mockImplementation(async () => { return }); + }); + + it('should be defined', () => { + expect(effects).toBeTruthy(); + expect(status).toBeTruthy(); + expect(comments).toBeTruthy(); + }); + + it('should return load comments success on happy path', () => { + // provide fake data that will be used in effect + const fakeComments = [ + { id: "1"} as any, + { id: "2"} as any, + ]; + const action = CommentActions.loadComments({ page: 1 }); + const completion = CommentActions.loadCommentsSuccess({ + comments: fakeComments, + page: 1, + }); + + // mock 'this.comments.list' call and return OBSERVABLE data! + jest.spyOn(comments, 'list') + .mockImplementation((_query: any) => { + return of(fakeComments); + }); + + // mock the observable action passed to effects + actions = hot('--a-', { a: action }); + + //specify expected output and run test + const expected = cold('--(b)', { b: completion }); + expect(effects.loadComments$).toBeObservable(expected); + }); + + it('should return load comments failure on error', () => { + // provide fake data that will be used in effect + const action = CommentActions.loadComments({ page: 1 }); + const completion = CommentActions.loadCommentsError({ error: undefined as any }); + + // mock 'this.comments.list' call and return OBSERVABLE data! + jest.spyOn(comments, 'list') + .mockImplementation((_query: any) => { + throw throwError("error"); + }); + + // mock the observable action passed to effects + actions = hot('--a-', { a: action }); + + //specify expected output and run test + const expected = cold('--(b)', { b: completion }); + expect(effects.loadComments$).toBeObservable(expected); + }); + + it('should return flag comment success on happy path', () => { + // provide fake data that will be used in effect + const fakeComment = { id: "1"} as any; + const action = CommentActions.flagComment({ dto: { commentId: "1" } }); + const completion = CommentActions.flagCommentSuccess({ comment: fakeComment }); + + // mock 'this.comments.flag' call and return OBSERVABLE data! + jest.spyOn(comments, 'flag') + .mockImplementation((_query: any, _dto: any) => { + return of(fakeComment); + }); + + // specify expected output and run test + actions = hot('--a-', { a: action }); + + //specify expected output and run test + const expected = cold('--(b)', { b: completion }); + expect(effects.flagComment$).toBeObservable(expected); + }); + + it('should return flag comment failure on error', () => { + const action = CommentActions.flagComment({ dto: { commentId: "1" } }); + const completion = CommentActions.flagCommentError({ error: undefined as any }); + + jest.spyOn(comments, 'flag') + .mockImplementation((_query: any, _dto: any) => { + throw throwError("error"); + }); + + // specify expected output and run test + actions = hot('--a-', { a: action }); + + //specify expected output and run test + const expected = cold('--(b)', { b: completion }); + expect(effects.flagComment$).toBeObservable(expected); + }); + + it('should return unflag comment success on happy path', () => { + // provide fake data that will be used in effect + const fakeComment = { id: "1"} as any; + const action = CommentActions.unflagComment({ dto: { commentId: "1" } }); + const completion = CommentActions.unflagCommentSuccess({ comment: fakeComment }); + + // mock 'this.comments.unflag' call and return OBSERVABLE data! + jest.spyOn(comments, 'unflag') + .mockImplementation((_query: any, _dto: any) => { + return of(fakeComment); + }); + + // specify expected output and run test + actions = hot('--a-', { a: action }); + + //specify expected output and run test + const expected = cold('--(b)', { b: completion }); + expect(effects.unflagComment$).toBeObservable(expected); + }); + + it('should return unflag comment failure on unhappy path', () => { + const action = CommentActions.unflagComment({ dto: { commentId: "1" } }); + const completion = CommentActions.unflagCommentError({ error: undefined as any }); + + jest.spyOn(comments, 'unflag') + .mockImplementation((_query: any, _dto: any) => { + throw throwError("error"); + }); + + // specify expected output and run test + actions = hot('--a-', { a: action }); + + //specify expected output and run test + const expected = cold('--(b)', { b: completion }); + expect(effects.unflagComment$).toBeObservable(expected); + }); + + it('should return hide comment success on happy path', () => { + // provide fake data that will be used in effect + const fakeComment = { id: "1"} as any; + const action = CommentActions.hideComment({ dto: { commentId: "1" } }); + const completion = CommentActions.hideCommentSuccess({ comment: fakeComment }); + + // mock 'this.comments.hide' call and return OBSERVABLE data! + jest.spyOn(comments, 'hide') + .mockImplementation((_query: any, _dto: any) => { + return of(fakeComment); + }); + + // specify expected output and run test + actions = hot('--a-', { a: action }); + + //specify expected output and run test + const expected = cold('--(b)', { b: completion }); + expect(effects.hideComment$).toBeObservable(expected); + }); + + it('should return hide comment failure on unhappy path', () => { + const action = CommentActions.hideComment({ dto: { commentId: "1" } }); + const completion = CommentActions.hideCommentError({ error: undefined as any }); + + jest.spyOn(comments, 'hide') + .mockImplementation((_query: any, _dto: any) => { + throw throwError("error"); + }); + + // specify expected output and run test + actions = hot('--a-', { a: action }); + + //specify expected output and run test + const expected = cold('--(b)', { b: completion }); + expect(effects.hideComment$).toBeObservable(expected); + }); + + it('should return unhide comment success on happy path', () => { + // provide fake data that will be used in effect + const fakeComment = { id: "1"} as any; + const action = CommentActions.unhideComment({ dto: { commentId: "1" } }); + const completion = CommentActions.unhideCommentSuccess({ comment: fakeComment }); + + // mock 'this.comments.unhide' call and return OBSERVABLE data! + jest.spyOn(comments, 'unhide') + .mockImplementation((_query: any, _dto: any) => { + return of(fakeComment); + }); + + // specify expected output and run test + actions = hot('--a-', { a: action }); + + //specify expected output and run test + const expected = cold('--(b)', { b: completion }); + expect(effects.unhideComment$).toBeObservable(expected); + }); + + it('should return unhide comment failure on unhappy path', () => { + const action = CommentActions.unhideComment({ dto: { commentId: "1" } }); + const completion = CommentActions.unhideCommentError({ error: undefined as any }); + + jest.spyOn(comments, 'unhide') + .mockImplementation((_query: any, _dto: any) => { + throw throwError("error"); + }); + + // specify expected output and run test + actions = hot('--a-', { a: action }); + + //specify expected output and run test + const expected = cold('--(b)', { b: completion }); + expect(effects.unhideComment$).toBeObservable(expected); + }); + +}); + + \ No newline at end of file diff --git a/libs/client/shared/data-access/src/lib/+state/comments/comments.effects.ts b/libs/client/shared/data-access/src/lib/+state/comments/comments.effects.ts new file mode 100644 index 00000000..77b48843 --- /dev/null +++ b/libs/client/shared/data-access/src/lib/+state/comments/comments.effects.ts @@ -0,0 +1,148 @@ +import { Injectable } from "@angular/core"; +import { StatusService } from "@involvemint/client/shared/util"; +import { Actions, createEffect, ofType } from "@ngrx/effects"; +import * as CommentsActions from './comments.actions'; +import { map, delayWhen, tap } from 'rxjs/operators'; +import { fetch, pessimisticUpdate } from "@nrwl/angular"; +import { from } from 'rxjs'; +import { CommentOrchestration } from "../../orchestrations/comment.orchestration"; +// eslint-disable-next-line @nrwl/nx/enforce-module-boundaries +import { CommentQuery } from "libs/shared/domain/src/lib/domain/comment/comment.queries"; + +@Injectable() +export class CommentEffects { + + /** Effect when loadComments is dispatched */ + readonly loadComments$ = createEffect(() => + this.actions$.pipe( + ofType(CommentsActions.loadComments), + fetch({ + run: ({ page }) => + this.comments.list({ + ...CommentQuery + }).pipe( + map((comments) => { + return CommentsActions.loadCommentsSuccess({ comments: comments, page: page}); + }) + ), + onError: (action, { error }) => { + this.status.presentNgRxActionAlert(action, error); + return CommentsActions.loadCommentsError({ error }); + } + }) + ) + ); + + /** Effects when createComment is dispatched */ + readonly createComment$ = createEffect(() => + this.actions$.pipe( + ofType(CommentsActions.createComment), + delayWhen(() => from(this.status.showLoader('Adding...'))), + pessimisticUpdate({ + run: ({ dto }) => + this.comments.create( + CommentQuery, + dto + ).pipe( + map((comment) => { + console.log('createCommentSuccess:', comment); + return CommentsActions.createCommentSuccess({ comment }); + }) + ), + onError: (action, { error }) => { + this.status.presentNgRxActionAlert(action, error) + return CommentsActions.createCommentError({ error }) + } + }), + tap(() => this.status.dismissLoader()) + ) + ); + + /** Effects when flagComment is dispatched */ + readonly flagComment$ = createEffect(() => + this.actions$.pipe( + ofType(CommentsActions.flagComment), + pessimisticUpdate({ + run: ({ dto }) => + this.comments.flag( + CommentQuery, + dto + ).pipe( + map((comment) => CommentsActions.flagCommentSuccess({ comment })) + ), + onError: (action, { error }) => { + this.status.presentNgRxActionAlert(action, error); + return CommentsActions.flagCommentError({ error }); + } + }), + ) + ); + + /** Effects when unflagComment is dispatched */ + readonly unflagComment$ = createEffect(() => + this.actions$.pipe( + ofType(CommentsActions.unflagComment), + pessimisticUpdate({ + run: ({ dto }) => + this.comments.unflag( + CommentQuery, + dto + ).pipe( + map((comment) => CommentsActions.unflagCommentSuccess({ comment })) + ), + onError: (action, { error }) => { + this.status.presentNgRxActionAlert(action, error); + return CommentsActions.unflagCommentError({ error }); + } + }), + ) + ); + + /** Effects when hideComment is dispatched */ +readonly hideComment$ = createEffect(() => + this.actions$.pipe( + ofType(CommentsActions.hideComment), + pessimisticUpdate({ + run: ({ dto }) => + this.comments.hide( + CommentQuery, + dto + ).pipe( + map((comment) => CommentsActions.hideCommentSuccess({ comment })) + ), + onError: (action, { error }) => { + this.status.presentNgRxActionAlert(action, error); + return CommentsActions.hideCommentError({ error }); + } + }), + tap(() => this.status.dismissLoader()) + ) +); + +/** Effects when unhideComment is dispatched */ +readonly unhideComment$ = createEffect(() => + this.actions$.pipe( + ofType(CommentsActions.unhideComment), + pessimisticUpdate({ + run: ({ dto }) => + this.comments.unhide( + CommentQuery, + dto + ).pipe( + map((comment) => CommentsActions.unhideCommentSuccess({ comment })) + ), + onError: (action, { error }) => { + this.status.presentNgRxActionAlert(action, error); + return CommentsActions.unhideCommentError({ error }); + } + }), + tap(() => this.status.dismissLoader()) + ) +); + + constructor( + private readonly actions$: Actions, + private readonly status: StatusService, + private readonly comments: CommentOrchestration, + ) {} +} diff --git a/libs/client/shared/data-access/src/lib/+state/comments/comments.reducer.spec.ts b/libs/client/shared/data-access/src/lib/+state/comments/comments.reducer.spec.ts new file mode 100644 index 00000000..771a518e --- /dev/null +++ b/libs/client/shared/data-access/src/lib/+state/comments/comments.reducer.spec.ts @@ -0,0 +1,129 @@ +import { initialState, commentsAdapter, CommentsReducer } from "./comments.reducer"; +import * as CommentsActions from './comments.actions'; + +describe('Comment Reducer', () => { + + beforeEach(() => { + + // reset the state to init + initialState.pagesLoaded = 0; + initialState.comments = { + entities: {}, + ids: [] + }; + + // spy methods used + jest.spyOn(commentsAdapter, 'upsertMany') + .mockImplementation((comments: any, existing: any) => { + comments.forEach((comment: any) => { + existing.ids.push(comment.id); + }); + return existing; + }); + + jest.spyOn(commentsAdapter, 'upsertOne') + .mockImplementation((comment: any, existing: any) => { + existing.ids.push(comment.id); + return existing; + }); + }); + + afterEach(() => { jest.clearAllMocks(); }) + + it('should return initial state when undefined start', () => { + const newState = CommentsReducer(undefined, CommentsActions.loadComments); + expect(newState).toEqual(initialState); + }); + + it('should return initial state when undefined start & action', () => { + const newState = CommentsReducer(undefined, { type: 'NOOP' } as any); + expect(newState).toEqual(initialState); + }); + + it('should return initial state on unknown action', () => { + const newState = CommentsReducer(initialState, { type: 'NOOP' } as any); + expect(newState).toEqual(initialState); + }); + + it('should return initial state on unregistered action', () => { + const newState = CommentsReducer( + initialState, + CommentsActions.flagComment({ dto: { commentId: "1"}}) + ); + expect(newState).toEqual(initialState); + }); + + it('should update comments & page on load comments success', () => { + const action = CommentsActions.loadCommentsSuccess({ comments: [{ id: 1 }, { id: 2 }], page: 1 } as any); + const newState = CommentsReducer(initialState, action); + expect(commentsAdapter.upsertMany).toBeCalledTimes(1); + expect(newState.pagesLoaded).toEqual(1); + expect(newState.comments.ids.length).toEqual(2); + }); + + it('should add comment on create comment success', () => { + const action = CommentsActions.createCommentSuccess({ comment: { id: 1 } } as any); + const newState = CommentsReducer(initialState, action); + expect(commentsAdapter.upsertOne).toBeCalledTimes(1); + expect(newState).toEqual({ + comments: { + entities: {}, + ids: [ 1 ] + }, + pagesLoaded: 0 + }); + }); + + it('should update comment on flag comment success', () => { + const action = CommentsActions.flagCommentSuccess({ comment: { id: 1 } } as any); + const newState = CommentsReducer(initialState, action); + expect(commentsAdapter.upsertOne).toBeCalledTimes(1); + expect(newState).toEqual({ + comments: { + entities: {}, + ids: [ 1 ] + }, + pagesLoaded: 0 + }); + }); + + it('should update comment on unflag comment success', () => { + const action = CommentsActions.unflagCommentSuccess({ comment: { id: 1 } } as any); + const newState = CommentsReducer(initialState, action); + expect(commentsAdapter.upsertOne).toBeCalledTimes(1); + expect(newState).toEqual({ + comments: { + entities: {}, + ids: [ 1 ] + }, + pagesLoaded: 0 + }); + }); + + it ('should update comment on hide comment success', () => { + const action = CommentsActions.hideCommentSuccess({ comment: { id: 1 } } as any); + const newState = CommentsReducer(initialState, action); + expect(commentsAdapter.upsertOne).toBeCalledTimes(1); + expect(newState).toEqual({ + comments: { + entities: {}, + ids: [ 1 ] + }, + pagesLoaded: 0 + }); + }); + + it ('should update comment on unhide comment success', () => { + const action = CommentsActions.unhideCommentSuccess({ comment: { id: 1 } } as any); + const newState = CommentsReducer(initialState, action); + expect(commentsAdapter.upsertOne).toBeCalledTimes(1); + expect(newState).toEqual({ + comments: { + entities: {}, + ids: [ 1 ] + }, + pagesLoaded: 0 + }); + }); + +}); \ No newline at end of file diff --git a/libs/client/shared/data-access/src/lib/+state/comments/comments.reducer.ts b/libs/client/shared/data-access/src/lib/+state/comments/comments.reducer.ts new file mode 100644 index 00000000..4a01e840 --- /dev/null +++ b/libs/client/shared/data-access/src/lib/+state/comments/comments.reducer.ts @@ -0,0 +1,91 @@ +import { createEntityAdapter, EntityState } from '@ngrx/entity'; +import { IParser } from '@orcha/common'; +import { createReducer, on } from '@ngrx/store'; +import * as CommentsActions from './comments.actions'; +import { Comment, CommentQuery } from '@involvemint/shared/domain'; + + +export const COMMENTS_KEY = 'Comments'; + +export type CommentStoreModel = IParser; + +export interface CommentsState { + comments: EntityState; + pagesLoaded: number; +} + +export const commentsAdapter = createEntityAdapter(); + +export const initialState: CommentsState = { + comments: commentsAdapter.getInitialState(), + pagesLoaded: 0, +} + +/** Defines the Comments Reducer and how state changes based on actions */ +export const CommentsReducer = createReducer( + initialState, + on( + CommentsActions.loadCommentsSuccess, + (state, { comments, page }): CommentsState => { + return { + comments: commentsAdapter.upsertMany(comments, state.comments), + pagesLoaded: page, + } + } + ), + on( + CommentsActions.createCommentSuccess, + (state, { comment }): CommentsState => { + return { + ...state, + comments: commentsAdapter.upsertOne(comment, state.comments) + } + } + ), + on( // reducer to add initial set of comments from post + CommentsActions.initComments, + (state, { comments }): CommentsState => { + state = initialState; + return { + ...state, + comments: commentsAdapter.upsertMany(comments, state.comments), + } + } + ), + on( + CommentsActions.flagCommentSuccess, + (state, { comment }): CommentsState => { + return { + ...state, + comments: commentsAdapter.upsertOne(comment, state.comments) + } + } + ), + on( + CommentsActions.unflagCommentSuccess, + (state, { comment }): CommentsState => { + return { + ...state, + comments: commentsAdapter.upsertOne(comment, state.comments) + } + } + ), + on( + CommentsActions.hideCommentSuccess, + (state, { comment }): CommentsState => { + return { + ...state, + comments: commentsAdapter.upsertOne(comment, state.comments) + } + } + ), + on( + CommentsActions.unhideCommentSuccess, + (state, { comment }): CommentsState => { + return { + ...state, + comments: commentsAdapter.upsertOne(comment, state.comments) + } + } + ), +) diff --git a/libs/client/shared/data-access/src/lib/+state/comments/comments.selectors.ts b/libs/client/shared/data-access/src/lib/+state/comments/comments.selectors.ts new file mode 100644 index 00000000..8e8dfe74 --- /dev/null +++ b/libs/client/shared/data-access/src/lib/+state/comments/comments.selectors.ts @@ -0,0 +1,19 @@ +import { createFeatureSelector, createSelector } from '@ngrx/store'; +import { COMMENTS_KEY, CommentsState, commentsAdapter } from './comments.reducer'; + +const { selectAll, selectEntities } = commentsAdapter.getSelectors(); +const getCommentsState = createFeatureSelector(COMMENTS_KEY); + +export const getComments = createSelector(getCommentsState, (state: CommentsState) => ({ + comments: selectAll(state.comments), + pagesLoaded: state.pagesLoaded, + loaded: state.pagesLoaded > 0, +})); + +export const getComment = (id: string) => + createSelector(getCommentsState, (state: CommentsState) => ({ + comment: selectEntities(state.comments)[id], + pagesLoaded: state.pagesLoaded, + loaded: state.pagesLoaded > 0, + })); + diff --git a/libs/client/shared/data-access/src/lib/+state/comments/index.ts b/libs/client/shared/data-access/src/lib/+state/comments/index.ts new file mode 100644 index 00000000..bacdeb11 --- /dev/null +++ b/libs/client/shared/data-access/src/lib/+state/comments/index.ts @@ -0,0 +1,4 @@ +export * from './comments.actions'; +export * from './comments.effects'; +export * from './comments.reducer'; +export * from './comments.selectors'; \ No newline at end of file diff --git a/libs/client/shared/data-access/src/lib/+state/session/user-session.effects.ts b/libs/client/shared/data-access/src/lib/+state/session/user-session.effects.ts index 1e759d03..455d32b1 100644 --- a/libs/client/shared/data-access/src/lib/+state/session/user-session.effects.ts +++ b/libs/client/shared/data-access/src/lib/+state/session/user-session.effects.ts @@ -33,7 +33,7 @@ export class UserSessionEffects { this.user.login({ token: true }, { id, password }).pipe( tap(({ token }) => ImAuthTokenStorage.setValue({ id, token })), map(({ token }) => { - this.route.to.ROOT(); + this.route.to.activityfeed.ROOT(); this.status.dismissLoader(); ImAuthTokenStorage.setValue({ id, token }); return UserSessionActions.userLoginSuccess({ id, token }); @@ -109,7 +109,7 @@ export class UserSessionEffects { this.user.signUp({ token: true }, dto).pipe( map(({ token }) => { ImAuthTokenStorage.setValue({ id: dto.id, token }); - if (environment.production) { + if (environment.production || environment.test) { (async () => { const modal = await this.infoModal.open({ title: 'Check your email', @@ -121,7 +121,7 @@ export class UserSessionEffects { await this.route.to.login.ROOT(); })(); } else { - this.route.to.ROOT(); + this.route.to.activityfeed.ROOT(); } return UserSessionActions.userSignUpSuccess({ token }); }) diff --git a/libs/client/shared/data-access/src/lib/+state/session/user-session.reducer.ts b/libs/client/shared/data-access/src/lib/+state/session/user-session.reducer.ts index 420fb7d9..3fd47c85 100644 --- a/libs/client/shared/data-access/src/lib/+state/session/user-session.reducer.ts +++ b/libs/client/shared/data-access/src/lib/+state/session/user-session.reducer.ts @@ -50,6 +50,7 @@ const initialState: UserSessionState = { loadingRoute: '', joyride: false, baAdmin: false, + dateLastLoggedIn: "", }; export const UserSessionReducer = createReducer( diff --git a/libs/client/shared/data-access/src/lib/+state/user.facade.ts b/libs/client/shared/data-access/src/lib/+state/user.facade.ts index 6d9368b1..a2ffa2a3 100644 --- a/libs/client/shared/data-access/src/lib/+state/user.facade.ts +++ b/libs/client/shared/data-access/src/lib/+state/user.facade.ts @@ -2,27 +2,37 @@ import { Injectable } from '@angular/core'; import { RouteService } from '@involvemint/client/shared/routes'; import { ChangePasswordDto, + CreateActivityPostDto, CreateChangeMakerProfileDto, + CreateCommentDto, DeleteEpImageDto, DeleteOfferImageDto, DeleteRequestImageDto, DeleteSpImageDto, + DisplayCommentsDto, EditCmProfileDto, EditEpProfileDto, EditSpProfileDto, + FlagCommentDto, + GetActivityPostDto, GetSuperAdminForExchangePartnerDto, + HideCommentDto, ImConfig, + LikeActivityPostDto, SignUpDto, SubmitEpApplicationDto, SubmitSpApplicationDto, TransactionDto, + UnflagCommentDto, + UnhideCommentDto, + UnlikeActivityPostDto, UpdateOfferDto, UpdateRequestDto, WalletTabs, } from '@involvemint/shared/domain'; import { UnArray } from '@involvemint/shared/util'; import { Actions, ofType } from '@ngrx/effects'; -import { Store } from '@ngrx/store'; +import { select, Store } from '@ngrx/store'; import { debounceTime, distinctUntilChanged, filter, map, switchMap, take, tap } from 'rxjs/operators'; import * as CreditsActions from './credits/credits.actions'; import * as CreditsSelectors from './credits/credits.selectors'; @@ -54,6 +64,12 @@ import * as TransactionsActions from './transactions/transactions.actions'; import * as TransactionsSelectors from './transactions/transactions.selectors'; import * as VouchersActions from './vouchers/vouchers.actions'; import * as VouchersSelectors from './vouchers/vouchers.selectors'; +import * as PostActions from './activity-posts/activity-posts.actions'; +import * as PostSelectors from './activity-posts/activity-posts.selectors'; +import * as CommentSelectors from './comments/comments.selectors'; +import * as CommentActions from './comments/comments.actions'; +import { CommentStoreModel } from './comments/comments.reducer'; +import { PostStoreModel } from './activity-posts'; @Injectable() export class UserFacade { @@ -487,7 +503,7 @@ export class UserFacade { * @param returnToEpStorefront True to route to EP Storefront route, False to route to marketplace * route after create */ - create: (returnToEpStorefront: boolean = false) => { + create: (returnToEpStorefront = false) => { this.store.dispatch(OffersActions.createOffer({ returnToEpStorefront })); }, update: (dto: UpdateOfferDto) => { @@ -497,7 +513,7 @@ export class UserFacade { * @param returnToEpStorefront True to route back to EP Storefront route, False to route back to * marketplace route after delete */ - delete: (offer: OfferStoreModel, returnToEpStorefront: boolean = false) => { + delete: (offer: OfferStoreModel, returnToEpStorefront = false) => { this.store.dispatch(OffersActions.deleteOffer({ offer, returnToEpStorefront })); }, uploadImages: (offer: OfferStoreModel, images: File[]) => { @@ -549,7 +565,7 @@ export class UserFacade { * @param returnToEpStorefront True to route to EP Storefront route, False to route to marketplace * route after create */ - create: (returnToEpStorefront: boolean = false) => { + create: (returnToEpStorefront = false) => { this.store.dispatch(RequestsActions.createRequest({ returnToEpStorefront })); }, update: (dto: UpdateRequestDto) => { @@ -559,7 +575,7 @@ export class UserFacade { * @param returnToEpStorefront True to route back to EP Storefront route, False to route back to * marketplace route after delete */ - delete: (request: RequestStoreModel, returnToEpStorefront: boolean = false) => { + delete: (request: RequestStoreModel, returnToEpStorefront = false) => { this.store.dispatch(RequestsActions.deleteRequest({ request, returnToEpStorefront })); }, uploadImages: (request: RequestStoreModel, images: File[]) => { @@ -636,6 +652,98 @@ export class UserFacade { }, }; + readonly posts = { + dispatchers: { + loadPosts: () => { + this.store.pipe(select(PostSelectors.getPosts), take(1)).subscribe((state) => { + this.store.dispatch(PostActions.loadPosts({ page: state.pagesLoaded + 1, limit: state.limit })); + }); + }, + get: (dto: GetActivityPostDto) => { + this.store.dispatch(PostActions.getPost({ dto })); + }, + create: (dto: CreateActivityPostDto) => { + this.store.dispatch(PostActions.createPost({ dto })); + }, + like: (dto: LikeActivityPostDto) => { + this.store.dispatch(PostActions.like({ dto })); + }, + unlike: (dto: UnlikeActivityPostDto) => { + this.store.dispatch(PostActions.unlike({ dto })); + }, + }, + selectors: { + posts$: this.store.pipe(select(PostSelectors.getPosts)).pipe( + tap(({ loaded, limit }) => { + if (!loaded) { + this.store.dispatch(PostActions.loadPosts({ page: 1, limit })); + } + }) + ), + getPost: (postId: string) => + this.store.pipe(select(PostSelectors.selectPost(postId))).pipe( + tap(({ loaded, limit }) => { + if (!loaded) { + this.store.dispatch(PostActions.loadPosts({ page: 1, limit })); + } + }) + ), + }, + actionListeners: { + loadPosts: { + success: this.actions$.pipe(ofType(PostActions.loadPostsSuccess)), + error: this.actions$.pipe(ofType(PostActions.loadPostsError)), + }, + } + } + + readonly comments = { + dispatchers: { + loadComments: () => { + this.store.pipe(select(CommentSelectors.getComments), take(1)).subscribe((state) => { + this.store.dispatch(CommentActions.loadComments({ page: state.pagesLoaded + 1})); + }); + }, + initComments: (comments: CommentStoreModel[]) => { + this.store.pipe(select(CommentSelectors.getComments), take(1)).subscribe((_state) => { + this.store.dispatch(CommentActions.initComments({ comments: comments})); + }) + }, + createComment: (dto: CreateCommentDto) => { + this.store.dispatch(CommentActions.createComment({ dto })); + }, + flagComment: (dto: FlagCommentDto) => { + this.store.dispatch(CommentActions.flagComment({ dto })); + }, + unflagComment: (dto: UnflagCommentDto) => { + this.store.dispatch(CommentActions.unflagComment({ dto })); + }, + hideComment: (dto: HideCommentDto) => { + this.store.dispatch(CommentActions.hideComment({ dto })); + }, + unhideComment: (dto: UnhideCommentDto) => { + this.store.dispatch(CommentActions.unhideComment({ dto })); + }, + }, + selectors: { + comments$: this.store.select(CommentSelectors.getComments), + getComment: (commentId: string) => + this.store.pipe(select(CommentSelectors.getComment(commentId))).pipe( + tap(({ loaded }) => { + if (!loaded) { + this.store.dispatch(CommentActions.loadComments({ page: 1 })); + } + }) + ), + }, + actionListeners: { + loads: { + success: this.actions$.pipe(ofType(CommentActions.loadCommentsSuccess)), + error: this.actions$.pipe(ofType(CommentActions.loadCommentsError)), + } + } + } + constructor( private readonly store: Store, private readonly actions$: Actions, diff --git a/libs/client/shared/data-access/src/lib/auth.interceptor.ts b/libs/client/shared/data-access/src/lib/auth.interceptor.ts index aca314e6..5372a7dc 100644 --- a/libs/client/shared/data-access/src/lib/auth.interceptor.ts +++ b/libs/client/shared/data-access/src/lib/auth.interceptor.ts @@ -23,7 +23,7 @@ export class AuthInterceptor implements OrchaInterceptor { next .handle(authReq) // Simulate HTTP delay for development. - .pipe(environment.production ? tap() : delay(Math.floor(Math.random() * (400 - 50 + 1) + 50))) + .pipe(environment.production || environment.test ? tap() : delay(Math.floor(Math.random() * (400 - 50 + 1) + 50))) ); } } diff --git a/libs/client/shared/data-access/src/lib/chat.service.ts b/libs/client/shared/data-access/src/lib/chat.service.ts index 02422405..4e69c2b9 100644 --- a/libs/client/shared/data-access/src/lib/chat.service.ts +++ b/libs/client/shared/data-access/src/lib/chat.service.ts @@ -95,9 +95,10 @@ export class ChatService extends StatefulComponent { const myRooms = await _myRooms.get().pipe(take(1)).toPromise(); + const wantedMembers = [...members.map(m => m.handleId), me.handle.id].sort() const existingRooms = myRooms.docs .map((d) => d.data() as ChatRoom) - .filter((r) => lodash.isEqual(r.members, [...members.map((member) => member.handleId), me.handle.id])); + .filter((r) => lodash.isEqual(wantedMembers, r.memberHandles.sort())) if (existingRooms.length > 0) { const alreadyExistingRoom = existingRooms[0]; diff --git a/libs/client/shared/data-access/src/lib/client-shared-data-access.module.ts b/libs/client/shared/data-access/src/lib/client-shared-data-access.module.ts index d5c13b99..72e50c17 100644 --- a/libs/client/shared/data-access/src/lib/client-shared-data-access.module.ts +++ b/libs/client/shared/data-access/src/lib/client-shared-data-access.module.ts @@ -5,6 +5,7 @@ import { IonicModule } from '@ionic/angular'; import { EffectsModule } from '@ngrx/effects'; import { StoreModule } from '@ngrx/store'; import { OrchaModule } from '@orcha/angular'; +import { PostEffects, PostsReducer, POSTS_KEY } from './+state/activity-posts'; import { CreditsEffects } from './+state/credits/credits.effects'; import { CreditReducer, CREDITS_KEY } from './+state/credits/credits.reducer'; import { ProjectsEffects } from './+state/market/market.effects'; @@ -31,8 +32,10 @@ import { EpApplicationGateway } from './gateways/ep-application.gateway'; import { SpApplicationGateway } from './gateways/sp-application.gateway'; import { ImProfileSelectModalModule } from './modals/im-profile-select-modal/im-profile-select-modal.module'; import { + ActivityPostOrchestration, ChangeMakerOrchestration, ChatOrchestration, + CommentOrchestration, CreditOrchestration, EnrollmentOrchestration, EpApplicationOrchestration, @@ -52,6 +55,8 @@ import { PoiOrchestration } from './orchestrations/poi.orchestration'; import { RequestOrchestration } from './orchestrations/request.orchestration'; import { ServePartnerOrchestration } from './orchestrations/serve-partner.orchestration'; import { StorageOrchestration } from './orchestrations/storage.orchestration'; +import { COMMENTS_KEY, CommentsReducer } from './+state/comments/comments.reducer'; +import { CommentEffects } from './+state/comments/comments.effects'; @NgModule({ imports: [ @@ -63,28 +68,35 @@ import { StorageOrchestration } from './orchestrations/storage.orchestration'; StoreModule.forFeature(CREDITS_KEY, CreditReducer), StoreModule.forFeature(MARKET_KEY, MarketReducer), StoreModule.forFeature(OFFERS_KEY, OfferReducer), + StoreModule.forFeature(POSTS_KEY, PostsReducer), StoreModule.forFeature(PROJECTS_KEY, ProjectsReducer), StoreModule.forFeature(REQUESTS_KEY, RequestReducer), StoreModule.forFeature(TRANSACTIONS_KEY, TransactionsReducer), StoreModule.forFeature(USER_SESSION_KEY, UserSessionReducer), StoreModule.forFeature(VOUCHERS_KEY, VoucherReducer), + StoreModule.forFeature(COMMENTS_KEY, CommentsReducer), EffectsModule.forFeature([ CmProfileEffects, CreditsEffects, + CommentEffects, EpProfileEffects, MarketEffects, OffersEffects, + PostEffects, ProjectsEffects, RequestsEffects, SpProfileEffects, TransactionsEffects, UserSessionEffects, VouchersEffects, + CommentEffects, ]), OrchaModule.forFeature({ orchestrations: [ + ActivityPostOrchestration, ChangeMakerOrchestration, ChatOrchestration, + CommentOrchestration, CreditOrchestration, EnrollmentOrchestration, EpApplicationOrchestration, diff --git a/libs/client/shared/data-access/src/lib/comment.service.ts b/libs/client/shared/data-access/src/lib/comment.service.ts new file mode 100644 index 00000000..d8f79213 --- /dev/null +++ b/libs/client/shared/data-access/src/lib/comment.service.ts @@ -0,0 +1,53 @@ +import { Injectable } from '@angular/core'; +import { AngularFirestore } from '@angular/fire/firestore'; +import { RouteService } from '@involvemint/client/shared/routes'; +import { StatefulComponent, StatusService } from '@involvemint/client/shared/util'; +import { + Comments, + FormattedComments, +} from '@involvemint/shared/domain'; +import { compareDesc } from 'date-fns'; +import firebase from 'firebase/app'; +import lodash from 'lodash'; +import { combineLatest } from 'rxjs'; +import { map, switchMap, take, tap } from 'rxjs/operators'; +import * as uuid from 'uuid'; +import { ActiveProfile } from './+state/session/user-session.reducer'; +import { UserFacade } from './+state/user.facade'; + +interface State { + poiComments: FormattedComments[]; +} + +@Injectable({ providedIn: 'root' }) +export class CommentService extends StatefulComponent { + private readonly collection = this.afs.collection('comments'); + + readonly store$ = this.state$; + + constructor( + private readonly afs: AngularFirestore, + private readonly uf: UserFacade, + private readonly route: RouteService, + private readonly status: StatusService, + ) { + super({ poiComments: [] }); + } + + async upsert() { + /* create chat room */ + const data: Comments = { + id: uuid.v4(), + comments: [] + }; + + await this.collection.doc(data.id).set(data); + return this.goToComments(data.id); + } + + async goToComments(postId: string) { + console.log('goToComments', postId); + console.log('goToComments2', this.route.to.comments.THREAD(postId)); + return this.route.to.comments.THREAD(postId); + } +} \ No newline at end of file diff --git a/libs/client/shared/data-access/src/lib/orchestrations/activity-post.orchestration.ts b/libs/client/shared/data-access/src/lib/orchestrations/activity-post.orchestration.ts new file mode 100644 index 00000000..afe80bdf --- /dev/null +++ b/libs/client/shared/data-access/src/lib/orchestrations/activity-post.orchestration.ts @@ -0,0 +1,30 @@ +import { ClientOperation, ClientOrchestration, IClientOrchestration } from "@orcha/angular"; +import { InvolvemintOrchestrations, IActivityPostOrchestration } from '@involvemint/shared/domain'; + + +@ClientOrchestration(InvolvemintOrchestrations.activityPost) +export class ActivityPostOrchestration implements IClientOrchestration { + @ClientOperation() + list!: IClientOrchestration['list']; + + @ClientOperation() + get!: IClientOrchestration['get']; + + @ClientOperation() + create!: IClientOrchestration['create']; + + @ClientOperation() + like!: IClientOrchestration['like']; + + @ClientOperation() + unlike!: IClientOrchestration['unlike']; + + @ClientOperation() + enable!: IClientOrchestration['enable']; + + @ClientOperation() + disable!: IClientOrchestration['disable']; + + @ClientOperation() + digest!: IClientOrchestration['digest']; +} \ No newline at end of file diff --git a/libs/client/shared/data-access/src/lib/orchestrations/comment.orchestration.ts b/libs/client/shared/data-access/src/lib/orchestrations/comment.orchestration.ts new file mode 100644 index 00000000..436abdfa --- /dev/null +++ b/libs/client/shared/data-access/src/lib/orchestrations/comment.orchestration.ts @@ -0,0 +1,23 @@ +import { ClientOperation, ClientOrchestration, IClientOrchestration } from '@orcha/angular'; +import { InvolvemintOrchestrations, ICommentOrchestration } from '@involvemint/shared/domain'; + +@ClientOrchestration(InvolvemintOrchestrations.comment) +export class CommentOrchestration implements IClientOrchestration { + @ClientOperation() + list!: IClientOrchestration['list']; + + @ClientOperation() + create!: IClientOrchestration['create']; + + @ClientOperation() + hide!: IClientOrchestration['hide']; + + @ClientOperation() + unhide!: IClientOrchestration['unhide']; + + @ClientOperation() + flag!: IClientOrchestration['flag']; + + @ClientOperation() + unflag!: IClientOrchestration['unflag']; +} diff --git a/libs/client/shared/data-access/src/lib/orchestrations/index.ts b/libs/client/shared/data-access/src/lib/orchestrations/index.ts index d30c5de9..e41023e6 100644 --- a/libs/client/shared/data-access/src/lib/orchestrations/index.ts +++ b/libs/client/shared/data-access/src/lib/orchestrations/index.ts @@ -1,5 +1,7 @@ +export * from './activity-post.orchestration'; export * from './change-maker.orchestration'; export * from './chat.orchestration'; +export * from './comment.orchestration'; export * from './credit.orchestration'; export * from './enrollment.orchestration'; export * from './ep-application.orchestration'; diff --git a/libs/client/shared/routes/src/lib/route.service.ts b/libs/client/shared/routes/src/lib/route.service.ts index 2f27bed3..2f106805 100644 --- a/libs/client/shared/routes/src/lib/route.service.ts +++ b/libs/client/shared/routes/src/lib/route.service.ts @@ -90,8 +90,10 @@ export class RouteService extends RxJSBaseClass { break; } } + console.log('3', obj); return obj as unknown as Routes; }; + console.log('4'); return traverse({ ...cloneDeep(this._routes.path), ROOT: '' }); } diff --git a/libs/client/shared/scss/src/lib/_index.scss b/libs/client/shared/scss/src/lib/_index.scss index 8bef0580..0764e05c 100644 --- a/libs/client/shared/scss/src/lib/_index.scss +++ b/libs/client/shared/scss/src/lib/_index.scss @@ -176,6 +176,14 @@ ion-back-button { background-color: var(--im-content-background); box-shadow: var(--im-border-color) 0px 0px 7px -2px; + &.feed { + box-sizing: border-box; + border: 2px solid var(--im-text-color); + filter: drop-shadow(0px 12px 36px rgba(0, 0, 0, 0.18)); + height: 36px; + width: 37.03px; + } + &.large { height: 75px; width: 75px; diff --git a/libs/client/shared/scss/src/lib/_vars.scss b/libs/client/shared/scss/src/lib/_vars.scss index bd7b1f5a..a41d74e4 100644 --- a/libs/client/shared/scss/src/lib/_vars.scss +++ b/libs/client/shared/scss/src/lib/_vars.scss @@ -119,6 +119,8 @@ --im-voucher-refunded: var(--im-red); --im-voucher-redeemed: var(--im-green); + --im-comment-background: #f4f5f8; + /* ___ _ ___ _ |_ _|___ _ _ (_)__ / __|___| |___ _ _ ___ @@ -256,6 +258,8 @@ --im-logo-text-color: #ffffff; + --im-comment-background: #4a4a4a; + ion-button.button-solid { --background: linear-gradient( 0deg, diff --git a/libs/client/shared/util/src/lib/status.service.ts b/libs/client/shared/util/src/lib/status.service.ts index 5bb87519..b8743585 100644 --- a/libs/client/shared/util/src/lib/status.service.ts +++ b/libs/client/shared/util/src/lib/status.service.ts @@ -15,7 +15,7 @@ export class StatusService { constructor(public loadingController: LoadingController, private alertController: AlertController) {} - async showLoader(message: string = 'Loading...'): Promise { + async showLoader(message = 'Loading...'): Promise { this.loaderCount++; if (this.loaderCount === 1) { let topLoader = await this.loadingController.getTop(); @@ -100,7 +100,7 @@ export class StatusService { /** * Presents a window with a checkmark indicating something successful has occurred. */ - async presentSuccess(message: string = 'Success!'): Promise { + async presentSuccess(message = 'Success!'): Promise { const loading = await this.loadingController.create({ message, duration: 2000, diff --git a/libs/client/shell/src/lib/activityfeed/activityfeed.module.ts b/libs/client/shell/src/lib/activityfeed/activityfeed.module.ts new file mode 100644 index 00000000..5e7c4b11 --- /dev/null +++ b/libs/client/shell/src/lib/activityfeed/activityfeed.module.ts @@ -0,0 +1,39 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { ReactiveFormsModule } from '@angular/forms'; +import { RouterModule } from '@angular/router'; +import { + ImagesViewerModalModule, + ImBlockModule, + ImStorageUrlPipeModule, +} from '@involvemint/client/shared/data-access'; +import { ImFormsModule, ImImageModule } from '@involvemint/client/shared/ui'; +import { IonicModule } from '@ionic/angular'; +import { ActivityFeedComponent } from './activityposts/activityposts.component'; +import { ModalDigestComponent } from './activityposts/modal-digest/modal-digest.component'; +import { PostComponent } from './activityposts/post/post.component'; +import { ModalPostComponent } from './activityposts/modal-post/modal-post.component'; +import { ModalCommentComponent } from './activityposts/comments/modal-comments.component'; + +@NgModule({ + declarations: [PostComponent, ActivityFeedComponent, ModalDigestComponent, ModalPostComponent, ModalCommentComponent], + imports: [ + CommonModule, + FormsModule, + IonicModule, + ImBlockModule, + ImFormsModule, + ReactiveFormsModule, + ImImageModule, + ImStorageUrlPipeModule, + ImagesViewerModalModule, + RouterModule.forChild([ + { + path: '', + component: ActivityFeedComponent, + } + ]), + ], +}) +export class ActivityFeedModule {} diff --git a/libs/client/shell/src/lib/activityfeed/activityposts/activityposts.component.html b/libs/client/shell/src/lib/activityfeed/activityposts/activityposts.component.html new file mode 100644 index 00000000..c4e6b967 --- /dev/null +++ b/libs/client/shell/src/lib/activityfeed/activityposts/activityposts.component.html @@ -0,0 +1,38 @@ + + + + + + + Activity + + + + {{state.digestPosts.length}} + + + + + + + +
+
+
There are no activity posts.
+
+ + + + + + + +
+
+
diff --git a/libs/client/shell/src/lib/activityfeed/activityposts/activityposts.component.scss b/libs/client/shell/src/lib/activityfeed/activityposts/activityposts.component.scss new file mode 100644 index 00000000..410e6fb8 --- /dev/null +++ b/libs/client/shell/src/lib/activityfeed/activityposts/activityposts.component.scss @@ -0,0 +1,14 @@ +.timeline-details { + display: grid; +} + +.timeline-detail-item { + align-items: flex-start; + display: flex; + flex-direction: column; + grid-template-columns: auto 1fr; + + ion-icon { + font-size: 1.5em; + } +} diff --git a/libs/client/shell/src/lib/activityfeed/activityposts/activityposts.component.spec.ts b/libs/client/shell/src/lib/activityfeed/activityposts/activityposts.component.spec.ts new file mode 100644 index 00000000..c9b3a597 --- /dev/null +++ b/libs/client/shell/src/lib/activityfeed/activityposts/activityposts.component.spec.ts @@ -0,0 +1,58 @@ +/** + * The code is a test suite for the Activity Posts component using ngneat/spectator testing + * framework. + * The test suite includes a beforeEach() function that creates an instance of Activity Post component + * using the createComponentFactory() method from ngneat/spectator. The createComponentFactory() + * method creates a factory function that creates a Activity Post component instance with the specified dependencies, + * and providers property is used to provide UserFacade service. + * The it() function tests whether the component is created or not by checking the toBeTruthy() function. + * The test suite also includes an assertion that is intended to fail to show how a failed test looks like. + +Note that the fdescribe() function is used to run only this test suite while excluding others. + */ +import { createComponentFactory, Spectator } from '@ngneat/spectator'; +import { ActivityFeedComponent } from './activityposts.component'; +import { RouteService } from '@involvemint/client/shared/routes'; +import { IonicModule } from '@ionic/angular'; +import { CommentService, ImBlockModule, ImStorageUrlPipeModule, UserFacade } from '@involvemint/client/shared/data-access'; +import { ChangeMakerFacade, EnrollmentsModalService } from '@involvemint/client/cm/data-access'; +import { of } from 'rxjs'; + +fdescribe('Activity Posts Component', () => { + let userFacade: UserFacade; + let spectator: Spectator; + + const createComponent = createComponentFactory( + { + component: ActivityFeedComponent, + imports: [IonicModule.forRoot(), ImBlockModule, ImStorageUrlPipeModule], + mocks: [ + RouteService, + ChangeMakerFacade, + EnrollmentsModalService, + UserFacade, + CommentService + ] + + }); + + beforeEach(() => { + userFacade = { + posts: { + selectors: { + posts$: of([]) + } + } + } as any; + spectator = createComponent({ + providers: [ + { provide: UserFacade, useValue: userFacade }] + }); + + }); + it('should create', () => { + + expect(spectator.component).toBeTruthy(); + // expect(true).toEqual(false); + }); +}); diff --git a/libs/client/shell/src/lib/activityfeed/activityposts/activityposts.component.ts b/libs/client/shell/src/lib/activityfeed/activityposts/activityposts.component.ts new file mode 100644 index 00000000..e611d26f --- /dev/null +++ b/libs/client/shell/src/lib/activityfeed/activityposts/activityposts.component.ts @@ -0,0 +1,155 @@ +import { ChangeDetectionStrategy, Component, OnInit, ViewChild } from '@angular/core'; +import { ChangeMakerFacade } from '@involvemint/client/cm/data-access'; +import { UserFacade, PostStoreModel, ActivityPostOrchestration } from '@involvemint/client/shared/data-access'; +import { StatefulComponent } from '@involvemint/client/shared/util'; +import { ActivityPostQuery, PoiStatus } from '@involvemint/shared/domain'; +import { parseDate } from '@involvemint/shared/util'; +import { IonInfiniteScroll } from '@ionic/angular'; +import { ModalController } from '@ionic/angular'; +import { compareDesc } from 'date-fns'; +import { tap } from 'rxjs/operators'; +import { CLOSED, ModalDigestComponent } from './modal-digest/modal-digest.component'; + + +/** + * Defining a 'State' interface for an Angular component and extending the + * 'StatefulComponent' allows the component to know what the state being tracked + * is and dynamically update/re-render the component when the state is updated. + * + * Activity Posts Component state tracks the currently loaded Activity Posts, + * the digest posts (notifications), and if the state has been loaded. + */ +interface State { + posts: Array; + digestPosts: Array; + loaded: boolean; +} + +/** + * Activity Posts Component. + * + * The primary component which works in conjunction with activityposts.component.html to + * provide the Activity Post Feed view. It is a stateful component (see state interface above), + * which tracks the Activity Posts loaded in the state manager using the posts$ selector + * view. It provides infiniteScrolling ability to users that will continuously fetch and display + * more posts as users scroll down until. This component handles mostly tracking/re-rending of post + * data and user requests, the rendering of individual posts data is handled mostly by post.component. + */ +@Component({ + selector: 'involvemint-activityposts', + templateUrl: './activityposts.component.html', + styleUrls: ['./activityposts.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ActivityFeedComponent extends StatefulComponent implements OnInit { + @ViewChild(IonInfiniteScroll) infiniteScroll!: IonInfiniteScroll; + loading = false; + allPagesLoaded = false; + event: any = null; + + get PoiStatus() { + return PoiStatus; + } + + constructor( + private readonly post: ActivityPostOrchestration, + private readonly user: UserFacade, + private readonly viewDigestModal: ModalController, + ) { + super({ posts: [], digestPosts: [], loaded: false }); + } + + ngOnInit(): void { + + this.effect(() => + this.user.posts.selectors.posts$.pipe( + tap(({ posts, loaded, allPagesLoaded }) => { + this.completeLoad(allPagesLoaded); + this.updateState({ + posts: posts + .sort((a, b) => + compareDesc(parseDate(a.dateCreated ?? new Date()), parseDate(b.dateCreated ?? new Date())) + ) + .map((post) => ({ + ...post + })), + loaded: loaded + }); + }) + ) + ); + + /** + * Register a new effect which loads the notifications for a user in the background. + * Add values to the state. + * This tests if it will work at all. + */ + this.user.session.selectors.state$.subscribe( + session => { + const weekAgo = new Date(); + weekAgo.setDate(weekAgo.getDate() - 7); + const lastLoggedIn = session.dateLastLoggedIn ? new Date(session.dateLastLoggedIn) : weekAgo; + const startDate = (lastLoggedIn > weekAgo ? lastLoggedIn : weekAgo).toISOString(); + this.post.digest(ActivityPostQuery, { startDate }).subscribe( + posts => { + this.updateState({ + digestPosts: posts + }) + } + ) + } + ) + + } + + /** Used on updates to state data to check if infiniteScroll reached end */ + completeLoad(allPagesLoaded: boolean): void { + this.allPagesLoaded = allPagesLoaded; + if (this.loading && this.event) { + this.event.target.complete(); + this.loading = false; + } + if (this.infiniteScroll) { + this.infiniteScroll.disabled = allPagesLoaded; + } + } + + /** Used by infiniteScroll to issue request to load more posts */ + loadMore(event: Event) { + if (!this.allPagesLoaded) { + this.user.posts.dispatchers.loadPosts(); + this.loading = true; + this.event = event; + } else { + this.event = event; + this.event.target.complete(); + } + } + + /** Used to track posts and prevent excessive re-rendering */ + trackPost(_index: number, post: PostStoreModel) { + return post.id; + } + + /** + * Opens the notification digest modal (modal-digest.component.ts) and waits. + * Updates the digest posts based on posts returned from modal. + */ + async viewDigest() { + const modal = await this.viewDigestModal.create({ + component: ModalDigestComponent, + componentProps: { + 'digestPosts': this.state.digestPosts + } + }); + + await modal.present(); + const { data, role } = await modal.onWillDismiss(); + + if (role === CLOSED) { + this.updateState({ + digestPosts: data + }); + } + } +} diff --git a/libs/client/shell/src/lib/activityfeed/activityposts/comments/modal-comments.component.html b/libs/client/shell/src/lib/activityfeed/activityposts/comments/modal-comments.component.html new file mode 100644 index 00000000..e6710f9f --- /dev/null +++ b/libs/client/shell/src/lib/activityfeed/activityposts/comments/modal-comments.component.html @@ -0,0 +1,69 @@ + + + + + + Comments + + + + + + + + + + + + No comments yet. + + + Be the first! + + + + +
+
+
+
+ + + {{ comment.name }} + + {{ comment.dateCreated | date: 'longDate' }} + + @{{ comment.handleId }} + {{ comment.text }} + This comment has been removed because it goes against community guidelines. + +
+
+
+
+ + + + + + + Post + + + + +
\ No newline at end of file diff --git a/libs/client/shell/src/lib/activityfeed/activityposts/comments/modal-comments.component.scss b/libs/client/shell/src/lib/activityfeed/activityposts/comments/modal-comments.component.scss new file mode 100644 index 00000000..29581c6d --- /dev/null +++ b/libs/client/shell/src/lib/activityfeed/activityposts/comments/modal-comments.component.scss @@ -0,0 +1,98 @@ +.comment-container { + display: flex; + flex-direction: row; + align-items: flex-start; + flex: 1; + padding-right: 10px; + --ion-item-background: var(--ion-toolbar-background) +} + +.comment { + flex-direction: column; + padding: 12px; + margin-top: 12px; + margin-bottom: 12px; + border-radius: 25px; + background: rgb(245, 245, 245); +} + +.date { + font-family: 'Inter'; + font-style: normal; + font-weight: 400; + font-size: 12px; + margin-top: +2px; + padding-left: 2px; + padding-right: 2px; +} + +.empty-comments { + align-items: center; + display: flex; + justify-content: center; + transform: translateY(175px); +} + +.handle { + font-family: 'Inter'; + font-style: normal; + font-weight: 400; + font-size: 12px; + padding-bottom: 4px; + text-decoration-line: underline; + background: var(--im-comment-background); +} + +.information-circle { + display: grid; + width: 13.68px !important; + height: 13.68px !important; + grid-template-columns: auto 1fr; + padding-left: 3px; + padding-right: 2px; + cursor: pointer; +} + +.member-handle { + align-self: flex-start; + padding-bottom: 8px; + padding-top: 8px; +} + +.name { + color: var(--im-green); +} + +.name-info-handle { + align-items: center; + display: flex; + font-size: small; + padding: 0px; + background: var(--im-comment-background); +} + +.ion-text-wrap { + color: var(--ion-text-color); + font-family: 'Inter'; + font-style: normal; + font-weight: 400; + font-size: 12px; + word-break: break-word; + hyphens: auto; + cursor: pointer; +} + +.ion-text-wrap.removed { + color: #ababab; + font-style: italic; +} + +.comment-input { + color: var(--ion-text-color); + background: var(--im-input-background); +} + +.modal-content { + --ion-item-background: var(--ion-toolbar-background); +} + \ No newline at end of file diff --git a/libs/client/shell/src/lib/activityfeed/activityposts/comments/modal-comments.component.spec.ts b/libs/client/shell/src/lib/activityfeed/activityposts/comments/modal-comments.component.spec.ts new file mode 100644 index 00000000..e69de29b diff --git a/libs/client/shell/src/lib/activityfeed/activityposts/comments/modal-comments.component.ts b/libs/client/shell/src/lib/activityfeed/activityposts/comments/modal-comments.component.ts new file mode 100644 index 00000000..2ac4a295 --- /dev/null +++ b/libs/client/shell/src/lib/activityfeed/activityposts/comments/modal-comments.component.ts @@ -0,0 +1,192 @@ +import { ChangeDetectionStrategy, Component, Input, OnInit, ViewChild } from '@angular/core'; +import { ImViewProfileModalService, PostStoreModel, UserFacade } from '@involvemint/client/shared/data-access'; +import { map, tap } from 'rxjs/operators'; + +import { AlertController, IonContent, ModalController, ActionSheetController } from '@ionic/angular'; +import { Observable } from 'rxjs'; +import { CommentStoreModel } from 'libs/client/shared/data-access/src/lib/+state/comments/comments.reducer'; +import { StatefulComponent } from '@involvemint/client/shared/util'; +import { compareDesc } from 'date-fns'; +import { parseDate } from '@involvemint/shared/util'; +import BadWords from 'bad-words'; + +const errorHeader = 'Error'; +const flagHeader = 'You flagged this comment!'; +const isAgainstCommunityGuidelinesErrorMessage = 'This comment goes against our community guidelines.'; +const flagMessage = "Thank you for flagging this comment. We will review it to see if it goes against our community guidelines and take action if neccesary." + +interface State { + comments: Array; + loaded: boolean; +} + +@Component({ + selector: 'app-modal-comments', + templateUrl: 'modal-comments.component.html', + styleUrls: ['./modal-comments.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ModalCommentComponent extends StatefulComponent implements OnInit { + @ViewChild(IonContent) content!: IonContent; + @ViewChild('popover') popover: any; + @Input() post!: PostStoreModel; + isOpen!: boolean; + msg!: string; + profilePicFilePath!: string; + name!: string; + handleID!: string; + + constructor( + private actionSheetController: ActionSheetController, + private alertController: AlertController, + private modalCtrl: ModalController, + private user: UserFacade, + private readonly viewProfileModal: ImViewProfileModalService, + ) { + super({ comments: [], loaded: true }); + } + + ngOnInit() { + this.user.comments.dispatchers.initComments(this.post.comments); + this.effect(() => + this.user.comments.selectors.comments$.pipe( + tap(({ comments }) => + this.updateState({ + comments: comments + .sort((a, b) => + compareDesc(parseDate(a.dateCreated ?? new Date()), parseDate(b.dateCreated ?? new Date())) + ) + }) + ) + ) + ); + + this.isOpen = false; + this.msg = ''; + this.handleID = this.getHandleID(); + this.name = this.getName(); + this.profilePicFilePath = this.getProfilePic(); + } + + comment() { + const badWords = new BadWords(); + const containsBadWord = badWords.isProfane(this.msg); + if (!(this.msg === '') && !containsBadWord) { + this.user.comments.dispatchers.createComment({ + postId: this.post.id, + text: this.msg, + commentsId: '', + handleId: this.handleID, + name: this.name, + profilePicFilePath: this.profilePicFilePath + }); + + // empty the "post comment" field + this.msg = ''; + + // give time to render comment, then scroll to the top + setTimeout(() => { + this.content.scrollToTop(1000); + }, 1000); + } else if (containsBadWord) { + this.presentMessage(errorHeader, isAgainstCommunityGuidelinesErrorMessage); + } + } + + viewProfile(handle: string) { + this.viewProfileModal.open({ handle }); + } + + getHandleID() { + const handleIDObservable: Observable = this.user.session.selectors.activeProfile$.pipe( + map(activeProfile => activeProfile.handle.id) + ) + let handleID: string = ''; + handleIDObservable.subscribe( + (url: string) => { + handleID = url; + } + ); + return handleID; + } + + getProfilePic() { + const profilePicObservable: Observable = this.user.session.selectors.changeMaker$.pipe( + map(changeMaker => changeMaker?.profilePicFilePath || '') + ); + let profilePic: string = ''; + profilePicObservable.subscribe( + (url: string) => { + profilePic = url; + } + ); + return profilePic; + } + + getName() { + const nameObservable: Observable = this.user.session.selectors.changeMaker$.pipe( + map(changeMaker => `${changeMaker?.firstName || ''} ${changeMaker?.lastName || ''}` || '') + ); + let name: string = ''; + nameObservable.subscribe( + (url: string) => { + name = url; + } + ); + return name; + } + + flag(id: string) { + this.user.comments.dispatchers.flagComment({ + commentId: id, + }) + } + + checkUserFlagged(comment: CommentStoreModel) { + let userId = ""; + this.user.session.selectors.email$.subscribe(s => userId = s); + const filteredObj = comment.flags.filter(obj => obj.user.id === userId); + return filteredObj.length != 0 + } + + checkCommentHidden(comment: CommentStoreModel) { + let userId = ""; + this.user.session.selectors.email$.subscribe(s => userId = s); + return comment.hidden + } + + trackComment(index: number, comment: CommentStoreModel) { + return comment.id; + } + + cancel() { + return this.modalCtrl.dismiss(this.state.comments, 'cancel'); + } + + async presentMessage(header: string, message: string) { + const alert = await this.alertController.create({ + header: header, + message: message, + buttons: ['OK'], + }); + + await alert.present(); + } + + async presentCommentActions(commentId: string, comment: CommentStoreModel) { + (await this.actionSheetController.create({ + buttons: [ + { + text: 'Flag this comment.', + handler: () => { + if (!this.checkUserFlagged(comment)) { + this.flag(commentId); + this.presentMessage(flagHeader, flagMessage) + } + }, + icon: 'flag-outline' + }, + ] + })).present(); + } +} diff --git a/libs/client/shell/src/lib/activityfeed/activityposts/modal-digest/modal-digest.component.html b/libs/client/shell/src/lib/activityfeed/activityposts/modal-digest/modal-digest.component.html new file mode 100644 index 00000000..e3a24093 --- /dev/null +++ b/libs/client/shell/src/lib/activityfeed/activityposts/modal-digest/modal-digest.component.html @@ -0,0 +1,51 @@ + + + + Notifications + + + + + + + + + + + + +
+ +
+
No Recent Notifications
+
+ + +
Your POI on {{ convertDate(post.dateCreated) }}
+
+
+
+ Your post has {{post.likes.length}} new {{post.likes.length === 1 ? "like" : "likes"}} + +
+
+
+
+
+ Your post has {{post.comments.length}} new {{post.comments.length === 1 ? "comment" : "comments"}} + +
+
+
+ +
+ +
+ +
\ No newline at end of file diff --git a/libs/client/shell/src/lib/activityfeed/activityposts/modal-digest/modal-digest.component.scss b/libs/client/shell/src/lib/activityfeed/activityposts/modal-digest/modal-digest.component.scss new file mode 100644 index 00000000..b6d7b179 --- /dev/null +++ b/libs/client/shell/src/lib/activityfeed/activityposts/modal-digest/modal-digest.component.scss @@ -0,0 +1,4 @@ +.notification-item { + max-width: 200%; + text-align: right !important; +} \ No newline at end of file diff --git a/libs/client/shell/src/lib/activityfeed/activityposts/modal-digest/modal-digest.component.spec.ts b/libs/client/shell/src/lib/activityfeed/activityposts/modal-digest/modal-digest.component.spec.ts new file mode 100644 index 00000000..e69de29b diff --git a/libs/client/shell/src/lib/activityfeed/activityposts/modal-digest/modal-digest.component.ts b/libs/client/shell/src/lib/activityfeed/activityposts/modal-digest/modal-digest.component.ts new file mode 100644 index 00000000..c662c57b --- /dev/null +++ b/libs/client/shell/src/lib/activityfeed/activityposts/modal-digest/modal-digest.component.ts @@ -0,0 +1,71 @@ +import { ChangeDetectionStrategy, Component, Input, OnInit } from "@angular/core"; +import { PostStoreModel, UserFacade } from "@involvemint/client/shared/data-access"; +import { ModalController } from "@ionic/angular"; +import { ModalPostComponent } from "../modal-post/modal-post.component"; + + +export const CLOSED = "close"; +export const OPEN = "open"; +const imagePlaceholder + = "https://upload.wikimedia.org/wikipedia/commons/thumb/d/d1/Image_not_available.png/640px-Image_not_available.png"; + +/** + * Digest Modal Component. + * + * The component responsible for rendering a modal to view the Activity Feed + * notification digest for a user. The modal requires a 'digestPosts' input + * which is a list of the posts with recent activity to generate digest + * notifications from. The digest modal also provides the ability to click + * on a notification which will fetch and display the corresponding post. + */ +@Component({ + selector: 'app-modal-digest', + templateUrl: './modal-digest.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ModalDigestComponent implements OnInit { + @Input() digestPosts!: Array; + constructor( + private readonly modalCtrl: ModalController, + private readonly user: UserFacade + ) { } + + ngOnInit(): void { } + + selectPostImage(imageFilePaths: string[]) { + if (imageFilePaths.length === 0) { + return [imagePlaceholder]; + } + return imageFilePaths; + } + + /** + * Opens a post modal to display individual post. First, fetches + * the post into state management. Second, updates the digestPosts + * list. Third, creates and displays the modal + passes post to it. + */ + async openPost(post: PostStoreModel) { + this.user.posts.dispatchers.get({ postId: post.id }); + this.digestPosts = this.digestPosts.filter(p => { + return p.id !== post.id + }); + const modal = await this.modalCtrl.create({ + component: ModalPostComponent, + componentProps: { post: post }, + }); + await modal.present(); + return (await modal.onDidDismiss()).data; + } + + close() { + return this.modalCtrl.dismiss(this.digestPosts, CLOSED); + } + + convertDate(date: Date | string) { + if (typeof date == "string") { + return new Date(date).toLocaleDateString(); + } + return date.toLocaleDateString(); + } + +} \ No newline at end of file diff --git a/libs/client/shell/src/lib/activityfeed/activityposts/modal-post/modal-post.component.html b/libs/client/shell/src/lib/activityfeed/activityposts/modal-post/modal-post.component.html new file mode 100644 index 00000000..d54af083 --- /dev/null +++ b/libs/client/shell/src/lib/activityfeed/activityposts/modal-post/modal-post.component.html @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/libs/client/shell/src/lib/activityfeed/activityposts/modal-post/modal-post.component.scss b/libs/client/shell/src/lib/activityfeed/activityposts/modal-post/modal-post.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/libs/client/shell/src/lib/activityfeed/activityposts/modal-post/modal-post.component.ts b/libs/client/shell/src/lib/activityfeed/activityposts/modal-post/modal-post.component.ts new file mode 100644 index 00000000..3316cc4c --- /dev/null +++ b/libs/client/shell/src/lib/activityfeed/activityposts/modal-post/modal-post.component.ts @@ -0,0 +1,59 @@ +import { ChangeDetectionStrategy, Component, Input, OnInit } from "@angular/core"; +import { PostStoreModel, UserFacade } from "@involvemint/client/shared/data-access"; +import { StatefulComponent } from "@involvemint/client/shared/util"; +import { ModalController } from "@ionic/angular"; +import { tap } from 'rxjs/operators'; +import { CLOSED } from "../post/post.component"; + + +/** + * Activity Post Modal state tracks the Activity Post that it is + * supposed to be rendering to the user. + */ +interface State { + post?: PostStoreModel; +} + +/** + * Activity Post Modal Component. + * + * The component responsible for tracking and rendering a modal to view a single + * Activity Post. Currently, it is used to view a Post attached to a notification. + * The component tracks the Activity Post using the 'getPost(...)' selector view + * and renders the Activity Post within the component using activitypost.component. + * The modal also requires a 'post' input which it uses to decipher which post + * to track. + */ +@Component({ + selector: 'app-modal-post', + templateUrl: './modal-post.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ModalPostComponent extends StatefulComponent implements OnInit { + @Input() post!: PostStoreModel; + constructor( + private readonly modalCtrl: ModalController, + private readonly user: UserFacade, + ) { + super({ + post: undefined + }); + } + + ngOnInit(): void { + this.effect(() => + this.user.posts.selectors.getPost(this.post.id).pipe( + tap(({ post }) => + this.updateState({ + post: post + }) + ) + ) + ); + } + + close() { + return this.modalCtrl.dismiss(null, CLOSED); + } + +} \ No newline at end of file diff --git a/libs/client/shell/src/lib/activityfeed/activityposts/post/post.component.html b/libs/client/shell/src/lib/activityfeed/activityposts/post/post.component.html new file mode 100644 index 00000000..f885cf98 --- /dev/null +++ b/libs/client/shell/src/lib/activityfeed/activityposts/post/post.component.html @@ -0,0 +1,154 @@ + + + +
+
+
+ +
+
+ +
+
{{ post.poi.enrollment.project.description }}
+
+ + +
+ + + +
+
+ +
+ +

{{ post.poi.enrollment.project.title }}

+ + + +
+
+

Time Worked

+

{{calculateTimeWorked(post.poi)}}

+
+
+
+ + + +
+
+ + + +
+
+
+
+ + + + +
+
+ +
+ +

{{ post.poi.enrollment.project.title }}

+ + + +
+
+

Time Worked

+

{{calculateTimeWorked(post.poi)}}

+
+
+
+ +
+
+ +
+ +

{{ post.poi.enrollment.project.title }}

+ + + +
+
+

Time Worked

+

{{calculateTimeWorked(post.poi)}}

+
+
+
+
+
+
+
+ +
+
+ +
+ + + +
+
+ + + +
+ + + +
+
+ +
+
+ + + +
+
+ + + +
+ +
+ +

Comments

+ +
+
+
+ +

Comments

+ +
+
+
+
+
diff --git a/libs/client/shell/src/lib/activityfeed/activityposts/post/post.component.scss b/libs/client/shell/src/lib/activityfeed/activityposts/post/post.component.scss new file mode 100644 index 00000000..fd3e5fff --- /dev/null +++ b/libs/client/shell/src/lib/activityfeed/activityposts/post/post.component.scss @@ -0,0 +1,645 @@ +// Mobile styles +@media screen and (max-width: 480px) { + .overflow { + inline-size: 125px; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + } + + .iconSize { + margin-top: 9px; + padding: -20px; + margin-left: -8%; + } + + .infoButton { + color: #fff !important; + margin: -10%; + width: 17px; + height: 17px; + } + + .projectText { + background: none; + color:white !important; + position: absolute; + bottom: 3%; + padding: 3%; + display: flex; + font-family: var(--ion-font-family) !important; + font-size: 18px; + } + + .statDescription { + margin-bottom: 0px !important; + } + + .stat { + margin-top: 0px; + } + + .statText { + color:white !important; + position: absolute; + bottom: 3%; + left: 50%; + font-family: var(--ion-font-family) !important; + margin-bottom: +7px; + width: 50%; + } + + .notification-item { + max-width: 200%; + text-align: right !important; + } + + .timeline-details { + display: grid; + } + + .timeline-detail-item { + align-items: flex-start; + display: flex; + flex-direction: column; + grid-template-columns: auto 1fr; + + .comment-button { + color: var(--ion-text-color) !important; + margin-left: -7px; + width: 22px; + height: 20.2px; + --ionicon-stroke-width: 40; + } + + .like-button { + color: var(--ion-text-color) !important; + width: 24px; + height: 22px; + --ionicon-stroke-width: 40; + } + + .like-text { + color: var(--ion-text-color) !important; + font-family: 'Inter'; + font-style: normal; + font-weight: 400; + font-size: 12px; + line-height: 138%; + margin-top: -18px; + padding-left: 8px; + } + + .button-spacing { + margin-top: -50px !important; + padding-bottom: 2px; + padding-top: 2px; + position: relative; + } + + ion-buttons { + grid-column-gap: -20px; + } + } + + .activity-post-carousel { + position: relative; + padding: 15px 0; + } + + .scroll-left { + position: absolute; + top: 50%; + transform: translateY(-50%); + left: 10px; + width: 40px; + height: 40px; + display: flex; + justify-content: center; + align-items: center; + } + + .scroll-right { + position: absolute; + top: 50%; + transform: translateY(-50%); + right: 10px; + width: 40px; + height: 40px; + display: flex; + justify-content: center; + align-items: center; + } + + .scroll-left ion-icon, + .scroll-right ion-icon { + font-size: 30px; + opacity: 0.7; + --color: white !important; + } + + .comment-dropdown { + color: var(--ion-text-color) !important; + font-family: 'Inter'; + font-style: normal; + font-weight: 400; + font-size: 12px; + line-height: 138%; + left: 0px !important; + margin-top: -13px; + padding-top: 0px; + padding-left: 8px; + --padding-start: 0; + --margin-start: 0; + } + + .comment-dropdown-nolikes { + color: var(--ion-text-color) !important; + font-family: 'Inter'; + font-style: normal; + font-weight: 400; + font-size: 12px; + line-height: 138%; + left: 0px !important; + margin-top: -40px; + padding-top: 0px; + padding-left: 8px; + --padding-start: 0; + --margin-start: 0; + } + + .date { + font-family: 'Inter'; + font-style: normal; + font-weight: 400; + font-size: 12px; + margin-top: 0px !important; + padding: 4px; + right: 0px; + position: absolute + } + + .handle { + font-family: 'Inter'; + font-style: normal; + font-weight: 400; + font-size: 12px; + line-height: 134%; + padding-left: 4px; + text-decoration-line: underline; + } + + .information-circle { + display: grid; + width: 13.68px !important; + height: 13.68px !important; + grid-template-columns: auto 1fr; + cursor: pointer; + } + + .name-color { + align-items: center; + color: var(--im-green); + display: flex; + font-family: 'Manrope'; + font-style: normal; + font-weight: 700; + font-size: 16px; + line-height: 22px; + padding: 4px; + text-align: center; + } + + .name-info { + grid-row: 1; + } + + .name-item { + display: grid; + grid-template-columns: auto; + } + + .name-handle { + align-items: center; + display: flex; + font-size: small; + padding: 0px; + margin-bottom: -4.5px; + --ion-item-background: var(--ion-toolbar-background); + } + + .project-description { + font-family: 'Inter'; + font-style: normal; + font-weight: 400; + font-size: 12px; + line-height: 138%; + overflow-wrap: break-word; + margin-bottom: -12px; + padding-left: 8px; + word-break: break-all; + } + + .project-title { + align-items: center; + display: flex; + font-family: 'Manrope'; + font-style: normal; + font-weight: 700; + font-size: 18px; + line-height: 25px; + padding-left: 8px; + --ion-item-background: var(--ion-toolbar-background); + } + + .separator { + margin: 4px 0 4px 0 !important; + } + + .swiper-pagination { + position: relative; + padding-bottom: 34px; + --bullet-background: var(--ion-text-color) !important; + --bullet-background-active: var(--ion-text-color) !important; + + ion-button { + -webkit-tap-highlight-color: transparent !important; + user-select: none !important; + } + + ion-slide { + pointer-events: none; + user-select: none; + } + } + + .user-info { + align-items: center; + display: grid; + flex-direction: column; + grid-template-columns: auto 1fr; + padding: 2px; + } + + .user-info { + align-items: center; + display: grid; + flex-direction: column; + grid-template-columns: auto 1fr; + padding: 2px; + } + + ::ng-deep .swiper-wrapper { + align-items: center !important; + } + + .shadow { + display: inline-block; + } + + .shadow:after { + content: ''; + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 300px; + background-image: linear-gradient(180deg, rgba(217, 217, 217, 0) 0%, rgba(63, 61, 86, 0.39) 65.1%, #3F3D56 100%); + } + + .relative { + position: relative; + } + + #post-user-handle { + cursor: pointer; + } + +} + +// Desktop styles +@media screen and (min-width: 480px) { + .overflow { + inline-size: 65%; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + font-size: 2em; + } + + .projectText { + background: none; + color:white !important; + position: absolute; + bottom: 3%; + padding: 3%; + margin-bottom: -25px; + display: flex; + font-family: var(--ion-font-family) !important; + } + + .overflow { + inline-size: 100%; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + font-size: 4em; + } + + .infoButton { + color: #fff !important; + margin: -10%; + } + + .iconSize { + margin-top: 15px; + padding: -30%; + margin-left: -15px; + } + + .statDescription { + margin-bottom: 0px !important; + font-size: 2em; + } + + .stat { + margin-top: 0px; + font-size: 4em; + } + + .statText { + color:white !important; + position: absolute; + bottom: 3%; + left: 60%; + font-family: var(--ion-font-family) !important; + width: 50%; + } + + .notification-item { + max-width: 200%; + text-align: right !important; + } + + .timeline-details { + display: grid; + } + + .timeline-detail-item { + align-items: flex-start; + display: flex; + flex-direction: column; + grid-template-columns: auto 1fr; + + .comment-button { + color: var(--ion-text-color) !important; + margin-left: -7px; + width: 22px; + height: 20.2px; + --ionicon-stroke-width: 40; + } + + .like-button { + color: var(--ion-text-color) !important; + width: 24px; + height: 22px; + --ionicon-stroke-width: 40; + } + + .like-text { + color: var(--ion-text-color) !important; + font-family: 'Inter'; + font-style: normal; + font-weight: 400; + font-size: 12px; + line-height: 138%; + margin-top: -17px; + padding-left: 8px; + } + + .button-spacing { + margin-top: -50px !important; + padding-bottom: 2px; + padding-top: 2px; + position: relative; + } + + ion-buttons { + grid-column-gap: -20px; + } + } + + .activity-post-carousel { + position: relative; + padding: 15px 0; + } + + .scroll-left { + position: absolute; + top: 50%; + transform: translateY(-50%); + width: 40px; + height: 40px; + display: flex; + left: 10px; + justify-content: center; + align-items: center; + } + + .scroll-right { + position: absolute; + top: 50%; + transform: translateY(-50%); + width: 40px; + height: 40px; + display: flex; + right: 10px; + justify-content: center; + align-items: center; + } + + .scroll-left ion-icon, + .scroll-right ion-icon { + font-size: 30px; + opacity: 0.7; + --color: white !important; + } + + .comment-dropdown { + color: var(--ion-text-color) !important; + font-family: 'Inter'; + font-style: normal; + font-weight: 400; + font-size: 12px; + line-height: 138%; + left: 0px !important; + margin-top: -7px; + padding-top: 0px; + padding-left: 8px; + --padding-start: 0; + --margin-start: 0; + } + + .comment-dropdown-nolikes { + color: var(--ion-text-color) !important; + font-family: 'Inter'; + font-style: normal; + font-weight: 400; + font-size: 12px; + line-height: 138%; + left: 0px !important; + margin-top: -40px; + padding-top: 0px; + padding-left: 8px; + --padding-start: 0; + --margin-start: 0; + } + + .date { + font-family: 'Inter'; + font-style: normal; + font-weight: 400; + font-size: 12px; + margin-top: +4px !important; + padding: 4px; + right: 10px; + position: absolute + } + + .handle { + font-family: 'Inter'; + font-style: normal; + font-weight: 400; + font-size: 12px; + line-height: 134%; + padding-left: 4px; + text-decoration-line: underline; + } + + .information-circle { + display: grid; + width: 13.68px !important; + height: 13.68px !important; + grid-template-columns: auto 1fr; + cursor: pointer; + } + + .name-color { + align-items: center; + color: var(--im-green); + display: flex; + font-family: 'Manrope'; + font-style: normal; + font-weight: 700; + font-size: 16px; + line-height: 22px; + padding: 4px; + text-align: center; + } + + .name-info { + grid-row: 1; + } + + .name-item { + display: grid; + grid-template-columns: auto; + } + + .name-handle { + align-items: center; + display: flex; + font-size: small; + padding: 0px; + margin-bottom: -4.5px; + --ion-item-background: var(--ion-toolbar-background); + } + + .project-description { + font-family: 'Inter'; + font-style: normal; + font-weight: 400; + font-size: 12px; + line-height: 138%; + overflow-wrap: break-word; + margin-bottom: -12px; + padding-left: 8px; + word-break: break-all; + } + + .project-title { + align-items: center; + display: flex; + font-family: 'Manrope'; + font-style: normal; + font-weight: 700; + font-size: 18px; + line-height: 25px; + padding-left: 8px; + --ion-item-background: var(--ion-toolbar-background); + } + + .separator { + margin: 4px 0 4px 0 !important; + } + + .swiper-pagination { + position: relative; + padding-bottom: 20px; + --bullet-background: var(--ion-text-color) !important; + --bullet-background-active: var(--ion-text-color) !important; + + ion-button { + -webkit-tap-highlight-color: transparent !important; + user-select: none !important; + } + + ion-slide { + pointer-events: none; + user-select: none; + } + } + + .user-info { + align-items: center; + display: grid; + flex-direction: column; + grid-template-columns: auto 1fr; + padding: 2px; + } + + .user-info { + align-items: center; + display: grid; + flex-direction: column; + grid-template-columns: auto 1fr; + padding: 2px; + } + + ::ng-deep .swiper-wrapper { + align-items: center !important; + } + + .shadow { + display: inline-block; + } + + .shadow:after { + content: ''; + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 300px; + background-image: linear-gradient(180deg, rgba(217, 217, 217, 0) 0%, rgba(63, 61, 86, 0.39) 65.1%, #3F3D56 100%); + } + + .relative { + position: relative; + } + + #post-user-handle { + cursor: pointer; + } + +} diff --git a/libs/client/shell/src/lib/activityfeed/activityposts/post/post.component.spec.ts b/libs/client/shell/src/lib/activityfeed/activityposts/post/post.component.spec.ts new file mode 100644 index 00000000..e69de29b diff --git a/libs/client/shell/src/lib/activityfeed/activityposts/post/post.component.ts b/libs/client/shell/src/lib/activityfeed/activityposts/post/post.component.ts new file mode 100644 index 00000000..aa52af9c --- /dev/null +++ b/libs/client/shell/src/lib/activityfeed/activityposts/post/post.component.ts @@ -0,0 +1,176 @@ +import { ChangeDetectionStrategy, Component, Input, OnInit, ViewChild } from "@angular/core"; +import { ChatService, ImViewProfileModalService, PostStoreModel, UserFacade } from "@involvemint/client/shared/data-access"; +import { RouteService } from "@involvemint/client/shared/routes"; +import { PoiStatus, calculatePoiStatus, calculatePoiTimeWorked } from "@involvemint/shared/domain"; +import { IonButton, IonSlides, ModalController } from "@ionic/angular"; +import { ModalCommentComponent } from "../comments/modal-comments.component"; + +/** + * Activity Post Component. + * + * The component responsible for the rendering of actual Activity Posts to users and providing + * like/unlike and comment functionality. The post to be rendered needs to be passed to the + * component via a 'post' input value. It is NOT a stateful component and it needs to be used + * within other stateful components that can track and re-render the component when necessary + * (see activityposts.component.ts/html for example). + */ +export const CLOSED = "close"; + +@Component({ + selector: 'app-post', + templateUrl: './post.component.html', + styleUrls: ['./post.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class PostComponent implements OnInit { + @Input() post!: PostStoreModel; + @ViewChild('likeButton', { read: IonButton }) likeButton!: IonButton; + @ViewChild('unlikeButton', { read: IonButton }) unlikeButton!: IonButton; + @ViewChild('slides', { read: IonSlides }) slides!: IonSlides; + private touchTimer: any; + constructor( + private readonly user: UserFacade, + private readonly viewCommentsModal: ModalController, + private readonly viewProfileModal: ImViewProfileModalService, + private readonly chat: ChatService, + public readonly route: RouteService, + ) { } + + ngOnInit(): void { } + + /** + * Dispatches a 'like' request for a post using NgRx state management. + * The changes resulting from the request can be tracked/re-rendered using post selectors. + */ + like(id: string) { + if (!this.likeButton.disabled) { + this.likeButton.disabled = true; // prevent click spam + this.user.posts.dispatchers.like({ + postId: id, + }); + } + } + + /** + * Dispatches a 'unlike' request for a post using NgRx state management. + * The changes resulting from the request can be tracked/re-rendered using post selectors. + */ + unlike(id: string) { + if (!this.unlikeButton.disabled) { + this.unlikeButton.disabled = true; // prevent click spam + this.user.posts.dispatchers.unlike({ + postId: id, + }); + } + } + + message(handle: string) { + this.chat.upsert([{ handleId: handle }]); + } + + /** Used to check which like button to display */ + checkUserLiked(post: PostStoreModel) { + let userId = ""; + this.user.session.selectors.email$.subscribe(s => userId = s); + const filteredObj = post.likes.filter(obj => obj.user.id === userId); + return filteredObj.length != 0 + } + + /** Functions to compute/provide UI values */ + calculatePoiStatus(poi: any) { + return calculatePoiStatus(poi); + } + calculateTimeWorked(poi: any) { + let tempString = calculatePoiTimeWorked(poi); + tempString = tempString.replace("seconds", "sec"); + tempString = tempString.replace("minutes", "min"); + tempString = tempString.replace("hours", "hrs"); + return tempString; + } + get PoiStatus() { + return PoiStatus; + } + + /** Functions to get user values */ + getUserAvatar(post: PostStoreModel) { + return post.user.changeMaker?.profilePicFilePath + } + getUserFirstName(post: PostStoreModel) { + return post.user.changeMaker?.firstName + } + getUserLastName(post: PostStoreModel) { + return post.user.changeMaker?.lastName + } + getUserHandle(post: PostStoreModel) { + if (post.user.changeMaker?.handle.id != undefined) { + return post.user.changeMaker?.handle.id + } else { + return "" + } + } + + /** + * Opens the CM profile modal. + */ + viewProfile(handle: string) { + this.viewProfileModal.open({ handle }); + } + + /** + * Opens the CM project page. + */ + viewProject(projectId: string) { + this.route.to.projects.COVER(projectId); + } + + /** + * Opens the comment modal (modal-comments.component.ts) and waits. + */ + async viewComments(post: PostStoreModel) { + const modal = await this.viewCommentsModal.create({ + component: ModalCommentComponent, + componentProps: { + 'post': post, + 'user': this.user, + } + }); + modal.present(); + + const { data } = await modal.onWillDismiss(); + post.comments = data; + } + + onTouchStart(event: any, id: string, projectId: string) { + const touched = event.target as HTMLElement; + if (touched.classList.contains('slide-next-button')) { + this.slides.slideNext(); + } + else if (touched.classList.contains('slide-prev-button')) { + this.slides.slidePrev(); + } + else if (touched.classList.contains('iconSize')) { + this.viewProject(projectId); + } + else { + if (!this.touchTimer) { + this.touchTimer = setTimeout(() => { + this.touchTimer = null; + }, 250) + } else { + clearTimeout(this.touchTimer); + this.touchTimer = null; + this.doubleTouched(id); + } + } + } + + onTouchEnd(event: any) { + event.preventDefault(); + } + + doubleTouched(id: string) { + if (this.likeButton) this.like(id); + else this.unlike(id); + } + +} diff --git a/libs/client/shell/src/lib/client-shell.module.ts b/libs/client/shell/src/lib/client-shell.module.ts index 6780e5ce..e21a2fe5 100644 --- a/libs/client/shell/src/lib/client-shell.module.ts +++ b/libs/client/shell/src/lib/client-shell.module.ts @@ -93,6 +93,7 @@ import { WalletComponent } from './wallet/wallet.component'; { path: ImRoutes.cm.ROOT, canLoad: [CmGuard], + // eslint-disable-next-line @nrwl/nx/enforce-module-boundaries loadChildren: () => import('@involvemint/client/cm/shell').then((m) => m.ClientCmShellModule), }, { @@ -116,6 +117,10 @@ import { WalletComponent } from './wallet/wallet.component'; loadChildren: () => import('@involvemint/client/admin/shell').then((m) => m.ClientAdminShellModule), }, + { + path: ImRoutes.activityfeed.ROOT, + loadChildren: () => import('./activityfeed/activityfeed.module').then((m) => m.ActivityFeedModule), + }, { path: ImRoutes.projects.ROOT, loadChildren: () => import('./projects/projects.module').then((m) => m.ProjectsModule), diff --git a/libs/client/shell/src/lib/im-app/im-app.component.html b/libs/client/shell/src/lib/im-app/im-app.component.html index a92c9db4..104ead7c 100644 --- a/libs/client/shell/src/lib/im-app/im-app.component.html +++ b/libs/client/shell/src/lib/im-app/im-app.component.html @@ -218,7 +218,7 @@