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.
Server
project.
Section 3-4: Setting up the I.-Create the main folder for the solution
- Create the main
full-stack-vue-with-graphql-the-ultimate-guide-nuxt
folder
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.md
and.gitignore
from the originalfull-stack-vue-with-graphql-the-ultimate-guide
project.
server
project using NetsJS
II.-Create the - We are now going to use the NestJS CLI to scaffold the project. Nest is a progressive
Node.js
framework for building efficient, reliable and scalable server-side applications.
- We need to ensure
NestJs CLI
is 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
server
project 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
dotenv
and@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/mongoose
andmongoose
packages.
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/mongoose
for 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
.dev
document thatdotenv
will use.
.dev
MONGO_URI=mongodb+srv://USERNAME:PASSWORD@CLUSTERID.mongodb.net/DATABASENAME?retryWrites=true&w=majority
- We are going to create the
src/config
folder with theconfig.module.ts
andconfig.service.ts
documents. It will be used to manage ourconfig
variables.
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/database
folder with thedatabase.providers.ts
anddatabase.module.ts
documents 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.ts
document 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
GraphQL
with Apollo Server
IV.-Setting up - As we can see on NestJs GraphQL Quick Start we need to install the
@nestjs/graphql
,apollo-server-express
,graphql-tools
andgraphql
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 --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
GraphQL
withNestJS
We need to install the
type-graphql
package
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.ts
document to registerGraphQLModule
and to addautoSchemaFile
property to the options object. As we have chosen theCode First
approach, theGraphQL
schema is going to be generated automatically byNestJS
.schema.gpl
is the name of the schema file that is going to be created.We are going to get rid of the
app.controller.ts
document 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 {}
User
model.
VII.Create the 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.
nestjs-typegoose
package to avoid having to create a schema and an interface
1.Use the - 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
model
folder and the model
documents.
2.Create the - Create the
users
folder and inside it themodel
folder. In this folder we are going to put the documents related to the databaseusers
colection. Theuser.model.ts
document must be created with the definition of theUser
model.
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
dtos
folder where we are going to put theData Transfer Objects
used to send data to the collections. We are going to create thecreate-user.dto.ts
document 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;
}
grahpql
folder with the graphql
documents
3.Create the We need to create
graphql
folder with theinputs
andtypes
subfolders.We need to create the
users.resolver.ts
with theGraphQL resolver
for 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
inputs
subfolder will contain the classes used to define thegraphQL
inputs used byQueries
andMutations
. We are going to create theuser.input.ts
document 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
types
subfolder will containt the classes used to define the types returned by thegraphQL Queries and Mutations
. We are going to create theuser.type.ts
document that will be used to return the data from theUser
collection.
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];
}
users service
4.Create the - We are going to use the
NestJs CLI
to create theusers
service.
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
users
collection.
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();
}
}
users module
5.Create the - We are going to use the
NestJs CLI
to create theusers
module.
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
module
created 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 {}
app.module.ts
6.-Modify the - We need to modify the
app.module.ts
to include theUsersModule
and 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"
}
}
}
Post
model.
VI.-Create the posts
folder with a module
and a `service.
1.-Create the - We need to create the
usr/posts
folder and use theNestJs CLI
to create themodule
and 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.ts
document 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 { }
model
folder and the model
documents.
2.-Create the - We need to create the
src/posts/model
andsrc/posts/model/dtos
folders and thepost.model.ts
andcreate-post.dto.ts
documents.
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;
}
grahpql
folder with the graphql
documents.
3.-Create the - We need to create the
src/posts/graphql
,src/posts/graphql/inputs
andsrc/posts/graphql/types
folders and theusers.resolver.ts
,post.input.ts
andpost.type.ts
documents.
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]
}
module
and service
documents.
4.-Modify the - We need to modify the
module
created 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
service
to define the methods used to manage the access to theposts
collection.
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
NuxtJs
app using the create-nuxt-app
CLI
I.Create the 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.json
file withnpm init -y
at 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
concurrently
package.
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.ts
to run the server on port4000
server/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.json
document to create the scripts needed to run theserver
and theclient
at 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 dev
script.
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
Vuetify
Theme
III.-Set up the - Modify the
nuxt.config.js
document to change thevuetify.theme
to 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) {}
}
}
Pages
(and remove the test code)
IV.-Create the - Modify the
client/layouts/default.vue
document to create the layout based on the originalApp.vue
document.
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.vue
document 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
post
folder with theadd.vue
andindex.vue
document.
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
profile
folder with theindex.vue
document.
client/pages/profile/index.vue
<template>
<v-container>
<h1>Profile</h1>
</v-container>
</template>
<script>
export default {
name: 'Profile'
}
</script>
- Create the
signin
folder with theindex.vue
document.
client/pages/signin/index.vue
<template>
<v-container>
<h1>Signin</h1>
</v-container>
</template>
<script>
export default {
name: 'Signin'
}
</script>
- Create the
signup
folder with theindex.vue
document.
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.
Typescript
V.-Sep up - We are going to follow the TypeScript Support documentation to set up the use of
Typescript
with ourNuxt
application
Typescript
packages
1.Install the needed - We need to install
@nuxt/typescript
indevDependencies
andts-node
independencies
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
tsconfig.json
document
2.-Create the - We need to create an empty
tsconfig.json
file 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.
nuxt.config.js
document to nuxt.config.ts
and modify it.
3.-rename the - To use TypeScript in the configuration file, we need to rename
nuxt.config.js
tonuxt.config.ts
and 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;
Linting
with ESLint
4.-Set up - We need to install
@typescript-eslint/eslint-plugin
and@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.js
document by adding the@typescript-eslint
plugin and making@typescript-eslint/parser
the 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.json
document.
. 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"
}
}
pages
to use TypeScript
5.-Modify the - We need to install the
vue-property-decorator
package that is going to help usingTypeScripts
inVue
documents.
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.vue
is not loaded.
typescript
branch to keep the changes
7.-Create the - We are going to create a new branch to keep the changes applied but we are not going to use
TypeScript
at 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
master
branch
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'.
multi-language
VI.-Set up multi-language
on the A simple multi-language site with Nuxt.js and nuxt-i18n articule
1.-Base the - We are going to follow the A simple multi-language site with Nuxt.js and nuxt-i18n articule and the nuxt-i18n web site.
nuxt-i18n
package
2.-Install the 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
warn
detected
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
lang
folder and the translations documents
3.-Create the - We need to create the
lang
folder and thees-ES.js
for theSpanish
translations and theen-US.js
for theEnglish
translations.
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'
}
nuxt.config.js
document to set up the pluging and the initial languages
4.-Modify the 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) {}
}
}
lang-switcher
button.
5.-Modify the layout to include a 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>
multi-language
works with the changes applied
6.-Test if the 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/apollo
and 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
apollo
folder and theapollo/customErrorHandler.js
document.
apollo/customErrorHandler.js
export default (err, { error }) => {
console.log(err)
error({ statusCode: 304, message: 'Server error!' })
}
- Create the
gql
folder and thegql/getPosts.gql
document.
gql/getPosts.gql
query {
getPosts {
_id
title
imageUrl
description
likes
}
}
- We are going to modify the
client/nuxt.config.js
document to set up the use of the@nuxtjs/apollo
package.
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.vue
document to include a call to thegetPosts
graphqlquery
from 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.vue
document to include theCorousel Vuetify
components.
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
GraphQL getPosts
query
I.Create the store to manage the 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
}
Home
page to read the Posts from the store
II.-Modify the 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 Model
to put the@prop
attribute to theavatar
field.
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 Service
to 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.
passport
and jwt
packaged needed to manage the authentication
1.-Install the - 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
SECRET
environment variable needed for theJWT
token:
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.ts
document to include the newSECRET
environment 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
Auth
folder to put inside theAuth Service
and everything related. This service is going to be used to manage theAuthentication
and theAuthorisation
of the GraphQLQueries
adnMutations
.We are going to create the
jwt-payload.interface.ts
to 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.ts
to create theGraphqlAuthGuard
guard derived from theNestJS AuthGuard
Guard to transform from theExceution Context
to 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.ts
document with theJwtStrategy
class used to read theUser
information obtained from the token passed through theauthorization
header with any request. It is attached to the request on theuser
object.
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.ts
to create theCurrentUser
custom decorator withUser
information obtained from the token passed through theauthentication
header with any request. Please, note it isauthentication
instead ofauthorisation
because 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.ts
document with theAuthService
service that has thesignUp
andsignIn
methods.
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.ts
with theAuthModule
module. It needs to import thePassportModule
and 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 {}
Users
Module
4.-Modify the files related to the - We need to modify the
app.module.ts
document to include thecontext
as part of theGraphQLModule
module.
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.ts
document to inlcude theConfigService
provider adn theAuthModule
module.
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
SignUserDto
Data 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.ts
anduser.model.ts
documents to include theToken
class.
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.ts
document to remove thesignupUser
andcreateToken
methods 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.ts
document to include theAuthService
service and to include the newsigninUser
mutation, the newgetCurrentUser
query and to modify thesignupUser
mutation.
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
signinUser
mutation 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
signupUser
mutation 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
getCurrentUser
query 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.js
document to add theauthenticationType: '',
property to theapollo
tag. Otherwise it would included theBearer
prefix 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) {}
}
}
gql
documents with queries and mutations
2.-Create the 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
}
}
localization
documents with new entries
3.-Update the 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!'
}
store
to manaage the Authentication
4.-Update the 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
}
SignIn
page
5.-Update the 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>
auth
middleware
6.-Create the client\middleware\auth.js
export default function({ store, redirect, app }) {
if (!store.getters.user) {
return redirect(app.localePath('signin'))
}
}
Profile
page to use the auth
middleware
7.-Update the client\pages\profile\index.vue
<template>
<v-container>
<h1>Profile</h1>
</v-container>
</template>
<script>
export default {
name: 'Profile',
middleware: 'auth'
}
</script>
Layout
with new options
8.-Update the 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
ignoring expiration
of the jwt
token.
I.-Modify the server app to avoid - We need to modify the
jwt.strategy.ts
document 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.
cookie-universal-nuxt
package
1.-Install the - We are going to install the cookie-universal-nuxt package to be able to remove cookies on both
client
andserver
sides.
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
FormAlert
Component and register it globally
2.-Create the - We need to create the
FormAlert.vue
Component document. Create theShared
folder fromclient\components
and theFormAlert.vue
document 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.js
plugin component that will be used to register all the components from theclient\components\Shared
folder 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)
})
nuxt.config.js
document to add a new module and a new pugin
3.-Modify the - We need to modify the
nuxt.config.js
document to include the newcookie-universal-nuxt
module and to register theshared-components
plugin.
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) {}
}
}
utils.js
document that will contain global functions
4.-Create a new - Create the
client\helpers
folder and theutils.js
document 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
Signup
action and the error
and authError
states.
5.-Modify the store to include the - We need to modify the
index.js
store document to include theSignup
action and theerror
andauthError
states.
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
}
localization
documents with new entries
6.-Update the 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`
}
signin
page to include the form validation.
7.-Modify the - We need to modify the
signin\index.vue
page 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>
signup
page to include to include all the functionality .
8.-Modify the - We need to modify the
signup\index.vue
page 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>
default
layout to include the AuthSnackbar
and AuthErrorSnackbar
components
9.-Modify the main - We need to modify the
default.vue
layout document to include theAuthSnackbar
andAuthErrorSnackbar
components.
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
infiniteScrollPosts
query.
I.-Modify the server app to include the PostPage
type
1.-Create the new - We are going to create the
post-page.type.ts
document with the newPostPage
type.
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
}
infiniteScrollPosts
method.
2.-Modify the Posts service to include the new - We need to modify the
\posts.service.ts
service document to include the newinfiniteScrollPosts
method.
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();
}
}
infiniteScrollPosts
Query.
3.-Modify the Posts resolver to include the new - We need to modify the
\posts.resolver.ts
resolver document to include the newinfiniteScrollPosts
Query.
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 });
}
}
infiniteScrollPosts
Query works propely
4.-Test if the new 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"
}
}
]
}
}
}
infiniteScrollPosts
Query.
II.-Modify the client app to make the Add Posts component using the new infiniteScrollPosts.gql
and addPost.gql
documents with the infiniteScrollPosts
Query and addPost
mutation.
1.-Create the - We need to create the
addPost.gql
document with theaddPost
Mutation
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.gql
document with the newinfiniteScrollPosts
Query
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
}
}
localization
documents with new entries
2.-Update the 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'
}
addPost
action
3.-Modify the store to include the - We need to modify the
store\index.js
store document to include theaddPost
action 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
}
post Add
page to include all the functionality .
4.-Modify the - We need to modify the
post\add.vue
page 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>
posts
page to include all the functionality .
5.-Modify the - We need to modify the
post\index.vue
page 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
getPost
Query and the addPostMessage
mutation.
I.-Modify the server app to include the - The folder structure for the models have been changed. Basically the
GraphQL
andDatabase(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 |
posts
module with the new structure and add the getPost
Query and the addPostMessage
mutation.
1.-Update the 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];
}
}
users
module with the new structure and add the getPostQuery and the
addPostMessage` mutation.
2.-Update the 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()
}
}
auth
module to change file locations because of the new structure.
3.-Update the 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
}
}
database.providers.ts
document to setup the useFindAndModify
setting value.
4.-Update the 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
schema
created 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"
}
}
}
}
Post
pages.
II.-Modify the client app to add the individual localization
documents with new entries
1.-Update the 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'
}
getPost.gql
and addPostMessage.gql
documents with the getPost
Query and addPostMessage
mutation.
2.-Create the - We need to create the
getPost.gql
document with thegetPost
Query
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.gql
document with theaddPostMessage
Mutation
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
}
}
}
Post
page.
3.-Create the individual - We need to create the
\posts\_id.vue
Post 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>
Posts
page to call the new individual Post
page
4.-Update the - We need to modify the
posts\index.vue
document.
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>
Home
page to call the new individual Post
page
5.-Update the - We need to modify the main
pages\index.vue
Home 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>
utils
helper to add a new function
6.-Modify the - We need to modify the
utils.js
document to add the newisValidObjectID
function.
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
Error
page to localise it
7.-Update the - We need to modify the
layouts\error.vue
Error 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
LikesFaves
Type and the likePost
and unlikePost
Mutations.
I.-Modify the server app to include the PostPage
type
1.-Create the new - We are going to create the
likes-faves.type.ts
document with the newLikesFaves
type.
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>[]
}
likePost
and unlikePost
methods.
2.-Modify the Posts service to include the new - We need to modify the
\posts.service.ts
service document to include the newlikePost
andunlikePost
methods.
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;
}
}
likePost
and unlikePost
Mutations.
3.-Modify the Posts resolver to include the new - We need to modify the
posts.resolver.ts
resolver document to include the newlikePost
andunlikePost
Mutations.
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.ts
resolver document to theUser
Typegoose 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 { }
likePost
and unlikePost
Mutations work propely
5.-Test if the new 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": []
}
}
}
Post
pages.
II.-Modify the client app to add the Likes / Unlikes on the likePost.gql
and unlikePost.gql
documents with the likePost
and unlikePost
mutations.
1.-Create the - We need to create the
likePost.gql
document with thelikePost
Mutation.
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.gql
document with theunlikePost
Mutation.
client\gql\unlikePost.gql
mutation unlikePost($postId: ID!, $username: String!) {
unlikePost(postId: $postId, username: $username) {
likes
favorites {
_id
title
imageUrl
}
}
}
userFavorites
getter.
2.-Modify the Store to include the - We need to modify the
store\index.js
store document to include theuserFavorites
getter
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.vue
document to manage when the user click onlike/unlike
buttton.
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.vue
document 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
searchPosts
Query.
I.-Modify the server app to include the 1.-Modify the Post Model to include a text index
- We need to modify the
post.model.ts
document to add atext
index.
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>[]
}
searchPosts
methods.
2.-Modify the Posts service to include the new - We need to modify the
posts.service.ts
service document to include the newsearchPosts
methods.
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;
}
}
searchPosts
Query.
3.-Modify the Posts resolver to include the new - We need to modify the
posts.resolver.ts
resolver document to include the newsearchPosts
Query.
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);
}
}
searchPosts
Query works propely
4.-Test if the new 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
}
}
Search Posts
on the Default layout.
II.-Modify the client app to implement searchPosts.gql
document with the searchPosts
query.
1.-Create the - We need to create the
searchPosts.gql
document with thesearchPosts
Query
client\gql\likePost.gql
query searchPosts($searchTerm: String) {
searchPosts(searchTerm: $searchTerm) {
_id
title
description
imageUrl
likes
}
}
searchResults
state.
2.-Modify the Store to include the - We need to modify the
store\index.js
store document to include thesearchResults
state.
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.vue
layout 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
getUserPosts
Query and the updateUserPost
and deleteUserPost
Mutations.
I.-Modify the server app to include the getUserPosts
, updateUserPost
and deleteUserPost
methods.
1.-Modify the Posts service to include the new - We need to create the
update-user-post.dto.ts
document with theUpdateUserPostDto
DTO.
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.ts
service document to include the newgetUserPosts
,updateUserPost
anddeleteUserPost
methods.
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;
}
}
getUserPosts
Query and and the new likePost
and unlikePost
Mutations.
2.-Modify the Posts resolver to include the new - We need to modify the
posts.resolver.ts
resolver document to include the newgetUserPosts
Query and and the newlikePost
andunlikePost
Mutations.
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);
}
}
getUserPosts
Query and and the new likePost
and unlikePost
Mutations work propely
3.-Test if the new 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"
}
}
}
Pofrile
pages.
II.-Modify the client app to be able to update and delete a post from the getUserPosts.gql
with the getUserPosts
query and the updateUserPost
and deleteUserPost.gql
documents with the updateUserPost
and deleteUserPost
mutations.
1.-Create the - We need to create the
getUserPosts.gql
document with thegetUserPosts
Query.
client\gql\getUserPosts.gql
query getUserPosts($userId: ID!) {
getUserPosts(userId: $userId) {
_id
title
imageUrl
description
categories
createdDate
likes
}
}
- We need to create the
updateUserPost.gql
document with theupdateUserPost
Mutation.
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.gql
document with thedeleteUserPost
Mutation.
client\gql\deleteUserPost.gql
mutation deleteUserPost($postId: ID!) {
deleteUserPost(postId: $postId) {
_id
}
}
userPosts
state and the getUserPosts
, updateUserPost
and deleteUserPost
actions.
2.-Modify the Store to include the - We need to modify the
store\index.js
store document to include theuserPosts
state and thegetUserPosts
,updateUserPost
anddeleteUserPost
actions.
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.js
localization 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.js
localization 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.vue
page 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>
default
layout document to show the snackbars when the error happenns on server rendering
4.-Modify the 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.js
apollo 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.vue
Post 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
@nuxtjs/moment
package
1.-Install the - 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.js
plugin to usemoment
withnuxt-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.js
config file to set up the@nuxtjs/moment
pacjage
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) {}
}
}
infiniteScrollPosts
state and to modify the updateUserPost
and deleteUserPost
to update the infiniteScrollPosts
and posts
states.
2.-Modify the Store to include the - Modify the
client\store\index.js
store document to include theinfiniteScrollPosts
state and to modify theupdateUserPost
anddeleteUserPost
to update theinfiniteScrollPosts
andposts
states.
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
}
infiniteScrollPosts
store state and manage the showMoreEnabled
property properly.
3.-Modify the Posts page to use the - We need to modify the
posts\index.vue
Posts page document to use theinfiniteScrollPosts
store state and manage theshowMoreEnabled
property 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>
Explore Posts
Button
4.-Modify the Home page document page document to add the - We need to modify the
lang\en-US.js
localization 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.js
localization 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.vue
Home page document page document to add theExplore Posts
Button
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>
h1
and h2
CSS segments.
5.-Modify the Default layout document to change the - We are going to modify the
layouts\default.vue
document to change theh1
andh2
CSS 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.vue
Profile page document to allow to go to the Posts from the Post Images. Also themoment
library 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.vue
Post 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.
Heroku Dashboard
to create a new app
1.-Browse to the - Browse to Heroku Login to log in.
- Click on
Create a new App
- Put the name:
nuxt-with-graphql-peelmicro
and then click onCreate app
.
2.-Add the settings variables
- Click on
Settings
andRevel 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.
client dist
folder
3.-Modify the server app to use the - Modify the
package.json
document to include thescripts
that 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.json
document to include"esModuleInterop": true
to avoid errors when running thestart:prod
node 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.ts
doccument to access theclient/dist
folder.
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();
Heroku CLI
4.-Install the 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
Heroku
5.-Log in from the terminal to 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
Heroku git
repository
6.-Set up the - Put
heroku git:remote -a nuxt-with-graphql-peelmicro
to set up theHeroku git
repository
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
Heroku
7.-Deploy to - 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