NestJs and Nuxt version of the 'Full-Stack Vue with GraphQL - The Ultimate Guide' course
Github Repositories
Source code for the NestJs and Nuxt version of the Full-Stack Vue with GraphQL - The Ultimate Guide Udemy course
Within the code you can see how to:
- Use NestJs with GraphQL and MongoDb to create powerful back ends applicaction.
- Use Apollo Boost to create powerful full-stack apps.
- Handle errors on the client and server with Apollo / GraphQL
- Implement session-based JWT authentication to GraphQL applications
- Integrate Apollo with Nuxt Vuex for more reliable and scalable state management
- Implement infinite scrolling functionality using Vue-Apollo
- Deploy full-stack JavaScript / GraphQL applications using Heroku.
- Learn how to write queries and mutations in the GraphQL language on both the client and server
- Make use of many useful MongoDB methods and features
- Be able to create attractive, sophisticated UIs using the Vuetify CSS framework
- Become more familiar with all the best ES6 / 7 features such as async / await, destructuring, spread operators, arrow functions, etc
- Create a multi-language application using nuxt-i18n
Table of contents
- Section 3-4: Setting up the Server project.
- Section 5: Create Vue Frontend with NuxtJs
- Section 6: Using Vue Apollo 0 / 4|24min
- Section 7: Integrate Vuex with ApolloClient
- Section 8: JWT Authentication for Signin / Signup
- Section 9: Error Handling and Form Validation
- Section 10: Add Post / Infinite Scroll Components
- Section 11: Post Component
- Section 12: Like / Unlike Post
- Section 13: Search Posts
- Section 14: Profile Page, Update / Delete Posts
- Section 15: Preparing for Deployment
- Section 16: Deployment with Heroku
You can find more information on Full-Stack Vue with GraphQL - The Ultimate Guide.
This is the Nuxt version of the Full-Stack Vue with GraphQL - The Ultimate Guide Udemy course.
Section 3-4: Setting up the Server project.
I.-Create the main folder for the solution
- Create the main
full-stack-vue-with-graphql-the-ultimate-guide-nuxtfolder
Juan.Pablo.Perez@RIMDUB-0232 MINGW64 /c/Work/Training/Pre/VueJs
$ mkdir full-stack-vue-with-graphql-the-ultimate-guide-nuxt
Juan.Pablo.Perez@RIMDUB-0232 MINGW64 /c/Work/Training/Pre/VueJs
$ cd full-stack-vue-with-graphql-the-ultimate-guide-nuxt
- Initialize
Git
Juan.Pablo.Perez@RIMDUB-0232 MINGW64 /c/Work/Training/Pre/VueJs/full-stack-vue-with-graphql-the-ultimate-guide-nuxt
$ git init
Initialized empty Git repository in C:/Work/Training/Pre/VueJs/full-stack-vue-with-graphql-the-ultimate-guide-nuxt/.git/
- Copy the
README.mdand.gitignorefrom the originalfull-stack-vue-with-graphql-the-ultimate-guideproject.
II.-Create the server project using NetsJS
- We are now going to use the NestJS CLI to scaffold the project. Nest is a progressive
Node.jsframework for building efficient, reliable and scalable server-side applications.

- We need to ensure
NestJs CLIis already installed.
Juan.Pablo.Perez@RIMDUB-0232 MINGW64 /c/Work/Training/Pre/VueJs/full-stack-vue-with-graphql-the-ultimate-guide-nuxt (master)
$ nest -V
6.5.0
- If it is not installed we can install it by using
npm i -g @nestjs/cli
Juan.Pablo.Perez@RIMDUB-0232 MINGW64 /c/Work/Training/Pre/Typescript
$ npm i -g @nestjs/cli
C:\Users\juan.pablo.perez\AppData\Roaming\npm\nest -> C:\Users\juan.pablo.perez\AppData\Roaming\npm\node_modules\@nestjs\cli\bin\nest.js
npm WARN optional SKIPPING OPTIONAL DEPENDENCY: fsevents@1.2.9 (node_modules\@nestjs\cli\node_modules\fsevents):
npm WARN notsup SKIPPING OPTIONAL DEPENDENCY: Unsupported platform for fsevents@1.2.9: wanted {"os":"darwin","arch":"any"} (current: {"os":"win32","arch":"x64"})
+ @nestjs/cli@6.5.0
added 255 packages from 174 contributors in 55.819s
- We are going to create the
serverproject using theNestJS CLI
Juan.Pablo.Perez@RIMDUB-0232 MINGW64 /c/Work/Training/Pre/VueJs/full-stack-vue-with-graphql-the-ultimate-guide-nuxt (master)
$ nest new server
⚡ We will scaffold your app in a few seconds..
CREATE /server/.prettierrc (51 bytes)
CREATE /server/nest-cli.json (84 bytes)
CREATE /server/nodemon-debug.json (163 bytes)
CREATE /server/nodemon.json (67 bytes)
CREATE /server/package.json (1802 bytes)
CREATE /server/README.md (3370 bytes)
CREATE /server/tsconfig.build.json (97 bytes)
CREATE /server/tsconfig.json (325 bytes)
CREATE /server/tslint.json (426 bytes)
CREATE /server/src/app.controller.spec.ts (617 bytes)
CREATE /server/src/app.controller.ts (274 bytes)
CREATE /server/src/app.module.ts (249 bytes)
CREATE /server/src/app.service.ts (142 bytes)
CREATE /server/src/main.ts (208 bytes)
CREATE /server/test/app.e2e-spec.ts (561 bytes)
CREATE /server/test/jest-e2e.json (183 bytes)
? Which package manager would you ❤️ to use? npm
▹▹▹▸▹ Installation in progress... ☕
� Successfully created project server
� Get started with the following commands:
$ cd server
$ npm run start
Thanks for installing Nest �
Please consider donating to our open collective
to help us maintain this package.
� Donate: https://opencollective.com/nest
- We can test if it has been installed properly by executing
npm run start
Juan.Pablo.Perez@RIMDUB-0232 MINGW64 /c/Work/Training/Pre/VueJs/full-stack-vue-with-graphql-the-ultimate-guide-nuxt (master)
$ cd server
Juan.Pablo.Perez@RIMDUB-0232 MINGW64 /c/Work/Training/Pre/VueJs/full-stack-vue-with-graphql-the-ultimate-guide-nuxt/server (master)
$ npm run start
> server@0.0.1 start C:\Work\Training\Pre\VueJs\full-stack-vue-with-graphql-the-ultimate-guide-nuxt\server
> ts-node -r tsconfig-paths/register src/main.ts
[Nest] 22508 - 06/17/2019, 5:20 AM [NestFactory] Starting Nest application...
[Nest] 22508 - 06/17/2019, 5:20 AM [InstanceLoader] AppModule dependencies initialized +23ms
[Nest] 22508 - 06/17/2019, 5:20 AM [RoutesResolver] AppController {/}: +13ms
[Nest] 22508 - 06/17/2019, 5:20 AM [RouterExplorer] Mapped {/, GET} route +7ms
[Nest] 22508 - 06/17/2019, 5:20 AM [NestApplication] Nest application successfully started +8ms
- Browse http://localhost:3000/

- Let's see what has been created.

III.-Setting up Configuration (using environment variables)
- We are going to use dotenv, so we need to install the
dotenvand@types/dotenv.
Juan.Pablo.Perez@RIMDUB-0232 MINGW64 /c/Work/Training/Pre/VueJs/full-stack-vue-with-graphql-the-ultimate-guide-nuxt/server (master)
$ npm i dotenv
npm WARN ts-jest@24.0.2 requires a peer of jest@>=24 <25 but none is installed. You must install peer dependencies yourself.
npm WARN server@0.0.1 No repository field.
npm WARN optional SKIPPING OPTIONAL DEPENDENCY: fsevents@2.0.7 (node_modules\@nestjs\graphql\node_modules\fsevents):
npm WARN notsup SKIPPING OPTIONAL DEPENDENCY: Unsupported platform for fsevents@2.0.7: wanted {"os":"darwin","arch":"any"} (current: {"os":"win32","arch":"x64"})
npm WARN optional SKIPPING OPTIONAL DEPENDENCY: fsevents@1.2.9 (node_modules\fsevents):
npm WARN notsup SKIPPING OPTIONAL DEPENDENCY: Unsupported platform for fsevents@1.2.9: wanted {"os":"darwin","arch":"any"} (current: {"os":"win32","arch":"x64"})
+ dotenv@8.0.0
added 1 package and audited 22998 packages in 21.757s
found 62 low severity vulnerabilities
run `npm audit fix` to fix them, or `npm audit` for details
$ npm i --save-dev @types/dotenv
npm WARN ts-jest@24.0.2 requires a peer of jest@>=24 <25 but none is installed. You must install peer dependencies yourself.
npm WARN server@0.0.1 No repository field.
npm WARN optional SKIPPING OPTIONAL DEPENDENCY: fsevents@2.0.7 (node_modules\@nestjs\graphql\node_modules\fsevents):
npm WARN notsup SKIPPING OPTIONAL DEPENDENCY: Unsupported platform for fsevents@2.0.7: wanted {"os":"darwin","arch":"any"} (current: {"os":"win32","arch":"x64"})
npm WARN optional SKIPPING OPTIONAL DEPENDENCY: fsevents@1.2.9 (node_modules\fsevents):
npm WARN notsup SKIPPING OPTIONAL DEPENDENCY: Unsupported platform for fsevents@1.2.9: wanted {"os":"darwin","arch":"any"} (current: {"os":"win32","arch":"x64"})
+ @types/dotenv@6.1.1
added 1 package from 4 contributors and audited 23000 packages in 15.297s
found 62 low severity vulnerabilities
run `npm audit fix` to fix them, or `npm audit` for details
V.-Setting up MongoDb
- As we can see on NestJS Mongo we have to install the
@nestjs/mongooseandmongoosepackages.
Juan.Pablo.Perez@RIMDUB-0232 MINGW64 /c/Work/Training/Pre/VueJs/full-stack-vue-with-graphql-the-ultimate-guide-nuxt/server (master)
$ npm install --save @nestjs/mongoose mongoose
npm WARN ts-jest@24.0.2 requires a peer of jest@>=24 <25 but none is installed. You must install peer dependencies yourself.
npm WARN server@0.0.1 No repository field.
npm WARN optional SKIPPING OPTIONAL DEPENDENCY: fsevents@2.0.7 (node_modules\@nestjs\graphql\node_modules\fsevents):
npm WARN notsup SKIPPING OPTIONAL DEPENDENCY: Unsupported platform for fsevents@2.0.7: wanted {"os":"darwin","arch":"any"} (current: {"os":"win32","arch":"x64"})
npm WARN optional SKIPPING OPTIONAL DEPENDENCY: fsevents@1.2.9 (node_modules\fsevents):
npm WARN notsup SKIPPING OPTIONAL DEPENDENCY: Unsupported platform for fsevents@1.2.9: wanted {"os":"darwin","arch":"any"} (current: {"os":"win32","arch":"x64"})
+ mongoose@5.6.0
+ @nestjs/mongoose@6.1.2
added 19 packages from 12 contributors and audited 22997 packages in 23.456s
found 62 low severity vulnerabilities
run `npm audit fix` to fix them, or `npm audit` for details
- We also need to install the
@types/mongoosefor the dev environment
Juan.Pablo.Perez@RIMDUB-0232 MINGW64 /c/Work/Training/Pre/VueJs/full-stack-vue-with-graphql-the-ultimate-guide-nuxt/server (master)
$ npm i --save-dev @types/mongoose
npm WARN ts-jest@24.0.2 requires a peer of jest@>=24 <25 but none is installed. You must install peer dependencies yourself.
npm WARN server@0.0.1 No repository field.
npm WARN optional SKIPPING OPTIONAL DEPENDENCY: fsevents@2.0.7 (node_modules\@nestjs\graphql\node_modules\fsevents):
npm WARN notsup SKIPPING OPTIONAL DEPENDENCY: Unsupported platform for fsevents@2.0.7: wanted {"os":"darwin","arch":"any"} (current: {"os":"win32","arch":"x64"})
npm WARN optional SKIPPING OPTIONAL DEPENDENCY: fsevents@1.2.9 (node_modules\fsevents):
npm WARN notsup SKIPPING OPTIONAL DEPENDENCY: Unsupported platform for fsevents@1.2.9: wanted {"os":"darwin","arch":"any"} (current: {"os":"win32","arch":"x64"})
+ @types/mongoose@5.5.6
added 3 packages from 47 contributors and audited 23006 packages in 16.131s
found 62 low severity vulnerabilities
run `npm audit fix` to fix them, or `npm audit` for details
- We are going to have the URI connection in a
.devdocument thatdotenvwill use.
.dev
MONGO_URI=mongodb+srv://USERNAME:PASSWORD@CLUSTERID.mongodb.net/DATABASENAME?retryWrites=true&w=majority
- We are going to create the
src/configfolder with theconfig.module.tsandconfig.service.tsdocuments. It will be used to manage ourconfigvariables.
src/config/config.service.ts
import * as dotenv from "dotenv";
import * as fs from "fs";
export class ConfigService {
MONGODB_URI: string;
private readonly envConfig: { [key: string]: string };
constructor() {
if (
process.env.NODE_ENV === "production" ||
process.env.NODE_ENV === "staging"
) {
this.envConfig = {
MONGO_URI: process.env.MONGO_URI
};
} else {
this.envConfig = dotenv.parse(fs.readFileSync(".env"));
}
}
get(key: string): string {
return this.envConfig[key];
}
}
src/config/config.module.ts
import { Module } from "@nestjs/common";
import { ConfigService } from "./config.service";
@Module({
providers: [ConfigService],
exports: [ConfigService]
})
export class ConfigModule {}
- We are going to create the
src/databasefolder with thedatabase.providers.tsanddatabase.module.tsdocuments that are going to be used to set upMongoDb
src/config/database.providers.ts
import { ConfigModule } from "../config/config.module";
import { ConfigService } from "../config/config.service";
import { MongooseModule } from "@nestjs/mongoose";
export const databaseProviders = [
MongooseModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: async (config: ConfigService) => ({
uri: config.get("MONGO_URI"),
useNewUrlParser: true
})
})
];
src/config/database.module.ts
import { Module } from "@nestjs/common";
import { databaseProviders } from "./database.providers";
@Module({
imports: [...databaseProviders],
exports: [...databaseProviders]
})
export class DatabaseModule {}
- We also need to modify the
app.module.tsdocument to import theDatabaseModule.
app.module.ts
import { Module } from "@nestjs/common";
import { AppController } from "./app.controller";
import { AppService } from "./app.service";
import { DatabaseModule } from "./database/database.module";
@Module({
imports: [DatabaseModule],
controllers: [AppController],
providers: [AppService]
})
export class AppModule {}
- We can test if everything is working without issues.
Juan.Pablo.Perez@RIMDUB-0232 MINGW64 /c/Work/Training/Pre/VueJs/full-stack-vue-with-graphql-the-ultimate-guide-nuxt/server (master)
$ npm run start:dev
> server@0.0.1 start:dev C:\Work\Training\Pre\VueJs\full-stack-vue-with-graphql-the-ultimate-guide-nuxt\server
> concurrently --handle-input "wait-on dist/main.js && nodemon" "tsc -w -p tsconfig.build.json"
[1]
[1]
6:40:04 PM - Starting compilation in watch mode...
[1]
[0] [nodemon] 1.19.1
[0] [nodemon] to restart at any time, enter `rs`
[0] [nodemon] watching: C:\Work\Training\Pre\VueJs\full-stack-vue-with-graphql-the-ultimate-guide-nuxt\server\dist/**/*
[0] [nodemon] starting `node dist/main`
[0] [nodemon] restarting due to changes...
[1]
[1] 6:40:09 PM - Found 0 errors. Watching for file changes.
[0] [nodemon] restarting due to changes...
[0] [nodemon] starting `node dist/main`
[0] [Nest] 15368 - 06/20/2019, 6:40 PM [NestFactory] Starting Nest application...
[0] [Nest] 15368 - 06/20/2019, 6:40 PM [InstanceLoader] DatabaseModule dependencies initialized +49ms
[0] [Nest] 15368 - 06/20/2019, 6:40 PM [InstanceLoader] MongooseModule dependencies initialized +2ms
[0] [Nest] 15368 - 06/20/2019, 6:40 PM [InstanceLoader] ConfigModule dependencies initialized +2ms
[0] [Nest] 15368 - 06/20/2019, 6:40 PM [InstanceLoader] AppModule dependencies initialized +12ms
[0] [Nest] 15368 - 06/20/2019, 6:40 PM [InstanceLoader] MongooseCoreModule dependencies initialized +866ms
[0] [Nest] 15368 - 06/20/2019, 6:40 PM [RoutesResolver] AppController {/}: +7ms
[0] [Nest] 15368 - 06/20/2019, 6:40 PM [RouterExplorer] Mapped {/, GET} route +3ms
[0] [Nest] 15368 - 06/20/2019, 6:40 PM [NestApplication] Nest application successfully started +3ms
IV.-Setting up GraphQL with Apollo Server
- As we can see on NestJs GraphQL Quick Start we need to install the
@nestjs/graphql,apollo-server-express,graphql-toolsandgraphqlpackages.
Juan.Pablo.Perez@RIMDUB-0232 MINGW64 /c/Work/Training/Pre/VueJs/full-stack-vue-with-graphql-the-ultimate-guide-nuxt/server (master)
$ npm i --save @nestjs/graphql apollo-server-express graphql-tools graphql
> core-js@3.1.4 postinstall C:\Work\Training\Pre\VueJs\full-stack-vue-with-graphql-the-ultimate-guide-nuxt\server\node_modules\apollo-env\node_modules\core-js
> node scripts/postinstall || echo "ignore"
Thank you for using core-js ( https://github.com/zloirock/core-js ) for polyfilling JavaScript standard library!
The project needs your help! Please consider supporting of core-js on Open Collective or Patreon:
> https://opencollective.com/core-js
> https://www.patreon.com/zloirock
Also, the author of core-js ( https://github.com/zloirock ) is looking for a good job -)
> protobufjs@6.8.8 postinstall C:\Work\Training\Pre\VueJs\full-stack-vue-with-graphql-the-ultimate-guide-nuxt\server\node_modules\protobufjs
> node scripts/postinstall
> type-graphql@0.17.4 postinstall C:\Work\Training\Pre\VueJs\full-stack-vue-with-graphql-the-ultimate-guide-nuxt\server\node_modules\type-graphql
> node ./dist/postinstall || exit 0
npm WARN ts-jest@24.0.2 requires a peer of jest@>=24 <25 but none is installed. You must install peer dependencies yourself.
npm WARN server@0.0.1 No repository field.
npm WARN optional SKIPPING OPTIONAL DEPENDENCY: fsevents@2.0.7 (node_modules\@nestjs\graphql\node_modules\fsevents):
npm WARN notsup SKIPPING OPTIONAL DEPENDENCY: Unsupported platform for fsevents@2.0.7: wanted {"os":"darwin","arch":"any"} (current: {"os":"win32","arch":"x64"})
npm WARN optional SKIPPING OPTIONAL DEPENDENCY: fsevents@1.2.9 (node_modules\fsevents):
npm WARN notsup SKIPPING OPTIONAL DEPENDENCY: Unsupported platform for fsevents@1.2.9: wanted {"os":"darwin","arch":"any"} (current: {"os":"win32","arch":"x64"})
+ graphql@14.3.1
+ graphql-tools@4.0.4
+ apollo-server-express@2.6.3
+ @nestjs/graphql@6.2.4
added 150 packages from 149 contributors and audited 22927 packages in 36.49s
found 62 low severity vulnerabilities
run `npm audit fix` to fix them, or `npm audit` for details
We are going to apply Code First approach for managing
GraphQLwithNestJSWe need to install the
type-graphqlpackage
Juan.Pablo.Perez@RIMDUB-0232 MINGW64 /c/Work/Training/Pre/VueJs/full-stack-vue-with-graphql-the-ultimate-guide-nuxt/server (master)
$ npm i type-graphql
> type-graphql@0.17.4 postinstall C:\Work\Training\Pre\VueJs\full-stack-vue-with-graphql-the-ultimate-guide-nuxt\server\node_modules\type-graphql
> node ./dist/postinstall || exit 0
npm WARN ts-jest@24.0.2 requires a peer of jest@>=24 <25 but none is installed. You must install peer dependencies yourself.
npm WARN server@0.0.1 No repository field.
npm WARN optional SKIPPING OPTIONAL DEPENDENCY: fsevents@2.0.7 (node_modules\@nestjs\graphql\node_modules\fsevents):
npm WARN notsup SKIPPING OPTIONAL DEPENDENCY: Unsupported platform for fsevents@2.0.7: wanted {"os":"darwin","arch":"any"} (current: {"os":"win32","arch":"x64"})
npm WARN optional SKIPPING OPTIONAL DEPENDENCY: fsevents@1.2.9 (node_modules\fsevents):
npm WARN notsup SKIPPING OPTIONAL DEPENDENCY: Unsupported platform for fsevents@1.2.9: wanted {"os":"darwin","arch":"any"} (current: {"os":"win32","arch":"x64"})
+ type-graphql@0.17.4
updated 1 package and audited 22957 packages in 15.604s
found 62 low severity vulnerabilities
run `npm audit fix` to fix them, or `npm audit` for details
We need to modify
src\app.module.tsdocument to registerGraphQLModuleand to addautoSchemaFileproperty to the options object. As we have chosen theCode Firstapproach, theGraphQLschema is going to be generated automatically byNestJS.schema.gplis the name of the schema file that is going to be created.We are going to get rid of the
app.controller.tsdocument as we are not going to use controllers butGrahpQL.
src\app.service.ts
import { Injectable } from "@nestjs/common";
@Injectable()
export class AppService {}
src\app.module.ts
import { Module } from "@nestjs/common";
import { AppService } from "./app.service";
import { DatabaseModule } from "./database/database.module";
import { GraphQLModule } from "@nestjs/graphql";
@Module({
imports: [
DatabaseModule,
GraphQLModule.forRoot({
autoSchemaFile: "schema.gql"
})
],
providers: [AppService]
})
export class AppModule {}
VII.Create the User model.
database schema: This is an organization of data as a blueprint for defining the structure and the types of data that the database needs to store.data transfer object: This is an object that defines how data will be sent over the network and carries the data between processes.interfaces: TypeScript interfaces are used for type-checking. It can be used to define the types of data that should be passed for an application.
1.Use the nestjs-typegoose package to avoid having to create a schema and an interface
- We need to install the typegoose and the nestjs-typegoose packages.
Juan.Pablo.Perez@RIMDUB-0232 MINGW64 /c/Work/Training/Pre/VueJs/full-stack-vue-with-graphql-the-ultimate-guide-nuxt/server (master)
$ npm i typegoose nestjs-typegoose
npm WARN ts-jest@24.0.2 requires a peer of jest@>=24 <25 but none is installed. You must install peer dependencies yourself.
npm WARN server@0.0.1 No repository field.
npm WARN optional SKIPPING OPTIONAL DEPENDENCY: fsevents@2.0.7 (node_modules\@nestjs\graphql\node_modules\fsevents):
npm WARN notsup SKIPPING OPTIONAL DEPENDENCY: Unsupported platform for fsevents@2.0.7: wanted {"os":"darwin","arch":"any"} (current: {"os":"win32","arch":"x64"})
npm WARN optional SKIPPING OPTIONAL DEPENDENCY: fsevents@1.2.9 (node_modules\fsevents):
npm WARN notsup SKIPPING OPTIONAL DEPENDENCY: Unsupported platform for fsevents@1.2.9: wanted {"os":"darwin","arch":"any"} (current: {"os":"win32","arch":"x64"})
+ nestjs-typegoose@5.2.1
+ typegoose@5.7.2
added 3 packages from 3 contributors and audited 23011 packages in 26.646s
found 62 low severity vulnerabilities
run `npm audit fix` to fix them, or `npm audit` for details
2.Create the model folder and the model documents.
- Create the
usersfolder and inside it themodelfolder. In this folder we are going to put the documents related to the databaseuserscolection. Theuser.model.tsdocument must be created with the definition of theUsermodel.
src/users/model/users.model.ts
import * as mongoose from "mongoose";
import { prop, Typegoose } from "typegoose";
import { IsString, IsDate, IsArray } from "class-validator";
export class User extends Typegoose {
@IsString()
@prop({ required: true, unique: true, trim: true })
username: string;
@IsString()
@prop({ required: true, trim: true })
email: string;
@IsString()
@prop({ required: true, trim: true })
password: string;
@IsString()
avatar?: string;
@IsDate()
@prop({ default: Date.now })
joinDate: Date;
@IsArray()
@prop({ required: true, ref: "Post" })
favorites: [mongoose.Schema.Types.ObjectId];
}
- We are going to create the
dtosfolder where we are going to put theData Transfer Objectsused to send data to the collections. We are going to create thecreate-user.dto.tsdocument that will be used to create a newuser.
src/users/model/dto/create-user.dto.ts
export class CreateUserDto {
readonly username: string;
readonly email: string;
readonly password: string;
}
3.Create the grahpql folder with the graphql documents
We need to create
graphqlfolder with theinputsandtypessubfolders.We need to create the
users.resolver.tswith theGraphQL resolverfor the user.
src/users/graphql/users.resolver.ts
import { Resolver, Query, Mutation, Args } from "@nestjs/graphql";
import { UsersService } from "../users.service";
import { UserInput } from "./inputs/user.input";
import { User } from "./types/user.type";
@Resolver()
export class UsersResolver {
constructor(private readonly usersService: UsersService) {}
@Query(() => [User])
async getUsers() {
return await this.usersService.getUsers();
}
@Mutation(() => User)
async signupUserWithInput(@Args("input") input: UserInput) {
return await this.usersService.signupUserWithInput(input);
}
@Mutation(() => User)
async signupUser(
@Args("username") username: string,
@Args("email") email: string,
@Args("password") password: string
) {
return await this.usersService.signupUser({ username, email, password });
}
}
- The
inputssubfolder will contain the classes used to define thegraphQLinputs used byQueriesandMutations. We are going to create theuser.input.tsdocument that will be used to create a new user when they sign up.
users//inputs/user.input.ts
import { InputType, Field, Int } from "type-graphql";
@InputType()
export class UserInput {
@Field()
readonly username: string;
@Field()
readonly email: string;
@Field()
readonly password: string;
}
- The
typessubfolder will containt the classes used to define the types returned by thegraphQL Queries and Mutations. We are going to create theuser.type.tsdocument that will be used to return the data from theUsercollection.
users//types/user.type.ts
import { Field, ObjectType, ID } from "type-graphql";
@ObjectType()
export class User {
@Field(() => ID)
readonly _id: string;
@Field()
readonly username: string;
@Field()
readonly email: string;
@Field()
readonly password: string;
@Field({ nullable: true })
readonly avatar: string;
@Field()
readonly joinDate: Date;
@Field(() => [ID], { nullable: true })
readonly favorites: [string];
}
4.Create the users service
- We are going to use the
NestJs CLIto create theusersservice.
Juan.Pablo.Perez@RIMDUB-0232 MINGW64 /c/Work/Training/Pre/VueJs/full-stack-vue-with-graphql-the-ultimate-guide-nuxt/server (master)
$ nest generate service users
CREATE /src/users/users.service.spec.ts (453 bytes)
CREATE /src/users/users.service.ts (89 bytes)
UPDATE /src/users/users.module.ts (159 bytes)
src/users/users.service.ts
import { Injectable } from "@nestjs/common";
@Injectable()
export class UsersService {}
src/users/users.service.spec.ts
import { Test, TestingModule } from "@nestjs/testing";
import { UsersService } from "./users.service";
describe("UsersService", () => {
let service: UsersService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [UsersService]
}).compile();
service = module.get<UsersService>(UsersService);
});
it("should be defined", () => {
expect(service).toBeDefined();
});
});
src/users/users.module.ts
import { Module } from "@nestjs/common";
@Module()
export class UsersModule {}
- We need to modify the new service to define the methods used to manage the access to the
userscollection.
src/users/users.service.ts
import { Injectable } from "@nestjs/common";
import { InjectModel } from "nestjs-typegoose";
import { User } from "./model/user.model";
import { CreateUserDto } from "./model/dtos/create-user.dto";
import { ModelType } from "typegoose";
@Injectable()
export class UsersService {
constructor(@InjectModel(User) private readonly userModel: ModelType<User>) {}
async getUsers(): Promise<User[] | null> {
return await this.userModel.find().exec();
}
async signupUserWithInput(createUserDto: CreateUserDto): Promise<User> {
const username = createUserDto.username;
const user = await this.userModel.findOne({ username });
if (user) {
throw new Error("User already exists");
}
const createdUser = new this.userModel(createUserDto);
return await createdUser.save();
}
async signupUser({ username, email, password }): Promise<User> {
const user = await this.userModel.findOne({ username });
if (user) {
throw new Error("User already exists");
}
const createdUser = new this.userModel({ username, email, password });
return await createdUser.save();
}
}
5.Create the users module
- We are going to use the
NestJs CLIto create theusersmodule.
Juan.Pablo.Perez@RIMDUB-0232 MINGW64 /c/Work/Training/Pre/VueJs/full-stack-vue-with-graphql-the-ultimate-guide-nuxt/server (master)
$ nest generate module users
CREATE /src/users/users.module.ts (82 bytes)
UPDATE /src/app.module.ts (438 bytes)
src/users/users.module.ts
import { Module } from "@nestjs/common";
@Module()
export class UsersModule {}
- We need to modify the
modulecreated to define the imports and provides used forUsers
src/users/users.module.ts
import { Module } from "@nestjs/common";
import { TypegooseModule } from "nestjs-typegoose";
import { User } from "./model/user.model";
import { UsersService } from "./users.service";
import { UsersResolver } from "./graphql/users.resolver";
@Module({
imports: [TypegooseModule.forFeature([User])],
providers: [UsersService, UsersResolver]
})
export class UsersModule {}
6.-Modify the app.module.ts
- We need to modify the
app.module.tsto include theUsersModuleand theGraphQLModule.
src/app.module.ts
import { Module } from "@nestjs/common";
import { AppService } from "./app.service";
import { DatabaseModule } from "./database/database.module";
import { GraphQLModule } from "@nestjs/graphql";
import { UsersModule } from './users/users.module';
@Module({
imports: [
DatabaseModule,
UsersModule,cd ..
GraphQLModule.forRoot({
autoSchemaFile: "schema.gql"
}),
],
providers: [AppService]
})
export class AppModule { }
7.-Examples
request
{
getUsers {
_id
username
email
password
avatar
joinDate
favorites
}
}
response
{
"data": {
"getUsers": [
{
"_id": "5d04cf30deb8673e8c38fc2d",
"username": "John",
"email": "john@gmail.com",
"password": "Password",
"avatar": null,
"joinDate": "2019-06-15T10:57:52.586Z",
"favorites": null
},
{
"_id": "5d0da3d023e0c02b9c90ef2a",
"username": "Paul",
"email": "paul@gmail.com",
"password": "Password",
"avatar": null,
"joinDate": "2019-06-22T03:43:12.472Z",
"favorites": null
},
{
"_id": "5d0da5312ce4d742386276af",
"username": "Sarah",
"email": "sarah@gmail.com",
"password": "Password",
"avatar": null,
"joinDate": "2019-06-22T03:49:05.357Z",
"favorites": null
},
{
"_id": "5d0da82b9b5def3308ad242a",
"username": "Mary",
"email": "mary@gmail.com",
"password": "Password",
"avatar": null,
"joinDate": "2019-06-22T04:01:47.521Z",
"favorites": null
},
{
"_id": "5d0e10127dd57444d060778e",
"username": "Jess",
"email": "jess@gmail.com",
"password": "Password",
"avatar": null,
"joinDate": "2019-06-22T11:25:06.222Z",
"favorites": null
},
{
"_id": "5d0e10297dd57444d060778f",
"username": "Juan",
"email": "juan@gmail.com",
"password": "Password",
"avatar": null,
"joinDate": "2019-06-22T11:25:29.692Z",
"favorites": null
}
]
}
}
request
mutation {
signupUser(username: "Juan", email: "juan@gmail.com", password: "Password") {
_id
username
email
avatar
password
joinDate
}
}
response
{
"data": {
"signupUser": {
"_id": "5d0e10297dd57444d060778f",
"username": "Juan",
"email": "juan@gmail.com",
"avatar": null,
"password": "Password",
"joinDate": "2019-06-22T11:25:29.692Z"
}
}
}
request
mutation {
signupUserWithInput(
input: { username: "Jess", email: "jess@gmail.com", password: "Password" }
) {
_id
username
email
avatar
password
joinDate
}
}
response
{
"data": {
"signupUserWithInput": {
"_id": "5d0e10127dd57444d060778e",
"username": "Jess",
"email": "jess@gmail.com",
"avatar": null,
"password": "Password",
"joinDate": "2019-06-22T11:25:06.222Z"
}
}
}
VI.-Create the Post model.
1.-Create the posts folder with a module and a `service.
- We need to create the
usr/postsfolder and use theNestJs CLIto create themoduleand theservice
Juan.Pablo.Perez@RIMDUB-0232 MINGW64 /c/Work/Training/Pre/VueJs/full-stack-vue-with-graphql-the-ultimate-guide-nuxt/server (master)
$ nest generate module posts
CREATE /src/posts/posts.module.ts (82 bytes)
UPDATE /src/app.module.ts (576 bytes)
Juan.Pablo.Perez@RIMDUB-0232 MINGW64 /c/Work/Training/Pre/VueJs/full-stack-vue-with-graphql-the-ultimate-guide-nuxt/server (master)
$ nest generate service posts
CREATE /src/posts/posts.service.spec.ts (453 bytes)
CREATE /src/posts/posts.service.ts (89 bytes)
UPDATE /src/posts/posts.module.ts (507 bytes)
- The
src/app.module.tsdocument is updated automatically
import { Module } from "@nestjs/common";
import { AppService } from "./app.service";
import { DatabaseModule } from "./database/database.module";
import { GraphQLModule } from "@nestjs/graphql";
import { UsersModule } from './users/users.module';
import { PostsModule } from './posts/posts.module';
@Module({
imports: [
DatabaseModule,
GraphQLModule.forRoot({
autoSchemaFile: "schema.gql"
}),
UsersModule,
PostsModule,
],
providers: [AppService]
})
export class AppModule { }
2.-Create the model folder and the model documents.
- We need to create the
src/posts/modelandsrc/posts/model/dtosfolders and thepost.model.tsandcreate-post.dto.tsdocuments.
src/posts/model/post.model.ts
import * as mongoose from "mongoose";
import { prop, Typegoose } from 'typegoose';
import { IsString, IsDate, IsArray, IsInt } from 'class-validator';
import { User } from '../../users/model/user.model'
class Message {
@IsString()
@prop({ required: true })
messageBody: string;
@IsDate()
@prop({ default: Date.now })
messageDate: Date;
@IsString()
@prop({ required: true, ref: User })
messageUser: mongoose.Schema.Types.ObjectId
}
export class Post extends Typegoose {
@IsString()
@prop({ required: true })
title: string;
@IsString()
@prop({ required: true })
imageUrl: string;
@IsString()
@prop({ required: true })
categories: string[];
@IsString()
@prop({ required: true })
description: string;
@IsDate()
@prop({ default: Date.now })
createdDate: Date
@IsInt()
@prop({ default: 0 })
likes: number;
@IsString()
@prop({ required: true, ref: User })
createdBy: mongoose.Schema.Types.ObjectId
@IsArray()
@prop({ ref: Message })
messages: [Message]
}
src/posts/model/dtos/create-post.dto.ts
export class CreatePostDto {
readonly title: string;
readonly imageUrl: string;
readonly categories: string[];
readonly description: string;
readonly createdBy: string;
}
3.-Create the grahpql folder with the graphql documents.
- We need to create the
src/posts/graphql,src/posts/graphql/inputsandsrc/posts/graphql/typesfolders and theusers.resolver.ts,post.input.tsandpost.type.tsdocuments.
src/posts/graphql/post.resolver.ts
import { Resolver, Query, Mutation, Args } from "@nestjs/graphql";
import { PostsService } from "../posts.service";
import { PostInput } from "./inputs/post.input";
import { Post } from "./types/post.type";
@Resolver()
export class PostsResolver {
constructor(private readonly postsService: PostsService) {}
@Query(() => [Post])
async getPosts() {
return await this.postsService.getPosts();
}
@Mutation(() => Post)
async addPostWithInput(@Args("input") input: PostInput) {
const createdBy = input.creatorId;
const { title, imageUrl, categories, description } = input;
return await this.postsService.addPost({
title,
imageUrl,
categories,
description,
createdBy
});
}
@Mutation(() => Post)
async addPost(
@Args("title") title: string,
@Args("imageUrl") imageUrl: string,
@Args({ name: "categories", type: () => [String] }) categories: [string],
@Args("description") description: string,
@Args("creatorId") creatorId: string
) {
const createdBy = creatorId;
return await this.postsService.addPost({
title,
imageUrl,
categories,
description,
createdBy
});
}
}
src/posts/graphql/inputs/post.input.ts
import { InputType, Field, ID } from 'type-graphql';
@InputType()
export class PostInput {
@Field()
readonly title: string;
@Field()
readonly imageUrl: string;
@Field(() => [String], { nullable: "items" })
readonly categories: string[];
@Field()
readonly description: string;
@Field(() => ID)
readonly creatorId: string;
}
src/posts/graphql/inputs/post.type.ts
import { Field, ObjectType, ID, Int } from 'type-graphql';
import { User } from '../../../users/graphql/types/user.type'
@ObjectType()
class Message {
@Field(() => ID)
readonly _id: string
@Field()
readonly messageBody: string;
@Field()
readonly messageDate: Date;
@Field()
readonly messageUser: string;
}
@ObjectType()
export class Post {
@Field(() => ID)
readonly _id: string
@Field()
readonly title: string;
@Field()
readonly imageUrl: string;
@Field(() => [String], { nullable: "items" })
readonly categories: string[];
@Field()
readonly description: string;
@Field()
readonly createdDate: Date
@Field(() => Int, { nullable: true })
likes: number;
@Field(() => User)
readonly createdBy: User
@Field(() => [Message], { nullable: true })
readonly messages: [Message]
}
4.-Modify the module and service documents.
- We need to modify the
modulecreated to define the imports and provides used forposts
src/users/posts.module.ts
import { Module } from "@nestjs/common";
import { TypegooseModule } from "nestjs-typegoose";
import { Post } from "./model/post.model";
import { PostsService } from "./posts.service";
import { PostsResolver } from "./graphql/posts.resolver";
@Module({
imports: [TypegooseModule.forFeature([Post])],
providers: [PostsService, PostsResolver]
})
export class PostsModule {}
- We need to modify the
serviceto define the methods used to manage the access to thepostscollection.
src/users/posts.service.ts
import { Injectable } from "@nestjs/common";
import { InjectModel } from "nestjs-typegoose";
import { Post } from "./model/post.model";
import { CreatePostDto } from "./model/dtos/create-post.dto";
import { ModelType } from "typegoose";
@Injectable()
export class PostsService {
constructor(@InjectModel(Post) private readonly postModel: ModelType<Post>) {}
async getPosts(): Promise<Post[] | null> {
const posts = await this.postModel
.find({})
.sort({ createdDate: "desc" })
.populate({
path: "createdBy",
model: "User"
});
return posts;
}
async addPost(createPostDto: CreatePostDto): Promise<Post> {
const newPost = new this.postModel(createPostDto);
return await newPost.save();
}
}
5.-Examples
request
{
getPosts {
_id
title
imageUrl
categories
description
createdDate
likes
createdBy {
_id
username
email
avatar
password
joinDate
}
}
}
response
{
"data": {
"getPosts": [
{
"_id": "5d0e0fdc7dd57444d060778d",
"title": "Credenza",
"imageUrl": "https://images.crateandbarrel.com/is/image/Crate/ClybournIICredenza3QF16/?$web_zoom_furn_av$&180802085137&wid=1008&hei=567",
"categories": ["Furniture"],
"description": "A piece of furniture I want to buy",
"createdDate": "2019-06-22T11:24:12.972Z",
"likes": 0,
"createdBy": {
"_id": "5d0da3d023e0c02b9c90ef2a",
"username": "Paul",
"email": "paul@gmail.com",
"avatar": null,
"password": "Password",
"joinDate": "2019-06-22T03:43:12.472Z"
}
},
{
"_id": "5d04d377deb8673e8c38fc2f",
"title": "Mona lisa",
"imageUrl": "https://images.duckduckgo.com/iu/?u=http%3A%2F%2Fwww.readingpublicmuseum.org%2Fexhibit_secrets-of-mona-lisa-4.jpg&f=1",
"categories": ["Art"],
"description": "A painting",
"createdDate": "2019-06-15T11:16:07.195Z",
"likes": 0,
"createdBy": {
"_id": "5d04cf30deb8673e8c38fc2d",
"username": "John",
"email": "john@gmail.com",
"avatar": null,
"password": "Password",
"joinDate": "2019-06-15T10:57:52.586Z"
}
}
]
}
}
request
mutation {
addPost(
title: "Credenza"
imageUrl: "https://images.crateandbarrel.com/is/image/Crate/ClybournIICredenza3QF16/?$web_zoom_furn_av$&180802085137&wid=1008&hei=567"
categories: ["Furniture"]
description: "A piece of furniture I want to buy"
creatorId: "5d0da3d023e0c02b9c90ef2a"
) {
title
imageUrl
categories
description
createdDate
likes
}
}
response
{
"data": {
"addPost": {
"title": "Credenza",
"imageUrl": "https://images.crateandbarrel.com/is/image/Crate/ClybournIICredenza3QF16/?$web_zoom_furn_av$&180802085137&wid=1008&hei=567",
"categories": ["Furniture"],
"description": "A piece of furniture I want to buy",
"createdDate": "2019-06-22T11:24:12.972Z",
"likes": 0
}
}
}
request
mutation {
addPostWithInput(
input: {
title: "Tasty coffee"
imageUrl: "https://images.pexels.com/photos/374757/pexels-photo-374757.jpeg?auto=compress&cs=tinysrgb&dpr=2&h=650&w=940"
categories: ["Art", "Food"]
description: "Some nice coffee artwork"
creatorId: "5d0da3d023e0c02b9c90ef2a"
}
) {
title
imageUrl
categories
description
createdDate
likes
}
}
response
{
"data": {
"addPostWithInput": {
"title": "Tasty coffee",
"imageUrl": "https://images.pexels.com/photos/374757/pexels-photo-374757.jpeg?auto=compress&cs=tinysrgb&dpr=2&h=650&w=940",
"categories": ["Art", "Food"],
"description": "Some nice coffee artwork",
"createdDate": "2019-06-22T11:27:58.416Z",
"likes": 0
}
}
}
Section 5: Create Vue Frontend with NuxtJs
I.Create the NuxtJs app using the create-nuxt-app CLI
Juan.Pablo.Perez@RIMDUB-0232 MINGW64 /c/Work/Training/Pre/VueJs/full-stack-vue-with-graphql-the-ultimate-guide-nuxt (master)
$ npx create-nuxt-app client
npx: installed 379 in 37.171s
> Generating Nuxt.js project in C:\Work\Training\Pre\VueJs\full-stack-vue-with-graphql-the-ultimate-guide-nuxt\client
? Project name client
? Project description Frontend for the Nuxt version of [Full-Stack Vue with GraphQL - The Ultimate Guide
? Use a custom server framework none
? Choose features to install Progressive Web App (PWA) Support, Linter / Formatter, Prettier
? Use a custom UI framework vuetify
? Use a custom test framework jest
? Choose rendering mode Universal
? Author name Juan Pablo Perez
? Choose a package manager npm
\ Installing packages with npm
Choose a package manager npm
> core-js@2.6.9 postinstall C:\Work\Training\Pre\VueJs\full-stack-vue-with-graphql-the-ultimate-guide-nuxt\client\node_modules\core-js
> node scripts/postinstall || echo "ignore"
> core-js@2.6.9 postinstall C:\Work\Training\Pre\VueJs\full-stack-vue-with-graphql-the-ultimate-guide-nuxt\client\node_modules\core-js
> node scripts/postinstall || echo "ignore"
> core-js@2.6.9 postinstall C:\Work\Training\Pre\VueJs\full-stack-vue-with-graphql-the-ultimate-guide-nuxt\client\node_modules\core-js
> node scripts/postinstall || echo "ignore"
Thank you for using core-js ( https://github.com/zloirock/core-js ) for polyfilling JavaScript standard library!
The project needs your help! Please consider supporting of core-js on Open Collective or Patreon:
> https://opencollective.com/core-js
> https://www.patreon.com/zloirock
Also, the author of core-js ( https://github.com/zloirock ) is looking for a good job -)
> core-js-pure@3.1.4 postinstall C:\Work\Training\Pre\VueJs\full-stack-vue-with-graphql-the-ultimate-guide-nuxt\client\node_modules\core-js-pure
> node scripts/postinstall || echo "ignore"
Thank you for using core-js ( https://github.com/zloirock/core-js ) for polyfilling JavaScript standard library!
The project needs your help! Please consider supporting of core-js on Open Collective or Patreon:
> https://opencollective.com/core-js
> https://www.patreon.com/zloirock
Also, the author of core-js ( https://github.com/zloirock ) is looking for a good job -)
> core-js@2.6.9 postinstall C:\Work\Training\Pre\VueJs\full-stack-vue-with-graphql-the-ultimate-guide-nuxt\client\node_modules\core-js
> node scripts/postinstall || echo "ignore"
Thank you for using core-js ( https://github.com/zloirock/core-js ) for polyfilling JavaScript standard library!
The project needs your help! Please consider supporting of core-js on Open Collective or Patreon:
> https://opencollective.com/core-js
> https://www.patreon.com/zloirock
Also, the author of core-js ( https://github.com/zloirock ) is looking for a good job -)
> core-js-pure@3.1.4 postinstall C:\Work\Training\Pre\VueJs\full-stack-vue-with-graphql-the-ultimate-guide-nuxt\client\node_modules\core-js-pure
> node scripts/postinstall || echo "ignore"
Thank you for using core-js ( https://github.com/zloirock/core-js ) for polyfilling JavaScript standard library!
The project needs your help! Please consider supporting of core-js on Open Collective or Patreon:
> https://opencollective.com/core-js
> https://www.patreon.com/zloirock
Also, the author of core-js ( https://github.com/zloirock ) is looking for a good job -)
> nodemon@1.19.1 postinstall C:\Work\Training\Pre\VueJs\full-stack-vue-with-graphql-the-ultimate-guide-nuxt\client\node_modules\nodemon
> node bin/postinstall || exit 0
npm
> core-js@2.6.9 postinstall C:\Work\Training\Pre\VueJs\full-stack-vue-with-graphql-the-ultimate-guide-nuxt\client\node_modules\core-js
> node scripts/postinstall || echo "ignore"
Thank you for using core-js ( https://github.com/zloirock/core-js ) for polyfilling JavaScript standard library!
The project needs your help! Please consider supporting of core-js on Open Collective or Patreon:
> https://opencollective.com/core-js
> https://www.patreon.com/zloirock
Also, the author of core-js ( https://github.com/zloirock ) is looking for a good job -)
> core-js-pure@3.1.4 postinstall C:\Work\Training\Pre\VueJs\full-stack-vue-with-graphql-the-ultimate-guide-nuxt\client\node_modules\core-js-pure
> node scripts/postinstall || echo "ignore"
Thank you for using core-js ( https://github.com/zloirock/core-js ) for polyfilling JavaScript standard library!
The project needs your help! Please consider supporting of core-js on Open Collective or Patreon:
> https://opencollective.com/core-js
> https://www.patreon.com/zloirock
Also, the author of core-js ( https://github.com/zloirock ) is looking for a good job -)
> nodemon@1.19.1 postinstall C:\Work\Training\Pre\VueJs\full-stack-vue-with-graphql-the-ultimate-guide-nuxt\client\node_modules\nodemon
> node bin/postinstall || exit 0
> core-js@2.6.9 postinstall C:\Work\Training\Pre\VueJs\full-stack-vue-with-graphql-the-ultimate-guide-nuxt\client\node_modules\core-js
> node scripts/postinstall || echo "ignore"
Thank you for using core-js ( https://github.com/zloirock/core-js ) for polyfilling JavaScript standard library!
The project needs your help! Please consider supporting of core-js on Open Collective or Patreon:
> https://opencollective.com/core-js
> https://www.patreon.com/zloirock
Also, the author of core-js ( https://github.com/zloirock ) is looking for a good job -)
> core-js-pure@3.1.4 postinstall C:\Work\Training\Pre\VueJs\full-stack-vue-with-graphql-the-ultimate-guide-nuxt\client\node_modules\core-js-pure
> node scripts/postinstall || echo "ignore"
Thank you for using core-js ( https://github.com/zloirock/core-js ) for polyfilling JavaScript standard library!
The project needs your help! Please consider supporting of core-js on Open Collective or Patreon:
> https://opencollective.com/core-js
> https://www.patreon.com/zloirock
Also, the author of core-js ( https://github.com/zloirock ) is looking for a good job -)
> nodemon@1.19.1 postinstall C:\Work\Training\Pre\VueJs\full-stack-vue-with-graphql-the-ultimate-guide-nuxt\client\node_modules\nodemon
> node bin/postinstall || exit 0
Love nodemon? You can now support the project via the open collective:
> https://opencollective.com/nodemon/donate
npm notice created a lockfile as package-lock.json. You should commit this file.
npm WARN @nuxtjs/vuetify@0.5.5 requires a peer of vuetify-loader@^1.2.1 but none is installed. You must install peer dependencies yourself.
npm WARN optional SKIPPING OPTIONAL DEPENDENCY: fsevents@1.2.9 (node_modules\watchpack\node_modules\fsevents):
npm WARN notsup SKIPPING OPTIONAL DEPENDENCY: Unsupported platform for fsevents@1.2.9: wanted {"os":"darwin","arch":"any"} (current: {"os":"win32","arch":"x64"})
npm WARN optional SKIPPING OPTIONAL DEPENDENCY: fsevents@1.2.9 (node_modules\nodemon\node_modules\fsevents):
npm WARN notsup SKIPPING OPTIONAL DEPENDENCY: Unsupported platform for fsevents@1.2.9: wanted {"os":"darwin","arch":"any"} (current: {"os":"win32","arch":"x64"})
npm WARN optional SKIPPING OPTIONAL DEPENDENCY: fsevents@1.2.9 (node_modules\jest-haste-map\node_modules\fsevents):
npm WARN notsup SKIPPING OPTIONAL DEPENDENCY: Unsupported platform for fsevents@1.2.9: wanted {"os":"darwin","arch":"any"} (current: {"os":"win32","arch":"x64"})
npm WARN optional SKIPPING OPTIONAL DEPENDENCY: fsevents@2.0.7 (node_modules\fsevents):
npm WARN notsup SKIPPING OPTIONAL DEPENDENCY: Unsupported platform for fsevents@2.0.7: wanted {"os":"darwin","arch":"any"} (current: {"os":"win32","arch":"x64"})
> core-js@2.6.9 postinstall C:\Work\Training\Pre\VueJs\full-stack-vue-with-graphql-the-ultimate-guide-nuxt\client\node_modules\core-js
> node scripts/postinstall || echo "ignore"
Thank you for using core-js ( https://github.com/zloirock/core-js ) for polyfilling JavaScript standard library!
The project needs your help! Please consider supporting of core-js on Open Collective or Patreon:
> https://opencollective.com/core-js
> https://www.patreon.com/zloirock
Also, the author of core-js ( https://github.com/zloirock ) is looking for a good job -)
> core-js-pure@3.1.4 postinstall C:\Work\Training\Pre\VueJs\full-stack-vue-with-graphql-the-ultimate-guide-nuxt\client\node_modules\core-js-pure
> node scripts/postinstall || echo "ignore"
Thank you for using core-js ( https://github.com/zloirock/core-js ) for polyfilling JavaScript standard library!
The project needs your help! Please consider supporting of core-js on Open Collective or Patreon:
> https://opencollective.com/core-js
> https://www.patreon.com/zloirock
Also, the author of core-js ( https://github.com/zloirock ) is looking for a good job -)
> nodemon@1.19.1 postinstall C:\Work\Training\Pre\VueJs\full-stack-vue-with-graphql-the-ultimate-guide-nuxt\client\node_modules\nodemon
> node bin/postinstall || exit 0
Love nodemon? You can now support the project via the open collective:
> https://opencollective.com/nodemon/donate
> client@1.0.0 lint C:\Work\Training\Pre\VueJs\full-stack-vue-with-graphql-the-ultimate-guide-nuxt\client
> eslint --ext .js,.vue --ignore-path .gitignore . "--fix"
To get started:
cd client
npm run dev
To build & start for production:
cd client
npm run build
npm start
To test:
cd client
npm run test
- Run de app to check if it works properly
Juan.Pablo.Perez@RIMDUB-0232 MINGW64 /c/Work/Training/Pre/VueJs/full-stack-vue-with-graphql-the-ultimate-guide-nuxt/client (master)
$ npm run dev
> client@1.0.0 dev C:\Work\Training\Pre\VueJs\full-stack-vue-with-graphql-the-ultimate-guide-nuxt\client
> nuxt
╭─────────────────────────────────────────────╮
│ │
│ Nuxt.js v2.8.1 │
│ Running in development mode (universal) │
│ │
│ Listening on: http://localhost:3000/ │
│ │
╰─────────────────────────────────────────────╯
i Preparing project for development 16:34:20
i Initial build may take a while 16:34:20
√ Builder initialized 16:34:21
√ Nuxt files generated 16:34:21
√ Client
Compiled successfully in 14.51s
√ Server
Compiled successfully in 11.43s
ERROR (node:13864) DeprecationWarning: Tapable.plugin is deprecated. Use new API on .hooks instead 16:34:25
i Waiting for file changes 16:34:40
i Memory usage: 235 MB (RSS: 337 MB)
- Browse to http://localhost:3000/


II.Create a script to run server and client at the same time
We are going to run server and client at the same time by using the concurrently package.
We need to create a new empty
package.jsonfile withnpm init -yat the main folder.
Juan.Pablo.Perez@RIMDUB-0232 MINGW64 /c/Work/Training/Pre/VueJs/full-stack-vue-with-graphql-the-ultimate-guide-nuxt (master)
$ npm init -y
Wrote to C:\Work\Training\Pre\VueJs\full-stack-vue-with-graphql-the-ultimate-guide-nuxt\package.json:
{
"name": "full-stack-vue-with-graphql-the-ultimate-guide-nuxt",
"version": "1.0.0",
"description": "Source code for the NestJs and Nuxt version of the 'Full-Stack Vue with GraphQL - The Ultimate Guide' Udemy course.", "main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"repository": {
"type": "git",
"url": "git+https://github.com/peelmicro/full-stack-vue-with-graphql-the-ultimate-guide-nuxt.git"
},
"keywords": [],
"author": "",
"license": "ISC",
"bugs": {
"url": "https://github.com/peelmicro/full-stack-vue-with-graphql-the-ultimate-guide-nuxt/issues"
},
"homepage": "https://github.com/peelmicro/full-stack-vue-with-graphql-the-ultimate-guide-nuxt#readme"
}
- We need to install the
concurrentlypackage.
Juan.Pablo.Perez@RIMDUB-0232 MINGW64 /c/Work/Training/Pre/VueJs/full-stack-vue-with-graphql-the-ultimate-guide-nuxt (master)
$ npm i --save-dev concurrently
npm notice created a lockfile as package-lock.json. You should commit this file.
+ concurrently@4.1.0
added 86 packages from 48 contributors and audited 103 packages in 21.691s
found 0 vulnerabilities
- Change the
server/src/main.tsto run the server on port4000server/src/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
await app.listen(process.env.PORT || 4000);
}
bootstrap();
- Modify the
package.jsondocument to create the scripts needed to run theserverand theclientat the same time
package.json
{
"name": "full-stack-vue-with-graphql-the-ultimate-guide-nuxt",
"version": "1.0.0",
"description": "Source code for the NestJs and Nuxt version of the 'Full-Stack Vue with GraphQL - The Ultimate Guide' Udemy course.",
"main": "index.js",
"scripts": {
"server": "cd server && npm run start:dev",
"client": "cd client && npm run dev",
"dev": "concurrently --names \"server,client\" \"npm run server --silent\" \"npm run client --silent\""
},
"repository": {
"type": "git",
"url": "git+https://github.com/peelmicro/full-stack-vue-with-graphql-the-ultimate-guide-nuxt.git"
},
"keywords": [],
"author": "",
"license": "ISC",
"bugs": {
"url": "https://github.com/peelmicro/full-stack-vue-with-graphql-the-ultimate-guide-nuxt/issues"
},
"homepage": "https://github.com/peelmicro/full-stack-vue-with-graphql-the-ultimate-guide-nuxt#readme",
"devDependencies": {
"concurrently": "^4.1.0"
}
}
- Execute the
npm run devscript.
Juan.Pablo.Perez@RIMDUB-0232 MINGW64 /c/Work/Training/Pre/VueJs/full-stack-vue-with-graphql-the-ultimate-guide-nuxt (master)
$ npm run dev
> full-stack-vue-with-graphql-the-ultimate-guide-nuxt@1.0.0 dev C:\Work\Training\Pre\VueJs\full-stack-vue-with-graphql-the-ultimate-guide-nuxt
> concurrently --names "server,client" "npm run server --silent" "npm run client --silent"
5:16:48 PM - Starting compilation in watch mode...
[server] [1]
[server] [0] [nodemon] 1.19.1
[server] [0] [nodemon] to restart at any time, enter `rs`
[server] [0] [nodemon] watching: C:\Work\Training\Pre\VueJs\full-stack-vue-with-graphql-the-ultimate-guide-nuxt\server\dist/**/*
[server] [0] [nodemon] starting `node dist/main`
[client] i Listening on: http://localhost:3000/
[client] i Preparing project for development
[client] i Initial build may take a while
[server] [0] [Nest] 17600 - 06/22/2019, 5:17 PM [NestFactory] Starting Nest application...
[server] [0] [Nest] 17600 - 06/22/2019, 5:17 PM [InstanceLoader] AppModule dependencies initialized +97ms
[server] [0] [Nest] 17600 - 06/22/2019, 5:17 PM [InstanceLoader] DatabaseModule dependencies initialized +2ms
[server] [0] [Nest] 17600 - 06/22/2019, 5:17 PM [InstanceLoader] TypegooseModule dependencies initialized +1ms
[server] [0] [Nest] 17600 - 06/22/2019, 5:17 PM [InstanceLoader] ConfigModule dependencies initialized +2ms
[server] [0] [Nest] 17600 - 06/22/2019, 5:17 PM [InstanceLoader] GraphQLModule dependencies initialized +24ms
[server] [1] node_modules/apollo-server-express/dist/ApolloServer.d.ts(1,8): error TS1192: Module '"C:/Work/Training/Pre/VueJs/full-stack-vue-with-graphql-the-ultimate-guide-nuxt/server/node_modules/@types/express/index"' has no default export.
[server] [1] node_modules/apollo-server-express/dist/ApolloServer.d.ts(2,8): error TS1192: Module '"C:/Work/Training/Pre/VueJs/full-stack-vue-with-graphql-the-ultimate-guide-nuxt/server/node_modules/@types/cors/index"' has no default export.
[server] [1]
[server] [1] 5:17:13 PM - Found 2 errors. Watching for file changes.
[server] [0] [Nest] 17600 - 06/22/2019, 5:17 PM [InstanceLoader] TypegooseCoreModule dependencies initialized +1552ms
[server] [0] [Nest] 17600 - 06/22/2019, 5:17 PM [InstanceLoader] TypegooseModule dependencies initialized +2ms
[server] [0] [Nest] 17600 - 06/22/2019, 5:17 PM [InstanceLoader] TypegooseModule dependencies initialized +2ms
[server] [0] [Nest] 17600 - 06/22/2019, 5:17 PM [InstanceLoader] UsersModule dependencies initialized +3ms
[server] [0] [Nest] 17600 - 06/22/2019, 5:17 PM [InstanceLoader] PostsModule dependencies initialized +2ms
[server] [0] [Nest] 17600 - 06/22/2019, 5:17 PM [NestApplication] Nest application successfully started +641ms
[client] √ Builder initialized
[client] √ Nuxt files generated
[client] i Compiling Client
[client] i Compiling Server
- Browse to http://localhost:3000/ to see the client

- Browse to http://localhost:4000/graphql to see the server
graphql

III.-Set up the Vuetify Theme
- Modify the
nuxt.config.jsdocument to change thevuetify.themeto use the same colours as the ones from the 'original` Vue app.
/client/nuxt.config.js
// import colors from 'vuetify/es5/util/colors'
export default {
mode: 'universal',
/*
** Headers of the page
*/
head: {
titleTemplate: '%s - ' + process.env.npm_package_name,
title: process.env.npm_package_name || '',
meta: [
{ charset: 'utf-8' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
{
hid: 'description',
name: 'description',
content: process.env.npm_package_description || ''
}
],
link: [
{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' },
{
rel: 'stylesheet',
href:
'https://fonts.googleapis.com/css?family=Roboto:300,400,500,700|Material+Icons'
}
]
},
/*
** Customize the progress-bar color
*/
loading: { color: '#fff' },
/*
** Global CSS
*/
css: [],
/*
** Plugins to load before mounting the App
*/
plugins: [],
/*
** Nuxt.js modules
*/
modules: ['@nuxtjs/vuetify', '@nuxtjs/pwa', '@nuxtjs/eslint-module'],
/*
** vuetify module configuration
** https://github.com/nuxt-community/vuetify-module
*/
vuetify: {
theme: {
// primary: colors.blue.darken2,
// accent: colors.grey.darken3,
// secondary: colors.amber.darken3,
// info: colors.teal.lighten1,
// warning: colors.amber.base,
// error: colors.deepOrange.accent4,
// success: colors.green.accent3
primary: '#3B125F',
secondary: '#8B5FBF',
accent: '#BF653F',
error: '#722530',
warning: '#A37513',
info: '#396893',
success: '#4caf50'
}
},
/*
** Build configuration
*/
build: {
/*
** You can extend webpack config here
*/
extend(config, ctx) {}
}
}

IV.-Create the Pages (and remove the test code)
- Modify the
client/layouts/default.vuedocument to create the layout based on the originalApp.vuedocument.
client/layouts/default.vue
<template>
<v-app style="background: #E3E3EE">
<!-- Side Navbar -->
<v-navigation-drawer v-model="sideNav" fixed app temporary>
<v-toolbar color="accent" dark flat>
<v-toolbar-side-icon @click="toggleSideNav"></v-toolbar-side-icon>
<nuxt-link to="/" tag="span" style="cursor: pointer">
<h1 class="title pl-3">VueShare</h1>
</nuxt-link>
</v-toolbar>
<v-divider></v-divider>
<!-- Side Navbar Links -->
<v-list>
<v-list-tile
v-for="(item, i) in sideNavItems"
:key="i"
:to="item.link"
ripple
>
<v-list-tile-action>
<v-icon>{{ item.icon }}</v-icon>
</v-list-tile-action>
<v-list-tile-content>
{{ item.title }}
</v-list-tile-content>
</v-list-tile>
</v-list>
</v-navigation-drawer>
<!-- Horizontal Navbar -->
<v-toolbar fixed color="primary" dark>
<!-- App Title -->
<v-toolbar-side-icon @click="toggleSideNav"></v-toolbar-side-icon>
<v-toolbar-title class="hidden-xs-only">
<nuxt-link to="/" tag="span" style="cursor: pointer">
VueShare
</nuxt-link>
</v-toolbar-title>
<v-spacer></v-spacer>
<!-- Search Input -->
<v-text-field
flex
prepend-icon="search"
placeholder="Search posts"
color="accent"
single-line
hide-details
></v-text-field>
<v-spacer></v-spacer>
<!-- Horizontal Navbar Links -->
<v-toolbar-items class="hidden-xs-only">
<v-btn
v-for="(item, i) in horizontalNavItems"
:key="i"
:to="item.link"
flat
>
<v-icon class="hidden-sm-only" left>{{ item.icon }}</v-icon>
{{ item.title }}
</v-btn>
</v-toolbar-items>
</v-toolbar>
</v-app>
</template>
<script>
export default {
data() {
return {
sideNav: false
}
},
computed: {
horizontalNavItems() {
return [
{ icon: 'chat', title: 'Posts', link: '/posts' },
{ icon: 'lock_open', title: 'Sign In', link: '/signin' },
{ icon: 'create', title: 'Sign Up', link: '/signup' }
]
},
sideNavItems() {
return [
{ icon: 'chat', title: 'Posts', link: '/posts' },
{ icon: 'lock_open', title: 'Sign In', link: '/signin' },
{ icon: 'create', title: 'Sign Up', link: '/signup' }
]
}
},
methods: {
toggleSideNav() {
this.sideNav = !this.sideNav
}
}
}
</script>
- Modify the
client/pages/index.vuedocument copy the content from the original/components/Home.vue
client/pages/index.vue
<template>
<v-container>
<h1>Home</h1>
</v-container>
</template>
<script>
export default {
name: 'Home'
}
</script>
- Create the
postfolder with theadd.vueandindex.vuedocument.
client/pages/posts/add.vue
<template>
<v-container>
<h1>Add Post</h1>
</v-container>
</template>
<script>
export default {
name: 'AddPost'
}
</script>
client/pages/posts/index.vue
<template>
<v-container>
<h1>Posts</h1>
</v-container>
</template>
<script>
export default {
name: 'Posts'
}
</script>
- Create the
profilefolder with theindex.vuedocument.
client/pages/profile/index.vue
<template>
<v-container>
<h1>Profile</h1>
</v-container>
</template>
<script>
export default {
name: 'Profile'
}
</script>
- Create the
signinfolder with theindex.vuedocument.
client/pages/signin/index.vue
<template>
<v-container>
<h1>Signin</h1>
</v-container>
</template>
<script>
export default {
name: 'Signin'
}
</script>
- Create the
signupfolder with theindex.vuedocument.
client/pages/signup/index.vue
<template>
<v-container>
<h1>Signup</h1>
</v-container>
</template>
<script>
export default {
name: 'Signup'
}
</script>

- Check if the application works the same way as the original application.




V.-Sep up Typescript
- We are going to follow the TypeScript Support documentation to set up the use of
Typescriptwith ourNuxtapplication
1.Install the needed Typescript packages
- We need to install
@nuxt/typescriptindevDependenciesandts-nodeindependencies
Juan.Pablo.Perez@RIMDUB-0232 MINGW64 /c/Work/Training/Pre/VueJs/full-stack-vue-with-graphql-the-ultimate-guide-nuxt/client (master)
$ npm i -D @nuxt/typescript
npm WARN deprecated @types/chokidar@2.1.3: This is a stub types definition. chokidar provides its own type definitions, so you do not need this installed.
npm WARN @nuxtjs/vuetify@0.5.5 requires a peer of vuetify-loader@^1.2.1 but none is installed. You must install peer dependencies yourself.
npm WARN optional SKIPPING OPTIONAL DEPENDENCY: fsevents@1.2.9 (node_modules\watchpack\node_modules\fsevents):
npm WARN notsup SKIPPING OPTIONAL DEPENDENCY: Unsupported platform for fsevents@1.2.9: wanted {"os":"darwin","arch":"any"} (current: {"os":"win32","arch":"x64"})
npm WARN optional SKIPPING OPTIONAL DEPENDENCY: fsevents@1.2.9 (node_modules\nodemon\node_modules\fsevents):
npm WARN notsup SKIPPING OPTIONAL DEPENDENCY: Unsupported platform for fsevents@1.2.9: wanted {"os":"darwin","arch":"any"} (current: {"os":"win32","arch":"x64"})
npm WARN optional SKIPPING OPTIONAL DEPENDENCY: fsevents@1.2.9 (node_modules\jest-haste-map\node_modules\fsevents):
npm WARN notsup SKIPPING OPTIONAL DEPENDENCY: Unsupported platform for fsevents@1.2.9: wanted {"os":"darwin","arch":"any"} (current: {"os":"win32","arch":"x64"})
npm WARN optional SKIPPING OPTIONAL DEPENDENCY: fsevents@1.2.9 (node_modules\fork-ts-checker-webpack-plugin\node_modules\fsevents):
npm WARN notsup SKIPPING OPTIONAL DEPENDENCY: Unsupported platform for fsevents@1.2.9: wanted {"os":"darwin","arch":"any"} (current: {"os":"win32","arch":"x64"})
npm WARN optional SKIPPING OPTIONAL DEPENDENCY: fsevents@2.0.7 (node_modules\fsevents):
npm WARN notsup SKIPPING OPTIONAL DEPENDENCY: Unsupported platform for fsevents@2.0.7: wanted {"os":"darwin","arch":"any"} (current: {"os":"win32","arch":"x64"})
+ @nuxt/typescript@2.8.1
added 55 packages from 162 contributors and audited 884211 packages in 72.918s
found 0 vulnerabilities
Juan.Pablo.Perez@RIMDUB-0232 MINGW64 /c/Work/Training/Pre/VueJs/full-stack-vue-with-graphql-the-ultimate-guide-nuxt/client (master)
$ npm i ts-node
npm WARN @nuxtjs/vuetify@0.5.5 requires a peer of vuetify-loader@^1.2.1 but none is installed. You must install peer dependencies yourself.
npm WARN optional SKIPPING OPTIONAL DEPENDENCY: fsevents@1.2.9 (node_modules\watchpack\node_modules\fsevents):
npm WARN notsup SKIPPING OPTIONAL DEPENDENCY: Unsupported platform for fsevents@1.2.9: wanted {"os":"darwin","arch":"any"} (current: {"os":"win32","arch":"x64"})
npm WARN optional SKIPPING OPTIONAL DEPENDENCY: fsevents@1.2.9 (node_modules\nodemon\node_modules\fsevents):
npm WARN notsup SKIPPING OPTIONAL DEPENDENCY: Unsupported platform for fsevents@1.2.9: wanted {"os":"darwin","arch":"any"} (current: {"os":"win32","arch":"x64"})
npm WARN optional SKIPPING OPTIONAL DEPENDENCY: fsevents@1.2.9 (node_modules\jest-haste-map\node_modules\fsevents):
npm WARN notsup SKIPPING OPTIONAL DEPENDENCY: Unsupported platform for fsevents@1.2.9: wanted {"os":"darwin","arch":"any"} (current: {"os":"win32","arch":"x64"})
npm WARN optional SKIPPING OPTIONAL DEPENDENCY: fsevents@1.2.9 (node_modules\fork-ts-checker-webpack-plugin\node_modules\fsevents):
npm WARN notsup SKIPPING OPTIONAL DEPENDENCY: Unsupported platform for fsevents@1.2.9: wanted {"os":"darwin","arch":"any"} (current: {"os":"win32","arch":"x64"})
npm WARN optional SKIPPING OPTIONAL DEPENDENCY: fsevents@2.0.7 (node_modules\fsevents):
npm WARN notsup SKIPPING OPTIONAL DEPENDENCY: Unsupported platform for fsevents@2.0.7: wanted {"os":"darwin","arch":"any"} (current: {"os":"win32","arch":"x64"})
+ ts-node@8.3.0
added 5 packages from 4 contributors and audited 884219 packages in 54.323s
found 0 vulnerabilities
2.-Create the tsconfig.json document
- We need to create an empty
tsconfig.jsonfile in the root project folder.
Juan.Pablo.Perez@RIMDUB-0232 MINGW64 /c/Work/Training/Pre/VueJs/full-stack-vue-with-graphql-the-ultimate-guide-nuxt/client (master)
$ touch tsconfig.json
' Note: The tsconfig.json file will automatically update with default values the first time we run the nuxt command.
3.-rename the nuxt.config.js document to nuxt.config.ts and modify it.
- To use TypeScript in the configuration file, we need to rename
nuxt.config.jstonuxt.config.tsand change the document.
nuxt.config.ts
// import colors from 'vuetify/es5/util/colors'
import NuxtConfiguration from '@nuxt/config'
const config: NuxtConfiguration = {
mode: 'universal',
/*
** Headers of the page
*/
head: {
titleTemplate: '%s - ' + process.env.npm_package_name,
title: process.env.npm_package_name || '',
meta: [
{ charset: 'utf-8' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
{
hid: 'description',
name: 'description',
content: process.env.npm_package_description || ''
}
],
link: [
{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' },
{
rel: 'stylesheet',
href:
'https://fonts.googleapis.com/css?family=Roboto:300,400,500,700|Material+Icons'
}
]
},
/*
** Customize the progress-bar color
*/
loading: { color: '#fff' },
/*
** Global CSS
*/
css: [],
/*
** Plugins to load before mounting the App
*/
plugins: [],
/*
** Nuxt.js modules
*/
modules: ['@nuxtjs/vuetify', '@nuxtjs/pwa', '@nuxtjs/eslint-module'],
/*
** vuetify module configuration
** https://github.com/nuxt-community/vuetify-module
*/
vuetify: {
theme: {
// primary: colors.blue.darken2,
// accent: colors.grey.darken3,
// secondary: colors.amber.darken3,
// info: colors.teal.lighten1,
// warning: colors.amber.base,
// error: colors.deepOrange.accent4,
// success: colors.green.accent3
primary: '#3B125F',
secondary: '#8B5FBF',
accent: '#BF653F',
error: '#722530',
warning: '#A37513',
info: '#396893',
success: '#4caf50'
}
},
/*
** Build configuration
*/
build: {
/*
** You can extend webpack config here
*/
extend(config, ctx) {}
}
}
export default config;
4.-Set up Linting with ESLint
- We need to install
@typescript-eslint/eslint-pluginand@typescript-eslint/parser.
Juan.Pablo.Perez@RIMDUB-0232 MINGW64 /c/Work/Training/Pre/VueJs/full-stack-vue-with-graphql-the-ultimate-guide-nuxt/client (master)
$ npm i -D @typescript-eslint/eslint-plugin @typescript-eslint/parser
npm WARN @nuxtjs/vuetify@0.5.5 requires a peer of vuetify-loader@^1.2.1 but none is installed. You must install peer dependencies yourself.
npm WARN optional SKIPPING OPTIONAL DEPENDENCY: fsevents@1.2.9 (node_modules\watchpack\node_modules\fsevents):
npm WARN notsup SKIPPING OPTIONAL DEPENDENCY: Unsupported platform for fsevents@1.2.9: wanted {"os":"darwin","arch":"any"} (current: {"os":"win32","arch":"x64"})
npm WARN optional SKIPPING OPTIONAL DEPENDENCY: fsevents@1.2.9 (node_modules\nodemon\node_modules\fsevents):
npm WARN notsup SKIPPING OPTIONAL DEPENDENCY: Unsupported platform for fsevents@1.2.9: wanted {"os":"darwin","arch":"any"} (current: {"os":"win32","arch":"x64"})
npm WARN optional SKIPPING OPTIONAL DEPENDENCY: fsevents@1.2.9 (node_modules\jest-haste-map\node_modules\fsevents):
npm WARN notsup SKIPPING OPTIONAL DEPENDENCY: Unsupported platform for fsevents@1.2.9: wanted {"os":"darwin","arch":"any"} (current: {"os":"win32","arch":"x64"})
npm WARN optional SKIPPING OPTIONAL DEPENDENCY: fsevents@1.2.9 (node_modules\fork-ts-checker-webpack-plugin\node_modules\fsevents):
npm WARN notsup SKIPPING OPTIONAL DEPENDENCY: Unsupported platform for fsevents@1.2.9: wanted {"os":"darwin","arch":"any"} (current: {"os":"win32","arch":"x64"})
npm WARN optional SKIPPING OPTIONAL DEPENDENCY: fsevents@2.0.7 (node_modules\fsevents):
npm WARN notsup SKIPPING OPTIONAL DEPENDENCY: Unsupported platform for fsevents@2.0.7: wanted {"os":"darwin","arch":"any"} (current: {"os":"win32","arch":"x64"})
+ @typescript-eslint/parser@1.10.2
+ @typescript-eslint/eslint-plugin@1.10.2
added 8 packages from 5 contributors and audited 884247 packages in 64.087s
found 0 vulnerabilities
- We need to edit the ESLint configuration
.eslintrc.jsdocument by adding the@typescript-eslintplugin and making@typescript-eslint/parserthe default parser.
.eslintrc.js (before changes)
module.exports = {
root: true,
env: {
browser: true,
node: true
},
parserOptions: {
parser: 'babel-eslint'
},
extends: [
'@nuxtjs',
'plugin:nuxt/recommended',
'plugin:prettier/recommended',
'prettier',
'prettier/vue'
],
plugins: [
'prettier'
],
// add your custom rules here
rules: {
"vue/component-name-in-template-casing": ["error", "PascalCase"],
"no-console": process.env.NODE_ENV === "production" ? "error" : "off",
"no-debugger": process.env.NODE_ENV === "production" ? "error" : "off"
},
}
.eslintrc.js (after changes)
module.exports = {
plugins: ['@typescript-eslint','prettier'],
root: true,
env: {
browser: true,
node: true
},
parserOptions: {
parser: '@typescript-eslint/parser'
},
extends: [
'@nuxtjs',
'plugin:nuxt/recommended',
'plugin:prettier/recommended',
'prettier',
'prettier/vue'
],
// add your custom rules here
rules: {
'@typescript-eslint/no-unused-vars': 'error',
"vue/component-name-in-template-casing": ["error", "PascalCase"],
"no-console": process.env.NODE_ENV === "production" ? "error" : "off",
"no-debugger": process.env.NODE_ENV === "production" ? "error" : "off"
},
}
- Finally, add or edit the lint script of the
package.jsondocument.
. package.json
{
"name": "client",
"version": "1.0.0",
"description": "Frontend for the Nuxt version of [Full-Stack Vue with GraphQL - The Ultimate Guide",
"author": "Juan Pablo Perez",
"private": true,
"scripts": {
"lint": "eslint --ext .ts,.js,.vue --ignore-path .gitignore .",
"precommit": "npm run lint",
"test": "jest",
"dev": "nuxt",
"build": "nuxt build",
"start": "nuxt start",
"generate": "nuxt generate"
},
"dependencies": {
"@nuxtjs/pwa": "^2.6.0",
"@nuxtjs/vuetify": "0.5.5",
"nuxt": "^2.0.0",
"nuxt-i18n": "^5.12.7",
"ts-node": "^8.3.0"
},
"devDependencies": {
"@nuxt/typescript": "^2.8.1",
"@nuxtjs/eslint-config": "^0.0.1",
"@nuxtjs/eslint-module": "^0.0.1",
"@typescript-eslint/eslint-plugin": "^1.10.2",
"@typescript-eslint/parser": "^1.10.2",
"@vue/test-utils": "^1.0.0-beta.27",
"babel-core": "7.0.0-bridge.0",
"babel-eslint": "^10.0.1",
"babel-jest": "^24.1.0",
"eslint": "^5.15.1",
"eslint-config-prettier": "^4.1.0",
"eslint-config-standard": ">=12.0.0",
"eslint-plugin-import": ">=2.16.0",
"eslint-plugin-jest": ">=22.3.0",
"eslint-plugin-node": ">=8.0.1",
"eslint-plugin-nuxt": ">=0.4.2",
"eslint-plugin-prettier": "^3.0.1",
"eslint-plugin-promise": ">=4.0.1",
"eslint-plugin-standard": ">=4.0.0",
"eslint-plugin-vue": "^5.2.2",
"jest": "^24.1.0",
"nodemon": "^1.18.9",
"prettier": "^1.16.4",
"stylus": "^0.54.5",
"stylus-loader": "^3.0.2",
"vue-jest": "^3.0.3"
}
}
5.-Modify the pages to use TypeScript
- We need to install the
vue-property-decoratorpackage that is going to help usingTypeScriptsinVuedocuments.
Juan.Pablo.Perez@RIMDUB-0232 MINGW64 /c/Work/Training/Pre/VueJs/full-stack-vue-with-graphql-the-ultimate-guide-nuxt (master)
$ npm i vue-property-decorator
npm WARN full-stack-vue-with-graphql-the-ultimate-guide-nuxt@1.0.0 scripts['server'] should probably be scripts['start'].
+ vue-property-decorator@8.2.1
added 3 packages from 2 contributors and audited 106 packages in 6.878s
found 0 vulnerabilities
layouts/default.vue
<template>
<v-app style="background: #E3E3EE">
<!-- Side Navbar -->
<v-navigation-drawer v-model="sideNav" fixed app temporary>
<v-toolbar color="accent" dark flat>
<v-toolbar-side-icon @click="toggleSideNav"></v-toolbar-side-icon>
<nuxt-link to="/" tag="span" style="cursor: pointer">
<h1 class="title pl-3">VueShare</h1>
</nuxt-link>
</v-toolbar>
<v-divider></v-divider>
<!-- Side Navbar Links -->
<v-list>
<v-list-tile
v-for="(item, i) in sideNavItems"
:key="i"
:to="item.link"
ripple
>
<v-list-tile-action>
<v-icon>{{ item.icon }}</v-icon>
</v-list-tile-action>
<v-list-tile-content>
{{ item.title }}
</v-list-tile-content>
</v-list-tile>
</v-list>
</v-navigation-drawer>
<!-- Horizontal Navbar -->
<v-toolbar fixed color="primary" dark>
<!-- App Title -->
<v-toolbar-side-icon @click="toggleSideNav"></v-toolbar-side-icon>
<v-toolbar-title class="hidden-xs-only">
<nuxt-link to="/" tag="span" style="cursor: pointer">
VueShare
</nuxt-link>
</v-toolbar-title>
<v-spacer></v-spacer>
<!-- Search Input -->
<v-text-field
flex
prepend-icon="search"
placeholder="Search posts"
color="accent"
single-line
hide-details
></v-text-field>
<v-spacer></v-spacer>
<!-- Horizontal Navbar Links -->
<v-toolbar-items class="hidden-xs-only">
<v-btn
v-for="(item, i) in horizontalNavItems"
:key="i"
:to="item.link"
flat
>
<v-icon class="hidden-sm-only" left>{{ item.icon }}</v-icon>
{{ item.title }}
</v-btn>
</v-toolbar-items>
</v-toolbar>
<v-content>
<v-container>
<nuxt />
</v-container>
</v-content>
</v-app>
</template>
<script lang="ts">
import { Vue } from 'vue-property-decorator'
export default class Layout extends Vue {
sideNav: boolean = false
// computed
get horizontalNavItems() {
return [
{ icon: 'chat', title: 'Posts', link: '/posts' },
{ icon: 'lock_open', title: 'Sign In', link: '/signin' },
{ icon: 'create', title: 'Sign Up', link: '/signup' }
]
}
get sideNavItems() {
return [
{ icon: 'chat', title: 'Posts', link: '/posts' },
{ icon: 'lock_open', title: 'Sign In', link: '/signin' },
{ icon: 'create', title: 'Sign Up', link: '/signup' }
]
}
// methods
toggleSideNav() {
this.sideNav = !this.sideNav
}
}
</script>
layout/error.vue (It hasn't been changed)
<template>
<v-app dark>
<h1 v-if="error.statusCode === 404">
{{ pageNotFound }}
</h1>
<h1 v-else>
{{ otherError }}
</h1>
<NuxtLink to="/">
Home page
</NuxtLink>
</v-app>
</template>
<script>
export default {
layout: 'empty',
props: {
error: {
type: Object,
default: null
}
},
head() {
const title =
this.error.statusCode === 404 ? this.pageNotFound : this.otherError
return {
title
}
},
data() {
return {
pageNotFound: '404 Not Found',
otherError: 'An error occurred'
}
}
}
</script>
<style scoped>
h1 {
font-size: 20px;
}
</style>
pages/posts/add.Vue
<template>
<v-container>
<h1>Add Post</h1>
</v-container>
</template>
<script lang="ts">
import { Vue } from 'vue-property-decorator'
export default class AddPostPage extends Vue {
name: string = 'AddPost'
}
</script>
pages/posts/index.Vue
<template>
<v-container>
<h1>Posts</h1>
</v-container>
</template>
<script lang="ts">
import { Vue } from 'vue-property-decorator'
export default class Posts extends Vue {
name: string = 'Posts'
}
</script>
pages/profile/index.Vue
<template>
<v-container>
<h1>Posts</h1>
</v-container>
</template>
<script lang="ts">
import { Vue } from 'vue-property-decorator'
export default class Posts extends Vue {
name: string = 'Posts'
}
</script>
pages/signin/index.Vue
<template>
<v-container>
<h1>Posts</h1>
</v-container>
</template>
<script lang="ts">
import { Vue } from 'vue-property-decorator'
export default class Posts extends Vue {
name: string = 'Posts'
}
</script>
pages/signup/index.Vue
<template>
<v-container>
<h1>Posts</h1>
</v-container>
</template>
<script lang="ts">
import { Vue } from 'vue-property-decorator'
export default class Posts extends Vue {
name: string = 'Posts'
}
</script>
pages/index.Vue
<template>
<v-container>
<h1>Home</h1>
</v-container>
</template>
<script lang="ts">
import { Vue } from 'vue-property-decorator'
export default class HomePage extends Vue {
name: string = 'Home'
}
</script>
6.-Run the application to ensure it's working
- Browse to http://localhost:3000/

- It doesn't work. It seems as if the
layout/default.vueis not loaded.
7.-Create the typescript branch to keep the changes
- We are going to create a new branch to keep the changes applied but we are not going to use
TypeScriptat the moment. Follow the Move existing, uncommitted work to a new branch in Git to create the new branch.
Juan.Pablo.Perez@RIMDUB-0232 MINGW64 /c/Work/Training/Pre/VueJs/full-stack-vue-with-graphql-the-ultimate-guide-nuxt (master)
$ git checkout -b typescript
Switched to a new branch 'typescript'
M client/.eslintrc.js
M client/layouts/default.vue
D client/nuxt.config.js
M client/package-lock.json
M client/package.json
M client/pages/index.vue
M client/pages/posts/add.vue
M client/pages/posts/index.vue
M client/pages/profile/index.vue
M client/pages/signin/index.vue
M client/pages/signup/index.vue
M package-lock.json
M package.json
Juan.Pablo.Perez@RIMDUB-0232 MINGW64 /c/Work/Training/Pre/VueJs/full-stack-vue-with-graphql-the-ultimate-guide-nuxt (typescript)
$ git add .
warning: LF will be replaced by CRLF in client/.eslintrc.js.
The file will have its original line endings in your working directory
.
.
.
Juan.Pablo.Perez@RIMDUB-0232 MINGW64 /c/Work/Training/Pre/VueJs/full-stack-vue-with-graphql-the-ultimate-guide-nuxt (typescript)
$ git status
On branch typescript
Changes to be committed:
(use "git reset HEAD <file>..." to unstage)
modified: client/.eslintrc.js
modified: client/layouts/default.vue
renamed: client/nuxt.config.js -> client/nuxt.config.ts
modified: client/package-lock.json
modified: client/package.json
modified: client/pages/index.vue
modified: client/pages/posts/add.vue
modified: client/pages/posts/index.vue
modified: client/pages/profile/index.vue
modified: client/pages/signin/index.vue
modified: client/pages/signup/index.vue
new file: client/tsconfig.json
modified: package-lock.json
modified: package.json
- Push the changes
Juan.Pablo.Perez@RIMDUB-0232 MINGW64 /c/Work/Training/Pre/VueJs/full-stack-vue-with-graphql-the-ultimate-guide-nuxt (typescript)
$ git push --set-upstream origin typescript
Enumerating objects: 42, done.
Counting objects: 100% (42/42), done.
Delta compression using up to 4 threads
Compressing objects: 100% (20/20), done.
Writing objects: 100% (23/23), 12.30 KiB | 286.00 KiB/s, done.
Total 23 (delta 12), reused 0 (delta 0)
remote: Resolving deltas: 100% (12/12), completed with 6 local objects.
remote:
remote: Create a pull request for 'typescript' on GitHub by visiting:
remote: https://github.com/peelmicro/full-stack-vue-with-graphql-the-ultimate-guide-nuxt/pull/new/typescript
remote:
To https://github.com/peelmicro/full-stack-vue-with-graphql-the-ultimate-guide-nuxt.git
* [new branch] typescript -> typescript
Branch 'typescript' set up to track remote branch 'typescript' from 'origin'.
- Go back to the
masterbranch
Juan.Pablo.Perez@RIMDUB-0232 MINGW64 /c/Work/Training/Pre/VueJs/full-stack-vue-with-graphql-the-ultimate-guide-nuxt (typescript)
$ git checkout master
Switched to branch 'master'
Your branch is up to date with 'origin/master'.
VI.-Set up multi-language
1.-Base the multi-language on the A simple multi-language site with Nuxt.js and nuxt-i18n articule
- We are going to follow the A simple multi-language site with Nuxt.js and nuxt-i18n articule and the nuxt-i18n web site.
2.-Install the nuxt-i18n package
Juan.Pablo.Perez@RIMDUB-0232 MINGW64 /c/Work/Training/Pre/VueJs/full-stack-vue-with-graphql-the-ultimate-guide-nuxt/client (master)
$ npm i nuxt-i18n --save
npm WARN @nuxtjs/vuetify@0.5.5 requires a peer of vuetify-loader@^1.2.1 but none is installed. You must install peer dependencies yourself.
npm WARN optional SKIPPING OPTIONAL DEPENDENCY: fsevents@2.0.7 (node_modules\fsevents):
npm WARN notsup SKIPPING OPTIONAL DEPENDENCY: Unsupported platform for fsevents@2.0.7: wanted {"os":"darwin","arch":"any"} (current: {"os":"win32","arch":"x64"})
npm WARN optional SKIPPING OPTIONAL DEPENDENCY: fsevents@1.2.9 (node_modules\watchpack\node_modules\fsevents):
npm WARN notsup SKIPPING OPTIONAL DEPENDENCY: Unsupported platform for fsevents@1.2.9: wanted {"os":"darwin","arch":"any"} (current: {"os":"win32","arch":"x64"})
npm WARN optional SKIPPING OPTIONAL DEPENDENCY: fsevents@1.2.9 (node_modules\nodemon\node_modules\fsevents):
npm WARN notsup SKIPPING OPTIONAL DEPENDENCY: Unsupported platform for fsevents@1.2.9: wanted {"os":"darwin","arch":"any"} (current: {"os":"win32","arch":"x64"})
npm WARN optional SKIPPING OPTIONAL DEPENDENCY: fsevents@1.2.9 (node_modules\jest-haste-map\node_modules\fsevents):
npm WARN notsup SKIPPING OPTIONAL DEPENDENCY: Unsupported platform for fsevents@1.2.9: wanted {"os":"darwin","arch":"any"} (current: {"os":"win32","arch":"x64"})
+ nuxt-i18n@5.12.7
added 7 packages from 6 contributors and audited 880983 packages in 37.288s
found 0 vulnerabilities
- Fix the
warndetected
Juan.Pablo.Perez@RIMDUB-0232 MINGW64 /c/Work/Training/Pre/VueJs/full-stack-vue-with-graphql-the-ultimate-guide-nuxt/client (master)
$ npm i vuetify-loader
npm WARN optional SKIPPING OPTIONAL DEPENDENCY: fsevents@2.0.7 (node_modules\fsevents):
npm WARN notsup SKIPPING OPTIONAL DEPENDENCY: Unsupported platform for fsevents@2.0.7: wanted {"os":"darwin","arch":"any"} (current: {"os":"win32","arch":"x64"})
npm WARN optional SKIPPING OPTIONAL DEPENDENCY: fsevents@1.2.9 (node_modules\watchpack\node_modules\fsevents):
npm WARN notsup SKIPPING OPTIONAL DEPENDENCY: Unsupported platform for fsevents@1.2.9: wanted {"os":"darwin","arch":"any"} (current: {"os":"win32","arch":"x64"})
npm WARN optional SKIPPING OPTIONAL DEPENDENCY: fsevents@1.2.9 (node_modules\nodemon\node_modules\fsevents):
npm WARN notsup SKIPPING OPTIONAL DEPENDENCY: Unsupported platform for fsevents@1.2.9: wanted {"os":"darwin","arch":"any"} (current: {"os":"win32","arch":"x64"})
npm WARN optional SKIPPING OPTIONAL DEPENDENCY: fsevents@1.2.9 (node_modules\jest-haste-map\node_modules\fsevents):
npm WARN notsup SKIPPING OPTIONAL DEPENDENCY: Unsupported platform for fsevents@1.2.9: wanted {"os":"darwin","arch":"any"} (current: {"os":"win32","arch":"x64"})
+ vuetify-loader@1.2.2
added 1 package and audited 880989 packages in 44.82s
found 0 vulnerabilities
3.-Create the lang folder and the translations documents
- We need to create the
langfolder and thees-ES.jsfor theSpanishtranslations and theen-US.jsfor theEnglishtranslations.
client/lang/es-ES.js
export default {
posts: 'Entradas',
signin: 'Iniciar sesión',
signup: 'Inscripción',
searchposts: 'Buscar entradas',
home: 'Inicio'
}
client/lang/en-US.js
export default {
posts: 'Posts',
signin: 'Sign in',
signup: 'Sign up',
searchposts: 'Search Posts',
home: 'Home'
}
4.-Modify the nuxt.config.js document to set up the pluging and the initial languages
nuxt.config.js
import es from './lang/es-ES.js'
import en from './lang/en-US.js'
// import colors from 'vuetify/es5/util/colors'
export default {
mode: 'universal',
/*
** Headers of the page
*/
head: {
titleTemplate: '%s - ' + process.env.npm_package_name,
title: process.env.npm_package_name || '',
meta: [
{ charset: 'utf-8' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
{
hid: 'description',
name: 'description',
content: process.env.npm_package_description || ''
}
],
link: [
{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' },
{
rel: 'stylesheet',
href:
'https://fonts.googleapis.com/css?family=Roboto:300,400,500,700|Material+Icons'
}
]
},
/*
** Customize the progress-bar color
*/
loading: { color: '#fff' },
/*
** Global CSS
*/
css: [],
/*
** Plugins to load before mounting the App
*/
plugins: [],
/*
** Nuxt.js modules
*/
modules: [
[
'nuxt-i18n',
{
seo: false,
locales: [
{
name: 'ESP',
code: 'es',
iso: 'es-ES'
},
{
name: 'ENG',
code: 'en',
iso: 'en-US'
}
],
strategy: 'prefix_and_default',
langDir: 'lang/',
defaultLocale: 'es',
vueI18n: {
fallbackLocale: 'es',
messages: { es, en }
}
}
],
'@nuxtjs/vuetify',
'@nuxtjs/pwa',
'@nuxtjs/eslint-module'
],
/*
** vuetify module configuration
** https://github.com/nuxt-community/vuetify-module
*/
vuetify: {
theme: {
// primary: colors.blue.darken2,
// accent: colors.grey.darken3,
// secondary: colors.amber.darken3,
// info: colors.teal.lighten1,
// warning: colors.amber.base,
// error: colors.deepOrange.accent4,
// success: colors.green.accent3
primary: '#3B125F',
secondary: '#8B5FBF',
accent: '#BF653F',
error: '#722530',
warning: '#A37513',
info: '#396893',
success: '#4caf50'
}
},
/*
** Build configuration
*/
build: {
/*
** You can extend webpack config here
*/
extend(config, ctx) {}
}
}
5.-Modify the layout to include a lang-switcher button.
client/layouts/default.vue
<template>
<v-app style="background: #E3E3EE">
<!-- Side Navbar -->
<v-navigation-drawer v-model="sideNav" fixed app temporary>
<v-toolbar color="accent" dark flat>
<v-toolbar-side-icon @click="toggleSideNav"></v-toolbar-side-icon>
<nuxt-link :to="localePath('index')" tag="span" style="cursor: pointer">
<h1 class="title pl-3">VueShare</h1>
</nuxt-link>
</v-toolbar>
<v-divider></v-divider>
<!-- Side Navbar Links -->
<v-list>
<v-list-tile
v-for="(item, i) in sideNavItems"
:key="i"
:to="localePath(item.link)"
ripple
>
<v-list-tile-action>
<v-icon>{{ item.icon }}</v-icon>
</v-list-tile-action>
<v-list-tile-content>
{{ item.title }}
</v-list-tile-content>
</v-list-tile>
</v-list>
</v-navigation-drawer>
<!-- Horizontal Navbar -->
<v-toolbar fixed color="primary" dark>
<!-- App Title -->
<v-toolbar-side-icon @click="toggleSideNav"></v-toolbar-side-icon>
<v-toolbar-title class="hidden-xs-only">
<nuxt-link :to="localePath('index')" tag="span" style="cursor: pointer">
VueShare
</nuxt-link>
</v-toolbar-title>
<v-spacer></v-spacer>
<!-- Search Input -->
<v-text-field
flex
prepend-icon="search"
color="accent"
single-line
hide-details
:placeholder="$t('searchposts')"
></v-text-field>
<v-spacer></v-spacer>
<!-- Horizontal Navbar Links -->
<v-toolbar-items class="hidden-xs-only">
<v-btn
v-for="(item, i) in horizontalNavItems"
:key="i"
:to="localePath(item.link)"
flat
>
<v-icon class="hidden-sm-only" left>{{ item.icon }}</v-icon>
{{ item.title }}
</v-btn>
</v-toolbar-items>
<v-toolbar-title class="hidden-xs-only">
<nuxt-link
v-for="(locale, i) in showLocales"
:key="i"
tag="span"
style="cursor: pointer"
class="lang-switcher"
:to="switchLocalePath(locale.code)"
>
{{ locale.name }}
</nuxt-link>
</v-toolbar-title>
</v-toolbar>
<v-content>
<v-container class="mt-4">
<transition name="fade">
<nuxt />
</transition>
</v-container>
</v-content>
</v-app>
</template>
<script>
export default {
head() {
return this.$nuxtI18nSeo()
},
data() {
return {
sideNav: false
}
},
computed: {
horizontalNavItems() {
return [
{ icon: 'chat', title: this.$i18n.t('posts'), link: 'posts' },
{ icon: 'lock_open', title: this.$i18n.t('signin'), link: 'signin' },
{ icon: 'create', title: this.$i18n.t('signup'), link: 'signup' }
]
},
sideNavItems() {
return [
{ icon: 'chat', title: this.$i18n.t('posts'), link: 'posts' },
{ icon: 'lock_open', title: this.$i18n.t('signin'), link: 'signin' },
{ icon: 'create', title: this.$i18n.t('signup'), link: 'signup' }
]
},
showLocales() {
return this.$i18n.locales.filter(
locale => locale.code !== this.$i18n.locale
)
}
},
methods: {
toggleSideNav() {
this.sideNav = !this.sideNav
}
}
}
</script>
<style>
.fade-enter-active,
.fade-leave-active {
transition-property: opacity;
transition-duration: 0.25s;
}
.fade-enter-active {
transition-delay: 0.25s;
}
.fade-enter,
.fade-leave-active {
opacity: 0;
}
</style>
6.-Test if the multi-language works with the changes applied





Section 6: Using Vue Apollo 0 / 4|24min
I.-Setting up Apollo Client / Vue Apollo, Firing getPosts Query from Client
- We are going to use the @nuxtjs/apollo package. We need to install the
@nuxtjs/apolloand graphql-tag packages.
Juan.Pablo.Perez@RIMDUB-0232 MINGW64 /c/Work/Training/Pre/VueJs/full-stack-vue-with-graphql-the-ultimate-guide-nuxt/client (master)
$ npm i @nuxtjs/apollo graphql-tag
> core-js@3.1.4 postinstall C:\Work\Training\Pre\VueJs\full-stack-vue-with-graphql-the-ultimate-guide-nuxt\client\node_modules\apollo-env\node_modules\core-js
> node scripts/postinstall || echo "ignore"
Thank you for using core-js ( https://github.com/zloirock/core-js ) for polyfilling JavaScript standard library!
The project needs your help! Please consider supporting of core-js on Open Collective or Patreon:
> https://opencollective.com/core-js
> https://www.patreon.com/zloirock
Also, the author of core-js ( https://github.com/zloirock ) is looking for a good job -)
> protobufjs@6.8.8 postinstall C:\Work\Training\Pre\VueJs\full-stack-vue-with-graphql-the-ultimate-guide-nuxt\client\node_modules\protobufjs
> node scripts/postinstall
npm WARN vue-cli-plugin-apollo@0.20.0 requires a peer of @vue/cli-shared-utils@^3.0.0 but none is installed. You must install peer dependencies yourself.
npm WARN ts-node@8.3.0 requires a peer of typescript@>=2.0 but none is installed. You must install peer dependencies yourself.
npm WARN optional SKIPPING OPTIONAL DEPENDENCY: fsevents@1.2.9 (node_modules\watchpack\node_modules\fsevents):
npm WARN notsup SKIPPING OPTIONAL DEPENDENCY: Unsupported platform for fsevents@1.2.9: wanted {"os":"darwin","arch":"any"} (current: {"os":"win32","arch":"x64"})
npm WARN optional SKIPPING OPTIONAL DEPENDENCY: fsevents@1.2.9 (node_modules\nodemon\node_modules\fsevents):
npm WARN notsup SKIPPING OPTIONAL DEPENDENCY: Unsupported platform for fsevents@1.2.9: wanted {"os":"darwin","arch":"any"} (current: {"os":"win32","arch":"x64"})
npm WARN optional SKIPPING OPTIONAL DEPENDENCY: fsevents@1.2.9 (node_modules\jest-haste-map\node_modules\fsevents):
npm WARN notsup SKIPPING OPTIONAL DEPENDENCY: Unsupported platform for fsevents@1.2.9: wanted {"os":"darwin","arch":"any"} (current: {"os":"win32","arch":"x64"})
npm WARN optional SKIPPING OPTIONAL DEPENDENCY: fsevents@2.0.7 (node_modules\fsevents):
npm WARN notsup SKIPPING OPTIONAL DEPENDENCY: Unsupported platform for fsevents@2.0.7: wanted {"os":"darwin","arch":"any"} (current: {"os":"win32","arch":"x64"})
+ graphql-tag@2.10.1
+ @nuxtjs/apollo@4.0.0-rc7
added 100 packages from 126 contributors and audited 883960 packages in 64.792s
found 0 vulnerabilities
- Create the
apollofolder and theapollo/customErrorHandler.jsdocument.
apollo/customErrorHandler.js
export default (err, { error }) => {
console.log(err)
error({ statusCode: 304, message: 'Server error!' })
}
- Create the
gqlfolder and thegql/getPosts.gqldocument.
gql/getPosts.gql
query {
getPosts {
_id
title
imageUrl
description
likes
}
}
- We are going to modify the
client/nuxt.config.jsdocument to set up the use of the@nuxtjs/apollopackage.
client/nuxt.config.js
import es from './lang/es-ES.js'
import en from './lang/en-US.js'
// import colors from 'vuetify/es5/util/colors'
export default {
mode: 'universal',
/*
** Headers of the page
*/
head: {
titleTemplate: '%s - ' + process.env.npm_package_name,
title: process.env.npm_package_name || '',
meta: [
{ charset: 'utf-8' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
{
hid: 'description',
name: 'description',
content: process.env.npm_package_description || ''
}
],
link: [
{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' },
{
rel: 'stylesheet',
href:
'https://fonts.googleapis.com/css?family=Roboto:300,400,500,700|Material+Icons'
}
]
},
/*
** Customize the progress-bar color
*/
loading: { color: '#fff' },
/*
** Global CSS
*/
css: [],
/*
** Plugins to load before mounting the App
*/
plugins: [],
/*
** Nuxt.js modules
*/
modules: [
[
'nuxt-i18n',
{
seo: false,
locales: [
{
name: 'ESP',
code: 'es',
iso: 'es-ES'
},
{
name: 'ENG',
code: 'en',
iso: 'en-US'
}
],
strategy: 'prefix_and_default',
langDir: 'lang/',
defaultLocale: 'es',
vueI18n: {
fallbackLocale: 'es',
messages: { es, en }
}
}
],
'@nuxtjs/vuetify',
'@nuxtjs/pwa',
'@nuxtjs/eslint-module',
'@nuxtjs/apollo'
],
apollo: {
errorHandler: '~/apollo/customErrorHandler.js',
clientConfigs: {
default: {
httpEndpoint:
process.env.HTTP_ENDPOINT || 'http://localhost:4000/graphql'
}
}
},
/*
** vuetify module configuration
** https://github.com/nuxt-community/vuetify-module
*/
vuetify: {
theme: {
// primary: colors.blue.darken2,
// accent: colors.grey.darken3,
// secondary: colors.amber.darken3,
// info: colors.teal.lighten1,
// warning: colors.amber.base,
// error: colors.deepOrange.accent4,
// success: colors.green.accent3
primary: '#3B125F',
secondary: '#8B5FBF',
accent: '#BF653F',
error: '#722530',
warning: '#A37513',
info: '#396893',
success: '#4caf50'
}
},
/*
** Build configuration
*/
build: {
/*
** You can extend webpack config here
*/
extend(config, ctx) {}
}
}
- We are going to modify the
client/pages/index.vuedocument to include a call to thegetPostsgraphqlqueryfrom the server.
client/pages/index.vue
<template>
<v-container>
<h1>Home</h1>
<ul v-for="post in getPosts" :key="post._id">
<li>{{ post.title }} {{ post.imageUrl }} {{ post.description }}</li>
<li>{{ post.likes }}</li>
</ul>
</v-container>
</template>
<script>
import { getPosts } from '~/gql/getPosts.gql'
export default {
name: 'Home',
apollo: {
getPosts: {
query: getPosts
}
}
}
</script>
- We need top test if everything works correctly.


- We are going to modify the
client/pages/index.vuedocument to include theCorousel Vuetifycomponents.
client/pages/index.vue
<template>
<v-container v-if="getPosts" text-xs-center>
<v-flex xs12>
<v-carousel v-bind="{ cycle: true }" interval="3000">
<v-carousel-item
v-for="post in getPosts"
:key="post._id"
:src="post.imageUrl"
>
<h1 id="carousel__title">{{ post.title }}</h1>
</v-carousel-item>
</v-carousel>
</v-flex>
</v-container>
</template>
<script>
import { getPosts } from '~/gql/getPosts.gql'
export default {
name: 'Home',
apollo: {
getPosts: {
query: getPosts
}
}
}
</script>
<style>
#carousel__title {
position: absolute;
background-color: rgba(0, 0, 0, 0.5);
color: white;
border-radius: 5px 5px 0 0;
padding: 0.5em;
margin: 0 auto;
bottom: 50px;
left: 0;
right: 0;
}
</style>


Section 7: Integrate Vuex with ApolloClient
I.Create the store to manage the GraphQL getPosts query
client\store\index.js
import { getPosts } from '~/gql/getPosts.gql'
export const state = () => ({
posts: [],
loading: false
})
export const mutations = {
setPosts: (state, payload) => {
state.posts = payload
},
setLoading: (state, payload) => {
state.loading = payload
}
}
export const actions = {
async nuxtServerInit({ dispatch }) {
await dispatch('getPosts')
},
async getPosts({ commit }) {
commit('setLoading', true)
try {
const result = await this.app.apolloProvider.defaultClient.query({
query: getPosts
})
commit('setPosts', result.data.getPosts)
} catch (error) {
console.log(error)
}
commit('setLoading', false)
}
}
export const getters = {
posts: state => state.posts,
loading: state => state.loading
}
II.-Modify the Home page to read the Posts from the store
client\pages\index.vue
<template>
<v-container text-xs-center>
<v-layout row>
<v-dialog v-model="loading" persistent fullscreen>
<v-container fill-height>
<v-layout row justify-center align-center>
<v-progress-circular
indeterminate
:size="70"
:width="7"
color="secondary"
></v-progress-circular>
</v-layout>
</v-container>
</v-dialog>
</v-layout>
<v-flex xs12>
<v-carousel
v-if="!loading && posts.length > 0"
v-bind="{ cycle: true }"
interval="3000"
>
<v-carousel-item
v-for="post in posts"
:key="post._id"
:src="post.imageUrl"
>
<h1 id="carousel__title">{{ post.title }}</h1>
</v-carousel-item>
</v-carousel>
</v-flex>
</v-container>
</template>
<script>
import { mapGetters } from 'vuex'
export default {
name: 'Home',
computed: {
...mapGetters(['loading', 'posts'])
}
}
</script>
<style>
#carousel__title {
position: absolute;
background-color: rgba(0, 0, 0, 0.5);
color: white;
border-radius: 5px 5px 0 0;
padding: 0.5em;
margin: 0 auto;
bottom: 50px;
left: 0;
right: 0;
}
</style>
III.-Test the integration of Vuex with ApolloClient



Section 8: JWT Authentication for Signin / Signup
I.-Create Gravatar Avatar and Hash User Passwords on Signup
- We need to install the MD5 package, a JavaScript function for hashing messages with MD5, and the node.bcrypt.js package, A library to help you hash passwords.
Juan.Pablo.Perez@RIMDUB-0232 MINGW64 /c/Work/Training/Pre/VueJs/full-stack-vue-with-graphql-the-ultimate-guide-nuxt (master)
$ npm i md5 bcrypt
> bcrypt@3.0.6 install C:\Work\Training\Pre\VueJs\full-stack-vue-with-graphql-the-ultimate-guide-nuxt\node_modules\bcrypt
> node-pre-gyp install --fallback-to-build
node-pre-gyp WARN Using needle for node-pre-gyp https download
[bcrypt] Success: "C:\Work\Training\Pre\VueJs\full-stack-vue-with-graphql-the-ultimate-guide-nuxt\node_modules\bcrypt\lib\binding\bcrypt_lib.node" is installed via remote
npm WARN full-stack-vue-with-graphql-the-ultimate-guide-nuxt@1.0.0 scripts['server'] should probably be scripts['start'].
+ md5@2.2.1
+ bcrypt@3.0.6
added 65 packages from 51 contributors and audited 202 packages in 8.927s
found 0 vulnerabilities
Juan.Pablo.Perez@RIMDUB-0232 MINGW64 /c/Work/Training/Pre/VueJs/full-stack-vue-with-graphql-the-ultimate-guide-nuxt (master)
$ npm --save-devnpm i --save-dev @types/md5 @types/bcrypt
npm WARN full-stack-vue-with-graphql-the-ultimate-guide-nuxt@1.0.0 scripts['server'] should probably be scripts['start'].
+ @types/md5@2.1.33
+ @types/bcrypt@3.0.0
added 3 packages from 41 contributors and audited 205 packages in 5.411s
found 0 vulnerabilities
- We need to modify the
User's Modelto put the@propattribute to theavatarfield.
server\src\users\model\user.model.ts
import * as mongoose from "mongoose";
import { prop, Typegoose } from 'typegoose';
import { IsString, IsDate, IsArray } from 'class-validator';
export class User extends Typegoose {
@IsString()
@prop({ required: true, unique: true, trim: true })
username: string;
@IsString()
@prop({ required: true, trim: true })
email: string;
@IsString()
@prop({ required: true, trim: true })
password: string;
@IsString()
@prop()
avatar?: string;
@IsDate()
@prop({ default: Date.now })
joinDate: Date
@IsArray()
@prop({ required: true, ref: "Post" })
favorites: [mongoose.Schema.Types.ObjectId]
}
- We need to modify the
User Serviceto assign a ramndom avatar to the user and to store the password hashed.
server\src\users\users.service.ts
import * as mongoose from "mongoose";
import { prop, Typegoose } from 'typegoose';
import { IsString, IsDate, IsArray } from 'class-validator';
export class User extends Typegoose {
@IsString()
@prop({ required: true, unique: true, trim: true })
username: string;
@IsString()
@prop({ required: true, trim: true })
email: string;
@IsString()
@prop({ required: true, trim: true })
password: string;
@IsString()
@prop()
avatar?: string;
@IsDate()
@prop({ default: Date.now })
joinDate: Date
@IsArray()
@prop({ required: true, ref: "Post" })
favorites: [mongoose.Schema.Types.ObjectId]
}
- We can test if the request to sign up a new user is working properly now.
mutation {
signupUserWithInput(
input: { username: "John", email: "john@google.com", password: "John" }
) {
_id
username
email
password
avatar
joinDate
favorites
}
}
{
"data": {
"signupUserWithInput": {
"_id": "5d36a9e007bced421c3a2031",
"username": "John",
"email": "john@google.com",
"password": "$2b$10$OPv9/YQC1zZatdGEDw5YYOjiUE2omaBjuiSn5Bf9cJXahre7A/hNO",
"avatar": "http://gravatar.com/avatar/61409aa1fd47d4a5332de23cbf59a36f?d=identicon",
"joinDate": "2019-07-23T06:32:00.328Z",
"favorites": null
}
}
}
II.-Modify the server app to include the authentication.
1.-Install the passport and jwt packaged needed to manage the authentication
- We need to install the @nestjs/jwt, @nestjs/passport, passport and passport-jwt packages.
Juan.Pablo.Perez@RIMDUB-0232 MINGW64 /c/Work/Training/Pre/VueJs/full-stack-vue-with-graphql-the-ultimate-guide-nuxt/server (master)
$ npm i @nestjs/jwt @nestjs/passport passport passport-jwt
npm WARN server@0.0.1 No repository field.
npm WARN optional SKIPPING OPTIONAL DEPENDENCY: fsevents@2.0.7 (node_modules\@nestjs\graphql\node_modules\fsevents):
npm WARN notsup SKIPPING OPTIONAL DEPENDENCY: Unsupported platform for fsevents@2.0.7: wanted {"os":"darwin","arch":"any"} (current: {"os":"win32","arch":"x64"})
npm WARN optional SKIPPING OPTIONAL DEPENDENCY: fsevents@1.2.9 (node_modules\fsevents):
npm WARN notsup SKIPPING OPTIONAL DEPENDENCY: Unsupported platform for fsevents@1.2.9: wanted {"os":"darwin","arch":"any"} (current: {"os":"win32","arch":"x64"})
+ passport-jwt@4.0.0
+ passport@0.4.0
+ @nestjs/passport@6.1.0
+ @nestjs/jwt@6.1.1
added 20 packages from 17 contributors and audited 877489 packages in 75.148s
found 0 vulnerabilities
- We also need to install the @types/passport-jwt packagae.
Juan.Pablo.Perez@RIMDUB-0232 MINGW64 /c/Work/Training/Pre/VueJs/full-stack-vue-with-graphql-the-ultimate-guide-nuxt/server (master)
$ npm i --save-dev @types/passport-jwt
npm WARN server@0.0.1 No repository field.
npm WARN optional SKIPPING OPTIONAL DEPENDENCY: fsevents@2.0.7 (node_modules\@nestjs\graphql\node_modules\fsevents):
npm WARN notsup SKIPPING OPTIONAL DEPENDENCY: Unsupported platform for fsevents@2.0.7: wanted {"os":"darwin","arch":"any"} (current: {"os":"win32","arch":"x64"})
npm WARN optional SKIPPING OPTIONAL DEPENDENCY: fsevents@1.2.9 (node_modules\fsevents):
npm WARN notsup SKIPPING OPTIONAL DEPENDENCY: Unsupported platform for fsevents@1.2.9: wanted {"os":"darwin","arch":"any"} (current: {"os":"win32","arch":"x64"})
+ @types/passport-jwt@3.0.1
added 3 packages from 12 contributors and audited 877533 packages in 15.893s
found 0 vulnerabilities
2.-Create the SECRET environment variable
- We need to create the new
SECRETenvironment variable needed for theJWTtoken:
server\fake.env
MONGO_URI=mongodb+srv://USERNAME:PASSWORD@CLUSTERID.mongodb.net/DATABASENAME?retryWrites=true&w=majority
SECRET=mysupersecretencryptionkey
- We need to modify the
config.service.tsdocument to include the newSECRETenvironment variable:
server\src\config\config.service.ts
import * as dotenv from 'dotenv'
import * as fs from 'fs'
export class ConfigService {
MONGODB_URI: string
private readonly envConfig: { [key: string]: string }
constructor() {
if (
process.env.NODE_ENV === 'production' ||
process.env.NODE_ENV === 'staging'
) {
this.envConfig = {
MONGO_URI: process.env.MONGO_URI,
SECRET: process.env.SECRET
}
} else {
this.envConfig = dotenv.parse(fs.readFileSync('.env'))
}
}
get(key: string): string {
return this.envConfig[key]
}
}
3.-Create the Auth Service
We need to create the
Authfolder to put inside theAuth Serviceand everything related. This service is going to be used to manage theAuthenticationand theAuthorisationof the GraphQLQueriesadnMutations.We are going to create the
jwt-payload.interface.tsto define the content of the 'jwt token`
server\src\auth\jwt-payload.interface.ts
export interface JwtPayload {
username: string
email: string
}
- We need to create the
graphql-auth.guard.tsto create theGraphqlAuthGuardguard derived from theNestJS AuthGuardGuard to transform from theExceution Contextto aGraphQL context
server\src\auth\graphql-auth.guard.ts
import { Injectable, ExecutionContext } from "@nestjs/common";
import { AuthGuard } from '@nestjs/passport';
import { GqlExecutionContext } from "@nestjs/graphql";
@Injectable()
export class GraphqlAuthGuard extends AuthGuard('jwt') {
getRequest(context: ExecutionContext) {
const ctx = GqlExecutionContext.create(context)
return ctx.getContext().req
}
}
- We need to create the
jwt.strategy.tsdocument with theJwtStrategyclass used to read theUserinformation obtained from the token passed through theauthorizationheader with any request. It is attached to the request on theuserobject.
server\src\auth\jwt.strategy.ts
import { PassportStrategy } from '@nestjs/passport'
import { Strategy, ExtractJwt } from 'passport-jwt'
import { Injectable, UnauthorizedException } from '@nestjs/common'
import { JwtPayload } from './jwt-payload.interface'
import { InjectModel } from 'nestjs-typegoose'
import { User } from '../users/model/user.model'
import { ModelType } from 'typegoose'
import { ConfigService } from '../config/config.service'
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(
@InjectModel(User) private readonly userModel: ModelType<User>,
private readonly configService: ConfigService
) {
super({
jwtFromRequest: ExtractJwt.fromHeader("authorization"),
secretOrKey: configService.get('SECRET'),
})
}
async validate(payload: JwtPayload): Promise<User> {
const { username } = payload
const user = await this.userModel.findOne({
username: username
}).populate({
path: "favorites",
model: "Post"
})
if (!user) {
throw new UnauthorizedException()
}
return user
}
}
- We are going to create the
current-user.decorator.tsto create theCurrentUsercustom decorator withUserinformation obtained from the token passed through theauthenticationheader with any request. Please, note it isauthenticationinstead ofauthorisationbecause it is the standard header for Apollo.
server\src\auth\current-user.decorator.ts
import { createParamDecorator } from '@nestjs/common'
export const CurrentUser = createParamDecorator(
(_data, [,, ctx]) => ctx.req.user,
)
- We are going to create the
auth.service.tsdocument with theAuthServiceservice that has thesignUpandsignInmethods.
server\src\auth\auth.service.ts
import { Injectable, ConflictException, InternalServerErrorException, UnauthorizedException } from '@nestjs/common'
import { InjectModel } from 'nestjs-typegoose'
import { ModelType } from 'typegoose'
import { JwtService } from '@nestjs/jwt'
import { SigninUserDto } from '../users/model/dtos/signin-user.dto'
import { CreateUserDto } from '../users/model/dtos/create-user.dto'
import { Token, User } from '../users/model/user.model'
import { JwtPayload } from './jwt-payload.interface'
import md5 from 'md5'
import * as bcrypt from 'bcrypt'
@Injectable()
export class AuthService {
constructor(
@InjectModel(User) private readonly userModel: ModelType<User>,
private readonly jwtService: JwtService,
) { }
async signUp(createUserDto: CreateUserDto): Promise<Token> {
const username = createUserDto.username
const user = await this.userModel.findOne({ username })
if (user) {
throw new ConflictException("User already exists")
}
const avatar = `http://gravatar.com/avatar/${md5(username)}?d=identicon`
var salt = bcrypt.genSaltSync(10)
var password = bcrypt.hashSync(createUserDto.password, salt)
const currentUser = { ...createUserDto, avatar, password }
const createdUser = new this.userModel(currentUser)
try {
const newUser = await createdUser.save()
const token = this.getAccessToken(newUser)
return token
}
catch (error) {
throw new InternalServerErrorException()
}
}
async signIn(signinUserDto: SigninUserDto): Promise<Token> {
const { username, password } = signinUserDto
const user = await this.userModel.findOne({ username })
if (!user) {
throw new UnauthorizedException("Invalid credentials")
}
const isValidPassword = await bcrypt.compare(password, user.password)
if (!isValidPassword) {
throw new UnauthorizedException("Invalid credentials")
}
const token = this.getAccessToken(user)
return token
}
private async getAccessToken(user: User): Promise<Token> {
const { username, email } = user
const payload: JwtPayload = { username, email }
const token = await this.jwtService.sign(payload)
return { token }
}
}
- We finally need to create the
auth.module.tswith theAuthModulemodule. It needs to import thePassportModuleand theJwtModule.
server\src\auth\auth.module.ts
import { Module } from '@nestjs/common'
import { AuthService } from './auth.service'
import { JwtModule } from '@nestjs/jwt'
import { PassportModule } from '@nestjs/passport'
import { JwtStrategy } from './jwt.strategy'
import { ConfigModule } from '../config/config.module';
import { ConfigService } from '../config/config.service';
import { TypegooseModule } from 'nestjs-typegoose'
import { User } from '../users/model/user.model';
@Module({
imports: [
PassportModule.register({ defaultStrategy: 'jwt' }),
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: async (configService: ConfigService) => ({
secret: configService.get('SECRET'),
signOptions: {
expiresIn: 216000, // 1 Hour: 60 * 60 * 60
},
}),
}),
TypegooseModule.forFeature([User]),
],
providers: [
JwtStrategy,
AuthService,
ConfigService
],
exports: [
AuthService
],
})
export class AuthModule {}
4.-Modify the files related to the Users Module
- We need to modify the
app.module.tsdocument to include thecontextas part of theGraphQLModulemodule.
server\src\app.module.ts
import { Module } from "@nestjs/common"
import { AppService } from "./app.service"
import { DatabaseModule } from "./database/database.module"
import { GraphQLModule } from "@nestjs/graphql"
import { UsersModule } from './users/users.module'
import { PostsModule } from './posts/posts.module'
@Module({
imports: [
DatabaseModule,
GraphQLModule.forRoot({
autoSchemaFile: "schema.gql",
context: ({ req }) => ({ req }),
}),
UsersModule,
PostsModule,
],
providers: [AppService]
})
export class AppModule { }
- We need to modify the
server\src\users\users.module.tsdocument to inlcude theConfigServiceprovider adn theAuthModulemodule.
server\src\users\users.module.ts
import { Module } from '@nestjs/common'
import { TypegooseModule } from 'nestjs-typegoose'
import { User } from './model/user.model'
import { UsersService } from './users.service'
import { UsersResolver } from "./graphql/users.resolver"
import { ConfigService } from '../config/config.service'
import { AuthModule } from '../auth/auth.module'
@Module({
imports: [
TypegooseModule.forFeature([User]),
AuthModule
],
providers: [UsersService, UsersResolver, ConfigService]
})
export class UsersModule { }
- We are going to create the new
SignUserDtoData Transfer Object.
server\src\users\model\dtos\signin-user.dto.ts
export class SigninUserDto {
readonly username: string;
readonly password: string
}
- We are going to modify the
user.type.tsanduser.model.tsdocuments to include theTokenclass.
server\src\users\graphql\types\user.type.ts
import { Field, ObjectType, ID } from 'type-graphql'
@ObjectType()
export class Token {
@Field()
readonly token: string
}
@ObjectType()
export class User {
@Field(() => ID)
readonly _id: string
@Field()
readonly username: string
@Field()
readonly email: string
@Field()
readonly password: string
@Field({ nullable: true })
readonly avatar: string
@Field()
readonly joinDate: Date
@Field(() => [ID], { nullable: true })
readonly favorites: string[]
}
server\src\users\model\user.model.ts
import * as mongoose from "mongoose"
import { prop, Typegoose } from 'typegoose'
import { IsString, IsDate, IsArray } from 'class-validator'
export class Token {
@IsString()
readonly token: string
}
export class User extends Typegoose {
@IsString()
@prop({ required: true, unique: true, trim: true })
username: string
@IsString()
@prop({ required: true, trim: true })
email: string
@IsString()
@prop({ required: true, trim: true })
password: string
@IsString()
@prop()
avatar?: string
@IsDate()
@prop({ default: Date.now })
joinDate: Date
@IsArray()
@prop({ required: true, ref: "Post" })
favorites: [mongoose.Schema.Types.ObjectId]
}
- We are going to modify the
users.service.tsdocument to remove thesignupUserandcreateTokenmethods that are now part of theAuthService.
server\src\users\users.service.ts
import { Injectable } from '@nestjs/common'
import { InjectModel } from 'nestjs-typegoose'
import { User } from './model/user.model'
import { ModelType } from 'typegoose'
@Injectable()
export class UsersService {
constructor(@InjectModel(User) private readonly userModel: ModelType<User>) { }
async getUsers(): Promise<User[] | null> {
return await this.userModel.find().exec()
}
}
- We are going to modify the
users.resolver.tsdocument to include theAuthServiceservice and to include the newsigninUsermutation, the newgetCurrentUserquery and to modify thesignupUsermutation.
server\src\users\graphql\users.resolver.ts
import { Resolver, Query, Mutation, Args } from '@nestjs/graphql'
import { UsersService } from '../users.service'
import { User, Token } from './types/user.type'
import { GraphqlAuthGuard } from '../../auth/graphql-auth.guard'
import { CurrentUser } from '../../auth/current-user.decorator'
import { AuthService } from '../../auth/auth.service'
import { UseGuards } from '@nestjs/common'
@Resolver()
export class UsersResolver {
constructor(
private readonly usersService: UsersService,
private readonly authService: AuthService,
) { }
@Query(() => User)
@UseGuards(GraphqlAuthGuard)
async getCurrentUser(@CurrentUser() currentUser: User) {
return currentUser
}
@Query(() => [User])
async getUsers() {
return await this.usersService.getUsers()
}
@Mutation(() => Token)
async signupUser(
@Args('username') username: string,
@Args('email') email: string,
@Args('password') password: string,
) {
return await this.authService.signUp({ username, email, password })
}
@Mutation(() => Token)
async signinUser(
@Args('username') username: string,
@Args('password') password: string,
) {
return await this.authService.signIn({ username, password })
}
}
- We are going to check if the new
signinUsermutation works properly:
Request
mutation {
signinUser(username: "Fofo", password: "Fofo") {
token
}
}
Response
{
"data": {
"signinUser": {
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6IkZvZm8iLCJlbWFpbCI6IkZvZm9AZ21haWwuY29tIiwiaWF0IjoxNTY1MDIxMTQ5LCJleHAiOjE1NjUyMzcxNDl9.OtGTm2qHC5XT-hqNmb6eypFHrZg3DRwBj7ozac3CPCs"
}
}
}
- We are going to check if the
signupUsermutation still works properly, but returning a Token instead of the user:
Request
mutation {
signupUser(username: "David", email: "david@gmail.com", password: "David") {
token
}
}
Response
{
"data": {
"signupUser": {
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6IkRhdmlkIiwiZW1haWwiOiJkYXZpZEBnbWFpbC5jb20iLCJpYXQiOjE1NjUwMTc4MzksImV4cCI6MTU2NTIzMzgzOX0.KcnzxLj4Wu9RkjT9KD6L55ILzBzYk0NNdnWp6o1-nFk"
}
}
}
- We are going to check if the new
getCurrentUserquery works properly:
Request
query {
getCurrentUser {
_id
username
email
password
avatar
joinDate
favorites
}
}
Http header
{
"authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6IkZvZm8iLCJlbWFpbCI6IkZvZm9AZ21haWwuY29tIiwiaWF0IjoxNTY1MDIxMTQ5LCJleHAiOjE1NjUyMzcxNDl9.OtGTm2qHC5XT-hqNmb6eypFHrZg3DRwBj7ozac3CPCs"
}
Response
{
"data": {
"getCurrentUser": {
"_id": "5d35390f58729c10f875d41c",
"username": "Fofo",
"email": "Fofo@gmail.com",
"password": "$2b$10$qb.j.FtbHSfSgyH8ZYGGwOW.cIFj8voPEs1m/jcmdFrX/j/kP1zky",
"avatar": "http://gravatar.com/avatar/d7ec7a6a7980721f40631794b6e691f8?d=identicon",
"joinDate": "2019-07-22T04:18:23.184Z",
"favorites": null
}
}
}
III.-Modify the client app to include the Authentication.
1.-Modify the Nuxt.Config to set up Apollo Authentication
- We need to modify the
nuxt.config.jsdocument to add theauthenticationType: '',property to theapollotag. Otherwise it would included theBearerprefix as the default value.
client\nuxt.config.js
import es from './lang/es-ES.js'
import en from './lang/en-US.js'
// import colors from 'vuetify/es5/util/colors'
export default {
mode: 'universal',
/*
** Headers of the page
*/
head: {
titleTemplate: '%s - ' + process.env.npm_package_name,
title: process.env.npm_package_name || '',
meta: [
{ charset: 'utf-8' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
{
hid: 'description',
name: 'description',
content: process.env.npm_package_description || ''
}
],
link: [
{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' },
{
rel: 'stylesheet',
href:
'https://fonts.googleapis.com/css?family=Roboto:300,400,500,700|Material+Icons'
}
]
},
/*
** Customize the progress-bar color
*/
loading: { color: '#fff' },
/*
** Global CSS
*/
css: [],
/*
** Plugins to load before mounting the App
*/
plugins: [],
/*
** Nuxt.js modules
*/
modules: [
[
'nuxt-i18n',
{
seo: false,
locales: [
{
name: 'ESP',
code: 'es',
iso: 'es-ES'
},
{
name: 'ENG',
code: 'en',
iso: 'en-US'
}
],
strategy: 'prefix_and_default',
langDir: 'lang/',
defaultLocale: 'es',
vueI18n: {
fallbackLocale: 'es',
messages: { es, en }
}
}
],
'@nuxtjs/vuetify',
'@nuxtjs/pwa',
'@nuxtjs/eslint-module',
'@nuxtjs/apollo'
],
apollo: {
errorHandler: '~/apollo/customErrorHandler.js',
authenticationType: '',
clientConfigs: {
default: {
httpEndpoint:
process.env.HTTP_ENDPOINT || 'http://localhost:4000/graphql'
}
}
},
/*
** vuetify module configuration
** https://github.com/nuxt-community/vuetify-module
*/
vuetify: {
theme: {
// primary: colors.blue.darken2,
// accent: colors.grey.darken3,
// secondary: colors.amber.darken3,
// info: colors.teal.lighten1,
// warning: colors.amber.base,
// error: colors.deepOrange.accent4,
// success: colors.green.accent3
primary: '#3B125F',
secondary: '#8B5FBF',
accent: '#BF653F',
error: '#722530',
warning: '#A37513',
info: '#396893',
success: '#4caf50'
}
},
/*
** Build configuration
*/
build: {
/*
** You can extend webpack config here
*/
extend(config, ctx) {}
}
}
2.-Create the gql documents with queries and mutations
client\gql\getCurrentUser.gql
query getCurrentUser {
getCurrentUser {
_id
username
email
password
avatar
joinDate
favorites {
_id
title
imageUrl
}
}
}
client\gql\signinUser.gql
mutation signinUser($username: String!, $password: String!) {
signinUser(username: $username, password: $password) {
token
}
}
client\gql\signupUser.gql
mutation signupUser($username: String!, $email: String!, $password: String!) {
signupUser(username: $username, email: $email, password: $password) {
token
}
}
3.-Update the localization documents with new entries
client\lang\en-US.js
export default {
posts: 'Posts',
signin: 'Sign in',
signup: 'Sign up',
searchposts: 'Search Posts',
home: 'Home',
createPost: 'Create Post',
profile: 'Profile',
signout: 'Signout',
password: 'Password',
username: 'Username',
dontHaveAnAccount: "Don't have an account?",
welcomeBack: 'Welcome Back!'
}
client\lang\es-ES.js
export default {
posts: 'Entradas',
signin: 'Iniciar sesión',
signup: 'Inscripción',
searchposts: 'Buscar entradas',
home: 'Inicio',
createPost: 'Crear Entrada',
profile: 'Perfil',
signout: 'Cerrar sesión',
username: 'Usuario',
password: 'Contraseña',
dontHaveAnAccount: '¿No tiene una cuenta?',
welcomeBack: '¡Bienvenido de nuevo!'
}
4.-Update the store to manaage the Authentication
client\store\index.js
import { getPosts } from '~/gql/getPosts.gql'
import { getCurrentUser } from '~/gql/getCurrentUser.gql'
import { signinUser } from '~/gql/signinUser.gql'
export const state = () => ({
posts: [],
user: null,
loading: false
})
export const mutations = {
setPosts: (state, payload) => {
state.posts = payload
},
setUser: (state, payload) => {
state.user = payload
},
setLoading: (state, payload) => {
state.loading = payload
},
clearUser: state => (state.user = null)
}
export const actions = {
async nuxtServerInit({ dispatch }) {
await dispatch('getCurrentUser')
await dispatch('getPosts')
},
async getPosts({ commit }) {
commit('setLoading', true)
try {
const result = await this.app.apolloProvider.defaultClient.query({
query: getPosts
})
commit('setPosts', result.data.getPosts)
} catch (error) {
console.log(error)
}
commit('setLoading', false)
},
async getCurrentUser({ commit }) {
if (!this.app.$apolloHelpers.getToken()) {
commit('clearUser')
return
}
commit('setLoading', true)
try {
const result = await this.app.apolloProvider.defaultClient.query({
query: getCurrentUser
})
// Add user data to state
commit('setUser', result.data.getCurrentUser)
} catch (error) {
console.error(error)
}
commit('setLoading', false)
},
async signinUser({ commit, dispatch }, payload) {
commit('setLoading', true)
try {
const result = await this.app.apolloProvider.defaultClient.mutate({
mutation: signinUser,
variables: payload
})
await this.app.$apolloHelpers.onLogin(result.data.signinUser.token)
await dispatch('getCurrentUser')
commit('setLoading', false)
} catch (error) {
console.error(error)
commit('setLoading', false)
}
},
async signoutUser({ commit }) {
// clear user in state
commit('clearUser')
await this.app.$apolloHelpers.onLogout()
// redirect home - kick users out of private pages (i.e. profile)
this.app.router.push(this.app.localePath('index'))
}
}
export const getters = {
posts: state => state.posts,
user: state => state.user,
loading: state => state.loading
}
5.-Update the SignIn page
client\pages\signin\index.vue
<template>
<v-container text-xs-center mt-5 pt-5>
<!-- Signin Title -->
<v-layout row wrap>
<v-flex xs12 sm6 offset-sm3>
<h1>{{ $t('welcomeBack') }}</h1>
</v-flex>
</v-layout>
<!-- Signin Form -->
<v-layout row wrap>
<v-flex xs12 sm6 offset-sm3>
<v-card color="secondary" dark>
<v-container>
<v-form @submit.prevent="handleSigninUser">
<v-layout row>
<v-flex xs12>
<v-text-field
v-model="username"
prepend-icon="face"
:label="$t('username')"
type="text"
required
></v-text-field>
</v-flex>
</v-layout>
<v-layout row>
<v-flex xs12>
<v-text-field
v-model="password"
prepend-icon="extension"
:label="$t('password')"
type="password"
required
></v-text-field>
</v-flex>
</v-layout>
<v-layout row>
<v-flex xs12>
<v-btn color="accent" type="submit">{{ $t('signin') }}</v-btn>
<h3>
{{ $t('dontHaveAnAccount') }}
<nuxt-link to="/signup">{{ $t('signup') }}</nuxt-link>
</h3>
</v-flex>
</v-layout>
</v-form>
</v-container>
</v-card>
</v-flex>
</v-layout>
</v-container>
</template>
<script>
import { mapGetters } from 'vuex'
export default {
name: 'Signin',
data() {
return {
username: '',
password: ''
}
},
computed: {
...mapGetters(['user'])
},
watch: {
user(value) {
// if user value changes, redirect to home page
if (value) {
this.$router.push(this.localePath('index'))
}
}
},
methods: {
handleSigninUser() {
this.$store.dispatch('signinUser', {
username: this.username,
password: this.password
})
}
}
}
</script>
6.-Create the auth middleware
client\middleware\auth.js
export default function({ store, redirect, app }) {
if (!store.getters.user) {
return redirect(app.localePath('signin'))
}
}
7.-Update the Profile page to use the auth middleware
client\pages\profile\index.vue
<template>
<v-container>
<h1>Profile</h1>
</v-container>
</template>
<script>
export default {
name: 'Profile',
middleware: 'auth'
}
</script>
8.-Update the Layout with new options
client\layouts\default.vue
<template>
<v-app style="background: #E3E3EE">
<!-- Side Navbar -->
<v-navigation-drawer v-model="sideNav" fixed app temporary>
<v-toolbar color="accent" dark flat>
<v-toolbar-side-icon @click="toggleSideNav"></v-toolbar-side-icon>
<nuxt-link :to="localePath('index')" tag="span" style="cursor: pointer">
<h1 class="title pl-3">VueShare</h1>
</nuxt-link>
</v-toolbar>
<v-divider></v-divider>
<!-- Side Navbar Links -->
<v-list>
<v-list-tile
v-for="(item, i) in sideNavItems"
:key="i"
:to="localePath(item.link)"
ripple
>
<v-list-tile-action>
<v-icon>{{ item.icon }}</v-icon>
</v-list-tile-action>
<v-list-tile-content>
{{ item.title }}
</v-list-tile-content>
</v-list-tile>
<!-- Signout Button -->
<v-list-tile v-if="user" @click="handleSignoutUser">
<v-list-tile-action>
<v-icon>exit_to_app</v-icon>
</v-list-tile-action>
<v-list-tile-content>{{ $t('signout') }}</v-list-tile-content>
</v-list-tile>
</v-list>
</v-navigation-drawer>
<!-- Horizontal Navbar -->
<v-toolbar fixed color="primary" dark>
<!-- App Title -->
<v-toolbar-side-icon @click="toggleSideNav"></v-toolbar-side-icon>
<v-toolbar-title class="hidden-xs-only">
<nuxt-link :to="localePath('index')" tag="span" style="cursor: pointer">
VueShare
</nuxt-link>
</v-toolbar-title>
<v-spacer></v-spacer>
<!-- Search Input -->
<v-text-field
flex
prepend-icon="search"
color="accent"
single-line
hide-details
:placeholder="$t('searchposts')"
></v-text-field>
<v-spacer></v-spacer>
<!-- Horizontal Navbar Links -->
<v-toolbar-items class="hidden-xs-only">
<v-btn
v-for="(item, i) in horizontalNavItems"
:key="i"
:to="localePath(item.link)"
flat
>
<v-icon class="hidden-sm-only" left>{{ item.icon }}</v-icon>
{{ item.title }}
</v-btn>
</v-toolbar-items>
<!-- Profile Button -->
<v-btn v-if="user" flat to="profile">
<v-icon class="hidden-sm-only" left>account_box</v-icon>
<v-badge right color="blue darken-2">
<!-- <span slot="badge"></span> -->
{{ $t('profile') }}
</v-badge>
</v-btn>
<!-- Signout Button -->
<v-btn v-if="user" flat @click="handleSignoutUser">
<v-icon class="hidden-sm-only" left>exit_to_app</v-icon>
{{ $t('signout') }}
</v-btn>
<v-toolbar-title class="hidden-xs-only">
<nuxt-link
v-for="(locale, i) in showLocales"
:key="i"
tag="span"
style="cursor: pointer"
class="lang-switcher"
:to="switchLocalePath(locale.code)"
>
{{ locale.name }}
</nuxt-link>
</v-toolbar-title>
</v-toolbar>
<!-- App Content -->
<main>
<v-container class="mt-4">
<transition name="fade">
<nuxt />
</transition>
</v-container>
</main>
</v-app>
</template>
<script>
import { mapGetters } from 'vuex'
export default {
head() {
return this.$nuxtI18nSeo()
},
data() {
return {
sideNav: false
}
},
computed: {
...mapGetters(['user']),
horizontalNavItems() {
let items = [
{ icon: 'chat', title: this.$i18n.t('posts'), link: 'posts' },
{ icon: 'lock_open', title: this.$i18n.t('signin'), link: 'signin' },
{ icon: 'create', title: this.$i18n.t('signup'), link: 'signup' }
]
if (this.user) {
items = [{ icon: 'chat', title: this.$i18n.t('posts'), link: 'posts' }]
}
return items
},
sideNavItems() {
let items = [
{ icon: 'chat', title: this.$i18n.t('posts'), link: 'posts' },
{ icon: 'lock_open', title: this.$i18n.t('signin'), link: 'signin' },
{ icon: 'create', title: this.$i18n.t('signup'), link: 'signup' }
]
if (this.user) {
items = [
{ icon: 'chat', title: this.$i18n.t('posts'), link: 'posts' },
{
icon: 'stars',
title: this.$i18n.t('createPost'),
link: 'posts-add'
},
{ icon: 'account_box', title: this.$i18n.t('posts'), link: 'profile' }
]
}
return items
},
showLocales() {
return this.$i18n.locales.filter(
locale => locale.code !== this.$i18n.locale
)
}
},
methods: {
handleSignoutUser() {
this.$store.dispatch('signoutUser')
},
toggleSideNav() {
this.sideNav = !this.sideNav
}
}
}
</script>
<style>
.fade-enter-active,
.fade-leave-active {
transition-property: opacity;
transition-duration: 0.25s;
}
.fade-enter-active {
transition-delay: 0.25s;
}
.fade-enter,
.fade-leave-active {
opacity: 0;
}
</style>
9.-Check if the changes works properly




- We can try if we can access the profile without being authenticated


- Check how the cookie is created:


Section 9: Error Handling and Form Validation
I.-Modify the server app to avoid ignoring expiration of the jwt token.
- We need to modify the
jwt.strategy.tsdocument to add the optionignoreExpiration: false.
server\src\auth\jwt.strategy.ts
import { PassportStrategy } from '@nestjs/passport'
import { Strategy, ExtractJwt } from 'passport-jwt'
import { Injectable, UnauthorizedException } from '@nestjs/common'
import { JwtPayload } from './jwt-payload.interface'
import { InjectModel } from 'nestjs-typegoose'
import { User } from '../users/model/user.model'
import { ModelType } from 'typegoose'
import { ConfigService } from '../config/config.service'
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(
@InjectModel(User) private readonly userModel: ModelType<User>,
private readonly configService: ConfigService
) {
super({
jwtFromRequest: ExtractJwt.fromHeader("authorization"),
ignoreExpiration: false,
secretOrKey: configService.get('SECRET'),
})
}
async validate(payload: any): Promise<User | null> {
const { username } = payload
const user = await this.userModel.findOne({
username: username
}).populate({
path: "favorites",
model: "Post"
})
if (!user) {
throw new UnauthorizedException()
}
return user
}
}
II.-Modify the client app to include Error Handling and Form Validation.
1.-Install the cookie-universal-nuxt package
- We are going to install the cookie-universal-nuxt package to be able to remove cookies on both
clientandserversides.
Juan.Pablo.Perez@RIMDUB-0232 MINGW64 /c/Work/Training/Pre/VueJs/full-stack-vue-with-graphql-the-ultimate-guide-nuxt/client (master)
$ npm i cookie-universal-nuxt
npm WARN ts-node@8.3.0 requires a peer of typescript@>=2.0 but none is installed. You must install peer dependencies yourself.
npm WARN optional SKIPPING OPTIONAL DEPENDENCY: fsevents@2.0.7 (node_modules\fsevents):
npm WARN notsup SKIPPING OPTIONAL DEPENDENCY: Unsupported platform for fsevents@2.0.7: wanted {"os":"darwin","arch":"any"} (current: {"os":"win32","arch":"x64"})
npm WARN optional SKIPPING OPTIONAL DEPENDENCY: fsevents@1.2.9 (node_modules\watchpack\node_modules\fsevents):
npm WARN notsup SKIPPING OPTIONAL DEPENDENCY: Unsupported platform for fsevents@1.2.9: wanted {"os":"darwin","arch":"any"} (current: {"os":"win32","arch":"x64"})
npm WARN optional SKIPPING OPTIONAL DEPENDENCY: fsevents@1.2.9 (node_modules\nodemon\node_modules\fsevents):
npm WARN notsup SKIPPING OPTIONAL DEPENDENCY: Unsupported platform for fsevents@1.2.9: wanted {"os":"darwin","arch":"any"} (current: {"os":"win32","arch":"x64"})
npm WARN optional SKIPPING OPTIONAL DEPENDENCY: fsevents@1.2.9 (node_modules\jest-haste-map\node_modules\fsevents):
npm WARN notsup SKIPPING OPTIONAL DEPENDENCY: Unsupported platform for fsevents@1.2.9: wanted {"os":"darwin","arch":"any"} (current: {"os":"win32","arch":"x64"})
+ cookie-universal-nuxt@2.0.17
added 3 packages from 2 contributors and audited 897031 packages in 67.312s
found 0 vulnerabilities
2.-Create the FormAlert Component and register it globally
- We need to create the
FormAlert.vueComponent document. Create theSharedfolder fromclient\componentsand theFormAlert.vuedocument in it.
client\components\Shared\FormAlert.vue
<template>
<v-alert type="error" :value="true" transition="scale-transition" dismissible>
<h3>{{ message }}</h3>
</v-alert>
</template>
<script>
export default {
props: {
message: {
type: String,
required: true
}
}
}
</script>
- We need to create the
shared-components.jsplugin component that will be used to register all the components from theclient\components\Sharedfolder as Global components. It uses the WebPack require-context method.
client\plugins\shared-components.js
import Vue from 'vue'
import upperFirst from 'lodash/upperFirst'
import camelCase from 'lodash/camelCase'
const requireComponent = require.context(
'~/components/Shared',
false,
/[A-Z]\w+\.(vue|js)$/
)
requireComponent.keys().forEach(fileName => {
const componentConfig = requireComponent(fileName)
const componentName = upperFirst(
camelCase(fileName.replace(/^\.\/(.*)\.\w+$/, '$1'))
)
Vue.component(componentName, componentConfig.default || componentConfig)
})
3.-Modify the nuxt.config.js document to add a new module and a new pugin
- We need to modify the
nuxt.config.jsdocument to include the newcookie-universal-nuxtmodule and to register theshared-componentsplugin.
client\nuxt.config.js
import es from './lang/es-ES.js'
import en from './lang/en-US.js'
// import colors from 'vuetify/es5/util/colors'
export default {
mode: 'universal',
/*
** Headers of the page
*/
head: {
titleTemplate: '%s - ' + process.env.npm_package_name,
title: process.env.npm_package_name || '',
meta: [
{ charset: 'utf-8' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
{
hid: 'description',
name: 'description',
content: process.env.npm_package_description || ''
}
],
link: [
{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' },
{
rel: 'stylesheet',
href:
'https://fonts.googleapis.com/css?family=Roboto:300,400,500,700|Material+Icons'
}
]
},
/*
** Customize the progress-bar color
*/
loading: { color: '#fff' },
/*
** Global CSS
*/
css: [],
/*
** Plugins to load before mounting the App
*/
plugins: ['@plugins/shared-components.js'],
/*
** Nuxt.js modules
*/
modules: [
[
'nuxt-i18n',
{
seo: false,
locales: [
{
name: 'ESP',
code: 'es',
iso: 'es-ES'
},
{
name: 'ENG',
code: 'en',
iso: 'en-US'
}
],
strategy: 'prefix_and_default',
langDir: 'lang/',
defaultLocale: 'es',
vueI18n: {
fallbackLocale: 'es',
messages: { es, en }
}
}
],
'@nuxtjs/vuetify',
'@nuxtjs/pwa',
'@nuxtjs/eslint-module',
'@nuxtjs/apollo',
`cookie-universal-nuxt`
],
apollo: {
authenticationType: '',
errorHandler: '~/apollo/customErrorHandler.js',
clientConfigs: {
default: {
httpEndpoint:
process.env.HTTP_ENDPOINT || 'http://localhost:4000/graphql'
}
}
},
/*
** vuetify module configuration
** https://github.com/nuxt-community/vuetify-module
*/
vuetify: {
theme: {
// primary: colors.blue.darken2,
// accent: colors.grey.darken3,
// secondary: colors.amber.darken3,
// info: colors.teal.lighten1,
// warning: colors.amber.base,
// error: colors.deepOrange.accent4,
// success: colors.green.accent3
primary: '#3B125F',
secondary: '#8B5FBF',
accent: '#BF653F',
error: '#722530',
warning: '#A37513',
info: '#396893',
success: '#4caf50'
}
},
/*
** Build configuration
*/
build: {
/*
** You can extend webpack config here
*/
extend(config, ctx) {}
}
}
4.-Create a new utils.js document that will contain global functions
- Create the
client\helpersfolder and theutils.jsdocument inside with global functions.
client\helpers\utils.js
const utils = {
isJwtTokenValid(jwtToken) {
if (!jwtToken) {
return false
}
const items = jwtToken.split('.')
if (!items) {
return false
}
return items.length > 2
},
getFirstGraphQLError(graphQLError) {
if (
graphQLError &&
graphQLError.graphQLErrors &&
graphQLError.graphQLErrors.length > 0
) {
return graphQLError.graphQLErrors[0]
}
if (graphQLError && graphQLError.message) {
return graphQLError.message
}
return graphQLError
},
getCurrentGraphQLError(graphQLError) {
const error = this.getFirstGraphQLError(graphQLError)
if (!error) {
return graphQLError
}
const currentError =
error.message && (!error.message.message || !error.message.error)
? error.message
: error.error
? error.error
: error.message && error.message.message
? error.message.message
: error.message && error.message.error
? error.message.error
: error
return currentError
}
}
export default utils
5.-Modify the store to include the Signup action and the error and authError states.
- We need to modify the
index.jsstore document to include theSignupaction and theerrorandauthErrorstates.
client\store\index.js
import { getPosts } from '~/gql/getPosts.gql'
import { getCurrentUser } from '~/gql/getCurrentUser.gql'
import { signinUser } from '~/gql/signinUser.gql'
import { signupUser } from '~/gql/signupUser.gql'
import utils from '~/helpers/utils'
export const state = () => ({
posts: [],
user: null,
loading: false,
error: null,
authError: null
})
export const mutations = {
setPosts: (state, payload) => {
state.posts = payload
},
setUser: (state, payload) => {
state.user = payload
},
setLoading: (state, payload) => {
state.loading = payload
},
clearUser: state => (state.user = null),
setError: (state, payload) => {
state.error = payload
},
clearError: state => (state.error = null),
setAuthError: (state, payload) => {
state.authError = payload
},
clearAuthError: state => (state.authError = null)
}
export const actions = {
async nuxtServerInit({ dispatch }) {
await dispatch('getCurrentUser')
await dispatch('getPosts')
},
async getPosts({ commit }) {
commit('clearError')
commit('setLoading', true)
try {
const result = await this.app.apolloProvider.defaultClient.query({
query: getPosts
})
commit('setPosts', result.data.getPosts)
} catch (error) {
const currentError = utils.getCurrentGraphQLError(error)
commit('setError', currentError)
console.error(utils.getFirstGraphQLError(error))
}
commit('setLoading', false)
},
async logOut({ commit }) {
this.app.$cookies.remove('apollo-token')
await this.app.$apolloHelpers.onLogout()
commit('clearUser')
},
async getCurrentUser({ commit, dispatch }) {
if (!utils.isJwtTokenValid(this.app.$apolloHelpers.getToken())) {
await dispatch('logOut')
return
}
commit('setLoading', true)
try {
const result = await this.app.apolloProvider.defaultClient.query({
query: getCurrentUser
})
// Add user data to state
commit('setUser', result.data.getCurrentUser)
commit('clearError')
commit('clearAuthError')
} catch (error) {
const currentError = utils.getCurrentGraphQLError(error)
if (currentError === 'Unauthorized') {
commit('setAuthError', currentError)
await dispatch('logOut')
} else {
commit('setError', currentError)
}
console.error(utils.getFirstGraphQLError(error))
}
commit('setLoading', false)
},
async signinUser({ commit, dispatch }, payload) {
commit('clearError')
commit('setLoading', true)
try {
const result = await this.app.apolloProvider.defaultClient.mutate({
mutation: signinUser,
variables: payload
})
await this.app.$apolloHelpers.onLogin(result.data.signinUser.token)
await dispatch('getCurrentUser')
} catch (error) {
const currentError = utils.getCurrentGraphQLError(error)
commit('setError', currentError)
console.error(utils.getFirstGraphQLError(error))
}
commit('setLoading', false)
},
async signupUser({ commit, dispatch }, payload) {
commit('clearError')
commit('setLoading', true)
try {
const result = await this.app.apolloProvider.defaultClient.mutate({
mutation: signupUser,
variables: payload
})
await this.app.$apolloHelpers.onLogin(result.data.signupUser.token)
await dispatch('getCurrentUser')
} catch (error) {
const currentError = utils.getCurrentGraphQLError(error)
commit('setError', currentError)
console.error(utils.getFirstGraphQLError(error))
}
commit('setLoading', false)
},
async signoutUser({ dispatch }) {
await dispatch('logOut')
// redirect home - kick users out of private pages (i.e. profile)
this.app.router.push(this.app.localePath('index'))
}
}
export const getters = {
posts: state => state.posts,
user: state => state.user,
loading: state => state.loading,
error: state => state.error,
authError: state => state.authError
}
6.-Update the localization documents with new entries
client\lang\en-US.js
export default {
posts: 'Posts',
signin: 'Sign in',
signup: 'Sign up',
searchposts: 'Search Posts',
home: 'Home',
createPost: 'Create Post',
profile: 'Profile',
signout: 'Signout',
username: 'Username',
password: 'Password',
confirmPassword: 'Confirm Password',
email: 'Email',
dontHaveAnAccount: "Don't have an account?",
alreadyHaveAnAccount: 'Already have an account?',
welcomeBack: 'Welcome Back!',
getStartedHere: 'Get Started Here',
isRequired: '{name} is required',
mustBeValid: '{name} must be valid',
cannotBeMoreThanCharacters: `{name} cannot be more than {number} characters`,
mustBeAtLeast: `{name} must be at least {number} characters`,
passwordsMustMatch: 'Passwords must match',
yourAreNowSignedIn: `You are now signed in!`,
close: `Close`
}
client\lang\es-ES.js
export default {
posts: 'Entradas',
signin: 'Iniciar sesión',
signup: 'Inscripción',
searchposts: 'Buscar entradas',
home: 'Inicio',
createPost: 'Crear Entrada',
profile: 'Perfil',
signout: 'Cerrar sesión',
username: 'Usuario',
password: 'Contraseña',
confirmPassword: 'Confirmar Contraseña',
email: 'Email',
dontHaveAnAccount: '¿No tiene una cuenta?',
alreadyHaveAnAccount: '¿Ya tiene una cuenta?',
welcomeBack: '¡Bienvenido de nuevo!',
getStartedHere: 'Comience aquí',
isRequired: '{name} es requerido',
mustBeValid: '{name} debe de ser válido',
cannotBeMoreThanCharacters: `{name} no puede contener más de {number} caracteres`,
mustBeAtLeast: `{name} debe contener al menos {number} caracteres`,
passwordsMustMatch: 'Las Contraseñas deben coincidir',
yourAreNowSignedIn: `¡Ahora está registrado!`,
close: `Cerrar`
}
7.-Modify the signin page to include the form validation.
- We need to modify the
signin\index.vuepage document to include the form validation.
client\pages\signin\index.vue
<template>
<v-container text-xs-center mt-5 pt-5>
<!-- Signin Title -->
<v-layout row wrap>
<v-flex xs12 sm6 offset-sm3>
<h1>{{ $t('welcomeBack') }}</h1>
</v-flex>
</v-layout>
<!-- Error Alert -->
<v-layout v-if="error" row wrap>
<v-flex xs12 sm6 offset-sm3>
<form-alert :message="error"></form-alert>
</v-flex>
</v-layout>
<!-- Signin Form -->
<v-layout row wrap>
<v-flex xs12 sm6 offset-sm3>
<v-card color="secondary" dark>
<v-container>
<v-form
ref="form"
v-model="isFormValid"
lazy-validation
@submit.prevent="handleSigninUser"
>
<v-layout row>
<v-flex xs12>
<v-text-field
v-model="username"
:rules="usernameRules"
prepend-icon="face"
:label="$t('username')"
type="text"
required
></v-text-field>
</v-flex>
</v-layout>
<v-layout row>
<v-flex xs12>
<v-text-field
v-model="password"
:rules="passwordRules"
prepend-icon="extension"
:label="$t('password')"
type="password"
required
></v-text-field>
</v-flex>
</v-layout>
<v-layout row>
<v-flex xs12>
<v-btn
:loading="loading"
:disabled="!isFormValid || loading"
color="accent"
type="submit"
>
<span slot="loader" class="custom-loader">
<v-icon light>cached</v-icon>
</span>
{{ $t('signin') }}</v-btn
>
<h3>
{{ $t('dontHaveAnAccount') }}
<nuxt-link :to="localePath('signup')">{{
$t('signup')
}}</nuxt-link>
</h3>
</v-flex>
</v-layout>
</v-form>
</v-container>
</v-card>
</v-flex>
</v-layout>
</v-container>
</template>
<script>
import { mapGetters } from 'vuex'
export default {
name: 'Signin',
data() {
return {
isFormValid: false,
username: '',
password: '',
usernameRules: [
// Check if username in input
username =>
!!username ||
this.$i18n.t('isRequired', { name: this.$i18n.t('username') }),
// Make sure username is less than 10 characters
username =>
username.length <= 10 ||
this.$i18n.t('cannotBeMoreThanCharacters', {
name: this.$i18n.t('username'),
number: 10
})
],
passwordRules: [
password =>
!!password ||
this.$i18n.t('isRequired', { name: this.$i18n.t('password') }),
// Make sure password is at least 4 characters
password =>
password.length >= 4 ||
this.$i18n.t('mustBeAtLeast', {
name: this.$i18n.t('password'),
number: 4
})
]
}
},
computed: {
...mapGetters(['loading', 'error', 'user'])
},
watch: {
user(value) {
// if user value changes, redirect to home page
if (value) {
this.$router.push(this.localePath('index'))
}
}
},
methods: {
handleSigninUser() {
if (this.$refs.form.validate()) {
this.$store.dispatch('signinUser', {
username: this.username,
password: this.password
})
}
}
}
}
</script>
<style>
.custom-loader {
animation: loader 1s infinite;
display: flex;
}
@-moz-keyframes loader {
from {
transform: rotate(0);
}
to {
transform: rotate(360deg);
}
}
@-webkit-keyframes loader {
from {
transform: rotate(0);
}
to {
transform: rotate(360deg);
}
}
@-o-keyframes loader {
from {
transform: rotate(0);
}
to {
transform: rotate(360deg);
}
}
@keyframes loader {
from {
transform: rotate(0);
}
to {
transform: rotate(360deg);
}
}
</style>
8.-Modify the signup page to include to include all the functionality .
- We need to modify the
signup\index.vuepage document to include all the functionality to manage theSignup.
client\pages\signup\index.vue
<template>
<v-container text-xs-center mt-5 pt-5>
<!-- Signup Title -->
<v-layout row wrap>
<v-flex xs12 sm6 offset-sm3>
<h1>{{ $t('getStartedHere') }}</h1>
</v-flex>
</v-layout>
<!-- Error Alert -->
<v-layout v-if="error" row wrap>
<v-flex xs12 sm6 offset-sm3>
<form-alert :message="error"></form-alert>
</v-flex>
</v-layout>
<!-- Signup Form -->
<v-layout row wrap>
<v-flex xs12 sm6 offset-sm3>
<v-card color="accent" dark>
<v-container>
<v-form
ref="form"
v-model="isFormValid"
lazy-validation
@submit.prevent="handleSignupUser"
>
<v-layout row>
<v-flex xs12>
<v-text-field
v-model="username"
:rules="usernameRules"
prepend-icon="face"
:label="$t('username')"
type="text"
required
></v-text-field>
</v-flex>
</v-layout>
<v-layout row>
<v-flex xs12>
<v-text-field
v-model="email"
:rules="emailRules"
prepend-icon="email"
:label="$t('email')"
type="email"
required
></v-text-field>
</v-flex>
</v-layout>
<v-layout row>
<v-flex xs12>
<v-text-field
v-model="password"
:rules="passwordRules"
prepend-icon="extension"
:label="$t('password')"
type="password"
required
></v-text-field>
</v-flex>
</v-layout>
<v-layout row>
<v-flex xs12>
<v-text-field
v-model="passwordConfirmation"
:rules="passwordRules"
prepend-icon="gavel"
:label="$t('confirmPassword')"
type="password"
required
></v-text-field>
</v-flex>
</v-layout>
<v-layout row>
<v-flex xs12>
<v-btn
:loading="loading"
:disabled="!isFormValid || loading"
color="info"
type="submit"
>
<span slot="loader" class="custom-loader">
<v-icon light>cached</v-icon>
</span>
{{ $t('signup') }}</v-btn
>
<h3>
{{ $t('alreadyHaveAnAccount') }}
<router-link :to="localePath('signin')">{{
$t('signin')
}}</router-link>
</h3>
</v-flex>
</v-layout>
</v-form>
</v-container>
</v-card>
</v-flex>
</v-layout>
</v-container>
</template>
<script>
import { mapGetters } from 'vuex'
export default {
name: 'Signup',
data() {
return {
isFormValid: false,
username: '',
email: '',
password: '',
passwordConfirmation: '',
usernameRules: [
username =>
!!username ||
this.$i18n.t('isRequired', { name: this.$i18n.t('username') }),
username =>
username.length <= 10 ||
this.$i18n.t('cannotBeMoreThanCharacters', {
name: this.$i18n.t('username'),
number: 10
})
],
emailRules: [
email =>
!!email ||
this.$i18n.t('isRequired', { name: this.$i18n.t('email') }),
email =>
/.@+./.test(email) ||
this.$i18n.t('mustBeValid', { name: this.$i18n.t('email') })
],
passwordRules: [
password =>
!!password ||
this.$i18n.t('isRequired', { name: this.$i18n.t('password') }),
password =>
password.length >= 4 ||
this.$i18n.t('mustBeAtLeast', {
name: this.$i18n.t('password'),
number: 4
}),
confirmation =>
confirmation === this.password || this.$i18n.t('passwordsMustMatch')
]
}
},
computed: {
...mapGetters(['loading', 'error', 'user'])
},
watch: {
user(value) {
// if user value changes, redirect to home page
if (value) {
this.$router.push(this.localePath('index'))
}
}
},
methods: {
handleSignupUser() {
if (this.$refs.form.validate()) {
this.$store.dispatch('signupUser', {
username: this.username,
email: this.email,
password: this.password
})
}
}
}
}
</script>
<style>
.custom-loader {
animation: loader 1s infinite;
display: flex;
}
@-moz-keyframes loader {
from {
transform: rotate(0);
}
to {
transform: rotate(360deg);
}
}
@-webkit-keyframes loader {
from {
transform: rotate(0);
}
to {
transform: rotate(360deg);
}
}
@-o-keyframes loader {
from {
transform: rotate(0);
}
to {
transform: rotate(360deg);
}
}
@keyframes loader {
from {
transform: rotate(0);
}
to {
transform: rotate(360deg);
}
}
</style>
9.-Modify the main default layout to include the AuthSnackbar and AuthErrorSnackbar components
- We need to modify the
default.vuelayout document to include theAuthSnackbarandAuthErrorSnackbarcomponents.
client\layouts\default.vue
<template>
<v-app style="background: #E3E3EE">
<!-- Side Navbar -->
<v-navigation-drawer v-model="sideNav" fixed app temporary>
<v-toolbar color="accent" dark flat>
<v-toolbar-side-icon @click="toggleSideNav"></v-toolbar-side-icon>
<nuxt-link :to="localePath('index')" tag="span" style="cursor: pointer">
<h1 class="title pl-3">VueShare</h1>
</nuxt-link>
</v-toolbar>
<v-divider></v-divider>
<!-- Side Navbar Links -->
<v-list>
<v-list-tile
v-for="(item, i) in sideNavItems"
:key="i"
:to="localePath(item.link)"
ripple
>
<v-list-tile-action>
<v-icon>{{ item.icon }}</v-icon>
</v-list-tile-action>
<v-list-tile-content>
{{ item.title }}
</v-list-tile-content>
</v-list-tile>
<!-- Signout Button -->
<v-list-tile v-if="user" @click="handleSignoutUser">
<v-list-tile-action>
<v-icon>exit_to_app</v-icon>
</v-list-tile-action>
<v-list-tile-content>{{ $t('signout') }}</v-list-tile-content>
</v-list-tile>
</v-list>
</v-navigation-drawer>
<!-- Horizontal Navbar -->
<v-toolbar fixed color="primary" dark>
<!-- App Title -->
<v-toolbar-side-icon @click="toggleSideNav"></v-toolbar-side-icon>
<v-toolbar-title class="hidden-xs-only">
<nuxt-link :to="localePath('index')" tag="span" style="cursor: pointer">
VueShare
</nuxt-link>
</v-toolbar-title>
<v-spacer></v-spacer>
<!-- Search Input -->
<v-text-field
flex
prepend-icon="search"
color="accent"
single-line
hide-details
:placeholder="$t('searchposts')"
></v-text-field>
<v-spacer></v-spacer>
<!-- Horizontal Navbar Links -->
<v-toolbar-items class="hidden-xs-only">
<v-btn
v-for="(item, i) in horizontalNavItems"
:key="i"
:to="localePath(item.link)"
flat
>
<v-icon class="hidden-sm-only" left>{{ item.icon }}</v-icon>
{{ item.title }}
</v-btn>
</v-toolbar-items>
<!-- Profile Button -->
<v-btn v-if="user" flat :to="localePath('profile')">
<v-icon class="hidden-sm-only" left>account_box</v-icon>
<v-badge right color="blue darken-2">
<!-- <span slot="badge"></span> -->
{{ $t('profile') }}
</v-badge>
</v-btn>
<!-- Signout Button -->
<v-btn v-if="user" flat @click="handleSignoutUser">
<v-icon class="hidden-sm-only" left>exit_to_app</v-icon>
{{ $t('signout') }}
</v-btn>
<v-toolbar-title class="hidden-xs-only">
<nuxt-link
v-for="(locale, i) in showLocales"
:key="i"
tag="span"
style="cursor: pointer"
class="lang-switcher"
:to="switchLocalePath(locale.code)"
>
{{ locale.name }}
</nuxt-link>
</v-toolbar-title>
</v-toolbar>
<!-- App Content -->
<main>
<v-container class="mt-4">
<transition name="fade">
<nuxt />
</transition>
<!-- Auth Snackbar -->
<v-snackbar
v-model="authSnackbar"
color="success"
:timeout="5000"
bottom
left
>
<v-icon class="mr-3">check_circle</v-icon>
<h3>{{ $t('yourAreNowSignedIn') }}</h3>
<v-btn dark flat @click="authSnackbar = false">{{
$t('close')
}}</v-btn>
</v-snackbar>
<!-- Auth Error Snackbar -->
<v-snackbar
v-if="authError"
v-model="authErrorSnackbar"
color="info"
:timeout="5000"
bottom
left
>
<v-icon class="mr-3">cancel</v-icon>
<h3>{{ authError.message }}</h3>
<v-btn dark flat :to="localePath('signin')">{{ $t('close') }}</v-btn>
</v-snackbar>
</v-container>
</main>
</v-app>
</template>
<script>
import { mapGetters } from 'vuex'
export default {
head() {
return this.$nuxtI18nSeo()
},
data() {
return {
sideNav: false,
authSnackbar: false,
authErrorSnackbar: false
}
},
computed: {
...mapGetters(['authError', 'user']),
horizontalNavItems() {
let items = [
{ icon: 'chat', title: this.$i18n.t('posts'), link: 'posts' },
{ icon: 'lock_open', title: this.$i18n.t('signin'), link: 'signin' },
{ icon: 'create', title: this.$i18n.t('signup'), link: 'signup' }
]
if (this.user) {
items = [{ icon: 'chat', title: this.$i18n.t('posts'), link: 'posts' }]
}
return items
},
sideNavItems() {
let items = [
{ icon: 'chat', title: this.$i18n.t('posts'), link: 'posts' },
{ icon: 'lock_open', title: this.$i18n.t('signin'), link: 'signin' },
{ icon: 'create', title: this.$i18n.t('signup'), link: 'signup' }
]
if (this.user) {
items = [
{ icon: 'chat', title: this.$i18n.t('posts'), link: 'posts' },
{
icon: 'stars',
title: this.$i18n.t('createPost'),
link: 'posts-add'
},
{
icon: 'account_box',
title: this.$i18n.t('profile'),
link: 'profile'
}
]
}
return items
},
showLocales() {
return this.$i18n.locales.filter(
locale => locale.code !== this.$i18n.locale
)
}
},
watch: {
user(newValue, oldValue) {
// if we had no value for user before, show snackbar
if (oldValue === null) {
this.authSnackbar = true
}
},
authError(value) {
// if auth error is not null, show auth error snackbar
if (value !== null) {
this.authErrorSnackbar = true
}
}
},
methods: {
handleSignoutUser() {
this.$store.dispatch('signoutUser')
},
toggleSideNav() {
this.sideNav = !this.sideNav
}
}
}
</script>
<style>
.fade-enter-active,
.fade-leave-active {
transition-property: opacity;
transition-duration: 0.25s;
}
.fade-enter-active {
transition-delay: 0.25s;
}
.fade-enter,
.fade-leave-active {
opacity: 0;
}
</style>
10.-We need to test if it works.












Section 10: Add Post / Infinite Scroll Components
I.-Modify the server app to include the infiniteScrollPosts query.
1.-Create the new PostPage type
- We are going to create the
post-page.type.tsdocument with the newPostPagetype.
server\src\posts\graphql\types\post-page.type.ts
import { Field, ObjectType } from 'type-graphql'
import { Post } from '../post.model'
import { Ref } from 'typegoose'
@ObjectType()
export class PostPage {
@Field(() => [Post], { nullable: "items" })
readonly posts: Ref<Post>[]
@Field(() => Boolean)
hasMore: boolean
}
2.-Modify the Posts service to include the new infiniteScrollPosts method.
- We need to modify the
\posts.service.tsservice document to include the newinfiniteScrollPostsmethod.
server\src\posts\posts.service.ts
import { Injectable } from '@nestjs/common';
import { InjectModel } from 'nestjs-typegoose';
import { Post } from './model/post.model';
import { PostPage } from './graphql/types/post-page.type';
import { CreatePostDto } from './model/dtos/create-post.dto'
import { ModelType } from 'typegoose';
@Injectable()
export class PostsService {
constructor(@InjectModel(Post) private readonly postModel: ModelType<Post>) { }
async getPosts(): Promise<Post[] | null> {
const posts = await this.postModel.find({})
.sort({ createdDate: "desc" })
.populate({
path: "createdBy",
model: "User"
});
return posts;
}
async infiniteScrollPosts(pageNum: number, pageSize: number): Promise<PostPage | null> {
const skips = pageSize * (pageNum - 1)
const posts = await this.postModel.find({})
.sort({ createdDate: "desc" })
.populate({
path: "createdBy",
model: "User"
})
.skip(skips)
.limit(pageSize)
.lean()
const totalDocs = await this.postModel.countDocuments()
const hasMore = totalDocs > pageSize * pageNum
const postPage: PostPage = {
posts,
hasMore
}
return postPage
}
async addPost(createPostDto: CreatePostDto): Promise<Post> {
const newPost = new this.postModel(createPostDto);
return await newPost.save();
}
}
3.-Modify the Posts resolver to include the new infiniteScrollPosts Query.
- We need to modify the
\posts.resolver.tsresolver document to include the newinfiniteScrollPostsQuery.
server\src\posts\graphql\posts.resolver.ts
import { Resolver, Query, Mutation, Args } from '@nestjs/graphql';
import { Int, ID } from 'type-graphql';
import { PostsService } from '../posts.service';
import { PostInput } from './inputs/post.input';
import { PostPage } from './types/post-page.type';
import { Post } from './types/post.type';
@Resolver()
export class PostsResolver {
constructor(private readonly postsService: PostsService) { }
@Query(() => [Post])
async getPosts() {
return await this.postsService.getPosts();
}
@Query(() => PostPage)
async infiniteScrollPosts(
@Args({ name: 'pageNum', type: () => Int}) pageNum: number,
@Args({ name: 'pageSize', type: () => Int}) pageSize: number
) {
return await this.postsService.infiniteScrollPosts(pageNum, pageSize);
}
@Mutation(() => Post)
async addPostWithInput(@Args('input') input: PostInput) {
const createdBy = input.creatorId;
const { title, imageUrl, categories, description } = input
return await this.postsService.addPost({ title, imageUrl, categories, description, createdBy });
}
@Mutation(() => Post)
async addPost(
@Args('title') title: string,
@Args('imageUrl') imageUrl: string,
@Args({ name: 'categories', type: () => [String], nullable: "items" }) categories: string[],
@Args('description') description: string,
@Args({ name: 'creatorId', type: () => ID}) creatorId: string,
) {
const createdBy = creatorId;
return await this.postsService.addPost({ title, imageUrl, categories, description, createdBy });
}
}
4.-Test if the new infiniteScrollPosts Query works propely
query
query($pageNum: Int!, $pageSize: Int!) {
infiniteScrollPosts(pageNum: $pageNum, pageSize: $pageSize) {
hasMore
posts {
_id
title
imageUrl
categories
description
likes
createdDate
messages {
_id
}
createdBy {
_id
username
avatar
}
}
}
}
variables
{
"pageNum": 1,
"pageSize": 3
}
response
{
"data": {
"infiniteScrollPosts": {
"hasMore": true,
"posts": [
{
"_id": "5d4fa77cfe7af92694bf3cae",
"title": "Abstract Painting",
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/9/97/Vassily_Kandinsky%2C_1939_-_Composition_10.jpg/1024px-Vassily_Kandinsky%2C_1939_-_Composition_10.jpg",
"categories": [
"Art"
],
"description": "Nice painting",
"likes": 0,
"createdDate": "2019-08-11T05:28:28.697Z",
"messages": [],
"createdBy": {
"_id": "5d35390f58729c10f875d41c",
"username": "Fofo",
"avatar": "http://gravatar.com/avatar/d7ec7a6a7980721f40631794b6e691f8?d=identicon"
}
},
{
"_id": "5d4fa31dcb16795b68483e10",
"title": "At the Beach",
"imageUrl": "https://images.pexels.com/photos/1139541/pexels-photo-1139541.jpeg?auto=compress&cs=tinysrgb&dpr=2&h=650&w=940",
"categories": [
"Photography",
"Travel"
],
"description": "A nice photo of the waves",
"likes": 0,
"createdDate": "2019-08-11T05:09:49.634Z",
"messages": [],
"createdBy": {
"_id": "5d35390f58729c10f875d41c",
"username": "Fofo",
"avatar": "http://gravatar.com/avatar/d7ec7a6a7980721f40631794b6e691f8?d=identicon"
}
},
{
"_id": "5d0e10be7dd57444d0607790",
"title": "Tasty coffee",
"imageUrl": "https://images.pexels.com/photos/374757/pexels-photo-374757.jpeg?auto=compress&cs=tinysrgb&dpr=2&h=650&w=940",
"categories": [
"Art",
"Food"
],
"description": "Some nice coffee artwork",
"likes": 0,
"createdDate": "2019-06-22T11:27:58.416Z",
"messages": null,
"createdBy": {
"_id": "5d36a9e007bced421c3a2031",
"username": "John",
"avatar": "http://gravatar.com/avatar/61409aa1fd47d4a5332de23cbf59a36f?d=identicon"
}
}
]
}
}
}
II.-Modify the client app to make the Add Posts component using the new infiniteScrollPosts Query.
1.-Create the infiniteScrollPosts.gql and addPost.gql documents with the infiniteScrollPosts Query and addPost mutation.
- We need to create the
addPost.gqldocument with theaddPostMutation
client\gql\addPost.gql
mutation addPost(
$title: String!
$imageUrl: String!
$categories: [String]!
$description: String!
$creatorId: ID!
) {
addPost(
title: $title
imageUrl: $imageUrl
categories: $categories
description: $description
creatorId: $creatorId
) {
_id
title
imageUrl
categories
description
}
}
- We need to create the
infiniteScrollPosts.gqldocument with the newinfiniteScrollPostsQuery
client\gql\infiniteScrollPosts.gql
- We are going to modify the `` document to put only the fields that we really need.
client\gql\getPosts.gql
query getPosts {
getPosts {
_id
title
imageUrl
}
}
2.-Update the localization documents with new entries
client\lang\en-US.js
export default {
posts: 'Posts',
signin: 'Sign in',
signup: 'Sign up',
searchposts: 'Search Posts',
home: 'Home',
createPost: 'Create Post',
profile: 'Profile',
signout: 'Signout',
username: 'Username',
password: 'Password',
confirmPassword: 'Confirm Password',
email: 'Email',
dontHaveAnAccount: "Don't have an account?",
alreadyHaveAnAccount: 'Already have an account?',
welcomeBack: 'Welcome Back!',
getStartedHere: 'Get Started Here',
isRequired: '{name} is required',
mustBeValid: '{name} must be valid',
cannotBeMoreThanCharacters: '{name} cannot be more than {number} characters',
mustBeAtLeast: '{name} must be at least {number} characters',
passwordsMustMatch: 'Passwords must match',
yourAreNowSignedIn: 'You are now signed in!',
close: 'Close',
sessionExpiredSignInAgain: 'Your session has ended. Please sign in again.',
addPost: 'Add Post',
postTitle: 'Post Title',
imageUrl: 'Image URL',
category: 'Category | Categories',
categoryItems: {
art: 'Art',
education: 'Education',
food: 'Food',
furniture: 'Furniture',
travel: 'Travel',
photography: 'Photography',
technology: 'Technology'
},
description: 'Description',
submit: 'Submit',
atLeastOne: 'At least one',
likes: 'likes',
comments: 'comments',
added: 'Added',
fetchMore: 'Fetch More'
}
client\lang\es-ES.js
export default {
posts: 'Entradas',
signin: 'Iniciar sesión',
signup: 'Inscripción',
searchposts: 'Buscar entradas',
home: 'Inicio',
createPost: 'Crear Entrada',
profile: 'Perfil',
signout: 'Cerrar sesión',
username: 'Usuario',
password: 'Contraseña',
confirmPassword: 'Confirmar Contraseña',
email: 'Email',
dontHaveAnAccount: '¿No tiene una cuenta?',
alreadyHaveAnAccount: '¿Ya tiene una cuenta?',
welcomeBack: '¡Bienvenido de nuevo!',
getStartedHere: 'Comience aquí',
isRequired: '{name} es requerido',
mustBeValid: '{name} debe de ser válido',
cannotBeMoreThanCharacters:
'{name} no puede contener más de {number} caracteres',
mustBeAtLeast: '{name} debe contener al menos {number} caracteres',
passwordsMustMatch: 'Las Contraseñas deben coincidir',
yourAreNowSignedIn: '¡Ahora está registrado!',
close: 'Cerrar',
sessionExpiredSignInAgain:
'Su sesión ha caducado. Por favor, inicie la sesión otra vez.',
addPost: 'Añadir Entrada',
postTitle: 'Título de la entrada',
imageUrl: 'URL de la imagen',
category: 'Categoría | Categorías',
categoryItems: {
art: 'Arte',
education: 'Educación',
food: 'Comida',
furniture: 'Muebles',
travel: 'Viajes',
photography: 'Fotografía',
technology: 'Tecnología'
},
description: 'Descripción',
submit: 'Enviar',
atLeastOne: 'Por lo menos una',
likes: 'me gusta',
comments: 'comentarios',
added: 'Creado',
fetchMore: 'Obtener más'
}
3.-Modify the store to include the addPost action
- We need to modify the
store\index.jsstore document to include theaddPostaction and mutation.
client\store\index.js
import { getPosts } from '~/gql/getPosts.gql'
import { getCurrentUser } from '~/gql/getCurrentUser.gql'
import { signinUser } from '~/gql/signinUser.gql'
import { signupUser } from '~/gql/signupUser.gql'
import { addPost } from '~/gql/addPost.gql'
import utils from '~/helpers/utils'
export const state = () => ({
posts: [],
user: null,
loading: false,
error: null,
authError: null
})
export const mutations = {
setPosts: (state, payload) => {
state.posts = payload
},
addPost: (state, payload) => {
const posts = state.posts
posts.unshift(payload)
state.posts = posts
},
setUser: (state, payload) => {
state.user = payload
},
setLoading: (state, payload) => {
state.loading = payload
},
clearUser: state => (state.user = null),
setError: (state, payload) => {
state.error = payload
},
clearError: state => (state.error = null),
setAuthError: (state, payload) => {
state.authError = payload
},
clearAuthError: state => (state.authError = null)
}
export const actions = {
async nuxtServerInit({ dispatch }) {
await dispatch('getCurrentUser')
await dispatch('getPosts')
},
async getPosts({ commit }) {
commit('clearError')
commit('setLoading', true)
try {
const result = await this.app.apolloProvider.defaultClient.query({
query: getPosts
})
commit('setPosts', result.data.getPosts)
} catch (error) {
const currentError = utils.getCurrentGraphQLError(error)
commit('setError', currentError)
console.error(utils.getFirstGraphQLError(error))
}
commit('setLoading', false)
},
async addPost({ commit }, payload) {
commit('clearError')
commit('setLoading', true)
try {
await this.app.apolloProvider.defaultClient.mutate({
mutation: addPost,
variables: payload
})
const { _id, title, imageUrl } = payload
const newPost = {
_id,
title,
imageUrl
}
commit('addPost', newPost)
} catch (error) {
const currentError = utils.getCurrentGraphQLError(error)
commit('setError', currentError)
console.error(utils.getFirstGraphQLError(error))
}
commit('setLoading', false)
},
async logOut({ commit }) {
this.app.$cookies.remove('apollo-token')
await this.app.$apolloHelpers.onLogout()
commit('clearUser')
},
async getCurrentUser({ commit, dispatch }) {
if (!utils.isJwtTokenValid(this.app.$apolloHelpers.getToken())) {
await dispatch('logOut')
return
}
commit('setLoading', true)
try {
const result = await this.app.apolloProvider.defaultClient.query({
query: getCurrentUser
})
// Add user data to state
commit('setUser', result.data.getCurrentUser)
commit('clearError')
commit('clearAuthError')
} catch (error) {
const currentError = utils.getCurrentGraphQLError(error)
if (currentError === 'Unauthorized') {
commit('setAuthError', this.app.i18n.t('sessionExpiredSignInAgain'))
await dispatch('logOut')
} else {
commit('setError', currentError)
}
console.error(utils.getFirstGraphQLError(error))
}
commit('setLoading', false)
},
async signinUser({ commit, dispatch }, payload) {
commit('clearError')
commit('setLoading', true)
try {
const result = await this.app.apolloProvider.defaultClient.mutate({
mutation: signinUser,
variables: payload
})
await this.app.$apolloHelpers.onLogin(result.data.signinUser.token)
await dispatch('getCurrentUser')
} catch (error) {
const currentError = utils.getCurrentGraphQLError(error)
commit('setError', currentError)
console.error(utils.getFirstGraphQLError(error))
}
commit('setLoading', false)
},
async signupUser({ commit, dispatch }, payload) {
commit('clearError')
commit('setLoading', true)
try {
const result = await this.app.apolloProvider.defaultClient.mutate({
mutation: signupUser,
variables: payload
})
await this.app.$apolloHelpers.onLogin(result.data.signupUser.token)
await dispatch('getCurrentUser')
} catch (error) {
const currentError = utils.getCurrentGraphQLError(error)
commit('setError', currentError)
console.error(utils.getFirstGraphQLError(error))
}
commit('setLoading', false)
},
async signoutUser({ dispatch }) {
await dispatch('logOut')
// redirect home - kick users out of private pages (i.e. profile)
this.app.router.push(this.app.localePath('index'))
}
}
export const getters = {
posts: state => state.posts,
user: state => state.user,
loading: state => state.loading,
error: state => state.error,
authError: state => state.authError
}
4.-Modify the post Add page to include all the functionality .
- We need to modify the
post\add.vuepage document to include all the functionality to manage the creation of a newPost
client\pages\posts\add.vue
<template>
<v-container text-xs-center mt-5 pt-5>
<!-- Add Post Title -->
<v-layout row wrap>
<v-flex xs12 sm6 offset-sm3>
<h1 class="primary--text">{{ $t('addPost') }}</h1>
</v-flex>
</v-layout>
<!-- Error Alert -->
<v-layout v-if="error" row wrap>
<v-flex xs12 sm6 offset-sm3>
<form-alert :message="error"></form-alert>
</v-flex>
</v-layout>
<!-- Add Post Form -->
<v-layout row wrap>
<v-flex xs12 sm6 offset-sm3>
<v-form
ref="form"
v-model="isFormValid"
lazy-validation
@submit.prevent="handleAddPost"
>
<!-- Title Input -->
<v-layout row>
<v-flex xs12>
<v-text-field
v-model="title"
:rules="titleRules"
:label="$t('postTitle')"
type="text"
required
></v-text-field>
</v-flex>
</v-layout>
<!-- Image Url Input -->
<v-layout row>
<v-flex xs12>
<v-text-field
v-model="imageUrl"
:rules="imageRules"
:label="$t('imageUrl')"
type="text"
required
></v-text-field>
</v-flex>
</v-layout>
<!-- Image Preview -->
<v-layout row>
<v-flex xs12>
<img :src="imageUrl" height="300px" />
</v-flex>
</v-layout>
<!-- Categories Select -->
<v-layout row>
<v-flex xs12>
<v-select
v-model="categories"
:rules="categoriesRules"
item-text="text"
item-value="value"
:items="categoryItems"
multiple
:label="$tc('category', 2)"
></v-select>
</v-flex>
</v-layout>
<!-- Description Text Area -->
<v-layout row>
<v-flex xs12>
<v-textarea
v-model="description"
:rules="descRules"
:label="$t('description')"
type="text"
required
></v-textarea>
</v-flex>
</v-layout>
<v-layout row>
<v-flex xs12>
<v-btn
:loading="loading"
:disabled="!isFormValid || loading"
color="info"
type="submit"
>
<span slot="loader" class="custom-loader">
<v-icon light>cached</v-icon>
</span>
{{ $t('submit') }}</v-btn
>
</v-flex>
</v-layout>
</v-form>
</v-flex>
</v-layout>
</v-container>
</template>
<script>
import { mapGetters } from 'vuex'
export default {
name: 'AddPost',
middleware: 'auth',
data() {
return {
isFormValid: true,
title: '',
imageUrl: '',
categories: [],
categoryItems: [
{ value: 'Art', text: this.$i18n.t('categoryItems.art') },
{ value: 'Education', text: this.$i18n.t('categoryItems.education') },
{ value: 'Food', text: this.$i18n.t('categoryItems.food') },
{ value: 'Furniture', text: this.$i18n.t('categoryItems.furniture') },
{ value: 'Travel', text: this.$i18n.t('categoryItems.travel') },
{
value: 'Photography',
text: this.$i18n.t('categoryItems.photography')
},
{
value: 'Technology',
text: this.$i18n.t('categoryItems.technology')
}
],
description: '',
titleRules: [
title =>
!!title ||
this.$i18n.t('isRequired', { name: this.$i18n.t('postTitle') }),
title =>
title.length <= 20 ||
this.$i18n.t('cannotBeMoreThanCharacters', {
name: this.$i18n.t('postTitle'),
number: 20
})
],
imageRules: [
image =>
!!image ||
this.$i18n.t('isRequired', { name: this.$i18n.t('imageUrl') })
],
categoriesRules: [
categories =>
categories.length >= 1 ||
this.$i18n.t('isRequired', {
name: `${this.$i18n.t('atLeastOne')} ${this.$i18n.tc(
'category',
1
)}`
})
],
descRules: [
desc =>
!!desc ||
this.$i18n.t('isRequired', { name: this.$i18n.t('description') }),
desc =>
desc.length <= 200 ||
this.$i18n.t('cannotBeMoreThanCharacters', {
name: this.$i18n.t('description'),
number: 200
})
]
}
},
computed: {
...mapGetters(['loading', 'error', 'user'])
},
methods: {
handleAddPost() {
if (this.$refs.form.validate()) {
// add post action
this.$store.dispatch('addPost', {
title: this.title,
imageUrl: this.imageUrl,
categories: this.categories,
description: this.description,
creatorId: this.user._id
})
this.$router.push(this.localePath('index'))
}
}
}
}
</script>
5.-Modify the posts page to include all the functionality .
- We need to modify the
post\index.vuepage document to include all the functionality to show all the posts incrementally.
client\pages\posts\index.vue
<template>
<v-container fluid grid-list-xl>
<!-- Post Cards -->
<v-layout v-if="infiniteScrollPosts" row wrap>
<v-flex
v-for="post in infiniteScrollPosts.posts"
:key="post._id"
xs12
sm6
>
<v-card hover>
<v-img :src="post.imageUrl" height="30vh" lazy></v-img>
<v-card-actions>
<v-card-title primary>
<div>
<div class="headline">{{ post.title }}</div>
<span class="grey--text"
>{{ post.likes }} {{ $t('likes') }} -
{{ post.messages ? post.messages.length : 0 }}
{{ $t('comments') }}</span
>
</div>
</v-card-title>
<v-spacer></v-spacer>
<v-btn icon @click="showPostCreator = !showPostCreator">
<v-icon>{{
`keyboard_arrow_${showPostCreator ? 'up' : 'down'}`
}}</v-icon>
</v-btn>
</v-card-actions>
<!-- Post Creator Tile -->
<v-slide-y-transition>
<v-card-text v-show="showPostCreator" class="grey lighten-4">
<v-list-tile avatar>
<v-list-tile-avatar>
<img :src="post.createdBy.avatar" />
</v-list-tile-avatar>
<v-list-tile-content>
<v-list-tile-title class="text--primary">{{
post.createdBy.username
}}</v-list-tile-title>
<v-list-tile-sub-title class="font-weight-thin"
>{{ $t('added') }}
{{ post.createdDate }}</v-list-tile-sub-title
>
</v-list-tile-content>
<v-list-tile-action>
<v-btn icon ripple>
<v-icon color="grey lighten-1">info</v-icon>
</v-btn>
</v-list-tile-action>
</v-list-tile>
</v-card-text>
</v-slide-y-transition>
</v-card>
</v-flex>
</v-layout>
<!-- Fetch More Button -->
<v-layout v-if="showMoreEnabled" column>
<v-flex xs12>
<v-layout justify-center row>
<v-btn color="info" @click="showMorePosts">{{
$t('fetchMore')
}}</v-btn>
</v-layout>
</v-flex>
</v-layout>
</v-container>
</template>
<script>
import { infiniteScrollPosts } from '~/gql/infiniteScrollPosts.gql'
const pageSize = 2
export default {
name: 'Posts',
data() {
return {
pageNum: 1,
showMoreEnabled: true,
showPostCreator: false
}
},
apollo: {
infiniteScrollPosts: {
query: infiniteScrollPosts,
variables: {
pageNum: 1,
pageSize
}
}
},
methods: {
showMorePosts() {
this.pageNum += 1
// fetch more data and transform original result
this.$apollo.queries.infiniteScrollPosts.fetchMore({
variables: {
// pageNum incremented by 1
pageNum: this.pageNum,
pageSize
},
updateQuery: (prevResult, { fetchMoreResult }) => {
console.log('previous result', prevResult.infiniteScrollPosts.posts)
console.log('fetch more result', fetchMoreResult)
const newPosts = fetchMoreResult.infiniteScrollPosts.posts
const hasMore = fetchMoreResult.infiniteScrollPosts.hasMore
this.showMoreEnabled = hasMore
return {
infiniteScrollPosts: {
__typename: prevResult.infiniteScrollPosts.__typename,
// Merge previous posts with new posts
posts: [...prevResult.infiniteScrollPosts.posts, ...newPosts],
hasMore
}
}
}
})
}
}
}
</script>
6.-We need to test if it works.











Section 11: Post Component
I.-Modify the server app to include the getPost Query and the addPostMessage mutation.
- The folder structure for the models have been changed. Basically the
GraphQLandDatabase(MongoDB)models have been merged.
Old format
| Level 1 | Level 2 | Level 3 | Description |
|---|---|---|---|
| graphql | Everthing related to GraphQL | ||
| - | inputs | input objects used in GraphQL Queries and Mutations | |
| - | post-input.ts | input object used when creating a post | |
| - | types | object types used by GraphQL | |
| - | post-page.type.ts | PostPage type | |
| - | post.type.ts | Post type | |
| - | posts.resolver.ts | GraphQL Post resolver | |
| model | Everthing related to database model | ||
| - | dtos | dtos used mainly for service input objects | |
| - | create-post-message.dto.ts | CreatePostMessage dto | |
| - | create-post.dto.ts | CreatePost dto | |
| - | post.model.ts | Database Post model |
New format
| Level 1 | Level 2 | Description |
|---|---|---|
| dtos | dtos used mainly for service input objects | |
| - | create-post-message.dto.ts | CreatePostMessage dto |
| - | create-post.dto.ts | CreatePost dto |
| inputs | input objects used in GraphQL Queries and Mutations | |
| - | post-input.ts | input object used when creating a post |
| post.model.ts | Database and GraphQL Post model | |
| posts.resolver.ts | GraphQL Post resolver |
1.-Update the posts module with the new structure and add the getPost Query and the addPostMessage mutation.
server\src\posts\dtos\create-post-message.dto.ts
export class CreatePostMessageDto {
readonly messageBody: string;
readonly userId: string;
readonly postId: string;
}
server\src\posts\dtos\create-post.dto.ts
export class CreatePostDto {
readonly title: string;
readonly imageUrl: string;
readonly categories: string[];
readonly description: string;
readonly createdBy: string;
}
server\src\posts\inputs\post.input.ts
import { InputType, Field, ID } from 'type-graphql';
@InputType()
export class PostInput {
@Field()
readonly title: string;
@Field()
readonly imageUrl: string;
@Field(() => [String], { nullable: "items" })
readonly categories: string[];
@Field()
readonly description: string;
@Field(() => ID)
readonly creatorId: string;
}
server\src\posts\types\post-page.type.ts
import { Field, ObjectType } from 'type-graphql'
import { Post } from '../post.model'
import { Ref } from 'typegoose'
@ObjectType()
export class PostPage {
@Field(() => [Post], { nullable: "items" })
readonly posts: Ref<Post>[]
@Field(() => Boolean)
hasMore: boolean
}
server\src\posts\post.model.ts
import { prop, arrayProp, Typegoose, Ref } from 'typegoose';
import { IsDate, IsInt } from 'class-validator';
import { Field, ObjectType, ID, Int } from 'type-graphql';
import { User } from '../users/user.model'
@ObjectType()
export class Message extends Typegoose {
@Field(() => ID)
_id: string
@Field()
@prop({ required: true })
messageBody: string;
@IsDate()
@Field()
@prop({ default: Date.now })
messageDate: Date;
@Field(() => User)
@prop({ required: true, ref: User })
messageUser: Ref<User>
}
@ObjectType()
export class Post extends Typegoose {
@Field(() => ID)
_id: string
@Field()
@prop({ required: true })
title: string;
@Field()
@prop({ required: true })
imageUrl: string;
@Field(() => [String], { nullable: "items" })
@prop({ required: true })
categories: string[];
@Field()
@prop({ required: true })
description: string;
@IsDate()
@Field()
@prop({ default: Date.now })
createdDate: Date
@IsInt()
@Field(() => Int, { nullable: true })
@prop({ default: 0 })
likes: number;
@Field(() => User)
@prop({ required: true, ref: User })
createdBy: Ref<User>
@Field(() => [Message], { nullable: "itemsAndList" })
@arrayProp({ items: Message })
messages?: Ref<Message>[]
}
server\src\posts\posts.module.ts
import { Module } from '@nestjs/common';
import { TypegooseModule } from 'nestjs-typegoose'
import { Post } from './post.model';
import { PostsService } from './posts.service';
import { PostsResolver } from './posts.resolver';
@Module({
imports: [TypegooseModule.forFeature([Post])],
providers: [PostsService, PostsResolver]
})
export class PostsModule { }
server\src\posts\posts.resolver.ts
import { Resolver, Query, Mutation, Args } from '@nestjs/graphql';
import { Int, ID } from 'type-graphql';
import { PostsService } from './posts.service';
import { PostInput } from './inputs/post.input';
import { PostPage } from './types/post-page.type';
import { Post, Message } from './post.model';
@Resolver()
export class PostsResolver {
constructor(private readonly postsService: PostsService) { }
@Query(() => [Post])
async getPosts() {
return await this.postsService.getPosts();
}
@Query(() => Post)
async getPost(@Args({ name: 'postId', type: () => ID}) postId: string) {
return await this.postsService.getPost(postId);
}
@Query(() => PostPage)
async infiniteScrollPosts(
@Args({ name: 'pageNum', type: () => Int}) pageNum: number,
@Args({ name: 'pageSize', type: () => Int}) pageSize: number
) {
return await this.postsService.infiniteScrollPosts(pageNum, pageSize);
}
@Mutation(() => Post)
async addPostWithInput(@Args('input') input: PostInput) {
const createdBy = input.creatorId;
const { title, imageUrl, categories, description } = input
return await this.postsService.addPost({ title, imageUrl, categories, description, createdBy });
}
@Mutation(() => Post)
async addPost(
@Args('title') title: string,
@Args('imageUrl') imageUrl: string,
@Args({ name: 'categories', type: () => [String], nullable: "items" }) categories: string[],
@Args('description') description: string,
@Args({ name: 'creatorId', type: () => ID}) creatorId: string,
) {
const createdBy = creatorId;
return await this.postsService.addPost({ title, imageUrl, categories, description, createdBy });
}
@Mutation(() => Message)
async addPostMessage(
@Args('messageBody') messageBody: string,
@Args({ name: 'userId', type: () => ID}) userId: string,
@Args({ name: 'postId', type: () => ID}) postId: string,
) {
return await this.postsService.addPostMessage({ messageBody, userId, postId });
}
}
server\src\posts\posts.service.ts
import { Injectable } from '@nestjs/common';
import { InjectModel } from 'nestjs-typegoose';
import { Post, Message } from './post.model';
import { PostPage } from './types/post-page.type';
import { CreatePostDto } from './dtos/create-post.dto'
import { CreatePostMessageDto } from './dtos/create-post-message.dto'
import { ModelType, Ref } from 'typegoose';
@Injectable()
export class PostsService {
constructor(@InjectModel(Post) private readonly postModel: ModelType<Post>) { }
async getPosts(): Promise<Post[] | null> {
const posts = await this.postModel.find({})
.sort({ createdDate: "desc" })
.populate({
path: "createdBy",
model: "User"
});
return posts;
}
async getPost(postId: string): Promise<Post | null> {
const post = await this.postModel.findOne({ _id: postId })
.populate({
path: "messages.messageUser",
model: "User"
});
return post;
}
async infiniteScrollPosts(pageNum: number, pageSize: number): Promise<PostPage | null> {
const skips = pageSize * (pageNum - 1)
const posts = await this.postModel.find({})
.sort({ createdDate: "desc" })
.populate({
path: "createdBy",
model: "User"
})
.skip(skips)
.limit(pageSize)
.lean()
const totalDocs = await this.postModel.countDocuments()
const hasMore = totalDocs > pageSize * pageNum
const postPage: PostPage = {
posts,
hasMore
}
return postPage
}
async addPost(createPostDto: CreatePostDto): Promise<Post> {
const newPost = new this.postModel(createPostDto);
return await newPost.save();
}
async addPostMessage(createPostMessageDto: CreatePostMessageDto): Promise<Ref<Message>> {
const newMessage = {
messageBody: createPostMessageDto.messageBody,
messageUser: createPostMessageDto.userId
};
const post = await this.postModel.findOneAndUpdate(
// find post by id
{ _id: createPostMessageDto.postId },
// prepend (push) new message to beginning of messages array
{ $push: { messages: { $each: [newMessage], $position: 0 } } },
// return fresh document after update
{ new: true }
).populate({
path: "messages.messageUser",
model: "User"
});
return post.messages[0];
}
}
2.-Update the users module with the new structure and add the getPostQuery and theaddPostMessage` mutation.
server\src\users\dtos\create-user.dto.ts
export class CreateUserDto {
readonly username: string;
readonly email: string;
readonly password: string
}
server\src\users\dtos\signin-user.dto.ts
export class SigninUserDto {
readonly username: string;
readonly password: string
}
server\src\users\inputs\user.input.ts
import { InputType, Field } from 'type-graphql';
@InputType()
export class UserInput {
@Field()
readonly username: string;
@Field()
readonly email: string;
@Field()
readonly password: string;
}
server\src\users\user.model.ts
import { Field, ObjectType, ID } from 'type-graphql'
import { arrayProp, prop, Typegoose, Ref } from 'typegoose'
import { IsEmail, IsDate, IsArray } from 'class-validator'
import { Post } from '../posts/post.model'
@ObjectType()
export class Token {
@Field()
readonly token: string
}
@ObjectType()
export class User extends Typegoose {
@Field(() => ID)
_id: string
@Field()
@prop({ required: true, unique: true, trim: true })
username: string
@IsEmail()
@Field()
@prop({ required: true, trim: true })
email: string
@Field()
@prop({ required: true, trim: true })
password: string
@Field({ nullable: true })
@prop()
avatar?: string
@IsDate()
@Field()
@prop({ default: Date.now })
joinDate: Date
@IsArray()
@Field(() => [Post], { nullable: "itemsAndList" })
@arrayProp({ itemsRef: 'Post' })
favorites?: Ref<Post>[]
}
server\src\users\users.module.ts
import { Module } from '@nestjs/common'
import { TypegooseModule } from 'nestjs-typegoose'
import { User } from './user.model'
import { UsersService } from './users.service'
import { UsersResolver } from "./users.resolver"
import { ConfigService } from '../config/config.service'
import { AuthModule } from '../auth/auth.module'
@Module({
imports: [
TypegooseModule.forFeature([User]),
AuthModule
],
providers: [UsersService, UsersResolver, ConfigService]
})
export class UsersModule { }
server\src\users\users.resolver.ts
import { Resolver, Query, Mutation, Args } from '@nestjs/graphql'
import { UsersService } from './users.service'
import { User, Token } from './user.model'
import { GraphqlAuthGuard } from '../auth/graphql-auth.guard'
import { CurrentUser } from '../auth/current-user.decorator'
import { AuthService } from '../auth/auth.service'
import { UseGuards } from '@nestjs/common'
@Resolver()
export class UsersResolver {
constructor(
private readonly usersService: UsersService,
private readonly authService: AuthService,
) { }
@Query(() => User)
@UseGuards(GraphqlAuthGuard)
async getCurrentUser(@CurrentUser() currentUser: User) {
return currentUser
}
@Query(() => [User])
async getUsers() {
return await this.usersService.getUsers()
}
@Mutation(() => Token)
async signupUser(
@Args('username') username: string,
@Args('email') email: string,
@Args('password') password: string,
) {
return await this.authService.signUp({ username, email, password })
}
@Mutation(() => Token)
async signinUser(
@Args('username') username: string,
@Args('password') password: string,
) {
return await this.authService.signIn({ username, password })
}
}
server\src\users\users.service.ts
import { Injectable } from '@nestjs/common'
import { InjectModel } from 'nestjs-typegoose'
import { User } from './user.model'
import { ModelType } from 'typegoose'
@Injectable()
export class UsersService {
constructor(@InjectModel(User) private readonly userModel: ModelType<User>) { }
async getUsers(): Promise<User[] | null> {
return await this.userModel.find().exec()
}
}
3.-Update the auth module to change file locations because of the new structure.
server\src\auth\auth.module.ts
import { Module } from '@nestjs/common'
import { AuthService } from './auth.service'
import { JwtModule } from '@nestjs/jwt'
import { PassportModule } from '@nestjs/passport'
import { JwtStrategy } from './jwt.strategy'
import { ConfigModule } from '../config/config.module';
import { ConfigService } from '../config/config.service';
import { TypegooseModule } from 'nestjs-typegoose'
import { User } from '../users/user.model';
@Module({
imports: [
PassportModule.register({ defaultStrategy: 'jwt' }),
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: async (configService: ConfigService) => ({
secret: configService.get('SECRET'),
signOptions: {
expiresIn: 216000, // 1 Hour: 60 * 60 * 60
},
}),
}),
TypegooseModule.forFeature([User]),
],
providers: [
JwtStrategy,
AuthService,
ConfigService
],
exports: [
AuthService
],
})
export class AuthModule {}
server\src\auth\auth.service.ts
import { Injectable, ConflictException, InternalServerErrorException, UnauthorizedException } from '@nestjs/common'
import { InjectModel } from 'nestjs-typegoose'
import { ModelType } from 'typegoose'
import { JwtService } from '@nestjs/jwt'
import { SigninUserDto } from '../users/dtos/signin-user.dto'
import { CreateUserDto } from '../users/dtos/create-user.dto'
import { Token, User } from '../users/user.model'
import { JwtPayload } from './jwt-payload.interface'
import md5 from 'md5'
import * as bcrypt from 'bcrypt'
@Injectable()
export class AuthService {
constructor(
@InjectModel(User) private readonly userModel: ModelType<User>,
private readonly jwtService: JwtService,
) { }
async signUp(createUserDto: CreateUserDto): Promise<Token> {
const username = createUserDto.username
const user = await this.userModel.findOne({ username })
if (user) {
throw new ConflictException("User already exists")
}
const avatar = `http://gravatar.com/avatar/${md5(username)}?d=identicon`
var salt = bcrypt.genSaltSync(10)
var password = bcrypt.hashSync(createUserDto.password, salt)
const currentUser = { ...createUserDto, avatar, password }
const createdUser = new this.userModel(currentUser)
try {
const newUser = await createdUser.save()
const token = this.getAccessToken(newUser)
return token
}
catch (error) {
throw new InternalServerErrorException()
}
}
async signIn(signinUserDto: SigninUserDto): Promise<Token> {
const { username, password } = signinUserDto
const user = await this.userModel.findOne({ username })
if (!user) {
throw new UnauthorizedException("Invalid credentials")
}
const isValidPassword = await bcrypt.compare(password, user.password)
if (!isValidPassword) {
throw new UnauthorizedException("Invalid credentials")
}
const token = this.getAccessToken(user)
return token
}
private async getAccessToken(user: User): Promise<Token> {
const { username, email } = user
const payload: JwtPayload = { username, email }
const token = await this.jwtService.sign(payload)
return { token }
}
}
server\src\auth\jwt.strategy.ts
import { PassportStrategy } from '@nestjs/passport'
import { Strategy, ExtractJwt } from 'passport-jwt'
import { Injectable, UnauthorizedException } from '@nestjs/common'
// import { JwtPayload } from './jwt-payload.interface'
import { InjectModel } from 'nestjs-typegoose'
import { User } from '../users/user.model'
import { ModelType } from 'typegoose'
import { ConfigService } from '../config/config.service'
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(
@InjectModel(User) private readonly userModel: ModelType<User>,
private readonly configService: ConfigService
) {
super({
jwtFromRequest: ExtractJwt.fromHeader("authorization"),
ignoreExpiration: false,
secretOrKey: configService.get('SECRET'),
})
}
async validate(payload: any): Promise<User | null> {
const { username } = payload
const user = await this.userModel.findOne({
username: username
}).populate({
path: "favorites",
model: "Post"
})
if (!user) {
throw new UnauthorizedException()
}
return user
}
}
4.-Update the database.providers.ts document to setup the useFindAndModify setting value.
server\src\database\database.providers.ts
import { ConfigModule } from '../config/config.module'
import { ConfigService } from '../config/config.service'
import { TypegooseModule } from 'nestjs-typegoose'
export const databaseProviders = [
TypegooseModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: async (config: ConfigService) => ({
uri: config.get('MONGO_URI'),
useNewUrlParser: true,
useCreateIndex: true,
useFindAndModify : false,
}),
}),
]
5.-We need to test if the new Query and Mutation work as expected
- The
schemacreated with the new changes is the following one.
server\schema.gql
# -----------------------------------------------
# !!! THIS FILE WAS GENERATED BY TYPE-GRAPHQL !!!
# !!! DO NOT MODIFY THIS FILE BY YOURSELF !!!
# -----------------------------------------------
"""
The javascript `Date` as string. Type represents date and time as the ISO Date string.
"""
scalar DateTime
type Message {
_id: ID!
messageBody: String!
messageDate: DateTime!
messageUser: User!
}
type Mutation {
signupUser(password: String!, email: String!, username: String!): Token!
signinUser(password: String!, username: String!): Token!
addPostWithInput(input: PostInput!): Post!
addPost(creatorId: ID!, description: String!, categories: [String]!, imageUrl: String!, title: String!): Post!
addPostMessage(postId: ID!, userId: ID!, messageBody: String!): Message!
}
type Post {
_id: ID!
title: String!
imageUrl: String!
categories: [String]!
description: String!
createdDate: DateTime!
likes: Int
createdBy: User!
messages: [Message]
}
input PostInput {
title: String!
imageUrl: String!
categories: [String]!
description: String!
creatorId: ID!
}
type PostPage {
posts: [Post]!
hasMore: Boolean!
}
type Query {
getCurrentUser: User!
getUsers: [User!]!
getPosts: [Post!]!
getPost(postId: ID!): Post!
infiniteScrollPosts(pageSize: Int!, pageNum: Int!): PostPage!
}
type Token {
token: String!
}
type User {
_id: ID!
username: String!
email: String!
password: String!
avatar: String
joinDate: DateTime!
favorites: [ID]
}
request
query {
getPost(postId: "5d505ca7c45cb259b0761e48") {
_id
title
imageUrl
categories
description
createdDate
likes
createdBy {
_id
}
messages {
_id
messageBody
messageDate
messageUser {
_id
username
avatar
}
}
}
}
response
{
"data": {
"getPost": {
"_id": "5d505ca7c45cb259b0761e48",
"title": "A tasty dinner",
"imageUrl": "https://images.pexels.com/photos/691114/pexels-photo-691114.jpeg?auto=compress&cs=tinysrgb&dpr=2&h=650&w=940",
"categories": [
"Food",
"Travel"
],
"description": "Picture of a recipe I would like to prepare",
"createdDate": "2019-08-11T18:21:27.565Z",
"likes": 0,
"createdBy": {
"_id": "5d35390f58729c10f875d41c"
},
"messages": [
{
"_id": "5d51a8bbffd7e267e86c34ed",
"messageBody": "Please, explain to me how to prepare it!",
"messageDate": "2019-08-12T17:58:19.241Z",
"messageUser": {
"_id": "5d35390f58729c10f875d41c",
"username": "Fofo",
"avatar": "http://gravatar.com/avatar/d7ec7a6a7980721f40631794b6e691f8?d=identicon"
}
},
{
"_id": "5d51a168ffd7e267e86c34eb",
"messageBody": "I want to taste it!",
"messageDate": "2019-08-12T17:27:04.085Z",
"messageUser": {
"_id": "5d35390f58729c10f875d41c",
"username": "Fofo",
"avatar": "http://gravatar.com/avatar/d7ec7a6a7980721f40631794b6e691f8?d=identicon"
}
},
{
"_id": "5d51a109ffd7e267e86c34ea",
"messageBody": "It looks yummy",
"messageDate": "2019-08-12T17:25:29.649Z",
"messageUser": {
"_id": "5d3730452dbcac4a24749fca",
"username": "Juan",
"avatar": "http://gravatar.com/avatar/92eaf3719159c372f3d50337e0a14f57?d=identicon"
}
}
]
}
}
}
Request
mutation($messageBody: String!, $userId: ID!, $postId: ID!) {
addPostMessage(messageBody: $messageBody, userId: $userId, postId: $postId) {
_id
messageBody
messageDate
messageUser {
_id
username
avatar
}
}
}
Variables
{
"messageBody": "Let's make iy by ourselves",
"userId": "5d3730452dbcac4a24749fca",
"postId": "5d505ca7c45cb259b0761e48"
}
Response
{
"data": {
"addPostMessage": {
"_id": "5d538c2115248e14e09241ac",
"messageBody": "Let's make iy by ourselves",
"messageDate": "2019-08-14T04:20:49.588Z",
"messageUser": {
"_id": "5d3730452dbcac4a24749fca",
"username": "Juan",
"avatar": "http://gravatar.com/avatar/92eaf3719159c372f3d50337e0a14f57?d=identicon"
}
}
}
}
II.-Modify the client app to add the individual Post pages.
1.-Update the localization documents with new entries
client\lang\en-US.js
export default {
posts: 'Posts',
signin: 'Sign in',
signup: 'Sign up',
searchposts: 'Search Posts',
home: 'Home',
createPost: 'Create Post',
profile: 'Profile',
signout: 'Signout',
username: 'Username',
password: 'Password',
confirmPassword: 'Confirm Password',
email: 'Email',
dontHaveAnAccount: "Don't have an account?",
alreadyHaveAnAccount: 'Already have an account?',
welcomeBack: 'Welcome Back!',
getStartedHere: 'Get Started Here',
isRequired: '{name} is required',
mustBeValid: '{name} must be valid',
cannotBeMoreThanCharacters: '{name} cannot be more than {number} characters',
mustBeAtLeast: '{name} must be at least {number} characters',
passwordsMustMatch: 'Passwords must match',
yourAreNowSignedIn: 'You are now signed in!',
close: 'Close',
sessionExpiredSignInAgain: 'Your session has ended. Please sign in again.',
addPost: 'Add Post',
postTitle: 'Post Title',
imageUrl: 'Image URL',
category: 'Category | Categories',
categoryItems: {
art: 'Art',
education: 'Education',
food: 'Food',
furniture: 'Furniture',
travel: 'Travel',
photography: 'Photography',
technology: 'Technology'
},
description: 'Description',
submit: 'Submit',
atLeastOne: 'At least one',
likes: 'likes',
comments: 'comments',
added: 'Added',
fetchMore: 'Fetch More',
message: 'Message | Messages',
clickToEnlargeImage: 'Click to enlarge image',
addMessage: 'Add Message',
pageNotFound: '404 Not Found',
otherError: 'An error occurred',
homePage: 'Home Page'
}
client\lang\es-ES.js
export default {
posts: 'Entradas',
signin: 'Iniciar sesión',
signup: 'Inscripción',
searchposts: 'Buscar entradas',
home: 'Inicio',
createPost: 'Crear Entrada',
profile: 'Perfil',
signout: 'Cerrar sesión',
username: 'Usuario',
password: 'Contraseña',
confirmPassword: 'Confirmar Contraseña',
email: 'Email',
dontHaveAnAccount: '¿No tiene una cuenta?',
alreadyHaveAnAccount: '¿Ya tiene una cuenta?',
welcomeBack: '¡Bienvenido de nuevo!',
getStartedHere: 'Comience aquí',
isRequired: '{name} es requerido',
mustBeValid: '{name} debe de ser válido',
cannotBeMoreThanCharacters:
'{name} no puede contener más de {number} caracteres',
mustBeAtLeast: '{name} debe contener al menos {number} caracteres',
passwordsMustMatch: 'Las Contraseñas deben coincidir',
yourAreNowSignedIn: '¡Ahora está registrado!',
close: 'Cerrar',
sessionExpiredSignInAgain:
'Su sesión ha caducado. Por favor, inicie la sesión otra vez.',
addPost: 'Añadir Entrada',
postTitle: 'Título de la entrada',
imageUrl: 'URL de la imagen',
category: 'Categoría | Categorías',
categoryItems: {
art: 'Arte',
education: 'Educación',
food: 'Comida',
furniture: 'Muebles',
travel: 'Viajes',
photography: 'Fotografía',
technology: 'Tecnología'
},
description: 'Descripción',
submit: 'Enviar',
atLeastOne: 'Por lo menos una',
likes: 'me gusta',
comments: 'comentarios',
added: 'Creado',
fetchMore: 'Obtener más',
message: 'Mensaje | Mensajes',
clickToEnlargeImage: 'Haga clic para ampliar la imagen',
addMessage: 'Añadir Mensaje',
pageNotFound: '404 No encontrado',
otherError: 'Oucrrió un error',
homePage: 'Página inicial'
}
2.-Create the getPost.gql and addPostMessage.gql documents with the getPost Query and addPostMessage mutation.
- We need to create the
getPost.gqldocument with thegetPostQuery
client\gql\getPost.gql
query getPost($postId: ID!) {
getPost(postId: $postId) {
_id
title
imageUrl
categories
description
likes
createdDate
messages {
_id
messageBody
messageDate
messageUser {
_id
username
avatar
}
}
}
}
- We need to create the
addPostMessage.gqldocument with theaddPostMessageMutation
client\gql\addPostMessage.gql
mutation addPostMessage($messageBody: String!, $userId: ID!, $postId: ID!) {
addPostMessage(messageBody: $messageBody, userId: $userId, postId: $postId) {
_id
messageBody
messageDate
messageUser {
_id
username
avatar
}
}
}
3.-Create the individual Post page.
- We need to create the
\posts\_id.vuePost page document.
client\pages\posts_id.vue
<template>
<v-container v-if="getPost" class="mt-3" flexbox center>
<!-- Post Card -->
<v-layout row wrap>
<v-flex xs12>
<v-card hover>
<v-card-title>
<h1>{{ getPost.title }}</h1>
<v-btn v-if="user" large icon>
<v-icon large color="grey">favorite</v-icon>
</v-btn>
<h3 class="ml-3 font-weight-thin">
{{ getPost.likes }} {{ $t('likes').toUpperCase() }}
</h3>
<v-spacer></v-spacer>
<v-icon color="info" large @click="goToPreviousPage"
>arrow_back</v-icon
>
</v-card-title>
<v-tooltip right>
<span>{{ $t('clickToEnlargeImage') }}</span>
<v-img
id="post__image"
slot="activator"
:src="getPost.imageUrl"
@click="toggleImageDialog"
></v-img>
</v-tooltip>
<!-- Post Image Dialog -->
<v-dialog v-model="dialog">
<v-card>
<v-img :src="getPost.imageUrl" height="80vh"></v-img>
</v-card>
</v-dialog>
<v-card-text>
<span v-for="(category, index) in getPost.categories" :key="index">
<v-chip class="mb-3" color="accent" text-color="white">{{
category
}}</v-chip>
</span>
<h3>{{ getPost.description }}</h3>
</v-card-text>
</v-card>
</v-flex>
</v-layout>
<!-- Messages Section -->
<div class="mt-3">
<!-- Message Input -->
<v-layout v-if="user" class="mb-3">
<v-flex xs12>
<v-form
ref="form"
v-model="isFormValid"
lazy-validation
@submit.prevent="handleAddPostMessage"
>
<v-layout row>
<v-flex xs12>
<v-text-field
v-model="messageBody"
:rules="messageRules"
clearable
:append-outer-icon="messageBody && 'send'"
:label="$t('addMessage')"
type="text"
prepend-icon="email"
required
@click:append-outer="handleAddPostMessage"
></v-text-field>
</v-flex>
</v-layout>
</v-form>
</v-flex>
</v-layout>
<!-- Messages -->
<v-layout row wrap>
<v-flex xs12>
<v-list subheader two-line>
<v-subheader
>{{ $tc('message', 2) }} ({{
getPost.messages.length
}})</v-subheader
>
<template v-for="message in getPost.messages">
<v-divider :key="message._id"></v-divider>
<v-list-tile :key="message.title" avatar inset>
<v-list-tile-avatar>
<img :src="message.messageUser.avatar" />
</v-list-tile-avatar>
<v-list-tile-content>
<v-list-tile-title>
{{ message.messageBody }}
</v-list-tile-title>
<v-list-tile-sub-title>
{{ message.messageUser.username }}
<span class="grey--text text--lighten-1 hidden-xs-only">{{
message.messageDate
}}</span>
</v-list-tile-sub-title>
</v-list-tile-content>
<v-list-tile-action class="hidden-xs-only">
<v-icon
:color="checkIfOwnMessage(message) ? 'accent' : 'grey'"
>chat_bubble</v-icon
>
</v-list-tile-action>
</v-list-tile>
</template>
</v-list>
</v-flex>
</v-layout>
</div>
</v-container>
</template>
<script>
import { mapGetters } from 'vuex'
import { getPost } from '~/gql/getPost.gql'
import { addPostMessage } from '~/gql/addPostMessage.gql'
import utils from '~/helpers/utils'
export default {
name: 'Post',
validate({ params }) {
return utils.isValidObjectID(params.id)
},
data() {
return {
postId: this.$route.params.id,
dialog: false,
messageBody: '',
isFormValid: true,
messageRules: [
message =>
!!message ||
this.$i18n.t('isRequired', { name: this.$i18n.tc('message', 1) }),
message =>
(message && message.length <= 75) ||
this.$i18n.t('cannotBeMoreThanCharacters', {
name: this.$i18n.tc('message', 1),
number: 75
})
]
}
},
apollo: {
getPost: {
query: getPost,
variables() {
return {
postId: this.postId
}
}
}
},
computed: {
...mapGetters(['user'])
},
methods: {
handleAddPostMessage() {
if (this.$refs.form.validate()) {
const variables = {
messageBody: this.messageBody,
userId: this.user._id,
postId: this.postId
}
this.$apollo
.mutate({
mutation: addPostMessage,
variables,
update: (cache, { data: { addPostMessage } }) => {
const data = cache.readQuery({
query: getPost,
variables: { postId: this.postId }
})
data.getPost.messages.unshift(addPostMessage)
cache.writeQuery({
query: getPost,
variables: { postId: this.postId },
data
})
}
})
.then(({ data }) => {
this.$refs.form.reset()
console.log(data.addPostMessage)
})
.catch(err => console.error(err))
}
},
goToPreviousPage() {
this.$router.go(-1)
},
toggleImageDialog() {
if (window.innerWidth > 500) {
this.dialog = !this.dialogs
}
},
checkIfOwnMessage(message) {
return this.user && this.user._id === message.messageUser._id
}
}
}
</script>
<style scoped>
#post__image {
height: 400px !important;
}
</style>
4.-Update the Posts page to call the new individual Post page
- We need to modify the
posts\index.vuedocument.
client\pages\posts\index.vue
<template>
<v-container fluid grid-list-xl>
<!-- Post Cards -->
<v-layout v-if="infiniteScrollPosts" row wrap>
<v-flex
v-for="post in infiniteScrollPosts.posts"
:key="post._id"
xs12
sm6
>
<v-card hover>
<v-img
:src="post.imageUrl"
height="30vh"
lazy
@click.native="goToPost(post._id)"
></v-img>
<v-card-actions>
<v-card-title primary>
<div>
<div class="headline">{{ post.title }}</div>
<span class="grey--text"
>{{ post.likes }} {{ $t('likes') }} -
{{ post.messages ? post.messages.length : 0 }}
{{ $t('comments') }}</span
>
</div>
</v-card-title>
<v-spacer></v-spacer>
<v-btn icon @click="showPostCreator = !showPostCreator">
<v-icon>{{
`keyboard_arrow_${showPostCreator ? 'up' : 'down'}`
}}</v-icon>
</v-btn>
</v-card-actions>
<!-- Post Creator Tile -->
<v-slide-y-transition>
<v-card-text v-show="showPostCreator" class="grey lighten-4">
<v-list-tile avatar>
<v-list-tile-avatar>
<img :src="post.createdBy.avatar" />
</v-list-tile-avatar>
<v-list-tile-content>
<v-list-tile-title class="text--primary">{{
post.createdBy.username
}}</v-list-tile-title>
<v-list-tile-sub-title class="font-weight-thin"
>{{ $t('added') }}
{{ post.createdDate }}</v-list-tile-sub-title
>
</v-list-tile-content>
<v-list-tile-action>
<v-btn icon ripple>
<v-icon color="grey lighten-1">info</v-icon>
</v-btn>
</v-list-tile-action>
</v-list-tile>
</v-card-text>
</v-slide-y-transition>
</v-card>
</v-flex>
</v-layout>
<!-- Fetch More Button -->
<v-layout v-if="showMoreEnabled" column>
<v-flex xs12>
<v-layout justify-center row>
<v-btn color="info" @click="showMorePosts">{{
$t('fetchMore')
}}</v-btn>
</v-layout>
</v-flex>
</v-layout>
</v-container>
</template>
<script>
import { infiniteScrollPosts } from '~/gql/infiniteScrollPosts.gql'
const pageSize = 2
export default {
name: 'Posts',
data() {
return {
pageNum: 1,
showMoreEnabled: true,
showPostCreator: false
}
},
apollo: {
infiniteScrollPosts: {
query: infiniteScrollPosts,
variables: {
pageNum: 1,
pageSize
}
}
},
methods: {
showMorePosts() {
this.pageNum += 1
// fetch more data and transform original result
this.$apollo.queries.infiniteScrollPosts.fetchMore({
variables: {
// pageNum incremented by 1
pageNum: this.pageNum,
pageSize
},
updateQuery: (prevResult, { fetchMoreResult }) => {
const newPosts = fetchMoreResult.infiniteScrollPosts.posts
const hasMore = fetchMoreResult.infiniteScrollPosts.hasMore
this.showMoreEnabled = hasMore
return {
infiniteScrollPosts: {
__typename: prevResult.infiniteScrollPosts.__typename,
// Merge previous posts with new posts
posts: [...prevResult.infiniteScrollPosts.posts, ...newPosts],
hasMore
}
}
}
})
},
goToPost(postId) {
this.$router.push(`${this.localePath('posts')}/${postId}`)
}
}
}
</script>
5.-Update the Home page to call the new individual Post page
- We need to modify the main
pages\index.vueHome page.
client\pages\index.vue
<template>
<v-container text-xs-center>
<v-layout row>
<v-dialog v-model="loading" persistent fullscreen>
<v-container fill-height>
<v-layout row justify-center align-center>
<v-progress-circular
indeterminate
:size="70"
:width="7"
color="secondary"
></v-progress-circular>
</v-layout>
</v-container>
</v-dialog>
</v-layout>
<v-flex xs12>
<v-carousel
v-if="!loading && posts.length > 0"
v-bind="{ cycle: true }"
interval="3000"
>
<v-carousel-item
v-for="post in posts"
:key="post._id"
:src="post.imageUrl"
@click.native="goToPost(post._id)"
>
<h1 id="carousel__title">{{ post.title }}</h1>
</v-carousel-item>
</v-carousel>
</v-flex>
</v-container>
</template>
<script>
import { mapGetters } from 'vuex'
export default {
name: 'Home',
computed: {
...mapGetters(['loading', 'posts'])
},
methods: {
goToPost(postId) {
this.$router.push(`${this.localePath('posts')}/${postId}`)
}
}
}
</script>
<style>
#carousel__title {
position: absolute;
background-color: rgba(0, 0, 0, 0.5);
color: white;
border-radius: 5px 5px 0 0;
padding: 0.5em;
margin: 0 auto;
bottom: 50px;
left: 0;
right: 0;
}
</style>
6.-Modify the utils helper to add a new function
- We need to modify the
utils.jsdocument to add the newisValidObjectIDfunction.
client\helpers\utils.js
const utils = {
isJwtTokenValid(jwtToken) {
if (!jwtToken) {
return false
}
const items = jwtToken.split('.')
if (!items) {
return false
}
return items.length > 2
},
getFirstGraphQLError(graphQLError) {
if (
graphQLError &&
graphQLError.graphQLErrors &&
graphQLError.graphQLErrors.length > 0
) {
return graphQLError.graphQLErrors[0]
}
if (graphQLError && graphQLError.message) {
return graphQLError.message
}
return graphQLError
},
getCurrentGraphQLError(graphQLError) {
const error = this.getFirstGraphQLError(graphQLError)
if (!error) {
return graphQLError
}
const currentError =
error.message && (!error.message.message || !error.message.error)
? error.message
: error.error
? error.error
: error.message && error.message.message
? error.message.message
: error.message && error.message.error
? error.message.error
: error
return currentError
},
isValidObjectID(str) {
str = str + ''
const len = str.length
let valid = false
if (len === 12 || len === 24) {
valid = /^[0-9a-fA-F]+$/.test(str)
}
return valid
}
}
export default utils
7.-Update the Error page to localise it
- We need to modify the
layouts\error.vueError page to localize it.
client\layouts\error.vue
<template>
<v-container>
<h1 v-if="error.statusCode === 404">
{{ pageNotFound }}
</h1>
<h1 v-else>
{{ otherError }}
</h1>
<NuxtLink :to="localePath('index')">
{{ $t('homePage') }}
</NuxtLink>
</v-container>
</template>
<script>
export default {
layout: 'empty',
props: {
error: {
type: Object,
default: null
}
},
head() {
const title =
this.error.statusCode === 404 ? this.pageNotFound : this.otherError
return {
title
}
},
data() {
return {
pageNotFound: this.$i18n.t('pageNotFound'),
otherError: this.$i18n.t('otherError')
}
}
}
</script>
<style scoped>
h1 {
font-size: 20px;
}
</style>
8.-We need to test if it works.














Section 12: Like / Unlike Post
I.-Modify the server app to include the LikesFaves Type and the likePost and unlikePost Mutations.
1.-Create the new PostPage type
- We are going to create the
likes-faves.type.tsdocument with the newLikesFavestype.
server\src\posts\types\likes-faves.type.ts
import { Field, ObjectType, Int } from 'type-graphql'
import { Ref } from 'typegoose'
import { Post } from '../post.model'
@ObjectType()
export class LikesFaves {
@Field(() => Int)
likes: number
@Field(() => [Post], { nullable: "items" })
readonly favorites: Ref<Post>[]
}
2.-Modify the Posts service to include the new likePost and unlikePost methods.
- We need to modify the
\posts.service.tsservice document to include the newlikePostandunlikePostmethods.
server\src\posts\posts.service.ts
import { Injectable } from '@nestjs/common';
import { InjectModel } from 'nestjs-typegoose';
import { Post, Message } from './post.model';
import { PostPage } from './types/post-page.type';
import { LikesFaves } from './types/likes-faves.type';
import { CreatePostDto } from './dtos/create-post.dto'
import { CreatePostMessageDto } from './dtos/create-post-message.dto'
import { ModelType, Ref } from 'typegoose';
import { User } from '../users/user.model';
@Injectable()
export class PostsService {
constructor(
@InjectModel(Post) private readonly postModel: ModelType<Post>,
@InjectModel(User) private readonly userModel: ModelType<User>
) { }
async getPosts(): Promise<Post[] | null> {
const posts = await this.postModel.find({})
.sort({ createdDate: "desc" })
.populate({
path: "createdBy",
model: "User"
});
return posts;
}
async getPost(postId: string): Promise<Post | null> {
const post = await this.postModel.findOne({ _id: postId })
.populate({
path: "messages.messageUser",
model: "User"
});
return post;
}
async infiniteScrollPosts(pageNum: number, pageSize: number): Promise<PostPage | null> {
const skips = pageSize * (pageNum - 1)
const posts = await this.postModel.find({})
.sort({ createdDate: "desc" })
.populate({
path: "createdBy",
model: "User"
})
.skip(skips)
.limit(pageSize)
.lean()
const totalDocs = await this.postModel.countDocuments()
const hasMore = totalDocs > pageSize * pageNum
const postPage: PostPage = {
posts,
hasMore
}
return postPage
}
async addPost(createPostDto: CreatePostDto): Promise<Post> {
const newPost = new this.postModel(createPostDto);
return await newPost.save();
}
async addPostMessage(createPostMessageDto: CreatePostMessageDto): Promise<Ref<Message>> {
const newMessage = {
messageBody: createPostMessageDto.messageBody,
messageUser: createPostMessageDto.userId
};
const post = await this.postModel.findOneAndUpdate(
// find post by id
{ _id: createPostMessageDto.postId },
// prepend (push) new message to beginning of messages array
{ $push: { messages: { $each: [newMessage], $position: 0 } } },
// return fresh document after update
{ new: true }
).populate({
path: "messages.messageUser",
model: "User"
});
return post.messages[0];
}
async likePost(postId: string, username: string): Promise<LikesFaves> {
const post = await this.postModel.findOneAndUpdate(
{ _id: postId },
{ $inc: { likes: 1 } },
{ new: true }
);
// Find User, add id of post to its favorites array (which will be populated as Posts)
const user = await this.userModel.findOneAndUpdate(
{ username },
{ $addToSet: { favorites: postId } },
{ new: true }
).populate({
path: "favorites",
model: "Post"
});
// Return only likes from 'post' and favorites from 'user'
const likesFaves: LikesFaves = {
likes: post.likes,
favorites: user.favorites
}
return likesFaves;
}
async unlikePost(postId: string, username: string): Promise<LikesFaves> {
// Find Post, add -1 to its 'like' value
const post = await this.postModel.findOneAndUpdate(
{ _id: postId },
{ $inc: { likes: -1 } },
{ new: true }
);
// Find User, remove id of post from its favorites array (which will be populated as Posts)
const user = await this.userModel.findOneAndUpdate(
{ username },
{ $pull: { favorites: postId } },
{ new: true }
).populate({
path: "favorites",
model: "Post"
});
// Return only likes from 'post' and favorites from 'user'
const likesFaves: LikesFaves = {
likes: post.likes,
favorites: user.favorites
}
return likesFaves;
}
}
3.-Modify the Posts resolver to include the new likePost and unlikePost Mutations.
- We need to modify the
posts.resolver.tsresolver document to include the newlikePostandunlikePostMutations.
server\src\posts\posts.resolver.ts
import { Resolver, Query, Mutation, Args } from '@nestjs/graphql';
import { Int, ID } from 'type-graphql';
import { PostsService } from './posts.service';
import { PostInput } from './inputs/post.input';
import { PostPage } from './types/post-page.type';
import { LikesFaves } from './types/likes-faves.type';
import { Post, Message } from './post.model';
@Resolver()
export class PostsResolver {
constructor(private readonly postsService: PostsService) { }
@Query(() => [Post])
async getPosts() {
return await this.postsService.getPosts();
}
@Query(() => Post)
async getPost(@Args({ name: 'postId', type: () => ID}) postId: string) {
return await this.postsService.getPost(postId);
}
@Query(() => PostPage)
async infiniteScrollPosts(
@Args({ name: 'pageNum', type: () => Int}) pageNum: number,
@Args({ name: 'pageSize', type: () => Int}) pageSize: number
) {
return await this.postsService.infiniteScrollPosts(pageNum, pageSize);
}
@Mutation(() => Post)
async addPostWithInput(@Args('input') input: PostInput) {
const createdBy = input.creatorId;
const { title, imageUrl, categories, description } = input
return await this.postsService.addPost({ title, imageUrl, categories, description, createdBy });
}
@Mutation(() => Post)
async addPost(
@Args('title') title: string,
@Args('imageUrl') imageUrl: string,
@Args({ name: 'categories', type: () => [String], nullable: "items" }) categories: string[],
@Args('description') description: string,
@Args({ name: 'creatorId', type: () => ID}) creatorId: string,
) {
const createdBy = creatorId;
return await this.postsService.addPost({ title, imageUrl, categories, description, createdBy });
}
@Mutation(() => Message)
async addPostMessage(
@Args('messageBody') messageBody: string,
@Args({ name: 'userId', type: () => ID}) userId: string,
@Args({ name: 'postId', type: () => ID}) postId: string,
) {
return await this.postsService.addPostMessage({ messageBody, userId, postId });
}
@Mutation(() => LikesFaves)
async likePost(
@Args({ name: 'postId', type: () => ID}) postId: string,
@Args('username') username: string
) {
return await this.postsService.likePost( postId, username );
}
@Mutation(() => LikesFaves)
async unlikePost(
@Args({ name: 'postId', type: () => ID}) postId: string,
@Args('username') username: string
) {
return await this.postsService.unlikePost( postId, username );
}
}
4.-Modify the Posts module to include the User Typegoose module
- We need to modify the
posts\posts.module.tsresolver document to theUserTypegoose module.
server\src\posts\posts.module.ts
import { Module } from '@nestjs/common';
import { TypegooseModule } from 'nestjs-typegoose'
import { Post } from './post.model';
import { User } from '../users/user.model';
import { PostsService } from './posts.service';
import { PostsResolver } from './posts.resolver';
@Module({
imports: [
TypegooseModule.forFeature([User]),
TypegooseModule.forFeature([Post])
],
providers: [PostsService, PostsResolver]
})
export class PostsModule { }
5.-Test if the new likePost and unlikePost Mutations work propely
query
mutation($postId: ID!, $username: String!) {
likePost(postId: $postId, username: $username) {
likes
favorites {
_id
title
imageUrl
}
}
}
variables
{
"postId": "5d504c6ad1edca3fc41f703a",
"username": "Juan"
}
response
{
"data": {
"likePost": {
"likes": 1,
"favorites": [
{
"_id": "5d504c6ad1edca3fc41f703a",
"title": "Abstract Painting",
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/9/97/Vassily_Kandinsky%2C_1939_-_Composition_10.jpg/1024px-Vassily_Kandinsky%2C_1939_-_Composition_10.jpg"
}
]
}
}
}
query
mutation($postId: ID!, $username: String!) {
unlikePost(postId: $postId, username: $username) {
likes
favorites {
_id
title
imageUrl
}
}
}
variables
{
"postId": "5d504c6ad1edca3fc41f703a",
"username": "Juan"
}
response
{
"data": {
"unlikePost": {
"likes": 0,
"favorites": []
}
}
}
II.-Modify the client app to add the Likes / Unlikes on the Post pages.
1.-Create the likePost.gql and unlikePost.gql documents with the likePost and unlikePost mutations.
- We need to create the
likePost.gqldocument with thelikePostMutation.
client\gql\likePost.gql
mutation likePost($postId: ID!, $username: String!) {
likePost(postId: $postId, username: $username) {
likes
favorites {
_id
title
imageUrl
}
}
}
- We need to create the
unlikePost.gqldocument with theunlikePostMutation.
client\gql\unlikePost.gql
mutation unlikePost($postId: ID!, $username: String!) {
unlikePost(postId: $postId, username: $username) {
likes
favorites {
_id
title
imageUrl
}
}
}
2.-Modify the Store to include the userFavorites getter.
- We need to modify the
store\index.jsstore document to include theuserFavoritesgetter
client\store\index.js
import { getPosts } from '~/gql/getPosts.gql'
import { getCurrentUser } from '~/gql/getCurrentUser.gql'
import { signinUser } from '~/gql/signinUser.gql'
import { signupUser } from '~/gql/signupUser.gql'
import { addPost } from '~/gql/addPost.gql'
import utils from '~/helpers/utils'
export const state = () => ({
posts: [],
user: null,
loading: false,
error: null,
authError: null
})
export const mutations = {
setPosts: (state, payload) => {
state.posts = payload
},
addPost: (state, payload) => {
const posts = state.posts
posts.unshift(payload)
state.posts = posts
},
setUser: (state, payload) => {
state.user = payload
},
setLoading: (state, payload) => {
state.loading = payload
},
clearUser: state => (state.user = null),
setError: (state, payload) => {
state.error = payload
},
clearError: state => (state.error = null),
setAuthError: (state, payload) => {
state.authError = payload
},
clearAuthError: state => (state.authError = null)
}
export const actions = {
async nuxtServerInit({ dispatch }) {
await dispatch('getCurrentUser')
await dispatch('getPosts')
},
async getPosts({ commit }) {
commit('clearError')
commit('setLoading', true)
try {
const result = await this.app.apolloProvider.defaultClient.query({
query: getPosts
})
commit('setPosts', result.data.getPosts)
} catch (error) {
const currentError = utils.getCurrentGraphQLError(error)
commit('setError', currentError)
console.error(utils.getFirstGraphQLError(error))
}
commit('setLoading', false)
},
async addPost({ commit }, payload) {
commit('clearError')
commit('setLoading', true)
try {
await this.app.apolloProvider.defaultClient.mutate({
mutation: addPost,
variables: payload
})
const { _id, title, imageUrl } = payload
const newPost = {
_id,
title,
imageUrl
}
commit('addPost', newPost)
} catch (error) {
const currentError = utils.getCurrentGraphQLError(error)
commit('setError', currentError)
console.error(utils.getFirstGraphQLError(error))
}
commit('setLoading', false)
},
async logOut({ commit }) {
this.app.$cookies.remove('apollo-token')
await this.app.$apolloHelpers.onLogout()
commit('clearUser')
},
async getCurrentUser({ commit, dispatch }) {
if (!utils.isJwtTokenValid(this.app.$apolloHelpers.getToken())) {
await dispatch('logOut')
return
}
commit('setLoading', true)
try {
const result = await this.app.apolloProvider.defaultClient.query({
query: getCurrentUser
})
// Add user data to state
commit('setUser', result.data.getCurrentUser)
commit('clearError')
commit('clearAuthError')
} catch (error) {
const currentError = utils.getCurrentGraphQLError(error)
if (currentError === 'Unauthorized') {
commit('setAuthError', this.app.i18n.t('sessionExpiredSignInAgain'))
await dispatch('logOut')
} else {
commit('setError', currentError)
}
console.error(utils.getFirstGraphQLError(error))
}
commit('setLoading', false)
},
async signinUser({ commit, dispatch }, payload) {
commit('clearError')
commit('setLoading', true)
try {
const result = await this.app.apolloProvider.defaultClient.mutate({
mutation: signinUser,
variables: payload
})
await this.app.$apolloHelpers.onLogin(result.data.signinUser.token)
await dispatch('getCurrentUser')
} catch (error) {
const currentError = utils.getCurrentGraphQLError(error)
commit('setError', currentError)
console.error(utils.getFirstGraphQLError(error))
}
commit('setLoading', false)
},
async signupUser({ commit, dispatch }, payload) {
commit('clearError')
commit('setLoading', true)
try {
const result = await this.app.apolloProvider.defaultClient.mutate({
mutation: signupUser,
variables: payload
})
await this.app.$apolloHelpers.onLogin(result.data.signupUser.token)
await dispatch('getCurrentUser')
} catch (error) {
const currentError = utils.getCurrentGraphQLError(error)
commit('setError', currentError)
console.error(utils.getFirstGraphQLError(error))
}
commit('setLoading', false)
},
async signoutUser({ dispatch }) {
await dispatch('logOut')
// redirect home - kick users out of private pages (i.e. profile)
this.app.router.push(this.app.localePath('index'))
}
}
export const getters = {
posts: state => state.posts,
user: state => state.user,
userFavorites: state => state.user && state.user.favorites,
loading: state => state.loading,
error: state => state.error,
authError: state => state.authError
}
3.-Modify the Post page to include the Like / Unlike toggle
- We need to modify the
posts\_id.vuedocument to manage when the user click onlike/unlikebuttton.
client\pages\posts_id.vue
<template>
<v-container v-if="getPost" class="mt-3" flexbox center>
<!-- Post Card -->
<v-layout row wrap>
<v-flex xs12>
<v-card hover>
<v-card-title>
<h1>{{ getPost.title }}</h1>
<v-btn v-if="user" large icon @click="handleToggleLike">
<v-icon
large
:color="checkIfPostLiked(getPost._id) ? 'red' : 'grey'"
>favorite</v-icon
>
</v-btn>
<h3 class="ml-3 font-weight-thin">
{{ getPost.likes }} {{ $t('likes').toUpperCase() }}
</h3>
<v-spacer></v-spacer>
<v-icon color="info" large @click="goToPreviousPage"
>arrow_back</v-icon
>
</v-card-title>
<v-tooltip right>
<span>{{ $t('clickToEnlargeImage') }}</span>
<v-img
id="post__image"
slot="activator"
:src="getPost.imageUrl"
@click="toggleImageDialog"
></v-img>
</v-tooltip>
<!-- Post Image Dialog -->
<v-dialog v-model="dialog">
<v-card>
<v-img :src="getPost.imageUrl" height="80vh"></v-img>
</v-card>
</v-dialog>
<v-card-text>
<span v-for="(category, index) in getPost.categories" :key="index">
<v-chip class="mb-3" color="accent" text-color="white">{{
category
}}</v-chip>
</span>
<h3>{{ getPost.description }}</h3>
</v-card-text>
</v-card>
</v-flex>
</v-layout>
<!-- Messages Section -->
<div class="mt-3">
<!-- Message Input -->
<v-layout v-if="user" class="mb-3">
<v-flex xs12>
<v-form
ref="form"
v-model="isFormValid"
lazy-validation
@submit.prevent="handleAddPostMessage"
>
<v-layout row>
<v-flex xs12>
<v-text-field
v-model="messageBody"
:rules="messageRules"
clearable
:append-outer-icon="messageBody && 'send'"
:label="$t('addMessage')"
type="text"
prepend-icon="email"
required
@click:append-outer="handleAddPostMessage"
></v-text-field>
</v-flex>
</v-layout>
</v-form>
</v-flex>
</v-layout>
<!-- Messages -->
<v-layout row wrap>
<v-flex xs12>
<v-list subheader two-line>
<v-subheader
>{{ $tc('message', 2) }} ({{
getPost.messages.length
}})</v-subheader
>
<template v-for="message in getPost.messages">
<v-divider :key="message._id"></v-divider>
<v-list-tile :key="message.title" avatar inset>
<v-list-tile-avatar>
<img :src="message.messageUser.avatar" />
</v-list-tile-avatar>
<v-list-tile-content>
<v-list-tile-title>
{{ message.messageBody }}
</v-list-tile-title>
<v-list-tile-sub-title>
{{ message.messageUser.username }}
<span class="grey--text text--lighten-1 hidden-xs-only">{{
message.messageDate
}}</span>
</v-list-tile-sub-title>
</v-list-tile-content>
<v-list-tile-action class="hidden-xs-only">
<v-icon
:color="checkIfOwnMessage(message) ? 'accent' : 'grey'"
>chat_bubble</v-icon
>
</v-list-tile-action>
</v-list-tile>
</template>
</v-list>
</v-flex>
</v-layout>
</div>
</v-container>
</template>
<script>
import { mapGetters } from 'vuex'
import { getPost } from '~/gql/getPost.gql'
import { addPostMessage } from '~/gql/addPostMessage.gql'
import { likePost } from '~/gql/likePost.gql'
import { unlikePost } from '~/gql/unlikePost.gql'
import utils from '~/helpers/utils'
export default {
name: 'Post',
validate({ params }) {
return utils.isValidObjectID(params.id)
},
data() {
return {
postLiked: false,
postId: this.$route.params.id,
dialog: false,
messageBody: '',
isFormValid: true,
messageRules: [
message =>
!!message ||
this.$i18n.t('isRequired', { name: this.$i18n.tc('message', 1) }),
message =>
(message && message.length <= 75) ||
this.$i18n.t('cannotBeMoreThanCharacters', {
name: this.$i18n.tc('message', 1),
number: 75
})
]
}
},
apollo: {
getPost: {
query: getPost,
variables() {
return {
postId: this.postId
}
}
}
},
computed: {
...mapGetters(['user', 'userFavorites'])
},
methods: {
checkIfPostLiked(postId) {
// check if user favorites includes post with id of 'postId'
this.postLiked =
this.userFavorites &&
this.userFavorites.some(fave => fave._id === postId)
return this.postLiked
},
handleToggleLike() {
if (this.postLiked) {
this.handleUnlikePost()
} else {
this.handleLikePost()
}
},
handleLikePost() {
const variables = {
postId: this.postId,
username: this.user.username
}
this.$apollo
.mutate({
mutation: likePost,
variables,
update: (cache, { data: { likePost } }) => {
const data = cache.readQuery({
query: getPost,
variables: { postId: this.postId }
})
data.getPost.likes += 1
cache.writeQuery({
query: getPost,
variables: { postId: this.postId },
data
})
}
})
.then(({ data }) => {
const updatedUser = {
...this.user,
favorites: data.likePost.favorites
}
this.$store.commit('setUser', updatedUser)
})
.catch(err => console.error(err))
},
handleUnlikePost() {
const variables = {
postId: this.postId,
username: this.user.username
}
this.$apollo
.mutate({
mutation: unlikePost,
variables,
update: (cache, { data: { unlikePost } }) => {
const data = cache.readQuery({
query: getPost,
variables: { postId: this.postId }
})
data.getPost.likes -= 1
cache.writeQuery({
query: getPost,
variables: { postId: this.postId },
data
})
}
})
.then(({ data }) => {
const updatedUser = {
...this.user,
favorites: data.unlikePost.favorites
}
this.$store.commit('setUser', updatedUser)
})
.catch(err => console.error(err))
},
handleAddPostMessage() {
if (this.$refs.form.validate()) {
const variables = {
messageBody: this.messageBody,
userId: this.user._id,
postId: this.postId
}
this.$apollo
.mutate({
mutation: addPostMessage,
variables,
update: (cache, { data: { addPostMessage } }) => {
const data = cache.readQuery({
query: getPost,
variables: { postId: this.postId }
})
data.getPost.messages.unshift(addPostMessage)
cache.writeQuery({
query: getPost,
variables: { postId: this.postId },
data
})
}
})
.then(({ data }) => {
this.$refs.form.reset()
console.log(data.addPostMessage)
})
.catch(err => console.error(err))
}
},
goToPreviousPage() {
this.$router.go(-1)
},
toggleImageDialog() {
if (window.innerWidth > 500) {
this.dialog = !this.dialogs
}
},
checkIfOwnMessage(message) {
return this.user && this.user._id === message.messageUser._id
}
}
}
</script>
<style scoped>
#post__image {
height: 400px !important;
}
</style>
4.-Modify the Default layout page to add Like Notification in Profile Button.
- We need to modify the
layouts\default.vuedocument to add Like Notification in Profile Button..
client\layouts\default.vue
<template>
<v-app style="background: #E3E3EE">
<!-- Side Navbar -->
<v-navigation-drawer v-model="sideNav" fixed app temporary>
<v-toolbar color="accent" dark flat>
<v-toolbar-side-icon @click="toggleSideNav"></v-toolbar-side-icon>
<nuxt-link :to="localePath('index')" tag="span" style="cursor: pointer">
<h1 class="title pl-3">VueShare</h1>
</nuxt-link>
</v-toolbar>
<v-divider></v-divider>
<!-- Side Navbar Links -->
<v-list>
<v-list-tile
v-for="(item, i) in sideNavItems"
:key="i"
:to="localePath(item.link)"
ripple
>
<v-list-tile-action>
<v-icon>{{ item.icon }}</v-icon>
</v-list-tile-action>
<v-list-tile-content>
{{ item.title }}
</v-list-tile-content>
</v-list-tile>
<!-- Signout Button -->
<v-list-tile v-if="user" @click="handleSignoutUser">
<v-list-tile-action>
<v-icon>exit_to_app</v-icon>
</v-list-tile-action>
<v-list-tile-content>{{ $t('signout') }}</v-list-tile-content>
</v-list-tile>
</v-list>
</v-navigation-drawer>
<!-- Horizontal Navbar -->
<v-toolbar fixed color="primary" dark>
<!-- App Title -->
<v-toolbar-side-icon @click="toggleSideNav"></v-toolbar-side-icon>
<v-toolbar-title class="hidden-xs-only">
<nuxt-link :to="localePath('index')" tag="span" style="cursor: pointer">
VueShare
</nuxt-link>
</v-toolbar-title>
<v-spacer></v-spacer>
<!-- Search Input -->
<v-text-field
flex
prepend-icon="search"
color="accent"
single-line
hide-details
:placeholder="$t('searchposts')"
></v-text-field>
<v-spacer></v-spacer>
<!-- Horizontal Navbar Links -->
<v-toolbar-items class="hidden-xs-only">
<v-btn
v-for="(item, i) in horizontalNavItems"
:key="i"
:to="localePath(item.link)"
flat
>
<v-icon class="hidden-sm-only" left>{{ item.icon }}</v-icon>
{{ item.title }}
</v-btn>
</v-toolbar-items>
<!-- Profile Button -->
<v-btn v-if="user" flat :to="localePath('profile')">
<v-icon class="hidden-sm-only" left>account_box</v-icon>
<v-badge right color="blue darken-2" :class="{ bounce: badgeAnimated }">
<span v-if="userFavorites.length" slot="badge">{{
userFavorites.length
}}</span>
{{ $t('profile') }}
</v-badge>
</v-btn>
<!-- Signout Button -->
<v-btn v-if="user" flat @click="handleSignoutUser">
<v-icon class="hidden-sm-only" left>exit_to_app</v-icon>
{{ $t('signout') }}
</v-btn>
<v-toolbar-title class="hidden-xs-only">
<nuxt-link
v-for="(locale, i) in showLocales"
:key="i"
tag="span"
style="cursor: pointer"
class="lang-switcher"
:to="switchLocalePath(locale.code)"
>
{{ locale.name }}
</nuxt-link>
</v-toolbar-title>
</v-toolbar>
<!-- App Content -->
<main>
<v-container class="mt-4">
<transition name="fade">
<nuxt />
</transition>
<!-- Auth Snackbar -->
<v-snackbar
v-model="authSnackbar"
color="success"
:timeout="5000"
bottom
left
>
<v-icon class="mr-3">check_circle</v-icon>
<h3>{{ $t('yourAreNowSignedIn') }}</h3>
<v-btn dark flat @click="authSnackbar = false">{{
$t('close')
}}</v-btn>
</v-snackbar>
<!-- Auth Error Snackbar -->
<v-snackbar
v-if="authError"
v-model="authErrorSnackbar"
color="info"
:timeout="5000"
bottom
left
>
<v-icon class="mr-3">cancel</v-icon>
<h3>{{ authError.message }}</h3>
<v-btn dark flat :to="localePath('signin')">{{ $t('close') }}</v-btn>
</v-snackbar>
</v-container>
</main>
</v-app>
</template>
<script>
import { mapGetters } from 'vuex'
export default {
head() {
return this.$nuxtI18nSeo()
},
data() {
return {
sideNav: false,
authSnackbar: false,
authErrorSnackbar: false,
badgeAnimated: false
}
},
computed: {
...mapGetters(['authError', 'user', 'userFavorites']),
horizontalNavItems() {
let items = [
{ icon: 'chat', title: this.$i18n.t('posts'), link: 'posts' },
{ icon: 'lock_open', title: this.$i18n.t('signin'), link: 'signin' },
{ icon: 'create', title: this.$i18n.t('signup'), link: 'signup' }
]
if (this.user) {
items = [{ icon: 'chat', title: this.$i18n.t('posts'), link: 'posts' }]
}
return items
},
sideNavItems() {
let items = [
{ icon: 'chat', title: this.$i18n.t('posts'), link: 'posts' },
{ icon: 'lock_open', title: this.$i18n.t('signin'), link: 'signin' },
{ icon: 'create', title: this.$i18n.t('signup'), link: 'signup' }
]
if (this.user) {
items = [
{ icon: 'chat', title: this.$i18n.t('posts'), link: 'posts' },
{
icon: 'stars',
title: this.$i18n.t('createPost'),
link: 'posts-add'
},
{
icon: 'account_box',
title: this.$i18n.t('profile'),
link: 'profile'
}
]
}
return items
},
showLocales() {
return this.$i18n.locales.filter(
locale => locale.code !== this.$i18n.locale
)
}
},
watch: {
user(newValue, oldValue) {
// if we had no value for user before, show snackbar
if (oldValue === null) {
this.authSnackbar = true
}
},
authError(value) {
// if auth error is not null, show auth error snackbar
if (value !== null) {
this.authErrorSnackbar = true
}
},
userFavorites(value) {
// if user favorites value changed at all
if (value) {
this.badgeAnimated = true
setTimeout(() => (this.badgeAnimated = false), 1000)
}
}
},
methods: {
handleSignoutUser() {
this.$store.dispatch('signoutUser')
},
toggleSideNav() {
this.sideNav = !this.sideNav
}
}
}
</script>
<style>
.fade-enter-active,
.fade-leave-active {
transition-property: opacity;
transition-duration: 0.25s;
}
.fade-enter-active {
transition-delay: 0.25s;
}
.fade-enter,
.fade-leave-active {
opacity: 0;
}
/* User Favorite Animation */
.bounce {
animation: bounce 1s both;
}
@keyframes bounce {
0%,
20%,
53%,
80%,
100% {
transform: translate3d(0, 0, 0);
}
40%,
43% {
transform: translate3d(0, -20px, 0);
}
70% {
transform: translate3d(0, -10px, 0);
}
90% {
transform: translate3d(0, -4px, 0);
}
}
</style>
5.-We need to test if it works.





Section 13: Search Posts
I.-Modify the server app to include the searchPosts Query.
1.-Modify the Post Model to include a text index
- We need to modify the
post.model.tsdocument to add atextindex.
server\src\posts\post.model.ts
import { prop, arrayProp, Typegoose, Ref, index } from 'typegoose';
import { IsDate, IsInt } from 'class-validator';
import { Field, ObjectType, ID, Int } from 'type-graphql';
import { User } from '../users/user.model'
@ObjectType()
export class Message extends Typegoose {
@Field(() => ID)
_id: string
@Field()
@prop({ required: true })
messageBody: string;
@IsDate()
@Field()
@prop({ default: Date.now })
messageDate: Date;
@Field(() => User)
@prop({ required: true, ref: 'User' })
messageUser: Ref<User>
}
// Create index to search on all fields of posts
@index({'$**': 'text'})
@ObjectType()
export class Post extends Typegoose {
@Field(() => ID)
_id: string
@Field()
@prop({ required: true })
title: string;
@Field()
@prop({ required: true })
imageUrl: string;
@Field(() => [String], { nullable: "items" })
@prop({ required: true })
categories: string[];
@Field()
@prop({ required: true })
description: string;
@IsDate()
@Field()
@prop({ default: Date.now })
createdDate: Date
@IsInt()
@Field(() => Int, { nullable: true })
@prop({ default: 0 })
likes: number;
@Field(() => User)
@prop({ required: true, ref: 'User' })
createdBy: Ref<User>
@Field(() => [Message], { nullable: "itemsAndList" })
@arrayProp({ items: Message })
messages?: Ref<Message>[]
}
2.-Modify the Posts service to include the new searchPosts methods.
- We need to modify the
posts.service.tsservice document to include the newsearchPostsmethods.
server\src\posts\posts.service.ts
import { Injectable } from '@nestjs/common';
import { InjectModel } from 'nestjs-typegoose';
import { Post, Message } from './post.model';
import { PostPage } from './types/post-page.type';
import { LikesFaves } from './types/likes-faves.type';
import { CreatePostDto } from './dtos/create-post.dto'
import { CreatePostMessageDto } from './dtos/create-post-message.dto'
import { ModelType, Ref } from 'typegoose';
import { User } from '../users/user.model';
@Injectable()
export class PostsService {
constructor(
@InjectModel(Post) private readonly postModel: ModelType<Post>,
@InjectModel(User) private readonly userModel: ModelType<User>
) { }
async getPosts(): Promise<Post[] | null> {
const posts = await this.postModel.find({})
.sort({ createdDate: "desc" })
.populate({
path: "createdBy",
model: "User"
});
return posts;
}
async getPost(postId: string): Promise<Post | null> {
const post = await this.postModel.findOne({ _id: postId })
.populate({
path: "messages.messageUser",
model: "User"
});
return post;
}
async searchPosts(searchTerm: string): Promise<Post[] | null> {
if (searchTerm) {
const searchResults = await this.postModel.find(
// Perform text search for search value of 'searchTerm'
{ $text: { $search: searchTerm } },
// Assign 'searchTerm' a text score to provide best match
{ score: { $meta: "textScore" } }
)
// Sort results according to that textScore (as well as by likes in descending order)
.sort({
score: { $meta: "textScore" },
likes: "desc"
})
.limit(5);
return searchResults;
}
}
async infiniteScrollPosts(pageNum: number, pageSize: number): Promise<PostPage | null> {
const skips = pageSize * (pageNum - 1)
const posts = await this.postModel.find({})
.sort({ createdDate: "desc" })
.populate({
path: "createdBy",
model: "User"
})
.skip(skips)
.limit(pageSize)
.lean()
const totalDocs = await this.postModel.countDocuments()
const hasMore = totalDocs > pageSize * pageNum
const postPage: PostPage = {
posts,
hasMore
}
return postPage
}
async addPost(createPostDto: CreatePostDto): Promise<Post> {
const newPost = new this.postModel(createPostDto);
return await newPost.save();
}
async addPostMessage(createPostMessageDto: CreatePostMessageDto): Promise<Ref<Message>> {
const newMessage = {
messageBody: createPostMessageDto.messageBody,
messageUser: createPostMessageDto.userId
};
const post = await this.postModel.findOneAndUpdate(
// find post by id
{ _id: createPostMessageDto.postId },
// prepend (push) new message to beginning of messages array
{ $push: { messages: { $each: [newMessage], $position: 0 } } },
// return fresh document after update
{ new: true }
).populate({
path: "messages.messageUser",
model: "User"
});
return post.messages[0];
}
async likePost(postId: string, username: string): Promise<LikesFaves> {
const post = await this.postModel.findOneAndUpdate(
{ _id: postId },
{ $inc: { likes: 1 } },
{ new: true }
);
// Find User, add id of post to its favorites array (which will be populated as Posts)
const user = await this.userModel.findOneAndUpdate(
{ username },
{ $addToSet: { favorites: postId } },
{ new: true }
).populate({
path: "favorites",
model: "Post"
});
// Return only likes from 'post' and favorites from 'user'
const likesFaves: LikesFaves = {
likes: post.likes,
favorites: user.favorites
}
return likesFaves;
}
async unlikePost(postId: string, username: string): Promise<LikesFaves> {
// Find Post, add -1 to its 'like' value
const post = await this.postModel.findOneAndUpdate(
{ _id: postId },
{ $inc: { likes: -1 } },
{ new: true }
);
// Find User, remove id of post from its favorites array (which will be populated as Posts)
const user = await this.userModel.findOneAndUpdate(
{ username },
{ $pull: { favorites: postId } },
{ new: true }
).populate({
path: "favorites",
model: "Post"
});
// Return only likes from 'post' and favorites from 'user'
const likesFaves: LikesFaves = {
likes: post.likes,
favorites: user.favorites
}
return likesFaves;
}
}
3.-Modify the Posts resolver to include the new searchPosts Query.
- We need to modify the
posts.resolver.tsresolver document to include the newsearchPostsQuery.
server\src\posts\posts.resolver.ts
import { Resolver, Query, Mutation, Args } from '@nestjs/graphql';
import { Int, ID } from 'type-graphql';
import { PostsService } from './posts.service';
import { PostInput } from './inputs/post.input';
import { PostPage } from './types/post-page.type';
import { LikesFaves } from './types/likes-faves.type';
import { Post, Message } from './post.model';
@Resolver()
export class PostsResolver {
constructor(private readonly postsService: PostsService) { }
@Query(() => [Post])
async getPosts() {
return await this.postsService.getPosts();
}
@Query(() => Post)
async getPost(@Args({ name: 'postId', type: () => ID }) postId: string) {
return await this.postsService.getPost(postId);
}
@Query(() => [Post], { nullable: true })
async searchPosts(@Args({ name: 'searchTerm', type: () => String, nullable: true }) searchTerm: string) {
return await this.postsService.searchPosts(searchTerm);
}
@Query(() => PostPage)
async infiniteScrollPosts(
@Args({ name: 'pageNum', type: () => Int }) pageNum: number,
@Args({ name: 'pageSize', type: () => Int }) pageSize: number
) {
return await this.postsService.infiniteScrollPosts(pageNum, pageSize);
}
@Mutation(() => Post)
async addPostWithInput(@Args('input') input: PostInput) {
const createdBy = input.creatorId;
const { title, imageUrl, categories, description } = input
return await this.postsService.addPost({ title, imageUrl, categories, description, createdBy });
}
@Mutation(() => Post)
async addPost(
@Args('title') title: string,
@Args('imageUrl') imageUrl: string,
@Args({ name: 'categories', type: () => [String], nullable: "items" }) categories: string[],
@Args('description') description: string,
@Args({ name: 'creatorId', type: () => ID }) creatorId: string,
) {
const createdBy = creatorId;
return await this.postsService.addPost({ title, imageUrl, categories, description, createdBy });
}
@Mutation(() => Message)
async addPostMessage(
@Args('messageBody') messageBody: string,
@Args({ name: 'userId', type: () => ID }) userId: string,
@Args({ name: 'postId', type: () => ID }) postId: string,
) {
return await this.postsService.addPostMessage({ messageBody, userId, postId });
}
@Mutation(() => LikesFaves)
async likePost(
@Args({ name: 'postId', type: () => ID }) postId: string,
@Args('username') username: string
) {
return await this.postsService.likePost(postId, username);
}
@Mutation(() => LikesFaves)
async unlikePost(
@Args({ name: 'postId', type: () => ID }) postId: string,
@Args('username') username: string
) {
return await this.postsService.unlikePost(postId, username);
}
}
4.-Test if the new searchPosts Query works propely
query
query searchPosts($searchTerm: String) {
searchPosts(searchTerm: $searchTerm) {
_id
title
description
imageUrl
likes
}
}
variables
{
"searchTerm": "photo"
}
response
{
"data": {
"searchPosts": [
{
"_id": "5d4fa31dcb16795b68483e10",
"title": "At the Beach",
"description": "A nice photo of the waves",
"imageUrl": "https://images.pexels.com/photos/1139541/pexels-photo-1139541.jpeg?auto=compress&cs=tinysrgb&dpr=2&h=650&w=940",
"likes": 0
},
{
"_id": "5d505ca7c45cb259b0761e48",
"title": "A tasty dinner",
"description": "Picture of a recipe I would like to prepare",
"imageUrl": "https://images.pexels.com/photos/691114/pexels-photo-691114.jpeg?auto=compress&cs=tinysrgb&dpr=2&h=650&w=940",
"likes": 1
},
{
"_id": "5d0e10be7dd57444d0607790",
"title": "Tasty coffee",
"description": "Some nice coffee artwork",
"imageUrl": "https://images.pexels.com/photos/374757/pexels-photo-374757.jpeg?auto=compress&cs=tinysrgb&dpr=2&h=650&w=940",
"likes": 0
}
]
}
}
query
query searchPosts($searchTerm: String) {
searchPosts(searchTerm: $searchTerm) {
_id
title
description
imageUrl
likes
}
}
variables
{
"searchTerm": ""
}
response
{
"data": {
"searchPosts": null
}
}
II.-Modify the client app to implement Search Posts on the Default layout.
1.-Create the searchPosts.gql document with the searchPosts query.
- We need to create the
searchPosts.gqldocument with thesearchPostsQuery
client\gql\likePost.gql
query searchPosts($searchTerm: String) {
searchPosts(searchTerm: $searchTerm) {
_id
title
description
imageUrl
likes
}
}
2.-Modify the Store to include the searchResults state.
- We need to modify the
store\index.jsstore document to include thesearchResultsstate.
client\store\index.js
import { getPosts } from '~/gql/getPosts.gql'
import { searchPosts } from '~/gql/searchPosts.gql'
import { getCurrentUser } from '~/gql/getCurrentUser.gql'
import { signinUser } from '~/gql/signinUser.gql'
import { signupUser } from '~/gql/signupUser.gql'
import { addPost } from '~/gql/addPost.gql'
import utils from '~/helpers/utils'
export const state = () => ({
posts: [],
searchResults: [],
user: null,
loading: false,
error: null,
authError: null
})
export const mutations = {
setPosts: (state, payload) => {
state.posts = payload
},
setSearchResults: (state, payload) => {
if (payload !== null && payload.length > 0) {
state.searchResults = payload
}
},
addPost: (state, payload) => {
const posts = state.posts
posts.unshift(payload)
state.posts = posts
},
setUser: (state, payload) => {
state.user = payload
},
setLoading: (state, payload) => {
state.loading = payload
},
clearUser: state => (state.user = null),
setError: (state, payload) => {
state.error = payload
},
clearError: state => (state.error = null),
setAuthError: (state, payload) => {
state.authError = payload
},
clearAuthError: state => (state.authError = null),
clearSearchResults: state => (state.searchResults = [])
}
export const actions = {
async nuxtServerInit({ dispatch }) {
await dispatch('getCurrentUser')
await dispatch('getPosts')
},
async getPosts({ commit }) {
commit('clearError')
commit('setLoading', true)
try {
const result = await this.app.apolloProvider.defaultClient.query({
query: getPosts
})
commit('setPosts', result.data.getPosts)
} catch (error) {
const currentError = utils.getCurrentGraphQLError(error)
commit('setError', currentError)
console.error(utils.getFirstGraphQLError(error))
}
commit('setLoading', false)
},
async searchPosts({ commit }, payload) {
commit('clearError')
try {
const result = await this.app.apolloProvider.defaultClient.query({
query: searchPosts,
variables: payload
})
commit('setSearchResults', result.data.searchPosts)
} catch (error) {
const currentError = utils.getCurrentGraphQLError(error)
commit('setError', currentError)
console.error(utils.getFirstGraphQLError(error))
}
},
async addPost({ commit }, payload) {
commit('clearError')
commit('setLoading', true)
try {
await this.app.apolloProvider.defaultClient.mutate({
mutation: addPost,
variables: payload
})
const { _id, title, imageUrl } = payload
const newPost = {
_id,
title,
imageUrl
}
commit('addPost', newPost)
} catch (error) {
const currentError = utils.getCurrentGraphQLError(error)
commit('setError', currentError)
console.error(utils.getFirstGraphQLError(error))
}
commit('setLoading', false)
},
async logOut({ commit }) {
this.app.$cookies.remove('apollo-token')
await this.app.$apolloHelpers.onLogout()
commit('clearUser')
},
async getCurrentUser({ commit, dispatch }) {
if (!utils.isJwtTokenValid(this.app.$apolloHelpers.getToken())) {
console.log('Invalid Token', this.app.i18n.t('sessionExpiredSignInAgain'))
commit('setAuthError', this.app.i18n.t('sessionExpiredSignInAgain'))
await dispatch('logOut')
return
}
commit('setLoading', true)
try {
const result = await this.app.apolloProvider.defaultClient.query({
query: getCurrentUser
})
// Add user data to state
commit('setUser', result.data.getCurrentUser)
commit('clearError')
commit('clearAuthError')
} catch (error) {
const currentError = utils.getCurrentGraphQLError(error)
if (currentError === 'Unauthorized') {
console.log('Error', this.app.i18n.t('sessionExpiredSignInAgain'))
commit('setAuthError', this.app.i18n.t('sessionExpiredSignInAgain'))
await dispatch('logOut')
} else {
commit('setError', currentError)
}
console.error(utils.getFirstGraphQLError(error))
}
commit('setLoading', false)
},
async signinUser({ commit, dispatch }, payload) {
commit('clearError')
commit('setLoading', true)
try {
const result = await this.app.apolloProvider.defaultClient.mutate({
mutation: signinUser,
variables: payload
})
await this.app.$apolloHelpers.onLogin(result.data.signinUser.token)
await dispatch('getCurrentUser')
} catch (error) {
const currentError = utils.getCurrentGraphQLError(error)
commit('setError', currentError)
console.error(utils.getFirstGraphQLError(error))
}
commit('setLoading', false)
},
async signupUser({ commit, dispatch }, payload) {
commit('clearError')
commit('setLoading', true)
try {
const result = await this.app.apolloProvider.defaultClient.mutate({
mutation: signupUser,
variables: payload
})
await this.app.$apolloHelpers.onLogin(result.data.signupUser.token)
await dispatch('getCurrentUser')
} catch (error) {
const currentError = utils.getCurrentGraphQLError(error)
commit('setError', currentError)
console.error(utils.getFirstGraphQLError(error))
}
commit('setLoading', false)
},
async signoutUser({ dispatch }) {
await dispatch('logOut')
// redirect home - kick users out of private pages (i.e. profile)
this.app.router.push(this.app.localePath('index'))
}
}
export const getters = {
posts: state => state.posts,
searchResults: state => state.searchResults,
user: state => state.user,
userFavorites: state => state.user && state.user.favorites,
loading: state => state.loading,
error: state => state.error,
authError: state => state.authError
}
3.-Modify the Default layout page to manage the search results.
- We need to modify the
layouts\default.vuelayout document to manage the search results.
client\layouts\default.vue
<template>
<v-app style="background: #E3E3EE">
<!-- Side Navbar -->
<v-navigation-drawer v-model="sideNav" fixed app temporary>
<v-toolbar color="accent" dark flat>
<v-toolbar-side-icon @click="toggleSideNav"></v-toolbar-side-icon>
<nuxt-link :to="localePath('index')" tag="span" style="cursor: pointer">
<h1 class="title pl-3">VueShare</h1>
</nuxt-link>
</v-toolbar>
<v-divider></v-divider>
<!-- Side Navbar Links -->
<v-list>
<v-list-tile
v-for="(item, i) in sideNavItems"
:key="i"
:to="localePath(item.link)"
ripple
>
<v-list-tile-action>
<v-icon>{{ item.icon }}</v-icon>
</v-list-tile-action>
<v-list-tile-content>
{{ item.title }}
</v-list-tile-content>
</v-list-tile>
<!-- Signout Button -->
<v-list-tile v-if="user" @click="handleSignoutUser">
<v-list-tile-action>
<v-icon>exit_to_app</v-icon>
</v-list-tile-action>
<v-list-tile-content>{{ $t('signout') }}</v-list-tile-content>
</v-list-tile>
</v-list>
</v-navigation-drawer>
<!-- Horizontal Navbar -->
<v-toolbar fixed color="primary" dark>
<!-- App Title -->
<v-toolbar-side-icon @click="toggleSideNav"></v-toolbar-side-icon>
<v-toolbar-title class="hidden-xs-only">
<nuxt-link :to="localePath('index')" tag="span" style="cursor: pointer">
VueShare
</nuxt-link>
</v-toolbar-title>
<v-spacer></v-spacer>
<!-- Search Input -->
<v-text-field
v-model="searchTerm"
flex
prepend-icon="search"
color="accent"
single-line
hide-details
:placeholder="$t('searchposts')"
@input="handleSearchPosts"
></v-text-field>
<!-- Search Results Card -->
<v-card v-if="searchResults.length" id="search__card" dark>
<v-list>
<v-list-tile
v-for="result in searchResults"
:key="result._id"
@click="goToSearchResult(result._id)"
>
<v-list-tile-title>
{{ result.title }} -
<span class="font-weight-thin">{{
formatDescription(result.description)
}}</span>
</v-list-tile-title>
<!-- Show Icon if Result Favorited by User -->
<v-list-tile-action v-if="checkIfUserFavorite(result._id)">
<v-icon>favorite</v-icon>
</v-list-tile-action>
</v-list-tile>
</v-list>
</v-card>
<v-spacer></v-spacer>
<!-- Horizontal Navbar Links -->
<v-toolbar-items class="hidden-xs-only">
<v-btn
v-for="(item, i) in horizontalNavItems"
:key="i"
:to="localePath(item.link)"
flat
>
<v-icon class="hidden-sm-only" left>{{ item.icon }}</v-icon>
{{ item.title }}
</v-btn>
</v-toolbar-items>
<!-- Profile Button -->
<v-btn v-if="user" flat :to="localePath('profile')">
<v-icon class="hidden-sm-only" left>account_box</v-icon>
<v-badge right color="blue darken-2" :class="{ bounce: badgeAnimated }">
<span v-if="userFavorites.length" slot="badge">{{
userFavorites.length
}}</span>
{{ $t('profile') }}
</v-badge>
</v-btn>
<!-- Signout Button -->
<v-btn v-if="user" flat @click="handleSignoutUser">
<v-icon class="hidden-sm-only" left>exit_to_app</v-icon>
{{ $t('signout') }}
</v-btn>
<v-toolbar-title class="hidden-xs-only">
<nuxt-link
v-for="(locale, i) in showLocales"
:key="i"
tag="span"
style="cursor: pointer"
class="lang-switcher"
:to="switchLocalePath(locale.code)"
>
{{ locale.name }}
</nuxt-link>
</v-toolbar-title>
</v-toolbar>
<!-- App Content -->
<main>
<v-container class="mt-4">
<transition name="fade">
<nuxt />
</transition>
<!-- Auth Snackbar -->
<v-snackbar
v-model="authSnackbar"
color="success"
:timeout="5000"
bottom
left
>
<v-icon class="mr-3">check_circle</v-icon>
<h3>{{ $t('yourAreNowSignedIn') }}</h3>
<v-btn dark flat @click="authSnackbar = false">{{
$t('close')
}}</v-btn>
</v-snackbar>
<!-- Auth Error Snackbar -->
<v-snackbar
v-if="authError"
v-model="authErrorSnackbar"
color="info"
:timeout="5000"
bottom
left
>
<v-icon class="mr-3">cancel</v-icon>
<h3>{{ authError.message }}</h3>
<v-btn dark flat :to="localePath('signin')">{{ $t('close') }}</v-btn>
</v-snackbar>
</v-container>
</main>
</v-app>
</template>
<script>
import { mapGetters } from 'vuex'
export default {
head() {
return this.$nuxtI18nSeo()
},
data() {
return {
searchTerm: '',
sideNav: false,
authSnackbar: false,
authErrorSnackbar: false,
badgeAnimated: false
}
},
computed: {
...mapGetters(['searchResults', 'authError', 'user', 'userFavorites']),
horizontalNavItems() {
let items = [
{ icon: 'chat', title: this.$i18n.t('posts'), link: 'posts' },
{ icon: 'lock_open', title: this.$i18n.t('signin'), link: 'signin' },
{ icon: 'create', title: this.$i18n.t('signup'), link: 'signup' }
]
if (this.user) {
items = [{ icon: 'chat', title: this.$i18n.t('posts'), link: 'posts' }]
}
return items
},
sideNavItems() {
let items = [
{ icon: 'chat', title: this.$i18n.t('posts'), link: 'posts' },
{ icon: 'lock_open', title: this.$i18n.t('signin'), link: 'signin' },
{ icon: 'create', title: this.$i18n.t('signup'), link: 'signup' }
]
if (this.user) {
items = [
{ icon: 'chat', title: this.$i18n.t('posts'), link: 'posts' },
{
icon: 'stars',
title: this.$i18n.t('createPost'),
link: 'posts-add'
},
{
icon: 'account_box',
title: this.$i18n.t('profile'),
link: 'profile'
}
]
}
return items
},
showLocales() {
return this.$i18n.locales.filter(
locale => locale.code !== this.$i18n.locale
)
}
},
watch: {
user(newValue, oldValue) {
// if we had no value for user before, show snackbar
if (oldValue === null) {
this.authSnackbar = true
}
},
authError(value) {
// if auth error is not null, show auth error snackbar
if (value !== null) {
this.authErrorSnackbar = true
}
},
userFavorites(value) {
// if user favorites value changed at all
if (value) {
this.badgeAnimated = true
setTimeout(() => (this.badgeAnimated = false), 1000)
}
}
},
methods: {
handleSearchPosts() {
this.$store.dispatch('searchPosts', {
searchTerm: this.searchTerm
})
},
goToSearchResult(resultId) {
// Clear search term
this.searchTerm = ''
// Go to desired result
this.$router.push(`${this.localePath('posts')}/${resultId}`)
// Clear search results
this.$store.commit('clearSearchResults')
},
formatDescription(desc) {
return desc.length > 30 ? `${desc.slice(0, 30)}...` : desc
},
checkIfUserFavorite(resultId) {
return (
this.userFavorites &&
this.userFavorites.some(fave => fave._id === resultId)
)
},
handleSignoutUser() {
this.$store.dispatch('signoutUser')
},
toggleSideNav() {
this.sideNav = !this.sideNav
}
}
}
</script>
<style>
.fade-enter-active,
.fade-leave-active {
transition-property: opacity;
transition-duration: 0.25s;
}
.fade-enter-active {
transition-delay: 0.25s;
}
.fade-enter,
.fade-leave-active {
opacity: 0;
}
/* Search Results Card */
#search__card {
position: absolute;
width: 100vw;
z-index: 8;
top: 100%;
left: 0%;
}
/* User Favorite Animation */
.bounce {
animation: bounce 1s both;
}
@keyframes bounce {
0%,
20%,
53%,
80%,
100% {
transform: translate3d(0, 0, 0);
}
40%,
43% {
transform: translate3d(0, -20px, 0);
}
70% {
transform: translate3d(0, -10px, 0);
}
90% {
transform: translate3d(0, -4px, 0);
}
}
</style>
4.-We need to test if it works.


Section 14: Profile Page, Update / Delete Posts
I.-Modify the server app to include the getUserPosts Query and the updateUserPost and deleteUserPost Mutations.
1.-Modify the Posts service to include the new getUserPosts, updateUserPost and deleteUserPost methods.
- We need to create the
update-user-post.dto.tsdocument with theUpdateUserPostDtoDTO.
server\src\posts\dtos\update-user-post.dto.ts
export class UpdateUserPostDto {
readonly postId: string;
readonly userId: string;
readonly title: string;
readonly imageUrl: string;
readonly categories: string[];
readonly description: string;
}
- We need to modify the
\posts.service.tsservice document to include the newgetUserPosts,updateUserPostanddeleteUserPostmethods.
server\src\posts\posts.service.ts
import { Injectable } from '@nestjs/common';
import { InjectModel } from 'nestjs-typegoose';
import { Post, Message } from './post.model';
import { PostPage } from './types/post-page.type';
import { LikesFaves } from './types/likes-faves.type';
import { CreatePostDto } from './dtos/create-post.dto'
import { UpdateUserPostDto } from './dtos/update-user-post.dto'
import { CreatePostMessageDto } from './dtos/create-post-message.dto'
import { ModelType, Ref } from 'typegoose';
import { User } from '../users/user.model';
@Injectable()
export class PostsService {
constructor(
@InjectModel(Post) private readonly postModel: ModelType<Post>,
@InjectModel(User) private readonly userModel: ModelType<User>
) { }
async getPosts(): Promise<Post[] | null> {
const posts = await this.postModel.find({})
.sort({ createdDate: "desc" })
.populate({
path: "createdBy",
model: "User"
});
return posts;
}
async getUserPosts(userId: string): Promise<Post[] | null> {
const posts = await this.postModel
.find({createdBy: userId })
.sort({ createdDate: "desc" });
return posts;
}
async getPost(postId: string): Promise<Post | null> {
const post = await this.postModel.findOne({ _id: postId })
.populate({
path: "messages.messageUser",
model: "User"
});
return post;
}
async searchPosts(searchTerm: string): Promise<Post[] | null> {
if (searchTerm) {
const searchResults = await this.postModel.find(
// Perform text search for search value of 'searchTerm'
{ $text: { $search: searchTerm } },
// Assign 'searchTerm' a text score to provide best match
{ score: { $meta: "textScore" } }
)
// Sort results according to that textScore (as well as by likes in descending order)
.sort({
score: { $meta: "textScore" },
likes: "desc"
})
.limit(5);
return searchResults;
}
}
async infiniteScrollPosts(pageNum: number, pageSize: number): Promise<PostPage | null> {
const skips = pageSize * (pageNum - 1)
const posts = await this.postModel.find({})
.sort({ createdDate: "desc" })
.populate({
path: "createdBy",
model: "User"
})
.skip(skips)
.limit(pageSize)
.lean()
const totalDocs = await this.postModel.countDocuments()
const hasMore = totalDocs > pageSize * pageNum
const postPage: PostPage = {
posts,
hasMore
}
return postPage
}
async addPost(createPostDto: CreatePostDto): Promise<Post> {
const newPost = new this.postModel(createPostDto);
return await newPost.save();
}
async updateUserPost(updateUserPostDto: UpdateUserPostDto): Promise<Post> {
const { postId, userId, title, imageUrl, categories, description } = updateUserPostDto
const post = await this.postModel.findOneAndUpdate(
// Find post by postId and createdBy
{ _id: postId, createdBy: userId },
{ $set: { title, imageUrl, categories, description } },
{ new: true }
);
return post;
}
async deleteUserPost(postId: string): Promise<Post> {
const post = await this.postModel.findOneAndRemove({ _id: postId });
return post;
}
async addPostMessage(createPostMessageDto: CreatePostMessageDto): Promise<Ref<Message>> {
const newMessage = {
messageBody: createPostMessageDto.messageBody,
messageUser: createPostMessageDto.userId
};
const post = await this.postModel.findOneAndUpdate(
// find post by id
{ _id: createPostMessageDto.postId },
// prepend (push) new message to beginning of messages array
{ $push: { messages: { $each: [newMessage], $position: 0 } } },
// return fresh document after update
{ new: true }
).populate({
path: "messages.messageUser",
model: "User"
});
return post.messages[0];
}
async likePost(postId: string, username: string): Promise<LikesFaves> {
const post = await this.postModel.findOneAndUpdate(
{ _id: postId },
{ $inc: { likes: 1 } },
{ new: true }
);
// Find User, add id of post to its favorites array (which will be populated as Posts)
const user = await this.userModel.findOneAndUpdate(
{ username },
{ $addToSet: { favorites: postId } },
{ new: true }
).populate({
path: "favorites",
model: "Post"
});
// Return only likes from 'post' and favorites from 'user'
const likesFaves: LikesFaves = {
likes: post.likes,
favorites: user.favorites
}
return likesFaves;
}
async unlikePost(postId: string, username: string): Promise<LikesFaves> {
// Find Post, add -1 to its 'like' value
const post = await this.postModel.findOneAndUpdate(
{ _id: postId },
{ $inc: { likes: -1 } },
{ new: true }
);
// Find User, remove id of post from its favorites array (which will be populated as Posts)
const user = await this.userModel.findOneAndUpdate(
{ username },
{ $pull: { favorites: postId } },
{ new: true }
).populate({
path: "favorites",
model: "Post"
});
// Return only likes from 'post' and favorites from 'user'
const likesFaves: LikesFaves = {
likes: post.likes,
favorites: user.favorites
}
return likesFaves;
}
}
2.-Modify the Posts resolver to include the new getUserPosts Query and and the new likePost and unlikePost Mutations.
- We need to modify the
posts.resolver.tsresolver document to include the newgetUserPostsQuery and and the newlikePostandunlikePostMutations.
server\src\posts\posts.resolver.ts
import { Resolver, Query, Mutation, Args } from '@nestjs/graphql';
import { Int, ID } from 'type-graphql';
import { PostsService } from './posts.service';
import { PostInput } from './inputs/post.input';
import { PostPage } from './types/post-page.type';
import { LikesFaves } from './types/likes-faves.type';
import { Post, Message } from './post.model';
@Resolver()
export class PostsResolver {
constructor(private readonly postsService: PostsService) { }
@Query(() => [Post])
async getPosts() {
return await this.postsService.getPosts();
}
@Query(() => [Post], { nullable: true })
async getUserPosts(@Args({ name: 'userId', type: () => ID }) userId: string) {
return await this.postsService.getUserPosts(userId);
}
@Query(() => Post)
async getPost(@Args({ name: 'postId', type: () => ID }) postId: string) {
return await this.postsService.getPost(postId);
}
@Query(() => [Post], { nullable: true })
async searchPosts(@Args({ name: 'searchTerm', type: () => String, nullable: true }) searchTerm: string) {
return await this.postsService.searchPosts(searchTerm);
}
@Query(() => PostPage)
async infiniteScrollPosts(
@Args({ name: 'pageNum', type: () => Int }) pageNum: number,
@Args({ name: 'pageSize', type: () => Int }) pageSize: number
) {
return await this.postsService.infiniteScrollPosts(pageNum, pageSize);
}
@Mutation(() => Post)
async addPostWithInput(@Args('input') input: PostInput) {
const createdBy = input.creatorId;
const { title, imageUrl, categories, description } = input
return await this.postsService.addPost({ title, imageUrl, categories, description, createdBy });
}
@Mutation(() => Post)
async addPost(
@Args('title') title: string,
@Args('imageUrl') imageUrl: string,
@Args({ name: 'categories', type: () => [String], nullable: "items" }) categories: string[],
@Args('description') description: string,
@Args({ name: 'creatorId', type: () => ID }) creatorId: string,
) {
const createdBy = creatorId;
return await this.postsService.addPost({ title, imageUrl, categories, description, createdBy });
}
@Mutation(() => Post)
async updateUserPost(
@Args({ name: 'postId', type: () => ID }) postId: string,
@Args({ name: 'userId', type: () => ID }) userId: string,
@Args('title') title: string,
@Args('imageUrl') imageUrl: string,
@Args({ name: 'categories', type: () => [String], nullable: "items" }) categories: string[],
@Args('description') description: string,
) {
return await this.postsService.updateUserPost({ postId, userId, title, imageUrl, categories, description });
}
@Mutation(() => Post)
async deleteUserPost(
@Args({ name: 'postId', type: () => ID }) postId: string,
) {
return await this.postsService.deleteUserPost( postId );
}
@Mutation(() => Message)
async addPostMessage(
@Args('messageBody') messageBody: string,
@Args({ name: 'userId', type: () => ID }) userId: string,
@Args({ name: 'postId', type: () => ID }) postId: string,
) {
return await this.postsService.addPostMessage({ messageBody, userId, postId });
}
@Mutation(() => LikesFaves)
async likePost(
@Args({ name: 'postId', type: () => ID }) postId: string,
@Args('username') username: string
) {
return await this.postsService.likePost(postId, username);
}
@Mutation(() => LikesFaves)
async unlikePost(
@Args({ name: 'postId', type: () => ID }) postId: string,
@Args('username') username: string
) {
return await this.postsService.unlikePost(postId, username);
}
}
3.-Test if the new getUserPosts Query and and the new likePost and unlikePost Mutations work propely
query
query getUserPosts($userId: ID!) {
getUserPosts(userId: $userId) {
_id
title
imageUrl
description
categories
createdDate
likes
}
}
variables
{
"userId": "5d3730452dbcac4a24749fca"
}
response
{
"data": {
"getPosts": [
{
"_id": "5d57dfceb24be86be004004a",
"title": "Chocolate Cake",
"imageUrl": "https://images.pexels.com/photos/132694/pexels-photo-132694.jpeg?auto=compress&cs=tinysrgb&dpr=2&h=650&w=940",
"categories": [
"Food"
],
"description": "A delicious piece of chocolate cake",
"createdDate": "2019-08-17T11:06:54.626Z",
"likes": 1,
"createdBy": {
"_id": "5d3730452dbcac4a24749fca",
"username": "Juan"
}
},
{
"_id": "5d57d42ef0db5f61cc3aa21a",
"title": "Credenza",
"imageUrl": "https://images.crateandbarrel.com/is/image/Crate/ClybournIICredenza3QF16/?$web_zoom_furn_av$&180802085137&wid=1008&hei=567",
"categories": [
"Furniture"
],
"description": "A piece of furniture I want to buy",
"createdDate": "2019-08-17T10:17:18.015Z",
"likes": 0,
"createdBy": {
"_id": "5d3730452dbcac4a24749fca",
"username": "Juan"
}
},
{
"_id": "5d505ca7c45cb259b0761e48",
"title": "A tasty dinner",
"imageUrl": "https://images.pexels.com/photos/691114/pexels-photo-691114.jpeg?auto=compress&cs=tinysrgb&dpr=2&h=650&w=940",
"categories": [
"Food",
"Travel"
],
"description": "Picture of a recipe I would like to prepare",
"createdDate": "2019-08-11T18:21:27.565Z",
"likes": 1,
"createdBy": {
"_id": "5d3730452dbcac4a24749fca",
"username": "Juan"
}
},
{
"_id": "5d502f6c685ff635a4be5336",
"title": "Abstract Art",
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/f/f0/Vassily_Kandinsky%2C_1923_-_Circles_in_a_Circle.jpg",
"categories": [
"Art"
],
"description": "A neat painting by Kandinsky",
"createdDate": "2019-08-11T15:08:28.203Z",
"likes": 0,
"createdBy": {
"_id": "5d3730452dbcac4a24749fca",
"username": "Juan"
}
},
{
"_id": "5d4fa31dcb16795b68483e10",
"title": "At the Beach",
"imageUrl": "https://images.pexels.com/photos/1139541/pexels-photo-1139541.jpeg?auto=compress&cs=tinysrgb&dpr=2&h=650&w=940",
"categories": [
"Photography",
"Travel"
],
"description": "A nice photo of the waves",
"createdDate": "2019-08-11T05:09:49.634Z",
"likes": 0,
"createdBy": {
"_id": "5d3730452dbcac4a24749fca",
"username": "Juan"
}
},
{
"_id": "5d0e10be7dd57444d0607790",
"title": "Tasty nice coffee",
"imageUrl": "https://images.pexels.com/photos/374757/pexels-photo-374757.jpeg?auto=compress&cs=tinysrgb&dpr=2&h=650&w=940",
"categories": [
"Art",
"Food"
],
"description": "Some nice coffee artwork",
"createdDate": "2019-06-22T11:27:58.416Z",
"likes": 1,
"createdBy": {
"_id": "5d3730452dbcac4a24749fca",
"username": "Juan"
}
},
{
"_id": "5d04d377deb8673e8c38fc2f",
"title": "Mona lisa!",
"imageUrl": "https://images.duckduckgo.com/iu/?u=http%3A%2F%2Fwww.readingpublicmuseum.org%2Fexhibit_secrets-of-mona-lisa-4.jpg&f=1",
"categories": [
"Art"
],
"description": "A painting",
"createdDate": "2019-06-15T11:16:07.195Z",
"likes": 0,
"createdBy": {
"_id": "5d3730452dbcac4a24749fca",
"username": "Juan"
}
}
]
}
}
query
mutation updateUserPost(
$postId: ID!
$userId: ID!
$title: String!
$imageUrl: String!
$categories: [String]!
$description: String!
) {
updateUserPost(
postId: $postId
userId: $userId
title: $title
imageUrl: $imageUrl
categories: $categories
description: $description
) {
_id
title
imageUrl
description
categories
createdDate
likes
createdBy {
_id
avatar
}
}
}
variables
{
"postId": "5d57dfceb24be86be004004a",
"userId": "5d3730452dbcac4a24749fca",
"title": "Chocolate Cake II",
"imageUrl": "https://images.pexels.com/photos/132694/pexels-photo-132694.jpeg?auto=compress&cs=tinysrgb&dpr=2&h=650&w=940",
"categories": [
"Food"
],
"description": "A delicious piece of chocolate cake"
}
response
{
"data": {
"updateUserPost": {
"_id": "5d57dfceb24be86be004004a",
"title": "Chocolate Cake II",
"imageUrl": "https://images.pexels.com/photos/132694/pexels-photo-132694.jpeg?auto=compress&cs=tinysrgb&dpr=2&h=650&w=940",
"description": "A delicious piece of chocolate cake",
"categories": [
"Food"
],
"createdDate": "2019-08-17T11:06:54.626Z",
"likes": 1,
"createdBy": {
"_id": "5d3730452dbcac4a24749fca",
"avatar": null
}
}
}
}
query
mutation deleteUserPost($postId: ID!) {
deleteUserPost(postId: $postId) {
_id
}
}
variables
{
"postId": "5d57dfceb24be86be004004a"
}
response
{
"data": {
"deleteUserPost": {
"_id": "5d57dfceb24be86be004004a"
}
}
}
II.-Modify the client app to be able to update and delete a post from the Pofrile pages.
1.-Create the getUserPosts.gql with the getUserPosts query and the updateUserPost and deleteUserPost.gql documents with the updateUserPost and deleteUserPost mutations.
- We need to create the
getUserPosts.gqldocument with thegetUserPostsQuery.
client\gql\getUserPosts.gql
query getUserPosts($userId: ID!) {
getUserPosts(userId: $userId) {
_id
title
imageUrl
description
categories
createdDate
likes
}
}
- We need to create the
updateUserPost.gqldocument with theupdateUserPostMutation.
client\gql\updateUserPost.gql
mutation updateUserPost(
$postId: ID!
$userId: ID!
$title: String!
$imageUrl: String!
$categories: [String]!
$description: String!
) {
updateUserPost(
postId: $postId
userId: $userId
title: $title
imageUrl: $imageUrl
categories: $categories
description: $description
) {
_id
title
imageUrl
description
categories
createdDate
likes
createdBy {
_id
avatar
}
}
}
- We need to create the
deleteUserPost.gqldocument with thedeleteUserPostMutation.
client\gql\deleteUserPost.gql
mutation deleteUserPost($postId: ID!) {
deleteUserPost(postId: $postId) {
_id
}
}
2.-Modify the Store to include the userPosts state and the getUserPosts, updateUserPost and deleteUserPost actions.
- We need to modify the
store\index.jsstore document to include theuserPostsstate and thegetUserPosts,updateUserPostanddeleteUserPostactions.
client\store\index.js
import { getPosts } from '~/gql/getPosts.gql'
import { searchPosts } from '~/gql/searchPosts.gql'
import { getUserPosts } from '~/gql/getUserPosts.gql'
import { getCurrentUser } from '~/gql/getCurrentUser.gql'
import { signinUser } from '~/gql/signinUser.gql'
import { signupUser } from '~/gql/signupUser.gql'
import { addPost } from '~/gql/addPost.gql'
import { updateUserPost } from '~/gql/updateUserPost.gql'
import { deleteUserPost } from '~/gql/deleteUserPost.gql'
import utils from '~/helpers/utils'
export const state = () => ({
posts: [],
userPosts: [],
searchResults: [],
user: null,
loading: false,
error: null,
authError: null
})
export const mutations = {
setPosts: (state, payload) => {
state.posts = payload
},
setSearchResults: (state, payload) => {
if (payload !== null && payload.length > 0) {
state.searchResults = payload
}
},
addPost: (state, payload) => {
const posts = state.posts
posts.unshift(payload)
state.posts = posts
},
setUser: (state, payload) => {
state.user = payload
},
setUserPosts: (state, payload) => {
state.userPosts = payload
},
setLoading: (state, payload) => {
state.loading = payload
},
clearUser: state => (state.user = null),
setError: (state, payload) => {
state.error = payload
},
clearError: state => (state.error = null),
setAuthError: (state, payload) => {
state.authError = payload
},
clearAuthError: state => (state.authError = null),
clearSearchResults: state => (state.searchResults = [])
}
export const actions = {
async nuxtServerInit({ dispatch }) {
await dispatch('getCurrentUser')
await dispatch('getPosts')
},
async getPosts({ commit }) {
commit('clearError')
commit('setLoading', true)
try {
const result = await this.app.apolloProvider.defaultClient.query({
query: getPosts
})
commit('setPosts', result.data.getPosts)
} catch (error) {
const currentError = utils.getCurrentGraphQLError(error)
commit('setError', currentError)
if (process.env.NODE_ENV === 'development')
console.error(utils.getFirstGraphQLError(error))
}
commit('setLoading', false)
},
async getUserPosts({ commit }, payload) {
commit('clearError')
commit('setLoading', true)
try {
const result = await this.app.apolloProvider.defaultClient.query({
query: getUserPosts,
variables: payload
})
commit('setUserPosts', result.data.getUserPosts)
} catch (error) {
const currentError = utils.getCurrentGraphQLError(error)
commit('setError', currentError)
if (process.env.NODE_ENV === 'development')
console.error(utils.getFirstGraphQLError(error))
}
commit('setLoading', false)
},
async searchPosts({ commit }, payload) {
commit('clearError')
try {
const result = await this.app.apolloProvider.defaultClient.query({
query: searchPosts,
variables: payload
})
commit('setSearchResults', result.data.searchPosts)
} catch (error) {
const currentError = utils.getCurrentGraphQLError(error)
commit('setError', currentError)
if (process.env.NODE_ENV === 'development')
console.error(utils.getFirstGraphQLError(error))
}
},
async addPost({ commit }, payload) {
commit('clearError')
commit('setLoading', true)
try {
await this.app.apolloProvider.defaultClient.mutate({
mutation: addPost,
variables: payload
})
const { _id, title, imageUrl } = payload
const newPost = {
_id,
title,
imageUrl
}
commit('addPost', newPost)
} catch (error) {
const currentError = utils.getCurrentGraphQLError(error)
commit('setError', currentError)
if (process.env.NODE_ENV === 'development')
console.error(utils.getFirstGraphQLError(error))
}
commit('setLoading', false)
},
async updateUserPost({ state, commit }, payload) {
commit('clearError')
commit('setLoading', true)
try {
const result = await this.app.apolloProvider.defaultClient.mutate({
mutation: updateUserPost,
variables: payload
})
const index = state.userPosts.findIndex(
post => post._id === result.data.updateUserPost._id
)
const userPosts = [
...state.userPosts.slice(0, index),
result.data.updateUserPost,
...state.userPosts.slice(index + 1)
]
commit('setUserPosts', userPosts)
} catch (error) {
const currentError = utils.getCurrentGraphQLError(error)
commit('setError', currentError)
if (process.env.NODE_ENV === 'development')
console.error(utils.getFirstGraphQLError(error))
}
commit('setLoading', false)
},
async deleteUserPost({ state, commit }, payload) {
commit('clearError')
commit('setLoading', true)
try {
const result = await this.app.apolloProvider.defaultClient.mutate({
mutation: deleteUserPost,
variables: payload
})
const index = state.userPosts.findIndex(
post => post._id === result.data.updateUserPost._id
)
const userPosts = [
...state.userPosts.slice(0, index),
...state.userPosts.slice(index + 1)
]
commit('setUserPosts', userPosts)
} catch (error) {
const currentError = utils.getCurrentGraphQLError(error)
commit('setError', currentError)
if (process.env.NODE_ENV === 'development')
console.error(utils.getFirstGraphQLError(error))
}
commit('setLoading', false)
},
async logOut({ commit }) {
this.app.$cookies.remove('apollo-token')
await this.app.$apolloHelpers.onLogout()
commit('clearUser')
},
async getCurrentUser({ commit, dispatch }) {
const token = this.app.$apolloHelpers.getToken()
if (!token) {
return
}
if (!utils.isJwtTokenValid(token)) {
commit('setAuthError', this.app.i18n.t('sessionExpiredSignInAgain'))
await dispatch('logOut')
return
}
commit('setLoading', true)
try {
const result = await this.app.apolloProvider.defaultClient.query({
query: getCurrentUser
})
// Add user data to state
commit('setUser', result.data.getCurrentUser)
commit('clearError')
commit('clearAuthError')
} catch (error) {
const currentError = utils.getCurrentGraphQLError(error)
if (process.env.NODE_ENV === 'development') console.log(currentError)
if (currentError && currentError.error === 'Unauthorized') {
const sessionExpiredSignInAgain = this.app.i18n.t(
'sessionExpiredSignInAgain'
)
commit('setAuthError', sessionExpiredSignInAgain)
await dispatch('logOut')
} else {
commit('setError', currentError)
}
if (process.env.NODE_ENV === 'development')
console.error(utils.getFirstGraphQLError(error))
}
commit('setLoading', false)
},
async signinUser({ commit, dispatch }, payload) {
commit('clearError')
commit('setLoading', true)
try {
const result = await this.app.apolloProvider.defaultClient.mutate({
mutation: signinUser,
variables: payload
})
await this.app.$apolloHelpers.onLogin(result.data.signinUser.token)
await dispatch('getCurrentUser')
} catch (error) {
const currentError = utils.getCurrentGraphQLError(error)
commit('setError', currentError)
if (process.env.NODE_ENV === 'development')
console.error(utils.getFirstGraphQLError(error))
}
commit('setLoading', false)
},
async signupUser({ commit, dispatch }, payload) {
commit('clearError')
commit('setLoading', true)
try {
const result = await this.app.apolloProvider.defaultClient.mutate({
mutation: signupUser,
variables: payload
})
await this.app.$apolloHelpers.onLogin(result.data.signupUser.token)
await dispatch('getCurrentUser')
} catch (error) {
const currentError = utils.getCurrentGraphQLError(error)
commit('setError', currentError)
if (process.env.NODE_ENV === 'development')
console.error(utils.getFirstGraphQLError(error))
}
commit('setLoading', false)
},
async signoutUser({ dispatch }) {
await dispatch('logOut')
// redirect home - kick users out of private pages (i.e. profile)
this.app.router.push(this.app.localePath('index'))
}
}
export const getters = {
posts: state => state.posts,
userPosts: state => state.userPosts,
searchResults: state => state.searchResults,
user: state => state.user,
userFavorites: state => state.user && state.user.favorites,
loading: state => state.loading,
error: state => state.error,
authError: state => state.authError
}
3.-Modify the Profile page to include all the functionality
- We need to modify the
lang\en-US.jslocalization document to add new entries for the page.
client\lang\en-US.js
export default {
posts: 'Posts',
signin: 'Sign in',
signup: 'Sign up',
searchposts: 'Search Posts',
home: 'Home',
createPost: 'Create Post',
profile: 'Profile',
signout: 'Signout',
username: 'Username',
password: 'Password',
confirmPassword: 'Confirm Password',
email: 'Email',
dontHaveAnAccount: "Don't have an account?",
alreadyHaveAnAccount: 'Already have an account?',
welcomeBack: 'Welcome Back!',
getStartedHere: 'Get Started Here',
isRequired: '{name} is required',
mustBeValid: '{name} must be valid',
cannotBeMoreThanCharacters: '{name} cannot be more than {number} characters',
mustBeAtLeast: '{name} must be at least {number} characters',
passwordsMustMatch: 'Passwords must match',
yourAreNowSignedIn: 'You are now signed in!',
close: 'Close',
sessionExpiredSignInAgain: 'Your session has ended. Please sign in again.',
addPost: 'Add Post',
postTitle: 'Post Title',
imageUrl: 'Image URL',
category: 'Category | Categories',
categoryItems: {
art: 'Art',
education: 'Education',
food: 'Food',
furniture: 'Furniture',
travel: 'Travel',
photography: 'Photography',
technology: 'Technology'
},
description: 'Description',
submit: 'Submit',
atLeastOne: 'At least one',
likes: 'likes',
comments: 'comments',
added: 'Added',
fetchMore: 'Fetch More',
message: 'Message | Messages',
clickToEnlargeImage: 'Click to enlarge image',
addMessage: 'Add Message',
pageNotFound: '404 Not Found',
otherError: 'An error occurred',
homePage: 'Home Page',
sureDeleteThisPost: 'Are you sure you want to delete this post?',
favorites: 'Favorites',
postsAdded: 'Posts Added',
joined: 'Joined',
noFavoritesCurrently: 'You have no favorites currently.',
noPostCurrently: 'You have no posts currently.',
goAndAddSome: 'Go and add some!',
favorited: `Favorited`,
yourPosts: 'Your Posts',
updatePost: 'Update Post',
update: 'Update',
cancel: 'Cancel'
}
- We need to modify the
lang\es-ES.jslocalization document to add new entries for the page.
client\lang\es-ES.js
export default {
posts: 'Entradas',
signin: 'Iniciar sesión',
signup: 'Inscripción',
searchposts: 'Buscar entradas',
home: 'Inicio',
createPost: 'Crear Entrada',
profile: 'Perfil',
signout: 'Cerrar sesión',
username: 'Usuario',
password: 'Contraseña',
confirmPassword: 'Confirmar Contraseña',
email: 'Email',
dontHaveAnAccount: '¿No tiene una cuenta?',
alreadyHaveAnAccount: '¿Ya tiene una cuenta?',
welcomeBack: '¡Bienvenido de nuevo!',
getStartedHere: 'Comience aquí',
isRequired: '{name} es requerido',
mustBeValid: '{name} debe de ser válido',
cannotBeMoreThanCharacters:
'{name} no puede contener más de {number} caracteres',
mustBeAtLeast: '{name} debe contener al menos {number} caracteres',
passwordsMustMatch: 'Las Contraseñas deben coincidir',
yourAreNowSignedIn: '¡Ahora está registrado!',
close: 'Cerrar',
sessionExpiredSignInAgain:
'Su sesión ha caducado. Por favor, inicie la sesión otra vez.',
addPost: 'Añadir Entrada',
postTitle: 'Título de la entrada',
imageUrl: 'URL de la imagen',
category: 'Categoría | Categorías',
categoryItems: {
art: 'Arte',
education: 'Educación',
food: 'Comida',
furniture: 'Muebles',
travel: 'Viajes',
photography: 'Fotografía',
technology: 'Tecnología'
},
description: 'Descripción',
submit: 'Enviar',
atLeastOne: 'Por lo menos una',
likes: 'me gusta',
comments: 'comentarios',
added: 'Creado',
fetchMore: 'Obtener más',
message: 'Mensaje | Mensajes',
clickToEnlargeImage: 'Haga clic para ampliar la imagen',
addMessage: 'Añadir Mensaje',
pageNotFound: '404 No encontrado',
otherError: 'Oucrrió un error',
homePage: 'Página inicial',
sureDeleteThisPost: '¿Está seguro de que desea eliminar esta entrada?',
favorites: 'Favoritos',
postsAdded: 'Entradas Añadidas',
joined: 'Inscrito',
noFavoritesCurrentlyAddSome: 'No tiene favoritos actualmente.',
noPostCurrently: 'No tiene entradas actualmente.',
goAndAddSome: '¡Acceda y agregue alguno!',
favorited: `Favoritos`,
yourPosts: 'Sus Entradas',
updatePost: 'Actualizar Entrada',
update: 'Actualizar',
cancel: 'Cancelar'
}
- We need to modify the
profile\index.vuepage document to include all the functionality.
client\pages\profile\index.vue
<template>
<v-container class="text-xs-center">
<!-- User Details Card -->
<v-flex sm6 offset-sm3>
<v-card class="white--text" color="secondary">
<v-layout>
<v-flex xs5>
<v-img height="125px" contain :src="user.avatar"></v-img>
</v-flex>
<v-flex xs7>
<v-card-title primary-title>
<div>
<div class="headline">{{ user.username }}</div>
<div>{{ $t('joined') }} {{ user.joinDate }}</div>
<div class="hidden-xs-only font-weight-thin">
{{ user.favorites.length }} {{ $t('favorites') }}
</div>
<div class="hidden-xs-only font-weight-thin">
{{ userPosts.length }} {{ $t('postsAdded') }}
</div>
</div>
</v-card-title>
</v-flex>
</v-layout>
</v-card>
</v-flex>
<!-- Posts Favorited by User -->
<v-container v-if="!userFavorites.length">
<v-layout row wrap>
<v-flex xs12>
<h2>{{ $t('noFavoritesCurrently') }} {{ $t('goAndAddSome') }}</h2>
</v-flex>
</v-layout>
</v-container>
<v-container v-else class="mt-3">
<v-flex xs12>
<h2 class="font-weight-light">
{{ $t('favorited') }}
<span class="font-weight-regular">({{ userFavorites.length }})</span>
</h2>
</v-flex>
<v-layout row wrap>
<v-flex v-for="favorite in userFavorites" :key="favorite._id" sm6 xs12>
<v-card class="mt-3 ml-1 mr-2" hover>
<v-img height="30vh" :src="favorite.imageUrl"></v-img>
<v-card-text>{{ favorite.title }}</v-card-text>
</v-card>
</v-flex>
</v-layout>
</v-container>
<!-- Posts Created By user -->
<v-container v-if="!userPosts.length">
<v-layout row wrap>
<v-flex xs12>
<h2>{{ $t('noPostCurrently') }} {{ $t('goAndAddSome') }}</h2>
</v-flex>
</v-layout>
</v-container>
<v-container v-else class="mt-3">
<v-flex xs12>
<h2 class="font-weight-light">
{{ $t('yourPosts') }}
<span class="font-weight-regular">({{ userPosts.length }})</span>
</h2>
</v-flex>
<v-layout row wrap>
<v-flex v-for="post in userPosts" :key="post._id" sm6 xs12>
<v-card class="mt-3 ml-1 mr-2" hover>
<v-btn color="info" floating fab small dark @click="loadPost(post)">
<v-icon>edit</v-icon>
</v-btn>
<v-btn
color="error"
floating
fab
small
dark
@click="handleDeleteUserPost(post)"
>
<v-icon>delete</v-icon>
</v-btn>
<v-img height="30vh" :src="post.imageUrl"></v-img>
<v-card-text>{{ post.title }}</v-card-text>
</v-card>
</v-flex>
</v-layout>
</v-container>
<!-- Edit Post Dialog -->
<v-dialog v-model="editPostDialog" xs12 sm6 offset-sm3 persistent>
<v-card>
<v-card-title class="headline grey lighten-2">{{
$t('updatePost')
}}</v-card-title>
<v-container>
<v-form
ref="form"
v-model="isFormValid"
lazy-validation
@submit.prevent="handleUpdateUserPost"
>
<!-- Title Input -->
<v-layout row>
<v-flex xs12>
<v-text-field
v-model="title"
:rules="titleRules"
:label="$t('postTitle')"
type="text"
required
></v-text-field>
</v-flex>
</v-layout>
<!-- Image Url Input -->
<v-layout row>
<v-flex xs12>
<v-text-field
v-model="imageUrl"
:rules="imageRules"
:label="$t('imageUrl')"
type="text"
required
></v-text-field>
</v-flex>
</v-layout>
<!-- Image Preview -->
<v-layout row>
<v-flex xs12>
<img :src="imageUrl" height="300px" />
</v-flex>
</v-layout>
<!-- Categories Select -->
<v-layout row>
<v-flex xs12>
<v-select
v-model="categories"
:rules="categoriesRules"
item-text="text"
item-value="value"
:items="categoryItems"
multiple
:label="$tc('category', 2)"
></v-select>
</v-flex>
</v-layout>
<!-- Description Text Area -->
<v-layout row>
<v-flex xs12>
<v-textarea
v-model="description"
:rules="descRules"
:label="$t('description')"
type="text"
required
></v-textarea>
</v-flex>
</v-layout>
<v-divider></v-divider>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn
:disabled="!isFormValid"
type="submit"
class="success--text"
flat
>{{ $t('update') }}</v-btn
>
<v-btn class="error--text" flat @click="editPostDialog = false">{{
$t('cancel')
}}</v-btn>
</v-card-actions>
</v-form>
</v-container>
</v-card>
</v-dialog>
</v-container>
</template>
<script>
import { mapGetters } from 'vuex'
export default {
name: 'Profile',
middleware: 'auth',
data() {
return {
editPostDialog: false,
isFormValid: true,
title: '',
imageUrl: '',
categories: [],
categoryItems: [
{ value: 'Art', text: this.$i18n.t('categoryItems.art') },
{ value: 'Education', text: this.$i18n.t('categoryItems.education') },
{ value: 'Food', text: this.$i18n.t('categoryItems.food') },
{ value: 'Furniture', text: this.$i18n.t('categoryItems.furniture') },
{ value: 'Travel', text: this.$i18n.t('categoryItems.travel') },
{
value: 'Photography',
text: this.$i18n.t('categoryItems.photography')
},
{
value: 'Technology',
text: this.$i18n.t('categoryItems.technology')
}
],
description: '',
titleRules: [
title =>
!!title ||
this.$i18n.t('isRequired', { name: this.$i18n.t('postTitle') }),
title =>
title.length <= 20 ||
this.$i18n.t('cannotBeMoreThanCharacters', {
name: this.$i18n.t('postTitle'),
number: 20
})
],
imageRules: [
image =>
!!image ||
this.$i18n.t('isRequired', { name: this.$i18n.t('imageUrl') })
],
categoriesRules: [
categories =>
categories.length >= 1 ||
this.$i18n.t('isRequired', {
name: `${this.$i18n.t('atLeastOne')} ${this.$i18n.tc(
'category',
1
)}`
})
],
descRules: [
desc =>
!!desc ||
this.$i18n.t('isRequired', { name: this.$i18n.t('description') }),
desc =>
desc.length <= 200 ||
this.$i18n.t('cannotBeMoreThanCharacters', {
name: this.$i18n.t('description'),
number: 200
})
]
}
},
computed: {
...mapGetters(['user', 'userFavorites', 'userPosts'])
},
created() {
this.handleGetUserPosts()
},
methods: {
handleGetUserPosts() {
this.$store.dispatch('getUserPosts', {
userId: this.user._id
})
},
handleUpdateUserPost() {
if (this.$refs.form.validate()) {
this.$store.dispatch('updateUserPost', {
postId: this.postId,
userId: this.user._id,
title: this.title,
imageUrl: this.imageUrl,
categories: this.categories,
description: this.description
})
this.editPostDialog = false
}
},
handleDeleteUserPost(post) {
this.loadPost(post, false)
const deletePost = window.confirm(this.$i18n.t('sureDeleteThisPost'))
if (deletePost) {
this.$store.dispatch('deleteUserPost', {
postId: this.postId
})
}
},
loadPost(
{ _id, title, imageUrl, categories, description },
editPostDialog = true
) {
this.editPostDialog = editPostDialog
this.postId = _id
this.title = title
this.imageUrl = imageUrl
this.categories = categories
this.description = description
}
}
}
</script>
4.-Modify the default layout document to show the snackbars when the error happenns on server rendering
client\layouts\default.vue
<template>
<v-app style="background: #E3E3EE">
<!-- Side Navbar -->
<v-navigation-drawer v-model="sideNav" fixed app temporary>
<v-toolbar color="accent" dark flat>
<v-toolbar-side-icon @click="toggleSideNav"></v-toolbar-side-icon>
<nuxt-link :to="localePath('index')" tag="span" style="cursor: pointer">
<h1 class="title pl-3">VueShare</h1>
</nuxt-link>
</v-toolbar>
<v-divider></v-divider>
<!-- Side Navbar Links -->
<v-list>
<v-list-tile
v-for="(item, i) in sideNavItems"
:key="i"
:to="localePath(item.link)"
ripple
>
<v-list-tile-action>
<v-icon>{{ item.icon }}</v-icon>
</v-list-tile-action>
<v-list-tile-content>
{{ item.title }}
</v-list-tile-content>
</v-list-tile>
<!-- Signout Button -->
<v-list-tile v-if="user" @click="handleSignoutUser">
<v-list-tile-action>
<v-icon>exit_to_app</v-icon>
</v-list-tile-action>
<v-list-tile-content>{{ $t('signout') }}</v-list-tile-content>
</v-list-tile>
</v-list>
</v-navigation-drawer>
<!-- Horizontal Navbar -->
<v-toolbar fixed color="primary" dark>
<!-- App Title -->
<v-toolbar-side-icon @click="toggleSideNav"></v-toolbar-side-icon>
<v-toolbar-title class="hidden-xs-only">
<nuxt-link :to="localePath('index')" tag="span" style="cursor: pointer">
VueShare
</nuxt-link>
</v-toolbar-title>
<v-spacer></v-spacer>
<!-- Search Input -->
<v-text-field
v-model="searchTerm"
flex
prepend-icon="search"
color="accent"
single-line
hide-details
:placeholder="$t('searchposts')"
@input="handleSearchPosts"
></v-text-field>
<!-- Search Results Card -->
<v-card v-if="searchResults.length" id="search__card" dark>
<v-list>
<v-list-tile
v-for="result in searchResults"
:key="result._id"
@click="goToSearchResult(result._id)"
>
<v-list-tile-title>
{{ result.title }} -
<span class="font-weight-thin">{{
formatDescription(result.description)
}}</span>
</v-list-tile-title>
<!-- Show Icon if Result Favorited by User -->
<v-list-tile-action v-if="checkIfUserFavorite(result._id)">
<v-icon>favorite</v-icon>
</v-list-tile-action>
</v-list-tile>
</v-list>
</v-card>
<v-spacer></v-spacer>
<!-- Horizontal Navbar Links -->
<v-toolbar-items class="hidden-xs-only">
<v-btn
v-for="(item, i) in horizontalNavItems"
:key="i"
:to="localePath(item.link)"
flat
>
<v-icon class="hidden-sm-only" left>{{ item.icon }}</v-icon>
{{ item.title }}
</v-btn>
</v-toolbar-items>
<!-- Profile Button -->
<v-btn v-if="user" flat :to="localePath('profile')">
<v-icon class="hidden-sm-only" left>account_box</v-icon>
<v-badge right color="blue darken-2" :class="{ bounce: badgeAnimated }">
<span v-if="userFavorites.length" slot="badge">{{
userFavorites.length
}}</span>
{{ $t('profile') }}
</v-badge>
</v-btn>
<!-- Signout Button -->
<v-btn v-if="user" flat @click="handleSignoutUser">
<v-icon class="hidden-sm-only" left>exit_to_app</v-icon>
{{ $t('signout') }}
</v-btn>
<v-toolbar-title class="hidden-xs-only">
<nuxt-link
v-for="(locale, i) in showLocales"
:key="i"
tag="span"
style="cursor: pointer"
class="lang-switcher"
:to="switchLocalePath(locale.code)"
>
{{ locale.name }}
</nuxt-link>
</v-toolbar-title>
</v-toolbar>
<!-- App Content -->
<main>
<v-container class="mt-4">
<transition name="fade">
<nuxt />
</transition>
<!-- Auth Snackbar -->
<v-snackbar
v-model="authSnackbar"
color="success"
:timeout="5000"
bottom
left
>
<v-icon class="mr-3">check_circle</v-icon>
<h3>{{ $t('yourAreNowSignedIn') }}</h3>
<v-btn dark flat @click="authSnackbar = false">{{
$t('close')
}}</v-btn>
</v-snackbar>
<!-- Auth Error Snackbar -->
<v-snackbar
v-if="authError"
v-model="authErrorSnackbar"
color="info"
:timeout="5000"
bottom
left
>
<v-icon class="mr-3">cancel</v-icon>
<h3>{{ authError }}</h3>
<v-btn dark flat :to="localePath('signin')">{{ $t('signin') }}</v-btn>
</v-snackbar>
</v-container>
</main>
</v-app>
</template>
<script>
import { mapGetters } from 'vuex'
export default {
head() {
return this.$nuxtI18nSeo()
},
data() {
return {
searchTerm: '',
sideNav: false,
authSnackbar: false,
authErrorSnackbar: false,
badgeAnimated: false
}
},
computed: {
...mapGetters(['searchResults', 'authError', 'user', 'userFavorites']),
horizontalNavItems() {
let items = [
{ icon: 'chat', title: this.$i18n.t('posts'), link: 'posts' },
{ icon: 'lock_open', title: this.$i18n.t('signin'), link: 'signin' },
{ icon: 'create', title: this.$i18n.t('signup'), link: 'signup' }
]
if (this.user) {
items = [{ icon: 'chat', title: this.$i18n.t('posts'), link: 'posts' }]
}
return items
},
sideNavItems() {
let items = [
{ icon: 'chat', title: this.$i18n.t('posts'), link: 'posts' },
{ icon: 'lock_open', title: this.$i18n.t('signin'), link: 'signin' },
{ icon: 'create', title: this.$i18n.t('signup'), link: 'signup' }
]
if (this.user) {
items = [
{ icon: 'chat', title: this.$i18n.t('posts'), link: 'posts' },
{
icon: 'stars',
title: this.$i18n.t('createPost'),
link: 'posts-add'
},
{
icon: 'account_box',
title: this.$i18n.t('profile'),
link: 'profile'
}
]
}
return items
},
showLocales() {
return this.$i18n.locales.filter(
locale => locale.code !== this.$i18n.locale
)
}
},
watch: {
user(newValue, oldValue) {
// if we had no value for user before, show snackbar
if (oldValue === null) {
this.authSnackbar = true
}
},
authError(value) {
// if auth error is not null, show auth error snackbar
if (value !== null) {
this.authErrorSnackbar = true
}
},
userFavorites(value) {
// if user favorites value changed at all
if (value) {
this.badgeAnimated = true
setTimeout(() => (this.badgeAnimated = false), 1000)
}
}
},
mounted() {
if (this.user) {
this.authSnackbar = true
}
if (this.authError !== null) {
this.authErrorSnackbar = true
}
},
methods: {
handleSearchPosts() {
this.$store.dispatch('searchPosts', {
searchTerm: this.searchTerm
})
},
goToSearchResult(resultId) {
// Clear search term
this.searchTerm = ''
// Go to desired result
this.$router.push(`${this.localePath('posts')}/${resultId}`)
// Clear search results
this.$store.commit('clearSearchResults')
},
formatDescription(desc) {
return desc.length > 30 ? `${desc.slice(0, 30)}...` : desc
},
checkIfUserFavorite(resultId) {
return (
this.userFavorites &&
this.userFavorites.some(fave => fave._id === resultId)
)
},
handleSignoutUser() {
this.$store.dispatch('signoutUser')
},
toggleSideNav() {
this.sideNav = !this.sideNav
}
}
}
</script>
<style>
.fade-enter-active,
.fade-leave-active {
transition-property: opacity;
transition-duration: 0.25s;
}
.fade-enter-active {
transition-delay: 0.25s;
}
.fade-enter,
.fade-leave-active {
opacity: 0;
}
/* Search Results Card */
#search__card {
position: absolute;
width: 100vw;
z-index: 8;
top: 100%;
left: 0%;
}
/* User Favorite Animation */
.bounce {
animation: bounce 1s both;
}
@keyframes bounce {
0%,
20%,
53%,
80%,
100% {
transform: translate3d(0, 0, 0);
}
40%,
43% {
transform: translate3d(0, -20px, 0);
}
70% {
transform: translate3d(0, -10px, 0);
}
90% {
transform: translate3d(0, -4px, 0);
}
}
</style>
5.-Modify some documents to show console logs only in the development environment
- We need to change the
customErrorHandler.jsapollo custom error document.
client\apollo\customErrorHandler.js
export default (err, { error }) => {
if (process.env.NODE_ENV === 'development') console.log(err)
error({ statusCode: 304, message: 'Server error!' })
}
- We need to modify the
\posts\_id.vuePost page.
client\pages\posts_id.vue
<template>
<v-container v-if="getPost" class="mt-3" flexbox center>
<!-- Post Card -->
<v-layout row wrap>
<v-flex xs12>
<v-card hover>
<v-card-title>
<h1>{{ getPost.title }}</h1>
<v-btn v-if="user" large icon @click="handleToggleLike">
<v-icon
large
:color="checkIfPostLiked(getPost._id) ? 'red' : 'grey'"
>favorite</v-icon
>
</v-btn>
<h3 class="ml-3 font-weight-thin">
{{ getPost.likes }} {{ $t('likes').toUpperCase() }}
</h3>
<v-spacer></v-spacer>
<v-icon color="info" large @click="goToPreviousPage"
>arrow_back</v-icon
>
</v-card-title>
<v-tooltip right>
<span>{{ $t('clickToEnlargeImage') }}</span>
<v-img
id="post__image"
slot="activator"
:src="getPost.imageUrl"
@click="toggleImageDialog"
></v-img>
</v-tooltip>
<!-- Post Image Dialog -->
<v-dialog v-model="dialog">
<v-card>
<v-img :src="getPost.imageUrl" height="80vh"></v-img>
</v-card>
</v-dialog>
<v-card-text>
<span v-for="(category, index) in getPost.categories" :key="index">
<v-chip class="mb-3" color="accent" text-color="white">{{
category
}}</v-chip>
</span>
<h3>{{ getPost.description }}</h3>
</v-card-text>
</v-card>
</v-flex>
</v-layout>
<!-- Messages Section -->
<div class="mt-3">
<!-- Message Input -->
<v-layout v-if="user" class="mb-3">
<v-flex xs12>
<v-form
ref="form"
v-model="isFormValid"
lazy-validation
@submit.prevent="handleAddPostMessage"
>
<v-layout row>
<v-flex xs12>
<v-text-field
v-model="messageBody"
:rules="messageRules"
clearable
:append-outer-icon="messageBody && 'send'"
:label="$t('addMessage')"
type="text"
prepend-icon="email"
required
@click:append-outer="handleAddPostMessage"
></v-text-field>
</v-flex>
</v-layout>
</v-form>
</v-flex>
</v-layout>
<!-- Messages -->
<v-layout row wrap>
<v-flex xs12>
<v-list subheader two-line>
<v-subheader
>{{ $tc('message', 2) }} ({{
getPost.messages.length
}})</v-subheader
>
<template v-for="message in getPost.messages">
<v-divider :key="message._id"></v-divider>
<v-list-tile :key="message.title" avatar inset>
<v-list-tile-avatar>
<img :src="message.messageUser.avatar" />
</v-list-tile-avatar>
<v-list-tile-content>
<v-list-tile-title>
{{ message.messageBody }}
</v-list-tile-title>
<v-list-tile-sub-title>
{{ message.messageUser.username }}
<span class="grey--text text--lighten-1 hidden-xs-only">{{
message.messageDate
}}</span>
</v-list-tile-sub-title>
</v-list-tile-content>
<v-list-tile-action class="hidden-xs-only">
<v-icon
:color="checkIfOwnMessage(message) ? 'accent' : 'grey'"
>chat_bubble</v-icon
>
</v-list-tile-action>
</v-list-tile>
</template>
</v-list>
</v-flex>
</v-layout>
</div>
</v-container>
</template>
<script>
import { mapGetters } from 'vuex'
import { getPost } from '~/gql/getPost.gql'
import { addPostMessage } from '~/gql/addPostMessage.gql'
import { likePost } from '~/gql/likePost.gql'
import { unlikePost } from '~/gql/unlikePost.gql'
import utils from '~/helpers/utils'
export default {
name: 'Post',
validate({ params }) {
return utils.isValidObjectID(params.id)
},
data() {
return {
postLiked: false,
postId: this.$route.params.id,
dialog: false,
messageBody: '',
isFormValid: true,
messageRules: [
message =>
!!message ||
this.$i18n.t('isRequired', { name: this.$i18n.tc('message', 1) }),
message =>
(message && message.length <= 75) ||
this.$i18n.t('cannotBeMoreThanCharacters', {
name: this.$i18n.tc('message', 1),
number: 75
})
]
}
},
apollo: {
getPost: {
query: getPost,
variables() {
return {
postId: this.postId
}
}
}
},
computed: {
...mapGetters(['user', 'userFavorites'])
},
methods: {
checkIfPostLiked(postId) {
// check if user favorites includes post with id of 'postId'
this.postLiked =
this.userFavorites &&
this.userFavorites.some(fave => fave._id === postId)
return this.postLiked
},
handleToggleLike() {
if (this.postLiked) {
this.handleUnlikePost()
} else {
this.handleLikePost()
}
},
handleLikePost() {
const variables = {
postId: this.postId,
username: this.user.username
}
this.$apollo
.mutate({
mutation: likePost,
variables,
update: (cache, { data: { likePost } }) => {
const data = cache.readQuery({
query: getPost,
variables: { postId: this.postId }
})
data.getPost.likes += 1
cache.writeQuery({
query: getPost,
variables: { postId: this.postId },
data
})
}
})
.then(({ data }) => {
const updatedUser = {
...this.user,
favorites: data.likePost.favorites
}
this.$store.commit('setUser', updatedUser)
})
.catch(err => {
if (process.env.NODE_ENV === 'development') console.error(err)
})
},
handleUnlikePost() {
const variables = {
postId: this.postId,
username: this.user.username
}
this.$apollo
.mutate({
mutation: unlikePost,
variables,
update: (cache, { data: { unlikePost } }) => {
const data = cache.readQuery({
query: getPost,
variables: { postId: this.postId }
})
data.getPost.likes -= 1
cache.writeQuery({
query: getPost,
variables: { postId: this.postId },
data
})
}
})
.then(({ data }) => {
const updatedUser = {
...this.user,
favorites: data.unlikePost.favorites
}
this.$store.commit('setUser', updatedUser)
})
.catch(err => {
if (process.env.NODE_ENV === 'development') console.error(err)
})
},
handleAddPostMessage() {
if (this.$refs.form.validate()) {
const variables = {
messageBody: this.messageBody,
userId: this.user._id,
postId: this.postId
}
this.$apollo
.mutate({
mutation: addPostMessage,
variables,
update: (cache, { data: { addPostMessage } }) => {
const data = cache.readQuery({
query: getPost,
variables: { postId: this.postId }
})
data.getPost.messages.unshift(addPostMessage)
cache.writeQuery({
query: getPost,
variables: { postId: this.postId },
data
})
}
})
.then(({ data }) => {
this.$refs.form.reset()
})
.catch(err => {
if (process.env.NODE_ENV === 'development') console.error(err)
})
}
},
goToPreviousPage() {
this.$router.go(-1)
},
toggleImageDialog() {
if (window.innerWidth > 500) {
this.dialog = !this.dialogs
}
},
checkIfOwnMessage(message) {
return this.user && this.user._id === message.messageUser._id
}
}
}
</script>
<style scoped>
#post__image {
height: 400px !important;
}
</style>
6.-We need to test if it works.






Section 15: Preparing for Deployment
I.-Modify the client app to make some changes to improve the app
1.-Install the @nuxtjs/moment package
- We need to install the @nuxtjs/moment library that will be use to forrmat and localize dates
Juan.Pablo.Perez@RIMDUB-0232 MINGW64 /c/Work/Training/Pre/VueJs/full-stack-vue-with-graphql-the-ultimate-guide-nuxt/client (master)
$ npm i @nuxtjs/moment
npm WARN ts-node@8.3.0 requires a peer of typescript@>=2.0 but none is installed. You must install peer dependencies yourself.
npm WARN optional SKIPPING OPTIONAL DEPENDENCY: fsevents@2.0.7 (node_modules\fsevents):
npm WARN notsup SKIPPING OPTIONAL DEPENDENCY: Unsupported platform for fsevents@2.0.7: wanted {"os":"darwin","arch":"any"} (current: {"os":"win32","arch":"x64"})
npm WARN optional SKIPPING OPTIONAL DEPENDENCY: fsevents@1.2.9 (node_modules\watchpack\node_modules\fsevents):
npm WARN notsup SKIPPING OPTIONAL DEPENDENCY: Unsupported platform for fsevents@1.2.9: wanted {"os":"darwin","arch":"any"} (current: {"os":"win32","arch":"x64"})
npm WARN optional SKIPPING OPTIONAL DEPENDENCY: fsevents@1.2.9 (node_modules\nodemon\node_modules\fsevents):
npm WARN notsup SKIPPING OPTIONAL DEPENDENCY: Unsupported platform for fsevents@1.2.9: wanted {"os":"darwin","arch":"any"} (current: {"os":"win32","arch":"x64"})
npm WARN optional SKIPPING OPTIONAL DEPENDENCY: fsevents@1.2.9 (node_modules\jest-haste-map\node_modules\fsevents):
npm WARN notsup SKIPPING OPTIONAL DEPENDENCY: Unsupported platform for fsevents@1.2.9: wanted {"os":"darwin","arch":"any"} (current: {"os":"win32","arch":"x64"})
+ @nuxtjs/moment@1.2.0
added 3 packages from 6 contributors and audited 897035 packages in 49.763s
found 0 vulnerabilities
- We need to create the
plugins\i18n-moment.jsplugin to usemomentwithnuxt-i18n
client\plugins\i18n-moment.js
export default function({ app }) {
app.i18n.beforeLanguageSwitch = (_oldLocale, newLocale) => {
app.$moment.locale(newLocale)
}
}
- We need to modify the
nuxt.config.jsconfig file to set up the@nuxtjs/momentpacjage
client\nuxt.config.js
import es from './lang/es-ES.js'
import en from './lang/en-US.js'
// import colors from 'vuetify/es5/util/colors'
export default {
mode: 'universal',
/*
** Headers of the page
*/
head: {
titleTemplate: '%s - ' + process.env.npm_package_name,
title: process.env.npm_package_name || '',
meta: [
{ charset: 'utf-8' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
{
hid: 'description',
name: 'description',
content: process.env.npm_package_description || ''
}
],
link: [
{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' },
{
rel: 'stylesheet',
href:
'https://fonts.googleapis.com/css?family=Roboto:300,400,500,700|Material+Icons'
}
]
},
/*
** Customize the progress-bar color
*/
loading: { color: '#fff' },
/*
** Global CSS
*/
css: [],
/*
** Plugins to load before mounting the App
*/
plugins: ['@plugins/shared-components.js', '@plugins/i18n-moment.js'],
/*
** Nuxt.js modules
*/
modules: [
[
'nuxt-i18n',
{
seo: false,
locales: [
{
name: 'ESP',
code: 'es',
iso: 'es-ES'
},
{
name: 'ENG',
code: 'en',
iso: 'en-US'
}
],
strategy: 'prefix_and_default',
langDir: 'lang/',
defaultLocale: 'es',
vueI18n: {
fallbackLocale: 'es',
messages: { es, en },
dateTimeFormats: {
es: {
short: {
year: 'numeric',
month: 'short',
day: 'numeric'
},
long: {
year: 'numeric',
month: 'short',
day: 'numeric',
weekday: 'short',
hour: 'numeric',
minute: 'numeric'
}
},
en: {
short: {
year: 'numeric',
month: 'short',
day: 'numeric'
},
long: {
year: 'numeric',
month: 'short',
day: 'numeric',
weekday: 'short',
hour: 'numeric',
minute: 'numeric'
}
}
}
}
}
],
['@nuxtjs/moment', { locales: ['es'], defaultLocale: 'es' }],
'@nuxtjs/vuetify',
'@nuxtjs/pwa',
'@nuxtjs/eslint-module',
'@nuxtjs/apollo',
`cookie-universal-nuxt`
],
apollo: {
authenticationType: '',
errorHandler: '~/apollo/customErrorHandler.js',
clientConfigs: {
default: {
httpEndpoint:
process.env.HTTP_ENDPOINT || 'http://localhost:4000/graphql'
}
}
},
/*
** vuetify module configuration
** https://github.com/nuxt-community/vuetify-module
*/
vuetify: {
theme: {
// primary: colors.blue.darken2,
// accent: colors.grey.darken3,
// secondary: colors.amber.darken3,
// info: colors.teal.lighten1,
// warning: colors.amber.base,
// error: colors.deepOrange.accent4,
// success: colors.green.accent3
primary: '#3B125F',
secondary: '#8B5FBF',
accent: '#BF653F',
error: '#722530',
warning: '#A37513',
info: '#396893',
success: '#4caf50'
}
},
/*
** Build configuration
*/
build: {
/*
** You can extend webpack config here
*/
extend(config, ctx) {}
}
}
2.-Modify the Store to include the infiniteScrollPosts state and to modify the updateUserPost and deleteUserPost to update the infiniteScrollPosts and posts states.
- Modify the
client\store\index.jsstore document to include theinfiniteScrollPostsstate and to modify theupdateUserPostanddeleteUserPostto update theinfiniteScrollPostsandpostsstates.
client\store\index.js
import { getPosts } from '~/gql/getPosts.gql'
import { infiniteScrollPosts } from '~/gql/infiniteScrollPosts.gql'
import { searchPosts } from '~/gql/searchPosts.gql'
import { getUserPosts } from '~/gql/getUserPosts.gql'
import { getCurrentUser } from '~/gql/getCurrentUser.gql'
import { signinUser } from '~/gql/signinUser.gql'
import { signupUser } from '~/gql/signupUser.gql'
import { addPost } from '~/gql/addPost.gql'
import { updateUserPost } from '~/gql/updateUserPost.gql'
import { deleteUserPost } from '~/gql/deleteUserPost.gql'
import utils from '~/helpers/utils'
export const state = () => ({
posts: [],
infiniteScrollPosts: {
posts: [],
hasMore: false
},
userPosts: [],
searchResults: [],
user: null,
loading: false,
error: null,
authError: null
})
export const mutations = {
setPosts: (state, payload) => {
state.posts = payload
},
setInfiniteScrollPosts: (state, payload) => {
const infiniteScrollPosts = state.infiniteScrollPosts.posts
state.infiniteScrollPosts = {
posts: [...infiniteScrollPosts, ...payload.posts],
hasMore: payload.hasMore
}
},
clearInfiniteScrollPosts: state => {
state.clearInfiniteScrollPosts = {
posts: [],
hasMore: false
}
},
setSearchResults: (state, payload) => {
if (payload !== null && payload.length > 0) {
state.searchResults = payload
}
},
addPost: (state, payload) => {
const posts = state.posts
posts.unshift(payload)
state.posts = posts
},
setUser: (state, payload) => {
state.user = payload
},
setUserPosts: (state, payload) => {
state.userPosts = payload
},
setLoading: (state, payload) => {
state.loading = payload
},
clearUser: state => (state.user = null),
setError: (state, payload) => {
state.error = payload
},
clearError: state => (state.error = null),
setAuthError: (state, payload) => {
state.authError = payload
},
clearAuthError: state => (state.authError = null),
clearSearchResults: state => (state.searchResults = [])
}
export const actions = {
async nuxtServerInit({ dispatch }) {
await dispatch('getCurrentUser')
await dispatch('getPosts')
},
async getPosts({ commit }) {
commit('clearError')
commit('setLoading', true)
try {
const result = await this.app.apolloProvider.defaultClient.query({
query: getPosts
})
commit('setPosts', result.data.getPosts)
} catch (error) {
const currentError = utils.getCurrentGraphQLError(error)
commit('setError', currentError)
if (process.env.NODE_ENV === 'development')
console.error(utils.getFirstGraphQLError(error))
}
commit('setLoading', false)
},
async getInfiniteScrollPosts({ commit }, payload) {
const pageSize = 2
commit('clearError')
commit('setLoading', true)
try {
const pageNum = payload || 1
const variables = {
pageNum,
pageSize
}
const result = await this.app.apolloProvider.defaultClient.query({
query: infiniteScrollPosts,
variables
})
commit('setInfiniteScrollPosts', result.data.infiniteScrollPosts)
} catch (error) {
const currentError = utils.getCurrentGraphQLError(error)
commit('setError', currentError)
if (process.env.NODE_ENV === 'development')
console.error(utils.getFirstGraphQLError(error))
}
commit('setLoading', false)
},
async getUserPosts({ commit }, payload) {
commit('clearError')
commit('setLoading', true)
try {
const result = await this.app.apolloProvider.defaultClient.query({
query: getUserPosts,
variables: payload
})
commit('setUserPosts', result.data.getUserPosts)
} catch (error) {
const currentError = utils.getCurrentGraphQLError(error)
commit('setError', currentError)
if (process.env.NODE_ENV === 'development')
console.error(utils.getFirstGraphQLError(error))
}
commit('setLoading', false)
},
async searchPosts({ commit }, payload) {
commit('clearError')
try {
const result = await this.app.apolloProvider.defaultClient.query({
query: searchPosts,
variables: payload
})
commit('setSearchResults', result.data.searchPosts)
} catch (error) {
const currentError = utils.getCurrentGraphQLError(error)
commit('setError', currentError)
if (process.env.NODE_ENV === 'development')
console.error(utils.getFirstGraphQLError(error))
}
},
async addPost({ commit, dispatch, state }, payload) {
commit('clearError')
commit('setLoading', true)
try {
await this.app.apolloProvider.defaultClient.mutate({
mutation: addPost,
variables: payload
})
const { _id, title, imageUrl } = payload
const newPost = {
_id,
title,
imageUrl
}
commit('addPost', newPost)
if (state.infiniteScrollPosts.posts.length > 0) {
commit('clearInfiniteSrollPosts')
await dispatch('getInfiniteScrollPosts')
}
} catch (error) {
const currentError = utils.getCurrentGraphQLError(error)
commit('setError', currentError)
if (process.env.NODE_ENV === 'development')
console.error(utils.getFirstGraphQLError(error))
}
commit('setLoading', false)
},
async updateUserPost({ state, commit, dispatch }, payload) {
commit('clearError')
commit('setLoading', true)
try {
const result = await this.app.apolloProvider.defaultClient.mutate({
mutation: updateUserPost,
variables: payload
})
const index = state.userPosts.findIndex(
post => post._id === result.data.updateUserPost._id
)
const userPosts = [
...state.userPosts.slice(0, index),
result.data.updateUserPost,
...state.userPosts.slice(index + 1)
]
commit('setUserPosts', userPosts)
await dispatch('getPosts')
if (state.infiniteScrollPosts.posts.length > 0) {
commit('clearInfiniteSrollPosts')
await dispatch('getInfiniteScrollPosts')
}
} catch (error) {
const currentError = utils.getCurrentGraphQLError(error)
commit('setError', currentError)
if (process.env.NODE_ENV === 'development')
console.error(utils.getFirstGraphQLError(error))
}
commit('setLoading', false)
},
async deleteUserPost({ state, commit, dispatch }, payload) {
commit('clearError')
commit('setLoading', true)
try {
const result = await this.app.apolloProvider.defaultClient.mutate({
mutation: deleteUserPost,
variables: payload
})
const index = state.userPosts.findIndex(
post => post._id === result.data.updateUserPost._id
)
const userPosts = [
...state.userPosts.slice(0, index),
...state.userPosts.slice(index + 1)
]
commit('setUserPosts', userPosts)
await dispatch('getPosts')
if (state.infiniteScrollPosts.posts.length > 0) {
commit('clearInfiniteSrollPosts')
await dispatch('getInfiniteScrollPosts')
}
} catch (error) {
const currentError = utils.getCurrentGraphQLError(error)
commit('setError', currentError)
if (process.env.NODE_ENV === 'development')
console.error(utils.getFirstGraphQLError(error))
}
commit('setLoading', false)
},
async logOut({ commit }) {
this.app.$cookies.remove('apollo-token')
await this.app.$apolloHelpers.onLogout()
commit('clearUser')
},
async getCurrentUser({ commit, dispatch }) {
const token = this.app.$apolloHelpers.getToken()
if (!token) {
return
}
if (!utils.isJwtTokenValid(token)) {
commit('setAuthError', this.app.i18n.t('sessionExpiredSignInAgain'))
await dispatch('logOut')
return
}
commit('setLoading', true)
try {
const result = await this.app.apolloProvider.defaultClient.query({
query: getCurrentUser
})
// Add user data to state
commit('setUser', result.data.getCurrentUser)
commit('clearError')
commit('clearAuthError')
} catch (error) {
const currentError = utils.getCurrentGraphQLError(error)
if (currentError && currentError.error === 'Unauthorized') {
const sessionExpiredSignInAgain = this.app.i18n.t(
'sessionExpiredSignInAgain'
)
commit('setAuthError', sessionExpiredSignInAgain)
await dispatch('logOut')
} else {
commit('setError', currentError)
}
if (process.env.NODE_ENV === 'development')
console.error(utils.getFirstGraphQLError(error))
}
commit('setLoading', false)
},
async signinUser({ commit, dispatch }, payload) {
commit('clearError')
commit('setLoading', true)
try {
const result = await this.app.apolloProvider.defaultClient.mutate({
mutation: signinUser,
variables: payload
})
await this.app.$apolloHelpers.onLogin(result.data.signinUser.token)
await dispatch('getCurrentUser')
} catch (error) {
const currentError = utils.getCurrentGraphQLError(error)
commit('setError', currentError)
if (process.env.NODE_ENV === 'development')
console.error(utils.getFirstGraphQLError(error))
}
commit('setLoading', false)
},
async signupUser({ commit, dispatch }, payload) {
commit('clearError')
commit('setLoading', true)
try {
const result = await this.app.apolloProvider.defaultClient.mutate({
mutation: signupUser,
variables: payload
})
await this.app.$apolloHelpers.onLogin(result.data.signupUser.token)
await dispatch('getCurrentUser')
} catch (error) {
const currentError = utils.getCurrentGraphQLError(error)
commit('setError', currentError)
if (process.env.NODE_ENV === 'development')
console.error(utils.getFirstGraphQLError(error))
}
commit('setLoading', false)
},
async signoutUser({ dispatch }) {
await dispatch('logOut')
// redirect home - kick users out of private pages (i.e. profile)
this.app.router.push(this.app.localePath('index'))
}
}
export const getters = {
posts: state => state.posts,
infiniteScrollPosts: state => state.infiniteScrollPosts,
userPosts: state => state.userPosts,
searchResults: state => state.searchResults,
user: state => state.user,
userFavorites: state => state.user && state.user.favorites,
loading: state => state.loading,
error: state => state.error,
authError: state => state.authError
}
3.-Modify the Posts page to use the infiniteScrollPosts store state and manage the showMoreEnabled property properly.
- We need to modify the
posts\index.vuePosts page document to use theinfiniteScrollPostsstore state and manage theshowMoreEnabledproperty properly. Also the dates are formated and localized.
client\pages\posts\index.vue
<template>
<v-container fluid grid-list-xl>
<!-- Post Cards -->
<v-layout v-if="infiniteScrollPosts" row wrap>
<v-flex
v-for="post in infiniteScrollPosts.posts"
:key="post._id"
xs12
sm6
>
<v-card hover>
<v-img
:src="post.imageUrl"
height="30vh"
lazy
@click.native="goToPost(post._id)"
></v-img>
<v-card-actions>
<v-card-title primary>
<div>
<div class="headline">{{ post.title }}</div>
<span class="grey--text"
>{{ post.likes }} {{ $t('likes') }} -
{{ post.messages ? post.messages.length : 0 }}
{{ $t('comments') }}</span
>
</div>
</v-card-title>
<v-spacer></v-spacer>
<v-btn icon @click="showPostCreator = !showPostCreator">
<v-icon>{{
`keyboard_arrow_${showPostCreator ? 'up' : 'down'}`
}}</v-icon>
</v-btn>
</v-card-actions>
<!-- Post Creator Tile -->
<v-slide-y-transition>
<v-card-text v-show="showPostCreator" class="grey lighten-4">
<v-list-tile avatar>
<v-list-tile-avatar>
<img :src="post.createdBy.avatar" />
</v-list-tile-avatar>
<v-list-tile-content>
<v-list-tile-title class="text--primary">{{
post.createdBy.username
}}</v-list-tile-title>
<v-list-tile-sub-title class="font-weight-thin"
>{{ $t('added') }}
{{ $d(new Date(post.createdDate), 'short') }}
</v-list-tile-sub-title>
</v-list-tile-content>
<v-list-tile-action>
<v-btn icon ripple>
<v-icon color="grey lighten-1">info</v-icon>
</v-btn>
</v-list-tile-action>
</v-list-tile>
</v-card-text>
</v-slide-y-transition>
</v-card>
</v-flex>
</v-layout>
<!-- Fetch More Button -->
<v-layout v-if="showMoreEnabled" column>
<v-flex xs12>
<v-layout justify-center row>
<v-btn color="info" @click="showMorePosts">{{
$t('fetchMore')
}}</v-btn>
</v-layout>
</v-flex>
</v-layout>
</v-container>
</template>
<script>
import { mapGetters } from 'vuex'
export default {
name: 'Posts',
data() {
return {
pageNum: 1,
showPostCreator: false
}
},
computed: {
...mapGetters(['infiniteScrollPosts']),
showMoreEnabled() {
return this.infiniteScrollPosts && this.infiniteScrollPosts.hasMore
}
},
async asyncData({ store }) {
if (store.state.infiniteScrollPosts.posts.length === 0) {
await store.dispatch('getInfiniteScrollPosts')
}
},
// async mounted() {
// await this.$store.dispatch('getInfiniteScrollPosts')
// },
methods: {
showMorePosts() {
this.pageNum += 1
this.$store.dispatch('getInfiniteScrollPosts', this.pageNum)
},
goToPost(postId) {
this.$router.push(`${this.localePath('posts')}/${postId}`)
}
}
}
</script>
4.-Modify the Home page document page document to add the Explore Posts Button
- We need to modify the
lang\en-US.jslocalization document to add new entries for the page.
client\lang\en-US.js
export default {
posts: 'Posts',
signin: 'Sign in',
signup: 'Sign up',
searchposts: 'Search Posts',
home: 'Home',
createPost: 'Create Post',
profile: 'Profile',
signout: 'Signout',
username: 'Username',
password: 'Password',
confirmPassword: 'Confirm Password',
email: 'Email',
dontHaveAnAccount: "Don't have an account?",
alreadyHaveAnAccount: 'Already have an account?',
welcomeBack: 'Welcome Back!',
getStartedHere: 'Get Started Here',
isRequired: '{name} is required',
mustBeValid: '{name} must be valid',
cannotBeMoreThanCharacters: '{name} cannot be more than {number} characters',
mustBeAtLeast: '{name} must be at least {number} characters',
passwordsMustMatch: 'Passwords must match',
yourAreNowSignedIn: 'You are now signed in!',
close: 'Close',
sessionExpiredSignInAgain: 'Your session has ended. Please sign in again.',
addPost: 'Add Post',
postTitle: 'Post Title',
imageUrl: 'Image URL',
category: 'Category | Categories',
categoryItems: {
art: 'Art',
education: 'Education',
food: 'Food',
furniture: 'Furniture',
travel: 'Travel',
photography: 'Photography',
technology: 'Technology'
},
description: 'Description',
submit: 'Submit',
atLeastOne: 'At least one',
likes: 'likes',
comments: 'comments',
added: 'Added',
fetchMore: 'Fetch More',
message: 'Message | Messages',
clickToEnlargeImage: 'Click to enlarge image',
addMessage: 'Add Message',
pageNotFound: '404 Not Found',
otherError: 'An error occurred',
homePage: 'Home Page',
sureDeleteThisPost: 'Are you sure you want to delete this post?',
favorites: 'Favorites',
postsAdded: 'Posts Added',
joined: 'Joined',
noFavoritesCurrently: 'You have no favorites currently.',
noPostCurrently: 'You have no posts currently.',
goAndAddSome: 'Go and add some!',
favorited: `Favorited`,
yourPosts: 'Your Posts',
updatePost: 'Update Post',
update: 'Update',
cancel: 'Cancel',
explorePosts: 'Explore Posts'
}
- We need to modify the
lang\es-ES.jslocalization document to add new entries for the page.
client\lang\es-ES.js
export default {
posts: 'Entradas',
signin: 'Iniciar sesión',
signup: 'Inscripción',
searchposts: 'Buscar entradas',
home: 'Inicio',
createPost: 'Crear Entrada',
profile: 'Perfil',
signout: 'Cerrar sesión',
username: 'Usuario',
password: 'Contraseña',
confirmPassword: 'Confirmar Contraseña',
email: 'Email',
dontHaveAnAccount: '¿No tiene una cuenta?',
alreadyHaveAnAccount: '¿Ya tiene una cuenta?',
welcomeBack: '¡Bienvenido de nuevo!',
getStartedHere: 'Comience aquí',
isRequired: '{name} es requerido',
mustBeValid: '{name} debe de ser válido',
cannotBeMoreThanCharacters:
'{name} no puede contener más de {number} caracteres',
mustBeAtLeast: '{name} debe contener al menos {number} caracteres',
passwordsMustMatch: 'Las Contraseñas deben coincidir',
yourAreNowSignedIn: '¡Ahora está registrado!',
close: 'Cerrar',
sessionExpiredSignInAgain:
'Su sesión ha caducado. Por favor, inicie la sesión otra vez.',
addPost: 'Añadir Entrada',
postTitle: 'Título de la entrada',
imageUrl: 'URL de la imagen',
category: 'Categoría | Categorías',
categoryItems: {
art: 'Arte',
education: 'Educación',
food: 'Comida',
furniture: 'Muebles',
travel: 'Viajes',
photography: 'Fotografía',
technology: 'Tecnología'
},
description: 'Descripción',
submit: 'Enviar',
atLeastOne: 'Por lo menos una',
likes: 'me gusta',
comments: 'comentarios',
added: 'Creado',
fetchMore: 'Obtener más',
message: 'Mensaje | Mensajes',
clickToEnlargeImage: 'Haga clic para ampliar la imagen',
addMessage: 'Añadir Mensaje',
pageNotFound: '404 No encontrado',
otherError: 'Oucrrió un error',
homePage: 'Página inicial',
sureDeleteThisPost: '¿Está seguro de que desea eliminar esta entrada?',
favorites: 'Favoritos',
postsAdded: 'Entradas Añadidas',
joined: 'Inscrito',
noFavoritesCurrentlyAddSome: 'No tiene favoritos actualmente.',
noPostCurrently: 'No tiene entradas actualmente.',
goAndAddSome: '¡Acceda y agregue alguno!',
favorited: `Favoritos`,
yourPosts: 'Sus Entradas',
updatePost: 'Actualizar Entrada',
update: 'Actualizar',
cancel: 'Cancelar',
explorePosts: 'Explorar Entradas'
}
- We are going to modify the
pages\index.vueHome page document page document to add theExplore PostsButton
client\pages\index.vue
<template>
<!-- Loading Spinner -->
<v-container text-xs-center>
<v-layout row>
<v-dialog v-model="loading" persistent fullscreen>
<v-container fill-height>
<v-layout row justify-center align-center>
<v-progress-circular
indeterminate
:size="70"
:width="7"
color="secondary"
></v-progress-circular>
</v-layout>
</v-container>
</v-dialog>
</v-layout>
<!-- Explore Posts Button -->
<v-layout v-if="!loading" class="mt-2 mb-3" row wrap>
<v-flex xs-12>
<v-btn class="secondary" :to="localePath('posts')" large dark>
{{ $t('explorePosts') }}
</v-btn>
</v-flex>
</v-layout>
<!-- Posts Carrousel -->
<v-flex xs12>
<v-carousel
v-if="!loading && posts.length > 0"
v-bind="{ cycle: true }"
interval="3000"
>
<v-carousel-item
v-for="post in posts"
:key="post._id"
:src="post.imageUrl"
@click.native="goToPost(post._id)"
>
<h1 id="carousel__title">{{ post.title }}</h1>
</v-carousel-item>
</v-carousel>
</v-flex>
</v-container>
</template>
<script>
import { mapGetters } from 'vuex'
export default {
name: 'Home',
computed: {
...mapGetters(['loading', 'posts'])
},
methods: {
goToPost(postId) {
this.$router.push(`${this.localePath('posts')}/${postId}`)
}
}
}
</script>
<style>
#carousel__title {
position: absolute;
cursor: pointer;
background-color: rgba(0, 0, 0, 0.5);
color: white;
border-radius: 5px 5px 0 0;
padding: 0.5em;
margin: 0 auto;
bottom: 50px;
left: 0;
right: 0;
}
</style>
5.-Modify the Default layout document to change the h1 and h2 CSS segments.
- We are going to modify the
layouts\default.vuedocument to change theh1andh2CSS segments.
client\layouts\default.vue
<template>
<v-app style="background: #E3E3EE">
<!-- Side Navbar -->
<v-navigation-drawer v-model="sideNav" fixed app temporary>
<v-toolbar color="accent" dark flat>
<v-toolbar-side-icon @click="toggleSideNav"></v-toolbar-side-icon>
<nuxt-link :to="localePath('index')" tag="span" style="cursor: pointer">
<h1 class="title pl-3">VueShare</h1>
</nuxt-link>
</v-toolbar>
<v-divider></v-divider>
<!-- Side Navbar Links -->
<v-list>
<v-list-tile
v-for="(item, i) in sideNavItems"
:key="i"
:to="localePath(item.link)"
ripple
>
<v-list-tile-action>
<v-icon>{{ item.icon }}</v-icon>
</v-list-tile-action>
<v-list-tile-content>
{{ item.title }}
</v-list-tile-content>
</v-list-tile>
<!-- Signout Button -->
<v-list-tile v-if="user" @click="handleSignoutUser">
<v-list-tile-action>
<v-icon>exit_to_app</v-icon>
</v-list-tile-action>
<v-list-tile-content>{{ $t('signout') }}</v-list-tile-content>
</v-list-tile>
</v-list>
</v-navigation-drawer>
<!-- Horizontal Navbar -->
<v-toolbar fixed color="primary" dark>
<!-- App Title -->
<v-toolbar-side-icon @click="toggleSideNav"></v-toolbar-side-icon>
<v-toolbar-title class="hidden-xs-only">
<nuxt-link :to="localePath('index')" tag="span" style="cursor: pointer">
VueShare
</nuxt-link>
</v-toolbar-title>
<v-spacer></v-spacer>
<!-- Search Input -->
<v-text-field
v-model="searchTerm"
flex
prepend-icon="search"
color="accent"
single-line
hide-details
:placeholder="$t('searchposts')"
@input="handleSearchPosts"
></v-text-field>
<!-- Search Results Card -->
<v-card v-if="searchResults.length" id="search__card" dark>
<v-list>
<v-list-tile
v-for="result in searchResults"
:key="result._id"
@click="goToSearchResult(result._id)"
>
<v-list-tile-title>
{{ result.title }} -
<span class="font-weight-thin">{{
formatDescription(result.description)
}}</span>
</v-list-tile-title>
<!-- Show Icon if Result Favorited by User -->
<v-list-tile-action v-if="checkIfUserFavorite(result._id)">
<v-icon>favorite</v-icon>
</v-list-tile-action>
</v-list-tile>
</v-list>
</v-card>
<v-spacer></v-spacer>
<!-- Horizontal Navbar Links -->
<v-toolbar-items class="hidden-xs-only">
<v-btn
v-for="(item, i) in horizontalNavItems"
:key="i"
:to="localePath(item.link)"
flat
>
<v-icon class="hidden-sm-only" left>{{ item.icon }}</v-icon>
{{ item.title }}
</v-btn>
</v-toolbar-items>
<!-- Profile Button -->
<v-btn v-if="user" flat :to="localePath('profile')">
<v-icon class="hidden-sm-only" left>account_box</v-icon>
<v-badge right color="blue darken-2" :class="{ bounce: badgeAnimated }">
<span v-if="userFavorites.length" slot="badge">{{
userFavorites.length
}}</span>
{{ $t('profile') }}
</v-badge>
</v-btn>
<!-- Signout Button -->
<v-btn v-if="user" flat @click="handleSignoutUser">
<v-icon class="hidden-sm-only" left>exit_to_app</v-icon>
{{ $t('signout') }}
</v-btn>
<v-toolbar-title class="hidden-xs-only">
<nuxt-link
v-for="(locale, i) in showLocales"
:key="i"
tag="span"
style="cursor: pointer"
class="lang-switcher"
:to="switchLocalePath(locale.code)"
>
{{ locale.name }}
</nuxt-link>
</v-toolbar-title>
</v-toolbar>
<!-- App Content -->
<main>
<v-container class="mt-4">
<transition name="fade">
<nuxt />
</transition>
<!-- Auth Snackbar -->
<v-snackbar
v-model="authSnackbar"
color="success"
:timeout="5000"
bottom
left
>
<v-icon class="mr-3">check_circle</v-icon>
<h3>{{ $t('yourAreNowSignedIn') }}</h3>
<v-btn dark flat @click="authSnackbar = false">{{
$t('close')
}}</v-btn>
</v-snackbar>
<!-- Auth Error Snackbar -->
<v-snackbar
v-if="authError"
v-model="authErrorSnackbar"
color="info"
:timeout="5000"
bottom
left
>
<v-icon class="mr-3">cancel</v-icon>
<h3>{{ authError }}</h3>
<v-btn dark flat :to="localePath('signin')">{{ $t('signin') }}</v-btn>
</v-snackbar>
</v-container>
</main>
</v-app>
</template>
<script>
import { mapGetters } from 'vuex'
export default {
head() {
return this.$nuxtI18nSeo()
},
data() {
return {
searchTerm: '',
sideNav: false,
authSnackbar: false,
authErrorSnackbar: false,
badgeAnimated: false
}
},
computed: {
...mapGetters(['searchResults', 'authError', 'user', 'userFavorites']),
horizontalNavItems() {
let items = [
{ icon: 'chat', title: this.$i18n.t('posts'), link: 'posts' },
{ icon: 'lock_open', title: this.$i18n.t('signin'), link: 'signin' },
{ icon: 'create', title: this.$i18n.t('signup'), link: 'signup' }
]
if (this.user) {
items = [{ icon: 'chat', title: this.$i18n.t('posts'), link: 'posts' }]
}
return items
},
sideNavItems() {
let items = [
{ icon: 'chat', title: this.$i18n.t('posts'), link: 'posts' },
{ icon: 'lock_open', title: this.$i18n.t('signin'), link: 'signin' },
{ icon: 'create', title: this.$i18n.t('signup'), link: 'signup' }
]
if (this.user) {
items = [
{ icon: 'chat', title: this.$i18n.t('posts'), link: 'posts' },
{
icon: 'stars',
title: this.$i18n.t('createPost'),
link: 'posts-add'
},
{
icon: 'account_box',
title: this.$i18n.t('profile'),
link: 'profile'
}
]
}
return items
},
showLocales() {
return this.$i18n.locales.filter(
locale => locale.code !== this.$i18n.locale
)
}
},
watch: {
user(newValue, oldValue) {
// if we had no value for user before, show snackbar
if (oldValue === null) {
this.authSnackbar = true
}
},
authError(value) {
// if auth error is not null, show auth error snackbar
if (value !== null) {
this.authErrorSnackbar = true
}
},
userFavorites(value) {
// if user favorites value changed at all
if (value) {
this.badgeAnimated = true
setTimeout(() => (this.badgeAnimated = false), 1000)
}
}
},
mounted() {
if (this.user) {
this.authSnackbar = true
}
if (this.authError !== null) {
this.authErrorSnackbar = true
}
},
methods: {
handleSearchPosts() {
this.$store.dispatch('searchPosts', {
searchTerm: this.searchTerm
})
},
goToSearchResult(resultId) {
// Clear search term
this.searchTerm = ''
// Go to desired result
this.$router.push(`${this.localePath('posts')}/${resultId}`)
// Clear search results
this.$store.commit('clearSearchResults')
},
formatDescription(desc) {
return desc.length > 30 ? `${desc.slice(0, 30)}...` : desc
},
checkIfUserFavorite(resultId) {
return (
this.userFavorites &&
this.userFavorites.some(fave => fave._id === resultId)
)
},
handleSignoutUser() {
this.$store.dispatch('signoutUser')
},
toggleSideNav() {
this.sideNav = !this.sideNav
}
}
}
</script>
<style>
h1 {
font-weight: 400;
font-size: 2.5rem;
}
h2 {
font-weight: 400;
font-size: 2rem;
}
.fade-enter-active,
.fade-leave-active {
transition-property: opacity;
transition-duration: 0.25s;
}
.fade-enter-active {
transition-delay: 0.25s;
}
.fade-enter,
.fade-leave-active {
opacity: 0;
}
/* Search Results Card */
#search__card {
position: absolute;
width: 100vw;
z-index: 8;
top: 100%;
left: 0%;
}
/* User Favorite Animation */
.bounce {
animation: bounce 1s both;
}
@keyframes bounce {
0%,
20%,
53%,
80%,
100% {
transform: translate3d(0, 0, 0);
}
40%,
43% {
transform: translate3d(0, -20px, 0);
}
70% {
transform: translate3d(0, -10px, 0);
}
90% {
transform: translate3d(0, -4px, 0);
}
}
</style>
6.-Modify the Profile page to allow to go to the Posts from the Post Images.
- We need to modify the
profile\index.vueProfile page document to allow to go to the Posts from the Post Images. Also themomentlibrary is used to format the dates.
client\pages\profile\index.vue
<template>
<v-container class="text-xs-center">
<!-- User Details Card -->
<v-flex sm6 offset-sm3>
<v-card class="white--text" color="secondary">
<v-layout>
<v-flex xs5>
<v-img height="125px" contain :src="user.avatar"></v-img>
</v-flex>
<v-flex xs7>
<v-card-title primary-title>
<div>
<div class="headline">{{ user.username }}</div>
<div>
{{ $t('joined') }} {{ $d(new Date(user.joinDate), 'short') }}
</div>
<div class="hidden-xs-only font-weight-thin">
{{ user.favorites.length }} {{ $t('favorites') }}
</div>
<div class="hidden-xs-only font-weight-thin">
{{ userPosts.length }} {{ $t('postsAdded') }}
</div>
</div>
</v-card-title>
</v-flex>
</v-layout>
</v-card>
</v-flex>
<!-- Posts Favorited by User -->
<v-container v-if="!userFavorites.length">
<v-layout row wrap>
<v-flex xs12>
<h2>{{ $t('noFavoritesCurrently') }} {{ $t('goAndAddSome') }}</h2>
</v-flex>
</v-layout>
</v-container>
<v-container v-else class="mt-3">
<v-flex xs12>
<h2 class="font-weight-light">
{{ $t('favorited') }}
<span class="font-weight-regular">({{ userFavorites.length }})</span>
</h2>
</v-flex>
<v-layout row wrap>
<v-flex v-for="favorite in userFavorites" :key="favorite._id" sm6 xs12>
<v-card class="mt-3 ml-1 mr-2" hover>
<v-img
height="30vh"
:src="favorite.imageUrl"
@click="goToPost(favorite._id)"
></v-img>
<v-card-text>{{ favorite.title }}</v-card-text>
</v-card>
</v-flex>
</v-layout>
</v-container>
<!-- Posts Created By user -->
<v-container v-if="!userPosts.length">
<v-layout row wrap>
<v-flex xs12>
<h2>{{ $t('noPostCurrently') }} {{ $t('goAndAddSome') }}</h2>
</v-flex>
</v-layout>
</v-container>
<v-container v-else class="mt-3">
<v-flex xs12>
<h2 class="font-weight-light">
{{ $t('yourPosts') }}
<span class="font-weight-regular">({{ userPosts.length }})</span>
</h2>
</v-flex>
<v-layout row wrap>
<v-flex v-for="post in userPosts" :key="post._id" sm6 xs12>
<v-card class="mt-3 ml-1 mr-2" hover>
<v-btn color="info" floating fab small dark @click="loadPost(post)">
<v-icon>edit</v-icon>
</v-btn>
<v-btn
color="error"
floating
fab
small
dark
@click="handleDeleteUserPost(post)"
>
<v-icon>delete</v-icon>
</v-btn>
<v-img
height="30vh"
:src="post.imageUrl"
@click="goToPost(post._id)"
></v-img>
<v-card-text>{{ post.title }}</v-card-text>
</v-card>
</v-flex>
</v-layout>
</v-container>
<!-- Edit Post Dialog -->
<v-dialog v-model="editPostDialog" xs12 sm6 offset-sm3 persistent>
<v-card>
<v-card-title class="headline grey lighten-2">{{
$t('updatePost')
}}</v-card-title>
<v-container>
<v-form
ref="form"
v-model="isFormValid"
lazy-validation
@submit.prevent="handleUpdateUserPost"
>
<!-- Title Input -->
<v-layout row>
<v-flex xs12>
<v-text-field
v-model="title"
:rules="titleRules"
:label="$t('postTitle')"
type="text"
required
></v-text-field>
</v-flex>
</v-layout>
<!-- Image Url Input -->
<v-layout row>
<v-flex xs12>
<v-text-field
v-model="imageUrl"
:rules="imageRules"
:label="$t('imageUrl')"
type="text"
required
></v-text-field>
</v-flex>
</v-layout>
<!-- Image Preview -->
<v-layout row>
<v-flex xs12>
<img :src="imageUrl" height="300px" />
</v-flex>
</v-layout>
<!-- Categories Select -->
<v-layout row>
<v-flex xs12>
<v-select
v-model="categories"
:rules="categoriesRules"
item-text="text"
item-value="value"
:items="categoryItems"
multiple
:label="$tc('category', 2)"
></v-select>
</v-flex>
</v-layout>
<!-- Description Text Area -->
<v-layout row>
<v-flex xs12>
<v-textarea
v-model="description"
:rules="descRules"
:label="$t('description')"
type="text"
required
></v-textarea>
</v-flex>
</v-layout>
<v-divider></v-divider>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn
:disabled="!isFormValid"
type="submit"
class="success--text"
flat
>{{ $t('update') }}</v-btn
>
<v-btn class="error--text" flat @click="editPostDialog = false">{{
$t('cancel')
}}</v-btn>
</v-card-actions>
</v-form>
</v-container>
</v-card>
</v-dialog>
</v-container>
</template>
<script>
import { mapGetters } from 'vuex'
export default {
name: 'Profile',
middleware: 'auth',
data() {
return {
editPostDialog: false,
isFormValid: true,
title: '',
imageUrl: '',
categories: [],
categoryItems: [
{ value: 'Art', text: this.$i18n.t('categoryItems.art') },
{ value: 'Education', text: this.$i18n.t('categoryItems.education') },
{ value: 'Food', text: this.$i18n.t('categoryItems.food') },
{ value: 'Furniture', text: this.$i18n.t('categoryItems.furniture') },
{ value: 'Travel', text: this.$i18n.t('categoryItems.travel') },
{
value: 'Photography',
text: this.$i18n.t('categoryItems.photography')
},
{
value: 'Technology',
text: this.$i18n.t('categoryItems.technology')
}
],
description: '',
titleRules: [
title =>
!!title ||
this.$i18n.t('isRequired', { name: this.$i18n.t('postTitle') }),
title =>
title.length <= 20 ||
this.$i18n.t('cannotBeMoreThanCharacters', {
name: this.$i18n.t('postTitle'),
number: 20
})
],
imageRules: [
image =>
!!image ||
this.$i18n.t('isRequired', { name: this.$i18n.t('imageUrl') })
],
categoriesRules: [
categories =>
categories.length >= 1 ||
this.$i18n.t('isRequired', {
name: `${this.$i18n.t('atLeastOne')} ${this.$i18n.tc(
'category',
1
)}`
})
],
descRules: [
desc =>
!!desc ||
this.$i18n.t('isRequired', { name: this.$i18n.t('description') }),
desc =>
desc.length <= 200 ||
this.$i18n.t('cannotBeMoreThanCharacters', {
name: this.$i18n.t('description'),
number: 200
})
]
}
},
computed: {
...mapGetters(['user', 'userFavorites', 'userPosts'])
},
created() {
this.handleGetUserPosts()
},
methods: {
goToPost(id) {
this.$router.push(`${this.localePath('posts')}/${id}`)
},
handleGetUserPosts() {
this.$store.dispatch('getUserPosts', {
userId: this.user._id
})
},
handleUpdateUserPost() {
if (this.$refs.form.validate()) {
this.$store.dispatch('updateUserPost', {
postId: this.postId,
userId: this.user._id,
title: this.title,
imageUrl: this.imageUrl,
categories: this.categories,
description: this.description
})
this.editPostDialog = false
}
},
handleDeleteUserPost(post) {
this.loadPost(post, false)
const deletePost = window.confirm(this.$i18n.t('sureDeleteThisPost'))
if (deletePost) {
this.$store.dispatch('deleteUserPost', {
postId: this.postId
})
}
},
loadPost(
{ _id, title, imageUrl, categories, description },
editPostDialog = true
) {
this.editPostDialog = editPostDialog
this.postId = _id
this.title = title
this.imageUrl = imageUrl
this.categories = categories
this.description = description
}
}
}
</script>
7.-Modify the Post page to format and lozalize the dates.
- We need to modify the
posts\_id.vuePost page document to format and lozalize the dates.
client\pages\posts_id.vue
<template>
<v-container class="text-xs-center">
<!-- User Details Card -->
<v-flex sm6 offset-sm3>
<v-card class="white--text" color="secondary">
<v-layout>
<v-flex xs5>
<v-img height="125px" contain :src="user.avatar"></v-img>
</v-flex>
<v-flex xs7>
<v-card-title primary-title>
<div>
<div class="headline">{{ user.username }}</div>
<div>
{{ $t('joined') }} {{ $d(new Date(user.joinDate), 'short') }}
</div>
<div class="hidden-xs-only font-weight-thin">
{{ user.favorites.length }} {{ $t('favorites') }}
</div>
<div class="hidden-xs-only font-weight-thin">
{{ userPosts.length }} {{ $t('postsAdded') }}
</div>
</div>
</v-card-title>
</v-flex>
</v-layout>
</v-card>
</v-flex>
<!-- Posts Favorited by User -->
<v-container v-if="!userFavorites.length">
<v-layout row wrap>
<v-flex xs12>
<h2>{{ $t('noFavoritesCurrently') }} {{ $t('goAndAddSome') }}</h2>
</v-flex>
</v-layout>
</v-container>
<v-container v-else class="mt-3">
<v-flex xs12>
<h2 class="font-weight-light">
{{ $t('favorited') }}
<span class="font-weight-regular">({{ userFavorites.length }})</span>
</h2>
</v-flex>
<v-layout row wrap>
<v-flex v-for="favorite in userFavorites" :key="favorite._id" sm6 xs12>
<v-card class="mt-3 ml-1 mr-2" hover>
<v-img
height="30vh"
:src="favorite.imageUrl"
@click="goToPost(favorite._id)"
></v-img>
<v-card-text>{{ favorite.title }}</v-card-text>
</v-card>
</v-flex>
</v-layout>
</v-container>
<!-- Posts Created By user -->
<v-container v-if="!userPosts.length">
<v-layout row wrap>
<v-flex xs12>
<h2>{{ $t('noPostCurrently') }} {{ $t('goAndAddSome') }}</h2>
</v-flex>
</v-layout>
</v-container>
<v-container v-else class="mt-3">
<v-flex xs12>
<h2 class="font-weight-light">
{{ $t('yourPosts') }}
<span class="font-weight-regular">({{ userPosts.length }})</span>
</h2>
</v-flex>
<v-layout row wrap>
<v-flex v-for="post in userPosts" :key="post._id" sm6 xs12>
<v-card class="mt-3 ml-1 mr-2" hover>
<v-btn color="info" floating fab small dark @click="loadPost(post)">
<v-icon>edit</v-icon>
</v-btn>
<v-btn
color="error"
floating
fab
small
dark
@click="handleDeleteUserPost(post)"
>
<v-icon>delete</v-icon>
</v-btn>
<v-img
height="30vh"
:src="post.imageUrl"
@click="goToPost(post._id)"
></v-img>
<v-card-text>{{ post.title }}</v-card-text>
</v-card>
</v-flex>
</v-layout>
</v-container>
<!-- Edit Post Dialog -->
<v-dialog v-model="editPostDialog" xs12 sm6 offset-sm3 persistent>
<v-card>
<v-card-title class="headline grey lighten-2">{{
$t('updatePost')
}}</v-card-title>
<v-container>
<v-form
ref="form"
v-model="isFormValid"
lazy-validation
@submit.prevent="handleUpdateUserPost"
>
<!-- Title Input -->
<v-layout row>
<v-flex xs12>
<v-text-field
v-model="title"
:rules="titleRules"
:label="$t('postTitle')"
type="text"
required
></v-text-field>
</v-flex>
</v-layout>
<!-- Image Url Input -->
<v-layout row>
<v-flex xs12>
<v-text-field
v-model="imageUrl"
:rules="imageRules"
:label="$t('imageUrl')"
type="text"
required
></v-text-field>
</v-flex>
</v-layout>
<!-- Image Preview -->
<v-layout row>
<v-flex xs12>
<img :src="imageUrl" height="300px" />
</v-flex>
</v-layout>
<!-- Categories Select -->
<v-layout row>
<v-flex xs12>
<v-select
v-model="categories"
:rules="categoriesRules"
item-text="text"
item-value="value"
:items="categoryItems"
multiple
:label="$tc('category', 2)"
></v-select>
</v-flex>
</v-layout>
<!-- Description Text Area -->
<v-layout row>
<v-flex xs12>
<v-textarea
v-model="description"
:rules="descRules"
:label="$t('description')"
type="text"
required
></v-textarea>
</v-flex>
</v-layout>
<v-divider></v-divider>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn
:disabled="!isFormValid"
type="submit"
class="success--text"
flat
>{{ $t('update') }}</v-btn
>
<v-btn class="error--text" flat @click="editPostDialog = false">{{
$t('cancel')
}}</v-btn>
</v-card-actions>
</v-form>
</v-container>
</v-card>
</v-dialog>
</v-container>
</template>
<script>
import { mapGetters } from 'vuex'
export default {
name: 'Profile',
middleware: 'auth',
data() {
return {
editPostDialog: false,
isFormValid: true,
title: '',
imageUrl: '',
categories: [],
categoryItems: [
{ value: 'Art', text: this.$i18n.t('categoryItems.art') },
{ value: 'Education', text: this.$i18n.t('categoryItems.education') },
{ value: 'Food', text: this.$i18n.t('categoryItems.food') },
{ value: 'Furniture', text: this.$i18n.t('categoryItems.furniture') },
{ value: 'Travel', text: this.$i18n.t('categoryItems.travel') },
{
value: 'Photography',
text: this.$i18n.t('categoryItems.photography')
},
{
value: 'Technology',
text: this.$i18n.t('categoryItems.technology')
}
],
description: '',
titleRules: [
title =>
!!title ||
this.$i18n.t('isRequired', { name: this.$i18n.t('postTitle') }),
title =>
title.length <= 20 ||
this.$i18n.t('cannotBeMoreThanCharacters', {
name: this.$i18n.t('postTitle'),
number: 20
})
],
imageRules: [
image =>
!!image ||
this.$i18n.t('isRequired', { name: this.$i18n.t('imageUrl') })
],
categoriesRules: [
categories =>
categories.length >= 1 ||
this.$i18n.t('isRequired', {
name: `${this.$i18n.t('atLeastOne')} ${this.$i18n.tc(
'category',
1
)}`
})
],
descRules: [
desc =>
!!desc ||
this.$i18n.t('isRequired', { name: this.$i18n.t('description') }),
desc =>
desc.length <= 200 ||
this.$i18n.t('cannotBeMoreThanCharacters', {
name: this.$i18n.t('description'),
number: 200
})
]
}
},
computed: {
...mapGetters(['user', 'userFavorites', 'userPosts'])
},
created() {
this.handleGetUserPosts()
},
methods: {
goToPost(id) {
this.$router.push(`${this.localePath('posts')}/${id}`)
},
handleGetUserPosts() {
this.$store.dispatch('getUserPosts', {
userId: this.user._id
})
},
handleUpdateUserPost() {
if (this.$refs.form.validate()) {
this.$store.dispatch('updateUserPost', {
postId: this.postId,
userId: this.user._id,
title: this.title,
imageUrl: this.imageUrl,
categories: this.categories,
description: this.description
})
this.editPostDialog = false
}
},
handleDeleteUserPost(post) {
this.loadPost(post, false)
const deletePost = window.confirm(this.$i18n.t('sureDeleteThisPost'))
if (deletePost) {
this.$store.dispatch('deleteUserPost', {
postId: this.postId
})
}
},
loadPost(
{ _id, title, imageUrl, categories, description },
editPostDialog = true
) {
this.editPostDialog = editPostDialog
this.postId = _id
this.title = title
this.imageUrl = imageUrl
this.categories = categories
this.description = description
}
}
}
</script>
8.-We need to ensure everything works properly.











Section 16: Deployment with Heroku
- We are going to use Heroku to deploy the whole application.
1.-Browse to the Heroku Dashboard to create a new app
- Browse to Heroku Login to log in.

- Click on
Create a new App

- Put the name:
nuxt-with-graphql-peelmicroand then click onCreate app.


2.-Add the settings variables
- Click on
SettingsandRevel Config Vars. Then add the followingConfig Vars:
| Name | Value | Description |
|---|---|---|
| NPM_CONFIG_PRODUCTION | false | Needed for both client and server to install the devDependencies that are needed to generate the code |
| MONGO_URI | mongodb+srv://USERNAME:PASSWORD@CLUSTERID.mongodb.net/DATABASENAME?retryWrites=true&w=majority | Connection to MongoDb database |
| SECRET | SecretKeyUsedToCreateTheJwtToken | Secret Key Used To Create The Jwt Token |
| HTTP_ENDPOINT | https://nuxt-with-graphql-peelmicro/graphql | server endpoint |

Modify the /server/src/main.ts doccument to access the client/dist folder.
3.-Modify the server app to use the client dist folder
- Modify the
package.jsondocument to include thescriptsthat are needed to deploy the app withHeroku.
package.json
{
"name": "full-stack-vue-with-graphql-the-ultimate-guide-nuxt",
"version": "1.0.0",
"description": "Source code for the NestJs and Nuxt version of the 'Full-Stack Vue with GraphQL - The Ultimate Guide' Udemy course.",
"main": "index.js",
"scripts": {
"heroku-prebuild": "npm run install:client && npm run install:server",
"install:client": "(cd client && npm install)",
"install:server": "(cd server && npm install)",
"build": "npm run build:client",
"build:client": "(cd client && npm run generate)",
"start": "npm run start:prod",
"start:prod": "(cd server && npm run start:prod)",
"server": "cd server && npm run start:dev",
"client": "cd client && npm run dev",
"dev": "concurrently --names \"server,client\" \"npm run server --silent\" \"npm run client --silent\""
},
"repository": {
"type": "git",
"url": "git+https://github.com/peelmicro/full-stack-vue-with-graphql-the-ultimate-guide-nuxt.git"
},
"keywords": [],
"author": "",
"license": "ISC",
"bugs": {
"url": "https://github.com/peelmicro/full-stack-vue-with-graphql-the-ultimate-guide-nuxt/issues"
},
"homepage": "https://github.com/peelmicro/full-stack-vue-with-graphql-the-ultimate-guide-nuxt#readme",
"devDependencies": {
"concurrently": "^4.1.0"
}
}
- Modify the
tsconfig.jsondocument to include"esModuleInterop": trueto avoid errors when running thestart:prodnode script.
server\tsconfig.json
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"target": "es6",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"esModuleInterop": true
},
"exclude": ["node_modules"]
}
- Modify the
main.tsdoccument to access theclient/distfolder.
server\src\main.ts
import * as express from 'express';
import { NestFactory } from '@nestjs/core';
import { join } from 'path';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
if (process.env.NODE_ENV === 'production') {
const CLIENT_FILES = join(__dirname, '..', '..', 'client', 'dist');
app.use(express.static(CLIENT_FILES));
}
await app.listen(process.env.PORT || 4000);
}
bootstrap();
4.-Install the Heroku CLI
Juan.Pablo.Perez@RIMDUB-0232 MINGW64 /c/Work/Training/Pre/VueJs/full-stack-vue-with-graphql-the-ultimate-guide-nuxt (master)
$ npm i -g heroku
npm WARN deprecated cross-spawn-async@2.2.5: cross-spawn no longer requires a build toolchain, use it instead
C:\Users\juan.pablo.perez\AppData\Roaming\npm\heroku -> C:\Users\juan.pablo.perez\AppData\Roaming\npm\node_modules\heroku\bin\run
+ heroku@7.27.1
updated 2 packages in 41.814s
5.-Log in from the terminal to Heroku
Juan.Pablo.Perez@RIMDUB-0232 MINGW64 /c/Work/Training/Pre/VueJs/full-stack-vue-with-graphql-the-ultimate-guide-nuxt (master)
$ heroku login
heroku: Press any key to open up the browser to login or q to exit:
Opening browser to https://cli-auth.heroku.com/auth/browser/014e0e39-5f93-4fb0-9fff-02f58b4cfc14
heroku: Waiting for login... |


Logging in... done
Logged in as juanp_perez@msn.com
6.-Set up the Heroku git repository
- Put
heroku git:remote -a nuxt-with-graphql-peelmicroto set up theHeroku gitrepository
Juan.Pablo.Perez@RIMDUB-0232 MINGW64 /c/Work/Training/Pre/VueJs/full-stack-vue-with-graphql-the-ultimate-guide-nuxt (master)
$ heroku git:remote -a nuxt-with-graphql-peelmicro
set git remote heroku to https://git.heroku.com/nuxt-with-graphql-peelmicro.git
7.-Deploy to Heroku
- Commit the current code.
Juan.Pablo.Perez@RIMDUB-0232 MINGW64 /c/Work/Training/Pre/VueJs/full-stack-vue-with-graphql-the-ultimate-guide-nuxt (master)
$ git commit -m "Heroku Deployment"
[master 0333090] Heroku Deployment
3 files changed, 16 insertions(+), 2 deletions(-)
- Push the new
commit
Juan.Pablo.Perez@RIMDUB-0232 MINGW64 /c/Work/Training/Pre/VueJs/full-stack-vue-with-graphql-the-ultimate-guide-nuxt (master)
$ git push heroku master
Enumerating objects: 11, done.
Counting objects: 100% (11/11), done.
Delta compression using up to 4 threads
Compressing objects: 100% (6/6), done.
Writing objects: 100% (6/6), 481 bytes | 240.00 KiB/s, done.
Total 6 (delta 5), reused 0 (delta 0)
remote: Compressing source files... done.
remote: Building source:
remote:
remote: -----> Node.js app detected
remote:
remote: -----> Creating runtime environment
remote:
remote: NPM_CONFIG_LOGLEVEL=error
remote: NPM_CONFIG_PRODUCTION=false
remote: NODE_ENV=production
remote: NODE_MODULES_CACHE=true
remote: NODE_VERBOSE=false
remote:
remote: -----> Installing binaries
remote: engines.node (package.json): unspecified
remote: engines.npm (package.json): unspecified (use default)
remote:
remote: Resolving node version 10.x...
remote: Downloading and installing node 10.16.3...
remote: Using default npm version: 6.9.0
remote:
remote: -----> Restoring cache
remote: - node_modules
remote:
remote: -----> Prebuild
remote: Running heroku-prebuild
remote:
remote: > full-stack-vue-with-graphql-the-ultimate-guide-nuxt@1.0.0 heroku-prebuild /tmp/build_53b0679ae7798a8233a34cf02eb7da63
remote: > npm run install:client && npm run install:server
remote:
remote:
remote: > full-stack-vue-with-graphql-the-ultimate-guide-nuxt@1.0.0 install:client /tmp/build_53b0679ae7798a8233a34cf02eb7da63
remote: > (cd client && npm install)
remote:
remote:
remote: > core-js@3.1.4 postinstall /tmp/build_53b0679ae7798a8233a34cf02eb7da63/client/node_modules/apollo-env/node_modules/core-js
remote: > node scripts/postinstall || echo "ignore"
remote:
remote:
remote: > core-js@2.6.9 postinstall /tmp/build_53b0679ae7798a8233a34cf02eb7da63/client/node_modules/core-js
remote: > node scripts/postinstall || echo "ignore"
remote:
remote:
remote: > core-js-pure@3.1.4 postinstall /tmp/build_53b0679ae7798a8233a34cf02eb7da63/client/node_modules/core-js-pure
remote: > node scripts/postinstall || echo "ignore"
remote:
remote:
remote: > protobufjs@6.8.8 postinstall /tmp/build_53b0679ae7798a8233a34cf02eb7da63/client/node_modules/protobufjs
remote: > node scripts/postinstall
remote:
remote:
remote: > nodemon@1.19.1 postinstall /tmp/build_53b0679ae7798a8233a34cf02eb7da63/client/node_modules/nodemon
remote: > node bin/postinstall || exit 0
remote:
remote: Love nodemon? You can now support the project via the open collective:
remote: > https://opencollective.com/nodemon/donate
remote:
remote:
remote: > nuxt@2.8.1 postinstall /tmp/build_53b0679ae7798a8233a34cf02eb7da63/client/node_modules/nuxt
remote: > opencollective || exit 0
remote:
remote: added 1684 packages from 992 contributors and audited 897035 packages in 42.376s
remote: found 0 vulnerabilities
remote:
remote:
remote: > full-stack-vue-with-graphql-the-ultimate-guide-nuxt@1.0.0 install:server /tmp/build_53b0679ae7798a8233a34cf02eb7da63
remote: > (cd server && npm install)
remote:
remote:
remote: > bcrypt@3.0.6 install /tmp/build_53b0679ae7798a8233a34cf02eb7da63/server/node_modules/bcrypt
remote: > node-pre-gyp install --fallback-to-build
remote:
remote: [bcrypt] Success: "/tmp/build_53b0679ae7798a8233a34cf02eb7da63/server/node_modules/bcrypt/lib/binding/bcrypt_lib.node" is installed via remote
remote:
remote: > core-js@3.1.4 postinstall /tmp/build_53b0679ae7798a8233a34cf02eb7da63/server/node_modules/apollo-env/node_modules/core-js
remote: > node scripts/postinstall || echo "ignore"
remote:
remote:
remote: > core-js@2.6.9 postinstall /tmp/build_53b0679ae7798a8233a34cf02eb7da63/server/node_modules/core-js
remote: > node scripts/postinstall || echo "ignore"
remote:
remote:
remote: > protobufjs@6.8.8 postinstall /tmp/build_53b0679ae7798a8233a34cf02eb7da63/server/node_modules/protobufjs
remote: > node scripts/postinstall
remote:
remote:
remote: > type-graphql@0.17.4 postinstall /tmp/build_53b0679ae7798a8233a34cf02eb7da63/server/node_modules/type-graphql
remote: > node ./dist/postinstall || exit 0
remote:
remote: Love TypeGraphQL or use it at work?
remote: You can now support the project via the Open Collective:
remote: > https://opencollective.com/typegraphql
remote:
remote:
remote: > @nestjs/core@6.5.3 postinstall /tmp/build_53b0679ae7798a8233a34cf02eb7da63/server/node_modules/@nestjs/core
remote: > opencollective || exit 0
remote:
remote:
remote: > nodemon@1.19.1 postinstall /tmp/build_53b0679ae7798a8233a34cf02eb7da63/server/node_modules/nodemon
remote: > node bin/postinstall || exit 0
remote:
remote: added 1020 packages from 744 contributors and audited 877533 packages in 25.009s
remote: found 0 vulnerabilities
remote:
remote:
remote: -----> Installing dependencies
remote: Installing node modules (package.json + package-lock)
remote: audited 103 packages in 1.136s
remote: found 0 vulnerabilities
remote:
remote:
remote: -----> Build
remote: Running build
remote:
remote: > full-stack-vue-with-graphql-the-ultimate-guide-nuxt@1.0.0 build /tmp/build_53b0679ae7798a8233a34cf02eb7da63
remote: > npm run build:client
remote:
remote:
remote: > full-stack-vue-with-graphql-the-ultimate-guide-nuxt@1.0.0 build:client /tmp/build_53b0679ae7798a8233a34cf02eb7da63
remote: > (cd client && npm run generate)
remote:
remote:
remote: > client@1.0.0 generate /tmp/build_53b0679ae7798a8233a34cf02eb7da63/client
remote: > nuxt generate
remote:
remote: ℹ Production build
remote: ✔ Builder initialized
remote: ✔ Nuxt files generated
remote: ℹ Compiling Client
remote:
remote: ERROR (node:2658) DeprecationWarning: Tapable.plugin is deprecated. Use new API on .hooks instead
remote:
remote: ✔ Client: Compiled successfully in 33.24s
remote: ℹ Compiling Server
remote: ✔ Server: Compiled successfully in 7.31s
remote:
remote: Hash: f2c8e9cef7f302628c06
remote: Version: webpack 4.35.0
remote: Time: 33239ms
remote: Built at: 08/19/2019 5:49:26 PM
remote: Asset Size Chunks Chunk Names
remote: ../server/client.manifest.json 13.5 KiB [emitted]
remote: 091eb1ba7c34d4d68a75.js 4.07 KiB 6 [emitted] pages/posts/add
remote: 1f04b5e33ef840ee4bd0.js 17.9 KiB 5 [emitted] pages/posts/_id
remote: 3683f0e4e74662961350.js 1.79 KiB 0 [emitted] lang-en-US
remote: 63dbc69e947c85b04cbe.js 3.55 KiB 9 [emitted] pages/signin/index
remote: 82426196f53d02d4ddca.js 4.42 KiB 10 [emitted] pages/signup/index
remote: 85604e8486fcc92fd1c6.js 2.96 KiB 7 [emitted] pages/posts/index
remote: 86d9527c4b20968645b9.js 7.39 KiB 8 [emitted] pages/profile/index
remote: LICENSES 71.2 KiB [emitted]
remote: a039c0d8194d984e641f.js 99.1 KiB 2 [emitted] app
remote: ac34cd61ed33f08f2b38.js 1.95 KiB 4 [emitted] pages/index
remote: cfe47ecee9b544654b87.js 939 KiB 12 [emitted] [big] vendors.app
remote: f0eb1e68fd0f6061b698.js 2.46 KiB 11 [emitted] runtime
remote: f73faa835d1b9e577503.js 162 KiB 3 [emitted] commons.app
remote: fb7614c155200c910176.js 2 KiB 1 [emitted] lang-es-ES
remote: manifest.bff934b8.json 290 bytes [emitted]
remote: + 2 hidden assets
remote: Entrypoint app [big] = f0eb1e68fd0f6061b698.js f73faa835d1b9e577503.js cfe47ecee9b544654b87.js a039c0d8194d984e641f.js
remote:
remote: WARNING in asset size limit: The following asset(s) exceed the recommended size limit (244 KiB).
remote: This can impact web performance.
remote: Assets:
remote: cfe47ecee9b544654b87.js (939 KiB)
remote:
remote: WARNING in entrypoint size limit: The following entrypoint(s) combined asset size exceeds the recommended limit (1000 KiB). This can impact web performance.
remote: Entrypoints:
remote: app (1.17 MiB)
remote: f0eb1e68fd0f6061b698.js
remote: f73faa835d1b9e577503.js
remote: cfe47ecee9b544654b87.js
remote: a039c0d8194d984e641f.js
remote:
remote:
remote: Hash: e6bcd576ff6a87bcc606
remote: Version: webpack 4.35.0
remote: Time: 7313ms
remote: Built at: 08/19/2019 5:49:33 PM
remote: Asset Size Chunks Chunk Names
remote: 29a76df4ab143063aaac.js 1.81 KiB 0 [emitted] lang-en-US
remote: 4b256be2c9276bc3356f.js 3.98 KiB 5 [emitted] pages/posts/add
remote: 5b31222f128b7c1ce738.js 7.18 KiB 7 [emitted] pages/profile/index
remote: 6189f6a1888146f1abc2.js 2.64 KiB 6 [emitted] pages/posts/index
remote: 8ab6a4547923cc14d1a8.js 17.8 KiB 4 [emitted] pages/posts/_id
remote: a3b8dedb61ed670c1c6d.js 4.49 KiB 9 [emitted] pages/signup/index
remote: b3499d3cded987a4c8bc.js 3.65 KiB 8 [emitted] pages/signin/index
remote: e0130c9f27a2fa423bbb.js 2.03 KiB 1 [emitted] lang-es-ES
remote: fe01929be13d0279e0ff.js 2.1 KiB 3 [emitted] pages/index
remote: server.js 306 KiB 2 [emitted] app
remote: server.manifest.json 1.17 KiB [emitted]
remote: + 10 hidden assets
remote: Entrypoint app = server.js server.js.map
remote: ℹ Generating pages
remote: ✔ Generated /signin
remote: ✔ Generated /profile
remote: ✔ Generated /signup
remote: ✔ Generated /en/signup
remote: ✔ Generated /es/profile
remote: ✔ Generated /en/signin
remote: ✔ Generated /es/signup
remote: ✔ Generated /en/posts
remote: ✔ Generated /es/signin
remote: ✔ Generated /en/profile
remote: ✔ Generated /posts
remote: ✔ Generated /
remote: ✔ Generated /es/
remote: ✔ Generated /en/
remote: ✔ Generated /es/posts/add
remote: ✔ Generated /en/posts/add
remote: ✔ Generated /posts/add
remote: ✔ Generated /es/posts
remote:
remote: -----> Pruning devDependencies
remote: Skipping because NPM_CONFIG_PRODUCTION is 'false'
remote:
remote: -----> Caching build
remote: - node_modules
remote:
remote: -----> Build succeeded!
remote: -----> Discovering process types
remote: Procfile declares types -> (none)
remote: Default types for buildpack -> web
remote:
remote: -----> Compressing...
remote: Done: 100.9M
remote: -----> Launching...
remote: Released v11
remote: https://nuxt-with-graphql-peelmicro.herokuapp.com/ deployed to Heroku
remote:
remote: Verifying deploy... done.
To https://git.heroku.com/nuxt-with-graphql-peelmicro.git
6d5243e..3c38d14 master -> master
8.- Ensure everything is working properly







