├── .circleci └── config.yml ├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── .graphqlconfig.yml ├── README.md ├── __mocks__ └── react-intersection-observer.ts ├── __tests__ ├── channel.test.tsx ├── channels.test.tsx ├── my-profile.test.tsx └── state.test.ts ├── amplify.yml ├── amplify ├── .config │ └── project-config.json ├── backend │ ├── api │ │ └── paginationandsorting │ │ │ ├── parameters.json │ │ │ ├── schema.graphql │ │ │ └── stacks │ │ │ └── CustomResources.json │ └── backend-config.json └── team-provider-info.json ├── babel.config.js ├── cypress.json ├── cypress ├── fixtures │ └── example.json ├── integration │ └── channels.spec.ts ├── plugins │ ├── cy-ts-preprocessor.js │ └── index.js ├── support │ ├── commands.js │ └── index.js ├── tsconfig.json └── videos │ └── channels.spec.ts.mp4 ├── jest.config.js ├── jest.setup.js ├── next-env.d.ts ├── next.config.js ├── package.json ├── pages ├── _document.tsx ├── channel.tsx ├── channels.tsx ├── index.tsx └── me.tsx ├── schema.graphql ├── scripts └── e2e.sh ├── src ├── API.ts ├── actions.ts ├── aws-exports.js ├── components │ ├── AppShell.tsx │ ├── Channel.tsx │ ├── Channels.tsx │ ├── Header.tsx │ ├── InputZone.tsx │ ├── MyProfile.tsx │ └── utils.tsx ├── graphql │ ├── mutations.ts │ ├── queries.ts │ ├── schema.json │ └── subscriptions.ts ├── index.html ├── index.tsx ├── models │ ├── Channel.ts │ ├── Channels.ts │ ├── ModelsContext.ts │ ├── User.ts │ ├── __mocks__ │ │ ├── Channel.ts │ │ ├── Channels.ts │ │ ├── ModelsContext.ts │ │ ├── User.ts │ │ └── custom-queries.ts │ └── custom-queries.ts ├── state.ts ├── test-utils │ ├── data-bank.ts │ └── selectors.ts ├── theme.ts └── types.ts ├── static ├── android-chrome-192x192.png ├── android-chrome-512x512.png ├── apple-touch-icon.png ├── favicon-16x16.png ├── favicon-32x32.png ├── logo.png ├── manifest.json ├── robots.txt └── safari-pinned-tab.svg ├── tsconfig.json └── yarn.lock /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | # Javascript Node CircleCI 2.0 configuration file 2 | # 3 | # Check https://circleci.com/docs/2.0/language-javascript/ for more details 4 | # 5 | version: 2 6 | jobs: 7 | build: 8 | docker: 9 | - image: circleci/node:8.12 10 | # Specify service dependencies here if necessary 11 | # CircleCI maintains a library of pre-built images 12 | # documented at https://circleci.com/docs/2.0/circleci-images/ 13 | # - image: circleci/mongo:3.4.4 14 | 15 | working_directory: ~/repo 16 | 17 | steps: 18 | - checkout 19 | 20 | # Download and cache dependencies 21 | - restore_cache: 22 | keys: 23 | - v2-dependencies-{{ checksum "package.json" }} 24 | # fallback to using the latest cache if no exact match is found 25 | - v2-dependencies- 26 | 27 | - run: yarn install 28 | 29 | - save_cache: 30 | paths: 31 | - node_modules 32 | key: v2-dependencies-{{ checksum "package.json" }} 33 | 34 | # run tests! 35 | - run: yarn test 36 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | src/graphql/ 2 | src/API.ts -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es6: true, 5 | "jest/globals": true, 6 | "cypress/globals": true 7 | }, 8 | extends: [ 9 | "eslint:recommended", 10 | "plugin:@typescript-eslint/recommended", 11 | "plugin:react/recommended" 12 | ], 13 | parserOptions: { 14 | ecmaFeatures: { 15 | jsx: true, 16 | modules: true 17 | }, 18 | ecmaVersion: 2018, 19 | sourceType: "module" 20 | }, 21 | 22 | parser: "@typescript-eslint/parser", 23 | plugins: ["@typescript-eslint", "react", "jest", "cypress"], 24 | rules: { 25 | "@typescript-eslint/explicit-function-return-type": "off", 26 | "react/prop-types": "off", 27 | "@typescript-eslint/ban-ts-ignore": "off" 28 | } 29 | }; 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /build 11 | /dist 12 | /.next 13 | 14 | # misc 15 | .DS_Store 16 | .env 17 | npm-debug.log* 18 | yarn-debug.log* 19 | yarn-error.log* 20 | .git-backup 21 | 22 | # amplify 23 | amplify/\#current-cloud-backend 24 | amplify/.config/local-* 25 | amplify/backend/amplify-meta.json 26 | amplify/backend/awscloudformation 27 | build/ 28 | dist/ 29 | node_modules/ 30 | # src/aws-exports.js 31 | awsconfiguration.json 32 | .cache 33 | 34 | tmp 35 | amplify/mock-data 36 | iam-credentials.csv 37 | -------------------------------------------------------------------------------- /.graphqlconfig.yml: -------------------------------------------------------------------------------- 1 | projects: 2 | paginationandsorting: 3 | schemaPath: amplify/backend/api/paginationandsorting/build/schema.graphql 4 | includes: 5 | - src/graphql/**/*.ts 6 | excludes: 7 | - ./amplify/** 8 | extensions: 9 | amplify: 10 | codeGenTarget: typescript 11 | generatedFileName: src/API.ts 12 | docsFilePath: src/graphql 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Building a group chat app with AWS Amplify 2 | 3 | The motivation behind this example is to explore the handling of live lists of sorted data with Amplify API (AppSync & GraphQL Transformer). 4 | 5 | The app consists of 3 routes: 6 | 7 | - `channel?id={id}` A chat room identified by an id. A user can send and receive messages in real-time. Messages are sorted by descending message creation data (newest at the bottom). 8 | 9 | - `channels` A list of channels sorted by ascending last update date (newest always at the top). 10 | 11 | - `me` A form that a user can fill to share more about themselves. 12 | 13 | 14 | ### Clone the repo 15 | 16 | ```sh 17 | git clone https://github.com/rakannimer/pagination-and-sorting-with-aws-amplify 18 | ``` 19 | 20 | ### Run locally 21 | 22 | ```sh 23 | npm install 24 | npm run dev 25 | # or 26 | yarn 27 | yarn dev 28 | ``` 29 | 30 | ### Deploy your own 31 | 32 | #### From the console 33 | 34 | [![amplifybutton](https://oneclick.amplifyapp.com/button.svg)](https://console.aws.amazon.com/amplify/home#/deploy?repo=https://github.com/rakannimer/pagination-and-sorting-with-aws-amplify) 35 | 36 | #### From the amplify cli 37 | 38 | ```sh 39 | rm src/aws-exports.js 40 | rm -rf amplify 41 | 42 | amplify init 43 | 44 | amplify add api 45 | 46 | # When prompted for a schema, point to ./schema.graphql 47 | 48 | amplify push 49 | 50 | ``` 51 | -------------------------------------------------------------------------------- /__mocks__/react-intersection-observer.ts: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | export const useInView = jest.fn().mockImplementation(() => { 4 | return [React.useRef(), true]; 5 | }); 6 | 7 | export default { 8 | useInView 9 | }; 10 | -------------------------------------------------------------------------------- /__tests__/channel.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, fireEvent, within } from "@testing-library/react"; 2 | import * as React from "react"; 3 | import { RouterContext } from "next-server/dist/lib/router-context"; 4 | import { Observable } from "rxjs"; 5 | import nanoid from "nanoid"; 6 | import { act } from "react-dom/test-utils"; 7 | 8 | import { ChannelRoute } from "../src/components/Channel"; 9 | import { models } from "../src/models/__mocks__/ModelsContext"; 10 | import { messages } from "../src/test-utils/selectors"; 11 | import { 12 | createOnCreateMessageEmission, 13 | createGetChannelMessagesEmission, 14 | getUsernameEmission 15 | } from "../src/test-utils/data-bank"; 16 | 17 | const channelName1 = "test-channel-" + nanoid(); 18 | 19 | const ChannelTestRoute = ({ 20 | pathname = "/channel", 21 | push = jest.fn(), 22 | query = { 23 | id: channelName1 24 | } 25 | }) => ( 26 | 34 | 35 | 36 | ); 37 | 38 | describe("channel", () => { 39 | let resolveGetChannelMessages; 40 | let fireOnCreateMessage; 41 | let resolveUsername; 42 | 43 | models.Channel.getChannelMessages.mockImplementation(() => { 44 | return new Promise(resolve => { 45 | resolveGetChannelMessages = () => { 46 | resolve(createGetChannelMessagesEmission(channelName1)); 47 | }; 48 | }); 49 | }); 50 | 51 | models.User.getUsername.mockImplementation(() => { 52 | return new Promise(resolve => { 53 | resolveUsername = () => { 54 | resolve(getUsernameEmission()); 55 | }; 56 | }); 57 | }); 58 | 59 | models.Channel.onCreateMessage.mockImplementation(() => { 60 | return new Observable(subscriber => { 61 | fireOnCreateMessage = () => { 62 | subscriber.next(createOnCreateMessageEmission(channelName1)); 63 | }; 64 | }); 65 | }); 66 | 67 | beforeEach(() => { 68 | localStorage.clear(); 69 | models.Channel.getChannelMessages.mockClear(); 70 | models.Channel.onCreateMessage.mockClear(); 71 | }); 72 | 73 | it("exports", () => { 74 | expect(ChannelRoute).toBeDefined(); 75 | }); 76 | 77 | it("renders (smoke test)", () => { 78 | const push = jest.fn(); 79 | render(); 80 | expect(models.Channel.getChannelMessages).toBeCalledWith(channelName1, ""); 81 | expect(models.Channel.onCreateMessage).toBeCalledWith(channelName1); 82 | }); 83 | 84 | it("renders new message when onCreateMessage fires", async () => { 85 | const push = jest.fn(); 86 | const testUtils = render(); 87 | await act(async () => { 88 | resolveGetChannelMessages(); 89 | }); 90 | await act(async () => { 91 | resolveUsername(); 92 | }); 93 | let renderedMessages = messages.messageList(testUtils); 94 | expect(renderedMessages.length).toEqual(1); 95 | act(() => { 96 | fireOnCreateMessage(); 97 | }); 98 | renderedMessages = messages.messageList(testUtils); 99 | expect(renderedMessages.length).toEqual(2); 100 | }); 101 | 102 | it("can add a message to channel", () => { 103 | const newMessage = "Test message"; 104 | const push = jest.fn(); 105 | const testUtils = render(); 106 | expect(models.Channel.onCreateMessage).toBeCalled(); 107 | const input = messages.input(testUtils); 108 | const submit = messages.button(testUtils); 109 | fireEvent.change(input, { target: { value: newMessage } }); 110 | expect(input.value).toEqual(newMessage); 111 | act(() => { 112 | fireEvent.click(submit); 113 | }); 114 | expect(input.value).toEqual(""); 115 | const renderedMessages = messages.messageList(testUtils); 116 | expect(renderedMessages.length).toEqual(1); 117 | }); 118 | }); 119 | -------------------------------------------------------------------------------- /__tests__/channels.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, fireEvent, within } from "@testing-library/react"; 2 | import * as React from "react"; 3 | import { RouterContext } from "next-server/dist/lib/router-context"; 4 | import { Observable } from "rxjs"; 5 | import nanoid from "nanoid"; 6 | import { act } from "react-dom/test-utils"; 7 | 8 | import { ChannelsRoute } from "../src/components/Channels"; 9 | import { models } from "../src/models/__mocks__/ModelsContext"; 10 | import { header, channels } from "../src/test-utils/selectors"; 11 | import { 12 | createOnCreateChannelEmission, 13 | createOnCreateMessageEmission, 14 | createGetChannelsEmission, 15 | createOnUpdateChannelEmission 16 | } from "../src/test-utils/data-bank"; 17 | 18 | const ChannelsTestRoute = ({ pathname = "/", push = jest.fn() }) => ( 19 | 26 | 27 | 28 | ); 29 | 30 | describe("channels", () => { 31 | //Fixtures 32 | const channelName1 = "test-channel-" + nanoid(); 33 | 34 | // Defining model mocks 35 | let resolveGetChannels; 36 | let fireOnCreateMessage; 37 | let fireOnCreateChannel; 38 | let fireOnUpdateChannel; 39 | 40 | models.Channels.getChannels.mockImplementation(() => { 41 | return new Promise(resolve => { 42 | resolveGetChannels = () => { 43 | resolve(createGetChannelsEmission(channelName1)); 44 | }; 45 | }); 46 | }); 47 | models.Channel.onCreateMessage.mockImplementation(() => { 48 | return new Observable(subscriber => { 49 | fireOnCreateMessage = () => { 50 | subscriber.next(createOnCreateMessageEmission(channelName1)); 51 | }; 52 | }); 53 | }); 54 | models.Channels.onUpdateChannel.mockImplementation(() => { 55 | return new Observable(subscriber => { 56 | fireOnUpdateChannel = (channelName, updatedAt = Date.now()) => { 57 | subscriber.next(createOnUpdateChannelEmission(channelName, updatedAt)); 58 | }; 59 | }); 60 | }); 61 | models.Channels.onCreateChannel.mockImplementation(() => { 62 | return new Observable(subscriber => { 63 | fireOnCreateChannel = (channelName = nanoid()) => { 64 | subscriber.next(createOnCreateChannelEmission(channelName)); 65 | }; 66 | }); 67 | }); 68 | 69 | beforeEach(() => { 70 | localStorage.clear(); 71 | models.Channels.onCreateChannel.mockClear(); 72 | models.Channels.onUpdateChannel.mockClear(); 73 | models.Channels.getChannels.mockClear(); 74 | models.Channel.onCreateMessage.mockClear(); 75 | }); 76 | 77 | it("exports", () => { 78 | expect(ChannelsRoute).toBeDefined(); 79 | }); 80 | 81 | it("renders (smoke test)", () => { 82 | const push = jest.fn(); 83 | render(); 84 | }); 85 | 86 | it("renders header for navigation", async () => { 87 | const push = jest.fn(); 88 | const testUtils = render(); 89 | 90 | await act(async () => { 91 | fireEvent.click(header.me(testUtils)); 92 | }); 93 | expect(push).toBeCalledWith("/me"); 94 | 95 | await act(async () => { 96 | fireEvent.click(header.channels(testUtils)); 97 | }); 98 | expect(push).toBeCalledWith("/channels"); 99 | }); 100 | 101 | it("renders app shell and gets channels", async () => { 102 | const push = jest.fn(); 103 | const testUtils = render(); 104 | 105 | expect(models.Channels.getChannels).toBeCalled(); 106 | await act(async () => { 107 | resolveGetChannels(); 108 | }); 109 | testUtils.getByText(channelName1); 110 | expect(models.Channel.onCreateMessage).toBeCalled(); 111 | }); 112 | 113 | it("renders new message in conversation card when subscription fires", async () => { 114 | const push = jest.fn(); 115 | const testUtils = render(); 116 | expect(models.Channels.getChannels).toBeCalled(); 117 | await act(async () => { 118 | resolveGetChannels(); 119 | }); 120 | testUtils.getByText(channelName1); 121 | expect(models.Channel.onCreateMessage).toBeCalled(); 122 | act(() => { 123 | fireOnCreateMessage(); 124 | }); 125 | // const delay = ms => new Promise(resolve => setTimeout(resolve, ms)); 126 | // await delay(1000); 127 | const lastMessage = testUtils.getByLabelText("Last message"); 128 | expect(lastMessage).toBeTruthy(); 129 | }); 130 | 131 | it("renders new channel when onCreateChannel fires", async () => { 132 | const push = jest.fn(); 133 | const testUtils = render(); 134 | expect(models.Channels.onCreateChannel).toBeCalled(); 135 | 136 | act(() => { 137 | fireOnCreateChannel(); 138 | }); 139 | const allChannels = channels.links(testUtils); 140 | expect("length" in allChannels && allChannels.length).toEqual(1); 141 | }); 142 | 143 | it("updates existing channel when onUpdateChannel fires", async () => { 144 | const push = jest.fn(); 145 | const testUtils = render(); 146 | expect(models.Channels.onCreateChannel).toBeCalled(); 147 | const channel1 = "channel-1"; 148 | const channel2 = "channel-2"; 149 | act(() => { 150 | fireOnCreateChannel(channel1); 151 | fireOnCreateChannel(channel2); 152 | }); 153 | let allChannels = channels.links(testUtils); 154 | expect("length" in allChannels && allChannels.length).toEqual(2); 155 | // Descending sorting by insertion data 156 | within(allChannels[0]).getByText(channel2); 157 | within(allChannels[1]).getByText(channel1); 158 | act(() => { 159 | fireOnUpdateChannel(channel1); 160 | }); 161 | allChannels = channels.links(testUtils); 162 | expect("length" in allChannels && allChannels.length).toEqual(2); 163 | // Make sure channel1 is back at the top 164 | within(allChannels[0]).getByText(channel1); 165 | within(allChannels[1]).getByText(channel2); 166 | }); 167 | 168 | it("navigates to channel page when a channel card is clicked", async () => { 169 | const push = jest.fn(); 170 | const testUtils = render(); 171 | expect(models.Channels.getChannels).toBeCalled(); 172 | await act(async () => { 173 | resolveGetChannels(); 174 | }); 175 | const allChannels = channels.links(testUtils); 176 | expect("length" in allChannels && allChannels.length).toEqual(1); 177 | fireEvent.click(allChannels[0]); 178 | expect(push.mock.calls.length).toEqual(1); 179 | expect(push.mock.calls[0][0].indexOf("/channel?id=")).toEqual(0); 180 | }); 181 | it("can add a channel", () => { 182 | const push = jest.fn(); 183 | const newChannelName = "New channel name"; 184 | 185 | const testUtils = render(); 186 | fireEvent.change(channels.input(testUtils), { 187 | target: { value: newChannelName } 188 | }); 189 | fireEvent.click(channels.button(testUtils)); 190 | }); 191 | }); 192 | -------------------------------------------------------------------------------- /__tests__/my-profile.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { RouterContext } from "next-server/dist/lib/router-context"; 3 | import { NextRouter } from "next/router"; 4 | import { render, fireEvent } from "@testing-library/react"; 5 | 6 | import { profile } from "../src/test-utils/selectors"; 7 | import { MyProfileRoute } from "../src/components/MyProfile"; 8 | import { models } from "../src/models/__mocks__/ModelsContext"; 9 | 10 | const MyProfileTestRoute = ({ pathname = "/", push = jest.fn() }) => ( 11 | 19 | 20 | 21 | ); 22 | 23 | describe("my-profile", () => { 24 | it("exports", () => { 25 | expect(MyProfileRoute).toBeDefined(); 26 | }); 27 | 28 | it("renders (smoke test)", () => { 29 | render(); 30 | }); 31 | 32 | it("renders form and submit button", async () => { 33 | const testUtils = render(); 34 | expect(models.User.getUser).toBeCalled(); 35 | expect(models.User.createUserIfNotExists).toBeCalled(); 36 | const newUsername = "Test Username"; 37 | const newUrl = "Test Url"; 38 | const newBio = "Test Bio"; 39 | const username = profile.username(testUtils); 40 | const url = profile.url(testUtils); 41 | const bio = profile.bio(testUtils); 42 | const submitButton = profile.submit(testUtils); 43 | 44 | fireEvent.change(username, { target: { value: newUsername } }); 45 | fireEvent.change(url, { target: { value: newUrl } }); 46 | fireEvent.change(bio, { target: { value: newBio } }); 47 | 48 | expect(username.value).toEqual(newUsername); 49 | expect(url.value).toEqual(newUrl); 50 | expect(bio.value).toEqual(newBio); 51 | 52 | fireEvent.click(submitButton); 53 | 54 | expect(models.User.upsertUser).toBeCalled(); 55 | const [calledWith] = models.User.upsertUser.mock.calls[0]; 56 | expect(calledWith.bio).toEqual(newBio); 57 | expect(calledWith.url).toEqual(newUrl); 58 | expect(calledWith.name).toEqual(newUsername); 59 | expect(calledWith.id).toBeDefined(); 60 | }); 61 | }); 62 | -------------------------------------------------------------------------------- /__tests__/state.test.ts: -------------------------------------------------------------------------------- 1 | import cases from "jest-in-case"; 2 | 3 | import { State, Action } from "../src/types"; 4 | import { reducer, getInitialState } from "../src/state"; 5 | import { createChannel, createMessage } from "../src/test-utils/data-bank"; 6 | 7 | type ReducerTest = { 8 | state: State; 9 | action: Action; 10 | assertions: (newState: State, state: State, action: Action) => void; 11 | name?: string; 12 | }; 13 | const tests: ReducerTest[] = [ 14 | { 15 | state: getInitialState(), 16 | action: { 17 | type: "append-channels", 18 | payload: { items: [createChannel()], nextToken: null } 19 | }, 20 | assertions: (newState, state, action) => { 21 | expect(newState.channels.items.length).toEqual(1); 22 | }, 23 | name: "Can append channel to empty state" 24 | }, 25 | { 26 | name: "Can prepend a single channel", 27 | state: getInitialState(), 28 | action: { 29 | type: "prepend-channel", 30 | payload: createChannel() 31 | }, 32 | assertions: (newState, state, action) => { 33 | expect(newState.channels.items.length).toEqual(1); 34 | } 35 | }, 36 | { 37 | name: "Can set messages list", 38 | state: getInitialState(), 39 | action: { 40 | type: "set-messages", 41 | payload: { 42 | messages: { 43 | items: [createMessage("test-channel-id")], 44 | nextToken: "" 45 | }, 46 | channelId: "test-channel-id" 47 | } 48 | }, 49 | assertions: (newState, state, action) => { 50 | expect(newState.channels.items.length).toEqual(1); 51 | } 52 | }, 53 | { 54 | name: "Can prepend instead of overwriting when set-channels is invoked", 55 | state: { 56 | ...getInitialState(), 57 | channels: { 58 | items: [createChannel("1")], 59 | nextToken: "" 60 | } 61 | }, 62 | action: { 63 | type: "set-channels", 64 | payload: { 65 | items: [createChannel("2")], 66 | nextToken: "" 67 | } 68 | }, 69 | assertions: (newState, state, action) => { 70 | expect(newState.channels.items.length).toEqual(2); 71 | expect(newState.channels.items[0].id).toEqual("2"); 72 | expect(newState.channels.items[1].id).toEqual("1"); 73 | } 74 | } 75 | ]; 76 | 77 | const runTest = testData => { 78 | const { state, action, assertions } = testData; 79 | const newState = reducer(state, action); 80 | assertions(newState, state, action); 81 | }; 82 | 83 | describe("reducer tests", () => { 84 | it("exports", () => { 85 | expect(reducer).toBeDefined(); 86 | }); 87 | cases("works", runTest, tests); 88 | }); 89 | -------------------------------------------------------------------------------- /amplify.yml: -------------------------------------------------------------------------------- 1 | version: 0.1 2 | frontend: 3 | phases: 4 | preBuild: 5 | commands: 6 | - yarn install 7 | build: 8 | commands: 9 | - yarn run test && yarn run build 10 | artifacts: 11 | baseDirectory: build 12 | files: 13 | - "**/*" 14 | cache: 15 | paths: 16 | - node_modules/**/* 17 | -------------------------------------------------------------------------------- /amplify/.config/project-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "projectName": "paginationandsorting", 3 | "version": "2.0", 4 | "frontend": "javascript", 5 | "javascript": { 6 | "framework": "react", 7 | "config": { 8 | "SourceDir": "src", 9 | "DistributionDir": "build", 10 | "BuildCommand": "npm run-script build", 11 | "StartCommand": "npm run-script start" 12 | } 13 | }, 14 | "providers": [ 15 | "awscloudformation" 16 | ] 17 | } -------------------------------------------------------------------------------- /amplify/backend/api/paginationandsorting/parameters.json: -------------------------------------------------------------------------------- 1 | { 2 | "AppSyncApiName": "paginationandsorting", 3 | "DynamoDBBillingMode": "PAY_PER_REQUEST", 4 | "DynamoDBEnableServerSideEncryption": "false" 5 | } -------------------------------------------------------------------------------- /amplify/backend/api/paginationandsorting/schema.graphql: -------------------------------------------------------------------------------- 1 | type Message @model { 2 | id: ID! 3 | text: String! 4 | createdAt: String 5 | senderId: String 6 | channel: Channel @connection(name: "SortedMessages") 7 | messageChannelId: String 8 | } 9 | 10 | type Channel @model { 11 | id: ID! 12 | name: String! 13 | createdAt: String! 14 | updatedAt: String! 15 | creatorId: String 16 | messages: [Message] 17 | @connection(name: "SortedMessages", sortField: "createdAt") 18 | channelList: ChannelList @connection(name: "SortedChannels") 19 | channelChannelListId: String 20 | } 21 | 22 | type ChannelList @model { 23 | id: ID! 24 | channels: [Channel] 25 | @connection(name: "SortedChannels", sortField: "updatedAt") 26 | } 27 | 28 | type User @model { 29 | id: ID! 30 | name: String 31 | bio: String 32 | url: String 33 | } 34 | 35 | type Subscription { 36 | onCreateChannelInList(channelChannelListId: ID!): Channel 37 | @aws_subscribe(mutations: ["createChannel"]) 38 | 39 | onUpdateChannelInList(channelChannelListId: ID!): Channel 40 | @aws_subscribe(mutations: ["updateChannel"]) 41 | 42 | onCreateMessageInChannel(messageChannelId: ID!): Message 43 | @aws_subscribe(mutations: ["createMessage"]) 44 | } 45 | -------------------------------------------------------------------------------- /amplify/backend/api/paginationandsorting/stacks/CustomResources.json: -------------------------------------------------------------------------------- 1 | { 2 | "AWSTemplateFormatVersion": "2010-09-09", 3 | "Description": "An auto-generated nested stack.", 4 | "Metadata": {}, 5 | "Parameters": { 6 | "AppSyncApiId": { 7 | "Type": "String", 8 | "Description": "The id of the AppSync API associated with this project." 9 | }, 10 | "AppSyncApiName": { 11 | "Type": "String", 12 | "Description": "The name of the AppSync API", 13 | "Default": "AppSyncSimpleTransform" 14 | }, 15 | "env": { 16 | "Type": "String", 17 | "Description": "The environment name. e.g. Dev, Test, or Production", 18 | "Default": "NONE" 19 | }, 20 | "S3DeploymentBucket": { 21 | "Type": "String", 22 | "Description": "The S3 bucket containing all deployment assets for the project." 23 | }, 24 | "S3DeploymentRootKey": { 25 | "Type": "String", 26 | "Description": "An S3 key relative to the S3DeploymentBucket that points to the root\nof the deployment directory." 27 | } 28 | }, 29 | "Resources": { 30 | "EmptyResource": { 31 | "Type": "Custom::EmptyResource", 32 | "Condition": "AlwaysFalse" 33 | } 34 | }, 35 | "Conditions": { 36 | "HasEnvironmentParameter": { 37 | "Fn::Not": [ 38 | { 39 | "Fn::Equals": [ 40 | { 41 | "Ref": "env" 42 | }, 43 | "NONE" 44 | ] 45 | } 46 | ] 47 | }, 48 | "AlwaysFalse": { 49 | "Fn::Equals": [ 50 | "true", 51 | "false" 52 | ] 53 | } 54 | }, 55 | "Outputs": { 56 | "EmptyOutput": { 57 | "Description": "An empty output. You may delete this if you have at least one resource above.", 58 | "Value": "" 59 | } 60 | } 61 | } -------------------------------------------------------------------------------- /amplify/backend/backend-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "api": { 3 | "paginationandsorting": { 4 | "service": "AppSync", 5 | "providerPlugin": "awscloudformation", 6 | "output": { 7 | "securityType": "API_KEY" 8 | } 9 | } 10 | } 11 | } -------------------------------------------------------------------------------- /amplify/team-provider-info.json: -------------------------------------------------------------------------------- 1 | { 2 | "prod": { 3 | "awscloudformation": { 4 | "AuthRoleName": "paginationandsorting-prod-20190816133651-authRole", 5 | "UnauthRoleArn": "arn:aws:iam::534592782540:role/paginationandsorting-prod-20190816133651-unauthRole", 6 | "AuthRoleArn": "arn:aws:iam::534592782540:role/paginationandsorting-prod-20190816133651-authRole", 7 | "Region": "us-east-1", 8 | "DeploymentBucketName": "paginationandsorting-prod-20190816133651-deployment", 9 | "UnauthRoleName": "paginationandsorting-prod-20190816133651-unauthRole", 10 | "StackName": "paginationandsorting-prod-20190816133651", 11 | "StackId": "arn:aws:cloudformation:us-east-1:534592782540:stack/paginationandsorting-prod-20190816133651/c2bc7b20-c011-11e9-9515-0ec11b444504" 12 | } 13 | } 14 | } -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | [ 4 | "@babel/preset-env", 5 | { 6 | targets: { 7 | node: "current" 8 | } 9 | } 10 | ], 11 | "@babel/preset-react", 12 | "@babel/preset-typescript" 13 | ] 14 | }; 15 | -------------------------------------------------------------------------------- /cypress.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /cypress/fixtures/example.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Using fixtures to represent data", 3 | "email": "hello@cypress.io", 4 | "body": "Fixtures are a great way to mock data for responses to routes" 5 | } -------------------------------------------------------------------------------- /cypress/integration/channels.spec.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import nanoid from "nanoid"; 3 | import * as Channels from "../../src/models/Channels"; 4 | import * as Channel from "../../src/models/Channel"; 5 | const PORT = Cypress.env("PORT") || 3000; 6 | const BASE_URL = `http://localhost:${PORT}/`; 7 | 8 | const header = { 9 | root: () => cy.getByLabelText("Header Navigation").should("be.visible"), 10 | me: () => 11 | header 12 | .root() 13 | .within(() => cy.getByText("My Profile")) 14 | .should("be.visible"), 15 | channels: () => 16 | header 17 | .root() 18 | .within(() => cy.getByText("Channels")) 19 | .should("be.visible") 20 | }; 21 | const profile = { 22 | form: () => cy.getByLabelText("Profile Form").should("be.visible"), 23 | submit: () => 24 | cy.getByLabelText("Profile Form Submit Button").should("be.visible"), 25 | username: () => 26 | cy.get('[aria-label="Username"]', { timeout: 5000 }).should("be.visible"), 27 | bio: () => 28 | cy.get('[aria-label="Bio"]', { timeout: 5000 }).should("be.visible"), 29 | url: () => 30 | cy.get('[aria-label="Url"]', { timeout: 5000 }).should("be.visible") 31 | }; 32 | 33 | const channels = { 34 | links: () => cy.get("main a"), 35 | input: () => cy.getByLabelText("Create a new channel").should("be.visible"), 36 | button: () => cy.getByLabelText("Create channel").should("be.visible"), 37 | list: () => cy.get('[aria-label="Channel List"]', { timeout: 7000 }) //getByLabelText("Channel List") //.should("be.visible") 38 | }; 39 | 40 | const messages = { 41 | messageList: () => cy.get('[data-testid="Message"]', { timeout: 7000 }), // cy.get('[aria-label="Message"]', { timeout: 7000 }), // 42 | input: () => cy.getByLabelText("Create a new message").should("be.visible"), 43 | button: () => cy.getByLabelText("Send message").should("be.visible"), 44 | list: () => cy.getByLabelText("Message List").should("be.visible") 45 | }; 46 | 47 | const user = { 48 | name: "Test username", 49 | url: "https://test-url.test", 50 | bio: "Bio Test @ Test BIO" 51 | }; 52 | 53 | const createChannelName = () => "Test Channel " + nanoid(); 54 | const createNewMessage = () => "Test Message " + nanoid(); 55 | // const a:number = 2 56 | const createChannel = async ( 57 | channelName = createChannelName(), 58 | channelId = nanoid(), 59 | creatorId = nanoid() 60 | ) => { 61 | await Channels.createChannel({ 62 | name: channelName, 63 | id: channelId, 64 | createdAt: `${Date.now()}`, 65 | updatedAt: `${Date.now()}`, 66 | creatorId, 67 | messages: { 68 | items: [], 69 | nextToken: "" 70 | } 71 | }); 72 | return channelId; 73 | }; 74 | 75 | const createMessage = async ( 76 | message = createNewMessage(), 77 | channelId: string 78 | ) => { 79 | await Channel.createMessage({ 80 | messageChannelId: channelId, 81 | id: nanoid(), 82 | createdAt: `${Date.now()}`, 83 | senderId: nanoid(), 84 | text: message 85 | }); 86 | }; 87 | 88 | const newMessage = createNewMessage(); 89 | const newChannelName = createChannelName(); 90 | 91 | describe("My Profile", () => { 92 | beforeEach(() => { 93 | cy.visit(BASE_URL); 94 | }); 95 | afterEach(() => { 96 | // For video to better capture what happened 97 | cy.wait(1000); 98 | }); 99 | it("Can visit profile and set information", () => { 100 | header.me().click(); 101 | cy.location("href").should("contain", "/me"); 102 | profile.username().type(`${user.name}{enter}`); 103 | cy.title().should("contain", `${user.name}'s Profile`); 104 | profile.bio().type(`${user.bio}{enter}`); 105 | profile.url().type(`${user.url}`); 106 | profile.submit().click(); 107 | 108 | // Make sure data is persisted between sessions 109 | cy.reload(); 110 | profile.username().should("contain.value", user.name); 111 | profile.bio().should("contain.value", user.bio); 112 | profile.url().should("contain.value", user.url); 113 | }); 114 | }); 115 | 116 | describe("Channel", () => { 117 | beforeEach(() => { 118 | cy.visit(BASE_URL); 119 | }); 120 | afterEach(() => { 121 | // For video to better capture what happened 122 | cy.wait(1000); 123 | }); 124 | 125 | it("Can add a new channel and send a message in it", () => { 126 | channels.input().type(newChannelName); 127 | channels.button().click(); 128 | channels.input().should("be.empty"); 129 | channels 130 | .list() 131 | .within(() => cy.getByText(newChannelName)) 132 | .should("be.visible") 133 | .click(); 134 | 135 | cy.title().should("include", newChannelName); 136 | 137 | messages.input().should("be.visible"); 138 | messages.button().should("be.visible"); 139 | messages.input().type(newMessage); 140 | messages.button().click(); 141 | messages.input().should("be.empty"); 142 | 143 | messages 144 | .list() 145 | .within(() => cy.getByText(newMessage)) 146 | .should("be.visible"); 147 | 148 | cy.reload(); 149 | 150 | messages 151 | .list() 152 | .within(() => cy.getByText(newMessage)) 153 | .should("be.visible"); 154 | }); 155 | it("Can scroll and load more in messages list", () => { 156 | const channelName = createChannelName(); 157 | 158 | const channelId = nanoid(); 159 | createChannel(channelName, channelId); 160 | channels 161 | .list() 162 | .within(() => cy.getByText(channelName)) 163 | .click(); 164 | cy.location("href").should("contain", "/channel?id=" + channelId); 165 | for (let i = 0; i < 15; i++) { 166 | createMessage(undefined, channelId); 167 | } 168 | 169 | cy.clearLocalStorage(); 170 | cy.reload(); 171 | 172 | messages.messageList().should("have.length", 10); 173 | messages.list().scrollTo("bottom"); 174 | messages.messageList().should("have.length", 15); 175 | }); 176 | }); 177 | 178 | describe("Channels", () => { 179 | beforeEach(() => { 180 | cy.visit(BASE_URL); 181 | }); 182 | afterEach(() => { 183 | // For video to better capture what happened 184 | cy.wait(1000); 185 | }); 186 | 187 | it("Can scroll and load more in channels list", () => { 188 | // Make sure there is enough channels for at least 2 pages. 189 | for (let i = 0; i < 15; i++) { 190 | createChannel(); 191 | } 192 | // Promise.all(createChannelsPromises); 193 | cy.clearLocalStorage(); 194 | cy.reload(); 195 | channels.links().should("have.length", 5); 196 | channels.list().scrollTo("bottom"); 197 | channels.links().should("have.length", 10); 198 | channels.list().scrollTo("bottom"); 199 | channels.links().should("have.length", 15); 200 | }); 201 | }); 202 | -------------------------------------------------------------------------------- /cypress/plugins/cy-ts-preprocessor.js: -------------------------------------------------------------------------------- 1 | const wp = require('@cypress/webpack-preprocessor') 2 | 3 | const webpackOptions = { 4 | resolve: { 5 | extensions: ['.ts', '.js'] 6 | }, 7 | module: { 8 | rules: [ 9 | { 10 | test: /\.ts$/, 11 | exclude: [/node_modules/], 12 | use: [ 13 | { 14 | loader: 'ts-loader' 15 | } 16 | ] 17 | } 18 | ] 19 | } 20 | } 21 | 22 | const options = { 23 | webpackOptions 24 | } 25 | 26 | module.exports = wp(options) 27 | -------------------------------------------------------------------------------- /cypress/plugins/index.js: -------------------------------------------------------------------------------- 1 | const cypressTypeScriptPreprocessor = require('./cy-ts-preprocessor') 2 | 3 | module.exports = on => { 4 | on('file:preprocessor', cypressTypeScriptPreprocessor) 5 | } 6 | -------------------------------------------------------------------------------- /cypress/support/commands.js: -------------------------------------------------------------------------------- 1 | import "@testing-library/cypress/add-commands"; 2 | -------------------------------------------------------------------------------- /cypress/support/index.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/index.js is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import './commands' 18 | 19 | // Alternatively you can use CommonJS syntax: 20 | // require('./commands') 21 | -------------------------------------------------------------------------------- /cypress/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "baseUrl": "../node_modules", 5 | "target": "es5", 6 | "lib": ["es5", "dom"], 7 | "types": ["cypress", "@testing-library/cypress/typings", "@types/node"], 8 | "esModuleInterop": true 9 | }, 10 | "include": ["**/*.ts"] 11 | } -------------------------------------------------------------------------------- /cypress/videos/channels.spec.ts.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rakannimer/pagination-and-sorting-with-aws-amplify/51dd4b6631030b451b3aa8633a15b61400491442/cypress/videos/channels.spec.ts.mp4 -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | modulePathIgnorePatterns: ["/cypress/"], 3 | setupFiles: ["./jest.setup.js"] 4 | }; 5 | -------------------------------------------------------------------------------- /jest.setup.js: -------------------------------------------------------------------------------- 1 | jest.mock("./src/models/ModelsContext"); 2 | jest.mock("./src/models/User"); 3 | jest.mock("./src/models/Channel"); 4 | jest.mock("./src/models/Channels"); 5 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | // const withOffline = require("next-offline"); 2 | 3 | module.exports = { 4 | webpack: config => { 5 | // Fixes npm packages that depend on `fs` module 6 | config.node = { 7 | fs: "empty" 8 | }; 9 | 10 | return config; 11 | } 12 | }; 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pagination-and-sorting", 3 | "scripts": { 4 | "build": "next build && next export -o build/", 5 | "dev": "next", 6 | "lint": "eslint src/**/*.ts* src/*.ts*", 7 | "mock:api": "yarn kill-amplify-mock-api && amplify mock api", 8 | "kill-amplify-mock-api": "kill-port 20002 && kill-port 20003", 9 | "reset-mock": "yarn kill-amplify-mock-api && amplify mock api", 10 | "start": "next start", 11 | "test:static": "yarn lint && yarn tsc", 12 | "test:jest": "yarn jest", 13 | "test:e2e": "/bin/bash ./scripts/e2e.sh", 14 | "test:e2e:dev": "yarn kill-amplify-mock-api && (amplify mock api &) && wait-on http-get://localhost:20002 && kill-port 3000 && (yarn dev &) && wait-on http-get://localhost:3000 && PORT=3000 cypress open --env PORT=3000", 15 | "test": "yarn test:static && yarn test:jest" 16 | }, 17 | "dependencies": { 18 | "@aws-amplify/api": "^1.0.42", 19 | "@aws-amplify/pubsub": "^1.1.0", 20 | "immer": "^3.2.0", 21 | "lodash.memoize": "^4.1.2", 22 | "nanoid": "^2.0.3", 23 | "next": "^9.0.3", 24 | "next-offline": "^4.0.3", 25 | "react": "^16.9.0", 26 | "react-animated-css": "^1.2.1", 27 | "react-dom": "^16.9.0", 28 | "react-intersection-observer": "^8.24.1", 29 | "react-native-web": "^0.11.6" 30 | }, 31 | "devDependencies": { 32 | "@babel/core": "^7.5.5", 33 | "@babel/preset-env": "^7.5.5", 34 | "@babel/preset-react": "^7.0.0", 35 | "@babel/preset-typescript": "^7.3.3", 36 | "@bahmutov/add-typescript-to-cypress": "^2.1.2", 37 | "@testing-library/cypress": "^4.1.0", 38 | "@testing-library/react": "^9.1.1", 39 | "@types/jest": "^24.0.17", 40 | "@types/jest-in-case": "^1.0.1", 41 | "@types/lodash.memoize": "^4.1.6", 42 | "@types/nanoid": "^2.0.0", 43 | "@types/node": "^12.7.1", 44 | "@types/react": "^16.8.24", 45 | "@types/react-native-web": "npm:@types/react-native", 46 | "@types/react-router": "^5.0.3", 47 | "@types/react-router-dom": "^4.3.4", 48 | "@typescript-eslint/eslint-plugin": "^2.0.0", 49 | "@typescript-eslint/parser": "^2.0.0", 50 | "babel-jest": "^24.8.0", 51 | "cypress": "^3.4.1", 52 | "eslint": "^6.1.0", 53 | "eslint-plugin-cypress": "^2.6.1", 54 | "eslint-plugin-jest": "^22.15.1", 55 | "eslint-plugin-react": "^7.14.3", 56 | "husky": "^3.0.4", 57 | "jest": "^24.8.0", 58 | "jest-in-case": "^1.0.2", 59 | "kill-port": "^1.5.1", 60 | "prettier": "^1.18.2", 61 | "rxjs": "^6.5.2", 62 | "typescript": "^3.5.3", 63 | "wait-on": "^3.3.0" 64 | }, 65 | "husky": { 66 | "pre-commit": "prettier --write \"src/*.ts\" \"src/**/*.ts*\"", 67 | "pre-push": "yarn test" 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import Document, { Head, Main, NextScript } from "next/document"; 2 | import React from "react"; 3 | import { AppRegistry } from "react-native-web"; 4 | 5 | // Force Next-generated DOM elements to fill their parent's height 6 | const normalizeNextElements = ` 7 | #__next { 8 | display: flex; 9 | flex-direction: column; 10 | height: 100%; 11 | } 12 | `; 13 | 14 | export default class MyDocument extends Document { 15 | static async getInitialProps({ renderPage }: { renderPage: Function }) { 16 | AppRegistry.registerComponent("Main", () => Main); 17 | //@ts-ignore Cheating because of lack of react-native-web types 18 | const { getStyleElement } = AppRegistry.getApplication("Main"); 19 | const page = renderPage(); 20 | const styles = [ 21 |