├── .editorconfig ├── .env ├── .eslintignore ├── .eslintrc.json ├── .gitignore ├── .prettierignore ├── .prettierrc ├── .vscode └── extensions.json ├── README.md ├── apps ├── api-e2e │ ├── .eslintrc.json │ ├── jest.config.ts │ ├── project.json │ ├── src │ │ ├── api │ │ │ └── api.spec.ts │ │ └── support │ │ │ ├── global-setup.ts │ │ │ ├── global-teardown.ts │ │ │ └── test-setup.ts │ ├── tsconfig.json │ └── tsconfig.spec.json ├── api │ ├── .eslintrc.json │ ├── jest.config.ts │ ├── project.json │ ├── src │ │ ├── app │ │ │ ├── .gitkeep │ │ │ └── app.module.ts │ │ ├── assets │ │ │ └── .gitkeep │ │ ├── environments │ │ │ ├── environment.prod.ts │ │ │ └── environment.ts │ │ └── main.ts │ ├── tsconfig.app.json │ ├── tsconfig.json │ ├── tsconfig.spec.json │ └── webpack.config.js ├── web-e2e │ ├── .eslintrc.json │ ├── playwright.config.ts │ ├── project.json │ ├── src │ │ └── example.spec.ts │ └── tsconfig.json └── web │ ├── .babelrc │ ├── .eslintrc.json │ ├── jest.config.ts │ ├── project.json │ ├── src │ ├── app │ │ ├── app.context.tsx │ │ ├── app.tsx │ │ ├── data-access │ │ │ ├── db.ts │ │ │ ├── donasi.model.ts │ │ │ ├── donasi.schema.ts │ │ │ ├── local.ts │ │ │ └── server.ts │ │ ├── footer │ │ │ ├── footer.tsx │ │ │ └── link.tsx │ │ ├── header │ │ │ └── header.tsx │ │ ├── icons │ │ │ ├── add.svg │ │ │ ├── back.svg │ │ │ ├── home.svg │ │ │ ├── more.svg │ │ │ ├── search.svg │ │ │ └── sync.svg │ │ └── main │ │ │ ├── add │ │ │ └── add.tsx │ │ │ ├── list │ │ │ ├── list.tsx │ │ │ ├── local-data.tsx │ │ │ ├── server-data.tsx │ │ │ └── summary.tsx │ │ │ ├── main.tsx │ │ │ ├── search │ │ │ ├── search.spec.tsx │ │ │ └── search.tsx │ │ │ └── sync │ │ │ └── sync.tsx │ ├── assets │ │ ├── .gitkeep │ │ └── icons │ │ │ ├── icon-128x128.png │ │ │ ├── icon-144x144.png │ │ │ ├── icon-152x152.png │ │ │ ├── icon-192x192.png │ │ │ ├── icon-384x384.png │ │ │ ├── icon-512x512.png │ │ │ ├── icon-72x72.png │ │ │ └── icon-96x96.png │ ├── favicon.ico │ ├── index.html │ ├── main.tsx │ ├── manifest.webmanifest │ ├── serviceworker.js │ └── styles.css │ ├── tsconfig.app.json │ ├── tsconfig.json │ ├── tsconfig.spec.json │ └── webpack.config.js ├── docker-compose.yml ├── jest.config.ts ├── jest.preset.js ├── libs ├── gql │ ├── .eslintrc.json │ ├── README.md │ ├── jest.config.ts │ ├── project.json │ ├── src │ │ ├── gql.module.ts │ │ ├── index.ts │ │ ├── models │ │ │ ├── batch-payload.ts │ │ │ ├── donasi-create-input.ts │ │ │ ├── donasi-list-input.ts │ │ │ ├── donasi-pagelist-input.ts │ │ │ └── donasi.ts │ │ └── resolvers │ │ │ └── donasi.resolver.ts │ ├── tsconfig.json │ ├── tsconfig.lib.json │ └── tsconfig.spec.json └── services │ ├── .eslintrc.json │ ├── README.md │ ├── jest.config.ts │ ├── project.json │ ├── src │ ├── donasi │ │ └── donasi.service.ts │ ├── index.ts │ ├── prisma │ │ └── prisma.service.ts │ └── services.module.ts │ ├── tsconfig.json │ ├── tsconfig.lib.json │ └── tsconfig.spec.json ├── nx.json ├── package-lock.json ├── package.json ├── prisma └── schema.prisma ├── screenshot ├── 1.png └── 2.png └── tsconfig.base.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | max_line_length = off 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | # Environment variables declared in this file are automatically made available to Prisma. 2 | # See the documentation for more detail: https://pris.ly/d/prisma-schema#using-environment-variables 3 | 4 | # Prisma supports the native connection string format for PostgreSQL, MySQL and SQLite. 5 | # See the documentation for all the connection string options: https://pris.ly/d/connection-strings 6 | 7 | ADMINER_PORT=7000 8 | 9 | POSTGRES_USER=postgres 10 | POSTGRES_PASSWORD=kosong 11 | POSTGRES_PORT=54321 12 | POSTGRES_DB=offline 13 | 14 | DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:${POSTGRES_PORT}/${POSTGRES_DB}?schema=public" 15 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "ignorePatterns": ["**/*"], 4 | "plugins": ["@nx"], 5 | "overrides": [ 6 | { 7 | "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], 8 | "rules": { 9 | "@nx/enforce-module-boundaries": [ 10 | "error", 11 | { 12 | "enforceBuildableLibDependency": true, 13 | "allow": [], 14 | "depConstraints": [ 15 | { 16 | "sourceTag": "*", 17 | "onlyDependOnLibsWithTags": ["*"] 18 | } 19 | ] 20 | } 21 | ] 22 | } 23 | }, 24 | { 25 | "files": ["*.ts", "*.tsx"], 26 | "extends": ["plugin:@nx/typescript"], 27 | "rules": {} 28 | }, 29 | { 30 | "files": ["*.js", "*.jsx"], 31 | "extends": ["plugin:@nx/javascript"], 32 | "rules": {} 33 | }, 34 | { 35 | "files": ["*.spec.ts", "*.spec.tsx", "*.spec.js", "*.spec.jsx"], 36 | "env": { 37 | "jest": true 38 | }, 39 | "rules": {} 40 | } 41 | ] 42 | } 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | dist 5 | tmp 6 | /out-tsc 7 | 8 | # dependencies 9 | node_modules 10 | 11 | # IDEs and editors 12 | /.idea 13 | .project 14 | .classpath 15 | .c9/ 16 | *.launch 17 | .settings/ 18 | *.sublime-workspace 19 | 20 | # IDE - VSCode 21 | .vscode/* 22 | !.vscode/settings.json 23 | !.vscode/tasks.json 24 | !.vscode/launch.json 25 | !.vscode/extensions.json 26 | 27 | # misc 28 | /.sass-cache 29 | /connect.lock 30 | /coverage 31 | /libpeerconnection.log 32 | npm-debug.log 33 | yarn-error.log 34 | testem.log 35 | /typings 36 | 37 | # System Files 38 | .DS_Store 39 | Thumbs.db 40 | 41 | .nx/cache 42 | 43 | /prisma/migrations 44 | /prisma/schema.gql 45 | migrations.json 46 | debug.log 47 | todo.txt -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Add files here to ignore them from prettier formatting 2 | /dist 3 | /coverage 4 | /.nx/cache -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "nrwl.angular-console", 4 | "esbenp.prettier-vscode", 5 | "firsttris.vscode-jest-runner", 6 | "ms-playwright.playwright" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Offline 2 | 3 | Sample offline first using Nx monorepo, React, Mantine, RxDb, NestJs, Prisma, GraphQl, PostgreSql 4 | 5 | ## Install 6 | 7 | clone this repo `git clone https://github.com/madipta/offline-first.git` 8 | 9 | goto folder 10 | `cd offline-first` 11 | 12 | install node_modules packages 13 | `npm install` or `yarn` 14 | 15 | run postgresql using docker 16 | `docker-compose up` 17 | 18 | make sure database service is running before generate database 19 | 20 | then generate database 21 | `npm run migrate:dev` or `yarn run migrate:dev` 22 | 23 | generate prisma client 24 | `npm run prisma:generate` or `yarn run prisma:generate` 25 | 26 | ## How to Run 27 | 28 | run nest api server 29 | `npx nx serve api` or `yarn nx serve api` 30 | 31 | run React web server 32 | `npx nx serve` or `yarn nx serve` 33 | 34 | open browser http://localhost:4200 35 | 36 |

37 | 38 | 39 |

40 | -------------------------------------------------------------------------------- /apps/api-e2e/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["../../.eslintrc.json"], 3 | "ignorePatterns": ["!**/*"], 4 | "overrides": [ 5 | { 6 | "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], 7 | "rules": {} 8 | }, 9 | { 10 | "files": ["*.ts", "*.tsx"], 11 | "rules": {} 12 | }, 13 | { 14 | "files": ["*.js", "*.jsx"], 15 | "rules": {} 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /apps/api-e2e/jest.config.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | export default { 3 | displayName: 'api-e2e', 4 | preset: '../../jest.preset.js', 5 | globalSetup: '/src/support/global-setup.ts', 6 | globalTeardown: '/src/support/global-teardown.ts', 7 | setupFiles: ['/src/support/test-setup.ts'], 8 | testEnvironment: 'node', 9 | transform: { 10 | '^.+\\.[tj]s$': [ 11 | 'ts-jest', 12 | { 13 | tsconfig: '/tsconfig.spec.json', 14 | }, 15 | ], 16 | }, 17 | moduleFileExtensions: ['ts', 'js', 'html'], 18 | coverageDirectory: '../../coverage/api-e2e', 19 | }; 20 | -------------------------------------------------------------------------------- /apps/api-e2e/project.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "api-e2e", 3 | "$schema": "../../node_modules/nx/schemas/project-schema.json", 4 | "implicitDependencies": ["api"], 5 | "projectType": "application", 6 | "targets": { 7 | "e2e": { 8 | "executor": "@nx/jest:jest", 9 | "outputs": ["{workspaceRoot}/coverage/{e2eProjectRoot}"], 10 | "options": { 11 | "jestConfig": "apps/api-e2e/jest.config.ts", 12 | "passWithNoTests": true 13 | } 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /apps/api-e2e/src/api/api.spec.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | describe('GET /api', () => { 4 | it('should return a message', async () => { 5 | const res = await axios.get(`/api`); 6 | 7 | expect(res.status).toBe(200); 8 | expect(res.data).toEqual({ message: 'Hello API' }); 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /apps/api-e2e/src/support/global-setup.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | var __TEARDOWN_MESSAGE__: string; 3 | 4 | module.exports = async function () { 5 | // Start services that that the app needs to run (e.g. database, docker-compose, etc.). 6 | console.log('\nSetting up...\n'); 7 | 8 | // Hint: Use `globalThis` to pass variables to global teardown. 9 | globalThis.__TEARDOWN_MESSAGE__ = '\nTearing down...\n'; 10 | }; 11 | -------------------------------------------------------------------------------- /apps/api-e2e/src/support/global-teardown.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 3 | module.exports = async function () { 4 | // Put clean up logic here (e.g. stopping services, docker-compose, etc.). 5 | // Hint: `globalThis` is shared between setup and teardown. 6 | console.log(globalThis.__TEARDOWN_MESSAGE__); 7 | }; 8 | -------------------------------------------------------------------------------- /apps/api-e2e/src/support/test-setup.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 3 | import axios from 'axios'; 4 | 5 | module.exports = async function () { 6 | // Configure axios for tests to use. 7 | const host = process.env.HOST ?? 'localhost'; 8 | const port = process.env.PORT ?? '3000'; 9 | axios.defaults.baseURL = `http://${host}:${port}`; 10 | }; 11 | -------------------------------------------------------------------------------- /apps/api-e2e/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "files": [], 4 | "include": [], 5 | "references": [ 6 | { 7 | "path": "./tsconfig.spec.json" 8 | } 9 | ], 10 | "compilerOptions": { 11 | "esModuleInterop": true 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /apps/api-e2e/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "module": "commonjs", 6 | "types": ["jest", "node"] 7 | }, 8 | "include": ["jest.config.ts", "src/**/*.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /apps/api/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["../../.eslintrc.json"], 3 | "ignorePatterns": ["!**/*"], 4 | "overrides": [ 5 | { 6 | "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], 7 | "rules": {} 8 | }, 9 | { 10 | "files": ["*.ts", "*.tsx"], 11 | "rules": {} 12 | }, 13 | { 14 | "files": ["*.js", "*.jsx"], 15 | "rules": {} 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /apps/api/jest.config.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | export default { 3 | displayName: 'api', 4 | preset: '../../jest.preset.js', 5 | testEnvironment: 'node', 6 | transform: { 7 | '^.+\\.[tj]s$': ['ts-jest', { tsconfig: '/tsconfig.spec.json' }], 8 | }, 9 | moduleFileExtensions: ['ts', 'js', 'html'], 10 | coverageDirectory: '../../coverage/apps/api', 11 | }; 12 | -------------------------------------------------------------------------------- /apps/api/project.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "api", 3 | "$schema": "../../node_modules/nx/schemas/project-schema.json", 4 | "sourceRoot": "apps/api/src", 5 | "projectType": "application", 6 | "targets": { 7 | "serve": { 8 | "executor": "@nx/js:node", 9 | "defaultConfiguration": "development", 10 | "options": { 11 | "buildTarget": "api:build" 12 | }, 13 | "configurations": { 14 | "development": { 15 | "buildTarget": "api:build:development" 16 | }, 17 | "production": { 18 | "buildTarget": "api:build:production" 19 | } 20 | } 21 | } 22 | }, 23 | "tags": [] 24 | } 25 | -------------------------------------------------------------------------------- /apps/api/src/app/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/madipta/offline-first/3ca3fe0a205c7ec9099cce23bc52f28d16be1ac2/apps/api/src/app/.gitkeep -------------------------------------------------------------------------------- /apps/api/src/app/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { GqlModule } from '@offline-first/gql'; 3 | 4 | @Module({ 5 | imports: [GqlModule], 6 | }) 7 | export class AppModule {} 8 | -------------------------------------------------------------------------------- /apps/api/src/assets/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/madipta/offline-first/3ca3fe0a205c7ec9099cce23bc52f28d16be1ac2/apps/api/src/assets/.gitkeep -------------------------------------------------------------------------------- /apps/api/src/environments/environment.prod.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | production: true, 3 | }; 4 | -------------------------------------------------------------------------------- /apps/api/src/environments/environment.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | production: false, 3 | }; 4 | -------------------------------------------------------------------------------- /apps/api/src/main.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from '@nestjs/common'; 2 | import { NestFactory } from '@nestjs/core'; 3 | import { AppModule } from './app/app.module'; 4 | 5 | async function bootstrap() { 6 | const app = await NestFactory.create(AppModule); 7 | const globalPrefix = 'graphql'; 8 | const port = process.env.PORT || 3333; 9 | app.setGlobalPrefix(globalPrefix); 10 | app.enableCors(); 11 | await app.listen(port, () => { 12 | Logger.log('Listening at http://localhost:' + port + '/' + globalPrefix); 13 | }); 14 | } 15 | 16 | bootstrap(); 17 | -------------------------------------------------------------------------------- /apps/api/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "module": "commonjs", 6 | "types": ["node"], 7 | "emitDecoratorMetadata": true, 8 | "target": "es2021" 9 | }, 10 | "exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"], 11 | "include": ["src/**/*.ts"] 12 | } 13 | -------------------------------------------------------------------------------- /apps/api/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "files": [], 4 | "include": [], 5 | "references": [ 6 | { 7 | "path": "./tsconfig.app.json" 8 | }, 9 | { 10 | "path": "./tsconfig.spec.json" 11 | } 12 | ], 13 | "compilerOptions": { 14 | "esModuleInterop": true 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /apps/api/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "module": "commonjs", 6 | "types": ["jest", "node"] 7 | }, 8 | "include": [ 9 | "jest.config.ts", 10 | "src/**/*.test.ts", 11 | "src/**/*.spec.ts", 12 | "src/**/*.d.ts" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /apps/api/webpack.config.js: -------------------------------------------------------------------------------- 1 | const { NxWebpackPlugin } = require('@nx/webpack'); 2 | const { join } = require('path'); 3 | 4 | module.exports = { 5 | output: { 6 | path: join(__dirname, '../../dist/apps/api'), 7 | }, 8 | plugins: [ 9 | new NxWebpackPlugin({ 10 | target: 'node', 11 | compiler: 'tsc', 12 | main: './src/main.ts', 13 | tsConfig: './tsconfig.app.json', 14 | assets: ['./src/assets'], 15 | optimization: false, 16 | outputHashing: 'none', 17 | }), 18 | ], 19 | }; 20 | -------------------------------------------------------------------------------- /apps/web-e2e/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["plugin:playwright/recommended", "../../.eslintrc.json"], 3 | "ignorePatterns": ["!**/*"], 4 | "overrides": [ 5 | { 6 | "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], 7 | "rules": {} 8 | }, 9 | { 10 | "files": ["*.ts", "*.tsx"], 11 | "rules": {} 12 | }, 13 | { 14 | "files": ["*.js", "*.jsx"], 15 | "rules": {} 16 | }, 17 | { 18 | "files": ["src/**/*.{ts,js,tsx,jsx}"], 19 | "rules": {} 20 | } 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /apps/web-e2e/playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, devices } from '@playwright/test'; 2 | import { nxE2EPreset } from '@nx/playwright/preset'; 3 | 4 | import { workspaceRoot } from '@nx/devkit'; 5 | 6 | // For CI, you may want to set BASE_URL to the deployed application. 7 | const baseURL = process.env['BASE_URL'] || 'http://localhost:4200'; 8 | 9 | /** 10 | * Read environment variables from file. 11 | * https://github.com/motdotla/dotenv 12 | */ 13 | // require('dotenv').config(); 14 | 15 | /** 16 | * See https://playwright.dev/docs/test-configuration. 17 | */ 18 | export default defineConfig({ 19 | ...nxE2EPreset(__filename, { testDir: './src' }), 20 | /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ 21 | use: { 22 | baseURL, 23 | /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ 24 | trace: 'on-first-retry', 25 | }, 26 | /* Run your local dev server before starting the tests */ 27 | webServer: { 28 | command: 'npx nx serve web', 29 | url: 'http://localhost:4200', 30 | reuseExistingServer: !process.env.CI, 31 | cwd: workspaceRoot, 32 | }, 33 | projects: [ 34 | { 35 | name: 'chromium', 36 | use: { ...devices['Desktop Chrome'] }, 37 | }, 38 | 39 | { 40 | name: 'firefox', 41 | use: { ...devices['Desktop Firefox'] }, 42 | }, 43 | 44 | { 45 | name: 'webkit', 46 | use: { ...devices['Desktop Safari'] }, 47 | }, 48 | 49 | // Uncomment for mobile browsers support 50 | /* { 51 | name: 'Mobile Chrome', 52 | use: { ...devices['Pixel 5'] }, 53 | }, 54 | { 55 | name: 'Mobile Safari', 56 | use: { ...devices['iPhone 12'] }, 57 | }, */ 58 | 59 | // Uncomment for branded browsers 60 | /* { 61 | name: 'Microsoft Edge', 62 | use: { ...devices['Desktop Edge'], channel: 'msedge' }, 63 | }, 64 | { 65 | name: 'Google Chrome', 66 | use: { ...devices['Desktop Chrome'], channel: 'chrome' }, 67 | } */ 68 | ], 69 | }); 70 | -------------------------------------------------------------------------------- /apps/web-e2e/project.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "web-e2e", 3 | "$schema": "../../node_modules/nx/schemas/project-schema.json", 4 | "projectType": "application", 5 | "sourceRoot": "apps/web-e2e/src", 6 | "targets": {}, 7 | "implicitDependencies": ["web"] 8 | } 9 | -------------------------------------------------------------------------------- /apps/web-e2e/src/example.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/test'; 2 | 3 | test('has title', async ({ page }) => { 4 | await page.goto('/'); 5 | 6 | // Expect h1 to contain a substring. 7 | expect(await page.locator('h1').innerText()).toContain('Welcome'); 8 | }); 9 | -------------------------------------------------------------------------------- /apps/web-e2e/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "compilerOptions": { 4 | "allowJs": true, 5 | "outDir": "../../dist/out-tsc", 6 | "module": "commonjs", 7 | "sourceMap": false 8 | }, 9 | "include": [ 10 | "**/*.ts", 11 | "**/*.js", 12 | "playwright.config.ts", 13 | "src/**/*.spec.ts", 14 | "src/**/*.spec.js", 15 | "src/**/*.test.ts", 16 | "src/**/*.test.js", 17 | "src/**/*.d.ts" 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /apps/web/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@nx/react/babel", 5 | { 6 | "runtime": "automatic" 7 | } 8 | ] 9 | ], 10 | "plugins": [] 11 | } 12 | -------------------------------------------------------------------------------- /apps/web/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["plugin:@nx/react", "../../.eslintrc.json"], 3 | "ignorePatterns": ["!**/*"], 4 | "overrides": [ 5 | { 6 | "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], 7 | "rules": {} 8 | }, 9 | { 10 | "files": ["*.ts", "*.tsx"], 11 | "rules": {} 12 | }, 13 | { 14 | "files": ["*.js", "*.jsx"], 15 | "rules": {} 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /apps/web/jest.config.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | export default { 3 | displayName: 'web', 4 | preset: '../../jest.preset.js', 5 | transform: { 6 | '^(?!.*\\.(js|jsx|ts|tsx|css|json)$)': '@nx/react/plugins/jest', 7 | '^.+\\.[tj]sx?$': ['babel-jest', { presets: ['@nx/react/babel'] }], 8 | }, 9 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], 10 | coverageDirectory: '../../coverage/apps/web', 11 | }; 12 | -------------------------------------------------------------------------------- /apps/web/project.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "web", 3 | "$schema": "../../node_modules/nx/schemas/project-schema.json", 4 | "sourceRoot": "apps/web/src", 5 | "projectType": "application", 6 | "targets": {}, 7 | "tags": [] 8 | } 9 | -------------------------------------------------------------------------------- /apps/web/src/app/app.context.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, useState } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | export const DonasiSumContext = createContext({ 5 | sum: 0, 6 | setSum: (val) => val 7 | }); 8 | 9 | export const DonasiSumProvider = (props) => { 10 | // Initial values are obtained from the props 11 | const { sum: initialSum, children } = props; 12 | 13 | // Use State to keep the values 14 | 15 | const [sum, setSum] = useState(initialSum); 16 | 17 | // Make the context object: 18 | const sumContext = { 19 | sum, 20 | setSum, 21 | }; 22 | 23 | // pass the value in provider and return 24 | return ( 25 | 26 | {children} 27 | 28 | ); 29 | }; 30 | 31 | DonasiSumProvider.propTypes = { 32 | sum: PropTypes.number, 33 | }; 34 | 35 | DonasiSumProvider.defaultProps = { 36 | sum: 0, 37 | }; 38 | -------------------------------------------------------------------------------- /apps/web/src/app/app.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | ApolloClient, 3 | HttpLink, 4 | InMemoryCache, 5 | ApolloProvider, 6 | } from '@apollo/client'; 7 | import { MantineProvider, AppShell, Footer, Header } from '@mantine/core'; 8 | import { DonasiSumProvider } from './app.context'; 9 | import AppFooter from './footer/footer'; 10 | import AppHeader from './header/header'; 11 | import AppMain from './main/main'; 12 | 13 | const client = new ApolloClient({ 14 | link: new HttpLink({ 15 | uri: 'http://localhost:3333/graphql', 16 | }), 17 | cache: new InMemoryCache(), 18 | }); 19 | 20 | export function App() { 21 | return ( 22 | 23 | 24 | 25 | ({ 30 | backgroundColor: theme.colors.gray[0], 31 | borderTopColor: theme.colors.gray[2], 32 | borderTopStyle: 'solid', 33 | borderTopWidth: '1px', 34 | display: 'flex', 35 | flexDirection: 'column', 36 | justifyContent: 'center', 37 | paddingTop: '4px', 38 | })} 39 | > 40 | 41 | 42 | } 43 | header={ 44 |
({ 47 | alignItems: 'center', 48 | backgroundColor: theme.colors.teal[9], 49 | color: '#fff', 50 | display: 'flex', 51 | fontSize: '1.8rem', 52 | lineHeight: '52px', 53 | padding: '0 16px 2px', 54 | })} 55 | > 56 | 57 |
58 | } 59 | > 60 | 61 |
62 |
63 |
64 |
65 | ); 66 | } 67 | 68 | export default App; 69 | -------------------------------------------------------------------------------- /apps/web/src/app/data-access/db.ts: -------------------------------------------------------------------------------- 1 | import { addRxPlugin, createRxDatabase } from 'rxdb'; 2 | import { getRxStorageDexie } from 'rxdb/plugins/storage-dexie'; 3 | import { RxDBDevModePlugin } from 'rxdb/plugins/dev-mode'; 4 | import { DonasiSchema } from './donasi.schema'; 5 | 6 | addRxPlugin(RxDBDevModePlugin); 7 | 8 | const createDatabase = async () => { 9 | const db = await createRxDatabase({ 10 | name: 'donasidb', 11 | storage: getRxStorageDexie(), 12 | }); 13 | await db.addCollections({ 14 | donasi: { 15 | schema: DonasiSchema, 16 | }, 17 | }); 18 | return db; 19 | }; 20 | 21 | let dbPromise = null; 22 | 23 | export const GetDatabase = async () => { 24 | return (dbPromise = dbPromise ?? (await createDatabase())); 25 | }; 26 | 27 | export const GetDonasi = async () => { 28 | return (await GetDatabase()).donasi; 29 | }; 30 | -------------------------------------------------------------------------------- /apps/web/src/app/data-access/donasi.model.ts: -------------------------------------------------------------------------------- 1 | export interface IDonasi { 2 | id: string; 3 | createdAt: number | Date, 4 | name: string; 5 | phone: string; 6 | amount: number; 7 | sync: 0 | 1; 8 | } 9 | -------------------------------------------------------------------------------- /apps/web/src/app/data-access/donasi.schema.ts: -------------------------------------------------------------------------------- 1 | export const DonasiSchema = { 2 | title: 'donasi schema', 3 | version: 0, 4 | type: 'object', 5 | primaryKey: 'id', 6 | properties: { 7 | id: { 8 | type: 'string', 9 | maxLength: 10 10 | }, 11 | createdAt: { 12 | type: 'number', 13 | minimum: 0, 14 | maximum: 10000000000000, 15 | multipleOf: 1 16 | }, 17 | name: { 18 | type: 'string', 19 | maxLength: 100 20 | }, 21 | phone: { 22 | type: 'string', 23 | }, 24 | amount: { 25 | type: 'number', 26 | }, 27 | syncedAt: { 28 | type: 'number', 29 | }, 30 | sync: { 31 | type: 'number', 32 | default: 0 33 | }, 34 | }, 35 | required: ['name', 'amount'], 36 | indexes: ['name', 'createdAt'], 37 | }; 38 | -------------------------------------------------------------------------------- /apps/web/src/app/data-access/local.ts: -------------------------------------------------------------------------------- 1 | import { customAlphabet } from 'nanoid'; 2 | import { useState, useEffect } from 'react'; 3 | import { GetDonasi } from './db'; 4 | 5 | const nanoid = customAlphabet('1234567890abcdefhijklmnopqrstuvwxyz', 10); 6 | 7 | export const FindDonasi = async (options) => { 8 | return (await GetDonasi()).find(options).exec(); 9 | }; 10 | 11 | export const InsertDonasi = async (values) => { 12 | values.id = values.id ?? nanoid(); 13 | values.amount = +values.amount; 14 | values.createdAt = Date.now(); 15 | values.sync = 0; 16 | return (await GetDonasi()).insert(values); 17 | }; 18 | 19 | export const UpdateDonasi = async (id, values) => { 20 | const donasi = await GetDonasi(); 21 | donasi 22 | .findOne({ selector: { $and: [{ id: { $eq: id }, sync: { $eq: 0 } }] } }) 23 | .exec() 24 | .then((doc) => { 25 | doc && doc.update({ $set: { ...values } }); 26 | }); 27 | }; 28 | 29 | export const DeleteDonasi = async (id) => { 30 | const donasi = await GetDonasi(); 31 | return donasi 32 | .find({ selector: { $and: [{ id: { $eq: id }, sync: { $eq: 0 } }] } }) 33 | .remove(); 34 | }; 35 | 36 | export function useLocalDonasiList() { 37 | const [data, setData] = useState([]); 38 | const [loading, setLoading] = useState(false); 39 | const [error, setError] = useState([]); 40 | useEffect(() => { 41 | setLoading(true); 42 | FindDonasi({ 43 | selector: {}, 44 | sort: [{ createdAt: 'desc' }], 45 | }) 46 | .then((res) => { 47 | setData(res); 48 | }) 49 | .catch((err) => { 50 | setError(err); 51 | }) 52 | .finally(() => { 53 | setLoading(false); 54 | }); 55 | }, []); 56 | return { data, loading, error }; 57 | } 58 | -------------------------------------------------------------------------------- /apps/web/src/app/data-access/server.ts: -------------------------------------------------------------------------------- 1 | import { gql, useLazyQuery, useMutation } from '@apollo/client'; 2 | import { customAlphabet } from 'nanoid'; 3 | 4 | const nanoid = customAlphabet('1234567890abcdefhijklmnopqrstuvwxyz', 10); 5 | 6 | const DonasiListGQL = gql` 7 | query DonasiListInput($filter: String, $sort: String, $order: String) { 8 | list(data: { filter: $filter, sort: $sort, order: $order }) { 9 | id 10 | createdAt 11 | name 12 | phone 13 | amount 14 | syncedAt 15 | sync 16 | } 17 | } 18 | `; 19 | 20 | export function useDonasiList() { 21 | return useLazyQuery(DonasiListGQL, { 22 | fetchPolicy: 'cache-and-network', 23 | }); 24 | } 25 | 26 | export function useDonasiSearch() { 27 | return useLazyQuery(DonasiListGQL, { 28 | fetchPolicy: 'cache-and-network', 29 | }); 30 | } 31 | 32 | const DonasiInsertGql = gql` 33 | mutation DonasiCreateInputs( 34 | $id: String! 35 | $createdAt: Float! 36 | $name: String! 37 | $phone: String 38 | $amount: Float! 39 | ) { 40 | create( 41 | data: { 42 | id: $id 43 | createdAt: $createdAt 44 | name: $name 45 | phone: $phone 46 | amount: $amount 47 | } 48 | ) { 49 | sync 50 | syncedAt 51 | } 52 | } 53 | `; 54 | 55 | const donasiMaper = (val) => { 56 | const { id, amount, cretedAt, ...rest } = val; 57 | return { 58 | ...rest, 59 | id: id ?? nanoid(), 60 | amount: +amount, 61 | createdAt: Date.now(), 62 | }; 63 | }; 64 | 65 | export function useDonasiInsert() { 66 | const [add, result] = useMutation(DonasiInsertGql); 67 | return [ 68 | async (opt) => { 69 | opt.variables = donasiMaper(opt.variables); 70 | return await add(opt); 71 | }, 72 | result, 73 | ]; 74 | } 75 | -------------------------------------------------------------------------------- /apps/web/src/app/footer/footer.tsx: -------------------------------------------------------------------------------- 1 | import { Box } from '@mantine/core'; 2 | import AddIcon from '../icons/add.svg'; 3 | import HomeIcon from '../icons/home.svg'; 4 | import SearchIcon from '../icons/search.svg'; 5 | import SyncIcon from '../icons/sync.svg'; 6 | import { Link } from './link'; 7 | 8 | export function Footer() { 9 | return ( 10 | ({ 13 | display: 'flex', 14 | justifyContent: 'space-around', 15 | margin: '0 auto', 16 | width: '220px', 17 | })} 18 | > 19 | }> 20 | }> 21 | }> 22 | }> 23 | 24 | ); 25 | } 26 | 27 | export default Footer; 28 | -------------------------------------------------------------------------------- /apps/web/src/app/footer/link.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Text } from '@mantine/core'; 2 | import { NavLink } from 'react-router-dom'; 3 | 4 | export function Link({ to, title, icon }) { 5 | return ( 6 | 17 | ({ 19 | color: theme.colors.green[9], 20 | height: '24px', 21 | width: '24px', 22 | })} 23 | > 24 | {icon} 25 | 26 | ({ 28 | color: theme.colors.gray[5], 29 | })} 30 | > 31 | {title} 32 | 33 | 34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /apps/web/src/app/header/header.tsx: -------------------------------------------------------------------------------- 1 | import { Route, Routes } from 'react-router-dom'; 2 | 3 | export function Header() { 4 | return ( 5 | 6 | 7 | 8 | 9 | 10 | 11 | ); 12 | } 13 | 14 | export default Header; 15 | -------------------------------------------------------------------------------- /apps/web/src/app/icons/add.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /apps/web/src/app/icons/back.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /apps/web/src/app/icons/home.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /apps/web/src/app/icons/more.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /apps/web/src/app/icons/search.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /apps/web/src/app/icons/sync.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /apps/web/src/app/main/add/add.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Button, Group, LoadingOverlay, TextInput } from '@mantine/core'; 2 | import { useForm } from '@mantine/form'; 3 | import { useState } from 'react'; 4 | import { InsertDonasi } from '../../data-access/local'; 5 | 6 | function Add() { 7 | const [isLoading, setLoading] = useState(false); 8 | const form = useForm({ 9 | initialValues: { 10 | name: '', 11 | phone: '', 12 | amount: 10000, 13 | }, 14 | validate: { 15 | name: (value) => { 16 | return value.trim().length < 3 ? 'Required, min: 3 chars' : null; 17 | }, 18 | amount: (value) => { 19 | return isNaN(value) || value < 100 20 | ? 'Numeric Required, min: 100' 21 | : null; 22 | }, 23 | }, 24 | }); 25 | 26 | return ( 27 | ({ 29 | maxWidth: '300px', 30 | margin: '12px auto', 31 | })} 32 | > 33 |
{ 36 | try { 37 | setLoading(true); 38 | await InsertDonasi(values); 39 | form.reset(); 40 | } catch (error) { 41 | console.log(error); 42 | } finally { 43 | setTimeout(() => { 44 | setLoading(false); 45 | }, 300); 46 | } 47 | }, 48 | (validationErrors, _values, _event) => { 49 | console.log(validationErrors); 50 | } 51 | )} 52 | > 53 | 54 | 60 | 65 | 71 | 72 | 75 | 76 | 77 |
78 | ); 79 | } 80 | 81 | export default Add; 82 | -------------------------------------------------------------------------------- /apps/web/src/app/main/list/list.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Tabs } from '@mantine/core'; 2 | import { useState } from 'react'; 3 | import LocalData from './local-data'; 4 | import ServerData from './server-data'; 5 | import Summary from './summary'; 6 | 7 | function List() { 8 | const [selected, setSelected] = useState('server'); 9 | const Comp = selected === 'server' ? ServerData : LocalData; 10 | return ( 11 | 18 | 19 | 20 | Saved 21 | Draft 22 | 23 | 24 | 32 | 33 | 34 | 35 | 36 | ); 37 | } 38 | 39 | export default List; 40 | -------------------------------------------------------------------------------- /apps/web/src/app/main/list/local-data.tsx: -------------------------------------------------------------------------------- 1 | import { Table } from '@mantine/core'; 2 | import { useContext, useEffect } from 'react'; 3 | import { DonasiSumContext } from '../../app.context'; 4 | import { useLocalDonasiList } from '../../data-access/local'; 5 | 6 | export function LocalData() { 7 | const { data } = useLocalDonasiList(); 8 | const sumContext = useContext(DonasiSumContext); 9 | const { setSum } = sumContext; 10 | useEffect(() => { 11 | if (data.length) { 12 | setSum(data.map((d) => d.amount).reduce((a, b) => a + b, 0)); 13 | } 14 | else { 15 | setSum(0); 16 | } 17 | }, [data, setSum]); 18 | return ( 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | {data.map((d, i) => ( 29 | 30 | 31 | 32 | 33 | 34 | ))} 35 | 36 |
No.Name+(Phone)Amount
{i + 1}{d.name} {d.phone ? ` (${d.phone})`: ""}{d.amount}
37 | ); 38 | } 39 | 40 | export default LocalData; 41 | -------------------------------------------------------------------------------- /apps/web/src/app/main/list/server-data.tsx: -------------------------------------------------------------------------------- 1 | import { Table } from '@mantine/core'; 2 | import { useContext, useEffect } from 'react'; 3 | import { DonasiSumContext } from '../../app.context'; 4 | import { useDonasiList } from '../../data-access/server'; 5 | 6 | export function ServerData() { 7 | const [getList, { data }] = useDonasiList(); 8 | const sumContext = useContext(DonasiSumContext); 9 | const { setSum } = sumContext; 10 | useEffect(() => { 11 | getList(); 12 | }, [getList]); 13 | useEffect(() => { 14 | if (data) { 15 | const list = data.list; 16 | if (list.length) { 17 | setSum(list.map((d) => d.amount).reduce((a, b) => a + b, 0)); 18 | } else { 19 | setSum(0); 20 | } 21 | } 22 | }, [data, setSum]); 23 | if (!data) return <> ; 24 | return ( 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | {data.list.map((d, i) => ( 35 | 36 | 37 | 38 | 39 | 40 | ))} 41 | 42 |
No.Name+(Phone)Amount
{i + 1}{d.name} {d.phone ? ` (${d.phone})`: ""}{d.amount}
43 | ); 44 | } 45 | 46 | export default ServerData; 47 | -------------------------------------------------------------------------------- /apps/web/src/app/main/list/summary.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Text } from '@mantine/core'; 2 | import { useContext } from 'react'; 3 | import { DonasiSumContext } from '../../app.context'; 4 | 5 | export function Summary() { 6 | const { sum } = useContext(DonasiSumContext); 7 | return ( 8 | ({ 10 | alignItems: 'center', 11 | display: 'flex', 12 | gap: '0.5rem', 13 | justifyContent: 'center', 14 | })} 15 | > 16 | ({ fontSize: '.8em' })}>total: 17 | ({ fontSize: '.85em' })}>{sum.toLocaleString()} 18 | 19 | ); 20 | } 21 | 22 | export default Summary; 23 | -------------------------------------------------------------------------------- /apps/web/src/app/main/main.tsx: -------------------------------------------------------------------------------- 1 | import { Route, Routes } from 'react-router-dom'; 2 | import Add from './add/add'; 3 | import List from './list/list'; 4 | import Search from './search/search'; 5 | import Sync from './sync/sync'; 6 | 7 | function Main() { 8 | return ( 9 | 10 | } /> 11 | } /> 12 | } /> 13 | } /> 14 | 15 | ); 16 | } 17 | 18 | export default Main; 19 | -------------------------------------------------------------------------------- /apps/web/src/app/main/search/search.spec.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react'; 2 | 3 | import Search from './search'; 4 | 5 | describe('Search', () => { 6 | it('should render successfully', () => { 7 | const { baseElement } = render(); 8 | expect(baseElement).toBeTruthy(); 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /apps/web/src/app/main/search/search.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Box, 3 | Button, 4 | Group, 5 | LoadingOverlay, 6 | Radio, 7 | Table, 8 | TextInput, 9 | } from '@mantine/core'; 10 | import { useForm } from '@mantine/form'; 11 | import { useState } from 'react'; 12 | import { useDonasiSearch } from '../../data-access/server'; 13 | 14 | export function Search() { 15 | const [isLoading, setLoading] = useState(false); 16 | const [getList, { data }] = useDonasiSearch(); 17 | const form = useForm({ 18 | initialValues: { 19 | search: '', 20 | source: 'server', 21 | }, 22 | validate: { 23 | search: (value) => { 24 | return value.trim().length < 3 ? 'Required, min: 3 chars' : null; 25 | }, 26 | }, 27 | }); 28 | return ( 29 | ({ 31 | maxWidth: '800px', 32 | margin: '12px auto 0', 33 | })} 34 | > 35 | ({ 37 | maxWidth: '300px', 38 | margin: '0 auto', 39 | })} 40 | > 41 |
{ 44 | try { 45 | setLoading(true); 46 | getList({ variables: { filter: values.search } }); 47 | } catch (error) { 48 | console.log(error); 49 | } finally { 50 | setTimeout(() => { 51 | setLoading(false); 52 | }, 300); 53 | } 54 | }, 55 | (validationErrors, _values, _event) => { 56 | console.log(validationErrors); 57 | } 58 | )} 59 | > 60 | 61 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 76 | 77 | 78 |
79 | {data && ( 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | {data.list.map((d, i) => ( 90 | 91 | 92 | 96 | 97 | 98 | ))} 99 | 100 |
No.Name+(Phone)Amount
{i + 1} 93 |

{d.name}

94 | {d.phone} 95 |
{d.amount.toLocaleString()}
101 | )} 102 |
103 | ); 104 | } 105 | 106 | export default Search; 107 | -------------------------------------------------------------------------------- /apps/web/src/app/main/sync/sync.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Button, LoadingOverlay, Table, Text } from '@mantine/core'; 2 | import { useForm } from '@mantine/form'; 3 | import { useReducer, useState } from 'react'; 4 | import { DeleteDonasi, FindDonasi } from '../../data-access/local'; 5 | import { useDonasiInsert } from '../../data-access/server'; 6 | 7 | export function Sync() { 8 | const [count, setCount] = useReducer((c, cp) => (cp ? c + 1 : 0), 0); 9 | const [success, setSuccess] = useReducer( 10 | (old, val) => (val ? [...old, val] : []), 11 | [] 12 | ); 13 | const form = useForm({}); 14 | const [isLoading, setLoading] = useState(false); 15 | const [add] = useDonasiInsert(); 16 | return ( 17 |
{ 19 | try { 20 | setLoading(true); 21 | const data = await FindDonasi({ 22 | selector: {}, 23 | sort: [{ createdAt: 'desc' }], 24 | }); 25 | if (data && data.length) { 26 | data.forEach(async (val, i) => { 27 | const { id, name, phone, amount, createdAt } = val; 28 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 29 | // @ts-ignore 30 | await add({ 31 | variables: { id, name, phone, amount, createdAt }, 32 | }); 33 | await DeleteDonasi(id); 34 | setCount(1); 35 | setSuccess({ id, name, phone, amount }); 36 | }); 37 | } 38 | } catch (error) { 39 | console.log(error); 40 | } finally { 41 | setLoading(false); 42 | } 43 | })} 44 | > 45 | 46 | ({ 48 | display: 'flex', 49 | flexDirection: 'column', 50 | alignItems: 'center', 51 | paddingTop: '24px', 52 | })} 53 | > 54 | 55 | Sinkronisasi data ke server 56 | 57 | 60 | 61 | {count} data synced. 62 | 63 | {count > 0 && ( 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | {success.map((d, i) => ( 74 | 75 | 76 | 79 | 80 | 81 | ))} 82 | 83 |
No.Name+(Phone)Amount
{i + 1} 77 | {d.name} {d.phone ? ` (${d.phone})` : ''} 78 | {d.amount.toLocaleString()}
84 | )} 85 |
86 | 87 | ); 88 | } 89 | 90 | export default Sync; 91 | -------------------------------------------------------------------------------- /apps/web/src/assets/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/madipta/offline-first/3ca3fe0a205c7ec9099cce23bc52f28d16be1ac2/apps/web/src/assets/.gitkeep -------------------------------------------------------------------------------- /apps/web/src/assets/icons/icon-128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/madipta/offline-first/3ca3fe0a205c7ec9099cce23bc52f28d16be1ac2/apps/web/src/assets/icons/icon-128x128.png -------------------------------------------------------------------------------- /apps/web/src/assets/icons/icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/madipta/offline-first/3ca3fe0a205c7ec9099cce23bc52f28d16be1ac2/apps/web/src/assets/icons/icon-144x144.png -------------------------------------------------------------------------------- /apps/web/src/assets/icons/icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/madipta/offline-first/3ca3fe0a205c7ec9099cce23bc52f28d16be1ac2/apps/web/src/assets/icons/icon-152x152.png -------------------------------------------------------------------------------- /apps/web/src/assets/icons/icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/madipta/offline-first/3ca3fe0a205c7ec9099cce23bc52f28d16be1ac2/apps/web/src/assets/icons/icon-192x192.png -------------------------------------------------------------------------------- /apps/web/src/assets/icons/icon-384x384.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/madipta/offline-first/3ca3fe0a205c7ec9099cce23bc52f28d16be1ac2/apps/web/src/assets/icons/icon-384x384.png -------------------------------------------------------------------------------- /apps/web/src/assets/icons/icon-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/madipta/offline-first/3ca3fe0a205c7ec9099cce23bc52f28d16be1ac2/apps/web/src/assets/icons/icon-512x512.png -------------------------------------------------------------------------------- /apps/web/src/assets/icons/icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/madipta/offline-first/3ca3fe0a205c7ec9099cce23bc52f28d16be1ac2/apps/web/src/assets/icons/icon-72x72.png -------------------------------------------------------------------------------- /apps/web/src/assets/icons/icon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/madipta/offline-first/3ca3fe0a205c7ec9099cce23bc52f28d16be1ac2/apps/web/src/assets/icons/icon-96x96.png -------------------------------------------------------------------------------- /apps/web/src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/madipta/offline-first/3ca3fe0a205c7ec9099cce23bc52f28d16be1ac2/apps/web/src/favicon.ico -------------------------------------------------------------------------------- /apps/web/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Donasi 6 | 7 | 8 | 9 | 10 | 11 | 12 | 51 | 52 | 53 |
54 | 88 | 89 | 90 | -------------------------------------------------------------------------------- /apps/web/src/main.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from 'react'; 2 | import * as ReactDOM from 'react-dom/client'; 3 | import { BrowserRouter } from 'react-router-dom'; 4 | 5 | import App from './app/app'; 6 | 7 | const root = ReactDOM.createRoot( 8 | document.getElementById('root') as HTMLElement 9 | ); 10 | root.render( 11 | 12 | 13 | 14 | 15 | 16 | ); 17 | -------------------------------------------------------------------------------- /apps/web/src/manifest.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "offline", 3 | "short_name": "offline", 4 | "theme_color": "#1b9aaa", 5 | "background_color": "#fafafa", 6 | "display": "standalone", 7 | "scope": "./", 8 | "start_url": "./", 9 | "icons": [ 10 | { 11 | "src": "assets/icons/icon-72x72.png", 12 | "sizes": "72x72", 13 | "type": "image/png", 14 | "purpose": "maskable any" 15 | }, 16 | { 17 | "src": "assets/icons/icon-96x96.png", 18 | "sizes": "96x96", 19 | "type": "image/png", 20 | "purpose": "maskable any" 21 | }, 22 | { 23 | "src": "assets/icons/icon-128x128.png", 24 | "sizes": "128x128", 25 | "type": "image/png", 26 | "purpose": "maskable any" 27 | }, 28 | { 29 | "src": "assets/icons/icon-144x144.png", 30 | "sizes": "144x144", 31 | "type": "image/png", 32 | "purpose": "maskable any" 33 | }, 34 | { 35 | "src": "assets/icons/icon-152x152.png", 36 | "sizes": "152x152", 37 | "type": "image/png", 38 | "purpose": "maskable any" 39 | }, 40 | { 41 | "src": "assets/icons/icon-192x192.png", 42 | "sizes": "192x192", 43 | "type": "image/png", 44 | "purpose": "maskable any" 45 | }, 46 | { 47 | "src": "assets/icons/icon-384x384.png", 48 | "sizes": "384x384", 49 | "type": "image/png", 50 | "purpose": "maskable any" 51 | }, 52 | { 53 | "src": "assets/icons/icon-512x512.png", 54 | "sizes": "512x512", 55 | "type": "image/png", 56 | "purpose": "maskable any" 57 | } 58 | ] 59 | } 60 | -------------------------------------------------------------------------------- /apps/web/src/serviceworker.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-restricted-globals */ 2 | 3 | const STATIC = 'precache-v1'; 4 | const DYNAMIC = 'runtime'; 5 | 6 | // A list of local resources we always want to be cached. 7 | const PRECACHE_URLS = [ 8 | 'index.html', 9 | './', // Alias for index.html 10 | ]; 11 | 12 | // The install handler takes care of precaching the resources we always need. 13 | self.addEventListener('install', (event) => { 14 | event.waitUntil( 15 | (async function () { 16 | const cache = await caches.open(STATIC); 17 | await cache.addAll(PRECACHE_URLS); 18 | })() 19 | ); 20 | }); 21 | 22 | // The activate handler takes care of cleaning up old caches. 23 | self.addEventListener('activate', (event) => { 24 | event.waitUntil( 25 | (async function () { 26 | const cacheNames = await caches.keys(); 27 | const currentCaches = [STATIC, DYNAMIC]; 28 | await Promise.all( 29 | cacheNames 30 | .filter((cacheName) => { 31 | return !currentCaches.includes(cacheName); 32 | }) 33 | .map((cacheName) => caches.delete(cacheName)) 34 | ); 35 | })() 36 | ); 37 | }); 38 | 39 | // The fetch handler serves responses for same-origin resources from a cache. 40 | // If no response is found, it populates the runtime cache with the response 41 | // from the network before returning it to the page. 42 | self.addEventListener('fetch', (event) => { 43 | const req = event.request; 44 | if (req.method === 'POST' && req.url.includes('/graphql')) { 45 | graphqlHandler(event); 46 | return; 47 | } 48 | if (req.method === 'GET') { 49 | event.respondWith( 50 | (async function () { 51 | const cache = await caches.open(DYNAMIC); 52 | const cachedResponse = await cache.match(event.request); 53 | try { 54 | const networkResponse = await fetch(event.request); 55 | event.waitUntil( 56 | (async function () { 57 | await cache.put(event.request, networkResponse.clone()); 58 | })() 59 | ); 60 | return networkResponse; 61 | } catch (err) { 62 | return cachedResponse; 63 | } 64 | })() 65 | ); 66 | return; 67 | } 68 | }); 69 | 70 | function hash(x) { 71 | let h, i, l; 72 | for (h = 5381 | 0, i = 0, l = x.length | 0; i < l; i++) { 73 | h = (h << 5) + h + x.charCodeAt(i); 74 | } 75 | 76 | return h >>> 0; 77 | } 78 | 79 | async function graphqlHandler(e) { 80 | const exclude = [/mutation/, /query Identity/]; 81 | const queryId = await e.request 82 | .clone() 83 | .json() 84 | .then(({ query, variables }) => { 85 | if (exclude.some((r) => r.test(query))) { 86 | return; 87 | } 88 | // Mocks a request since `caches` only works with requests. 89 | return `https://query_${hash(JSON.stringify({ query, variables }))}`; 90 | }); 91 | 92 | if (!queryId) { 93 | return; 94 | } 95 | 96 | const networkResponse = fromNetwork(e.request); 97 | 98 | e.respondWith( 99 | (async () => { 100 | const cachedResult = queryId && (await fromCache(queryId)); 101 | if (cachedResult) { 102 | return cachedResult; 103 | } 104 | return networkResponse.then((res) => res.clone()); 105 | })() 106 | ); 107 | 108 | e.waitUntil( 109 | (async () => { 110 | try { 111 | const res = await networkResponse.then((res) => res.clone()); 112 | if (queryId) { 113 | await saveToCache(queryId, res); 114 | } 115 | } catch (err) { 116 | console.log(err); 117 | } 118 | })() 119 | ); 120 | } 121 | 122 | async function fromCache(request) { 123 | const cache = await caches.open(DYNAMIC); 124 | const matching = await cache.match(request); 125 | 126 | return matching; 127 | } 128 | 129 | function fromNetwork(request) { 130 | return fetch(request); 131 | } 132 | 133 | async function saveToCache(request, response) { 134 | const cache = await caches.open(DYNAMIC); 135 | await cache.put(request, response); 136 | } 137 | -------------------------------------------------------------------------------- /apps/web/src/styles.css: -------------------------------------------------------------------------------- 1 | /* You can add global styles to this file, and also import other style files */ 2 | -------------------------------------------------------------------------------- /apps/web/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "types": [ 6 | "node", 7 | 8 | "@nx/react/typings/cssmodule.d.ts", 9 | "@nx/react/typings/image.d.ts" 10 | ] 11 | }, 12 | "exclude": [ 13 | "jest.config.ts", 14 | "src/**/*.spec.ts", 15 | "src/**/*.test.ts", 16 | "src/**/*.spec.tsx", 17 | "src/**/*.test.tsx", 18 | "src/**/*.spec.js", 19 | "src/**/*.test.js", 20 | "src/**/*.spec.jsx", 21 | "src/**/*.test.jsx" 22 | ], 23 | "include": ["src/**/*.js", "src/**/*.jsx", "src/**/*.ts", "src/**/*.tsx"] 24 | } 25 | -------------------------------------------------------------------------------- /apps/web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "jsx": "react-jsx", 4 | "allowJs": false, 5 | "esModuleInterop": false, 6 | "allowSyntheticDefaultImports": true, 7 | "strict": false 8 | }, 9 | "files": [], 10 | "include": [], 11 | "references": [ 12 | { 13 | "path": "./tsconfig.app.json" 14 | }, 15 | { 16 | "path": "./tsconfig.spec.json" 17 | } 18 | ], 19 | "extends": "../../tsconfig.base.json" 20 | } 21 | -------------------------------------------------------------------------------- /apps/web/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "module": "commonjs", 6 | "types": [ 7 | "jest", 8 | "node", 9 | "@nx/react/typings/cssmodule.d.ts", 10 | "@nx/react/typings/image.d.ts" 11 | ] 12 | }, 13 | "include": [ 14 | "jest.config.ts", 15 | "src/**/*.test.ts", 16 | "src/**/*.spec.ts", 17 | "src/**/*.test.tsx", 18 | "src/**/*.spec.tsx", 19 | "src/**/*.test.js", 20 | "src/**/*.spec.js", 21 | "src/**/*.test.jsx", 22 | "src/**/*.spec.jsx", 23 | "src/**/*.d.ts" 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /apps/web/webpack.config.js: -------------------------------------------------------------------------------- 1 | const { NxWebpackPlugin } = require('@nx/webpack'); 2 | const { NxReactWebpackPlugin } = require('@nx/react'); 3 | const { join } = require('path'); 4 | 5 | module.exports = { 6 | output: { 7 | path: join(__dirname, '../../dist/apps/web'), 8 | }, 9 | devServer: { 10 | port: 4200, 11 | }, 12 | plugins: [ 13 | new NxWebpackPlugin({ 14 | tsConfig: './tsconfig.app.json', 15 | compiler: 'babel', 16 | main: './src/main.tsx', 17 | index: './src/index.html', 18 | baseHref: '/', 19 | assets: [ 20 | './src/favicon.ico', 21 | './src/manifest.webmanifest', 22 | './src/serviceworker.js', 23 | './src/assets', 24 | ], 25 | styles: ['./src/styles.css'], 26 | outputHashing: process.env['NODE_ENV'] === 'production' ? 'all' : 'none', 27 | optimization: process.env['NODE_ENV'] === 'production', 28 | }), 29 | new NxReactWebpackPlugin({ 30 | // Uncomment this line if you don't want to use SVGR 31 | // See: https://react-svgr.com/ 32 | // svgr: false 33 | }), 34 | ], 35 | }; 36 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | adminer: 4 | image: adminer:4-standalone 5 | restart: always 6 | ports: 7 | - ${ADMINER_PORT}:8080 8 | pg: 9 | image: "postgres:13-alpine" 10 | env_file: 11 | - .env 12 | ports: 13 | - ${POSTGRES_PORT}:5432 14 | volumes: 15 | - ./tmp/:/var/lib/postgresql/data/ 16 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | import { getJestProjects } from '@nx/jest'; 2 | 3 | export default { 4 | projects: getJestProjects(), 5 | }; 6 | -------------------------------------------------------------------------------- /jest.preset.js: -------------------------------------------------------------------------------- 1 | const nxPreset = require('@nx/jest/preset').default; 2 | 3 | module.exports = { ...nxPreset }; 4 | -------------------------------------------------------------------------------- /libs/gql/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["../../.eslintrc.json"], 3 | "ignorePatterns": ["!**/*"], 4 | "overrides": [ 5 | { 6 | "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], 7 | "rules": {} 8 | }, 9 | { 10 | "files": ["*.ts", "*.tsx"], 11 | "rules": {} 12 | }, 13 | { 14 | "files": ["*.js", "*.jsx"], 15 | "rules": {} 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /libs/gql/README.md: -------------------------------------------------------------------------------- 1 | # gql 2 | 3 | This library was generated with [Nx](https://nx.dev). 4 | 5 | ## Running unit tests 6 | 7 | Run `nx test gql` to execute the unit tests via [Jest](https://jestjs.io). 8 | -------------------------------------------------------------------------------- /libs/gql/jest.config.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | export default { 3 | displayName: 'gql', 4 | preset: '../../jest.preset.js', 5 | testEnvironment: 'node', 6 | transform: { 7 | '^.+\\.[tj]s$': ['ts-jest', { tsconfig: '/tsconfig.spec.json' }], 8 | }, 9 | moduleFileExtensions: ['ts', 'js', 'html'], 10 | coverageDirectory: '../../coverage/libs/gql', 11 | }; 12 | -------------------------------------------------------------------------------- /libs/gql/project.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gql", 3 | "$schema": "../../node_modules/nx/schemas/project-schema.json", 4 | "sourceRoot": "libs/gql/src", 5 | "projectType": "library", 6 | "targets": {}, 7 | "tags": [] 8 | } 9 | -------------------------------------------------------------------------------- /libs/gql/src/gql.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo'; 3 | import { GraphQLModule } from '@nestjs/graphql'; 4 | import { join } from 'path'; 5 | import { DonasiService, ServicesModule } from '@offline-first/services'; 6 | import { DonasiResolver } from './resolvers/donasi.resolver'; 7 | 8 | @Module({ 9 | imports: [ 10 | ServicesModule, 11 | GraphQLModule.forRoot({ 12 | driver: ApolloDriver, 13 | autoSchemaFile: join(process.cwd(), 'prisma/schema.gql'), 14 | installSubscriptionHandlers: true, 15 | }), 16 | ], 17 | providers: [DonasiService, DonasiResolver], 18 | }) 19 | export class GqlModule {} 20 | -------------------------------------------------------------------------------- /libs/gql/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './gql.module'; 2 | -------------------------------------------------------------------------------- /libs/gql/src/models/batch-payload.ts: -------------------------------------------------------------------------------- 1 | import { ObjectType, Field } from '@nestjs/graphql'; 2 | 3 | @ObjectType() 4 | export class BatchPayload { 5 | @Field() 6 | count: number; 7 | } 8 | -------------------------------------------------------------------------------- /libs/gql/src/models/donasi-create-input.ts: -------------------------------------------------------------------------------- 1 | import { InputType, Field } from '@nestjs/graphql'; 2 | 3 | @InputType() 4 | export class DonasiCreateInput { 5 | @Field() 6 | createdAt: number; 7 | 8 | @Field(() => String) 9 | id: string; 10 | 11 | @Field(() => String) 12 | name?: string | null; 13 | 14 | @Field(() => String, { nullable: true }) 15 | phone?: string | null; 16 | 17 | @Field() 18 | amount: number; 19 | } 20 | -------------------------------------------------------------------------------- /libs/gql/src/models/donasi-list-input.ts: -------------------------------------------------------------------------------- 1 | import { InputType, Field } from '@nestjs/graphql'; 2 | 3 | @InputType() 4 | export class DonasiListInput { 5 | @Field(() => String, { nullable: true }) 6 | filter?: string | null; 7 | 8 | @Field(() => String, { nullable: true }) 9 | sort?: string | null; 10 | 11 | @Field(() => String, { nullable: true }) 12 | order?: string | null; 13 | } 14 | -------------------------------------------------------------------------------- /libs/gql/src/models/donasi-pagelist-input.ts: -------------------------------------------------------------------------------- 1 | import { InputType, Field } from '@nestjs/graphql'; 2 | 3 | @InputType() 4 | export class DonasiPagelistInput { 5 | @Field() 6 | take: number; 7 | 8 | @Field() 9 | skip: number; 10 | 11 | @Field(() => String, { nullable: true }) 12 | filter?: string | null; 13 | 14 | @Field(() => String, { nullable: true }) 15 | sort?: string | null; 16 | 17 | @Field(() => String, { nullable: true }) 18 | order?: string | null; 19 | } 20 | -------------------------------------------------------------------------------- /libs/gql/src/models/donasi.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata' 2 | import { ObjectType, Field, ID } from '@nestjs/graphql'; 3 | 4 | @ObjectType() 5 | export class Donasi { 6 | @Field(() => ID) 7 | sid: number 8 | 9 | @Field() 10 | id: string 11 | 12 | @Field() 13 | createdAt: Date 14 | 15 | @Field(() => String, { nullable: true }) 16 | name?: string | null 17 | 18 | @Field(() => String, { nullable: true }) 19 | phone?: string | null 20 | 21 | @Field() 22 | amount: number 23 | 24 | @Field(() => Date, { nullable: true }) 25 | syncedAt?: Date | null 26 | 27 | @Field() 28 | sync: boolean 29 | } 30 | -------------------------------------------------------------------------------- /libs/gql/src/resolvers/donasi.resolver.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata'; 2 | import { 3 | Resolver, 4 | Query, 5 | Mutation, 6 | Args, 7 | InputType, 8 | Field, 9 | } from '@nestjs/graphql'; 10 | import { Inject } from '@nestjs/common'; 11 | import { DonasiService } from '@offline-first/services'; 12 | import { Donasi } from '../models/donasi'; 13 | import { DonasiCreateInput } from '../models/donasi-create-input'; 14 | import { BatchPayload } from '../models/batch-payload'; 15 | import { DonasiListInput } from '../models/donasi-list-input'; 16 | import { DonasiPagelistInput } from '../models/donasi-pagelist-input'; 17 | 18 | @InputType() 19 | class DonasiCreateInputs { 20 | @Field(() => [DonasiCreateInput]) 21 | batch: DonasiCreateInput[]; 22 | } 23 | 24 | const donasiMapper = (data: DonasiCreateInput) => { 25 | const { createdAt, ...rest } = data; 26 | return { 27 | ...rest, 28 | createdAt: new Date(createdAt), 29 | }; 30 | }; 31 | 32 | @Resolver(Donasi) 33 | export class DonasiResolver { 34 | constructor(@Inject(DonasiService) private donasiService: DonasiService) {} 35 | 36 | @Mutation(() => Donasi) 37 | async create(@Args('data') data: DonasiCreateInput): Promise { 38 | return this.donasiService.create(donasiMapper(data)); 39 | } 40 | 41 | @Mutation(() => BatchPayload) 42 | async createMany( 43 | @Args('data') data: DonasiCreateInputs 44 | ): Promise { 45 | const dataFixed = data.batch.map((val) => donasiMapper(val)); 46 | return this.donasiService.createMany(dataFixed); 47 | } 48 | 49 | @Query(() => Donasi, { nullable: true }) 50 | async oneById(@Args('id') sid: number) { 51 | return this.donasiService.oneById({ sid }); 52 | } 53 | 54 | @Query(() => [Donasi]) 55 | async pagelist(@Args('data') data: DonasiPagelistInput) { 56 | const { skip, take, filter } = data; 57 | let { sort, order } = data; 58 | sort = sort ?? 'syncedAt'; 59 | order = order ?? 'desc'; 60 | const orderBy = { [sort]: order }; 61 | let where = {}; 62 | if (filter) { 63 | where = { 64 | OR: [{ name: { contains: filter } }, { phone: { contains: filter } }], 65 | }; 66 | } 67 | return this.donasiService.pagelist({ 68 | skip, 69 | take, 70 | where, 71 | orderBy, 72 | }); 73 | } 74 | 75 | @Query(() => [Donasi]) 76 | async list(@Args('data') data: DonasiListInput) { 77 | let { sort, order } = data; 78 | sort = sort ?? 'syncedAt'; 79 | order = order ?? 'desc'; 80 | const orderBy = { [sort]: order }; 81 | const filter = data.filter; 82 | let where = {}; 83 | if (filter) { 84 | where = { 85 | OR: [{ name: { contains: filter } }, { phone: { contains: filter } }], 86 | }; 87 | } 88 | return this.donasiService.pagelist({ 89 | where, 90 | orderBy, 91 | }); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /libs/gql/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "compilerOptions": { 4 | "module": "commonjs", 5 | "forceConsistentCasingInFileNames": true, 6 | "strict": false, 7 | "noImplicitOverride": true, 8 | "noPropertyAccessFromIndexSignature": true, 9 | "noImplicitReturns": true, 10 | "noFallthroughCasesInSwitch": true 11 | }, 12 | "files": [], 13 | "include": [], 14 | "references": [ 15 | { 16 | "path": "./tsconfig.lib.json" 17 | }, 18 | { 19 | "path": "./tsconfig.spec.json" 20 | } 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /libs/gql/tsconfig.lib.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "declaration": true, 6 | "types": ["node"], 7 | "target": "es2021", 8 | "strictNullChecks": true, 9 | "noImplicitAny": true, 10 | "strictBindCallApply": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "noFallthroughCasesInSwitch": true 13 | }, 14 | "include": ["src/**/*.ts"], 15 | "exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"] 16 | } 17 | -------------------------------------------------------------------------------- /libs/gql/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "module": "commonjs", 6 | "types": ["jest", "node"] 7 | }, 8 | "include": [ 9 | "jest.config.ts", 10 | "src/**/*.test.ts", 11 | "src/**/*.spec.ts", 12 | "src/**/*.d.ts" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /libs/services/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["../../.eslintrc.json"], 3 | "ignorePatterns": ["!**/*"], 4 | "overrides": [ 5 | { 6 | "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], 7 | "rules": {} 8 | }, 9 | { 10 | "files": ["*.ts", "*.tsx"], 11 | "rules": {} 12 | }, 13 | { 14 | "files": ["*.js", "*.jsx"], 15 | "rules": {} 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /libs/services/README.md: -------------------------------------------------------------------------------- 1 | # services 2 | 3 | This library was generated with [Nx](https://nx.dev). 4 | 5 | ## Running unit tests 6 | 7 | Run `nx test services` to execute the unit tests via [Jest](https://jestjs.io). 8 | -------------------------------------------------------------------------------- /libs/services/jest.config.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | export default { 3 | displayName: 'services', 4 | preset: '../../jest.preset.js', 5 | testEnvironment: 'node', 6 | transform: { 7 | '^.+\\.[tj]s$': ['ts-jest', { tsconfig: '/tsconfig.spec.json' }], 8 | }, 9 | moduleFileExtensions: ['ts', 'js', 'html'], 10 | coverageDirectory: '../../coverage/libs/services', 11 | }; 12 | -------------------------------------------------------------------------------- /libs/services/project.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "services", 3 | "$schema": "../../node_modules/nx/schemas/project-schema.json", 4 | "sourceRoot": "libs/services/src", 5 | "projectType": "library", 6 | "targets": {}, 7 | "tags": [] 8 | } 9 | -------------------------------------------------------------------------------- /libs/services/src/donasi/donasi.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { Donasi, Prisma } from '@prisma/client'; 3 | import { PrismaService } from '../prisma/prisma.service'; 4 | 5 | @Injectable() 6 | export class DonasiService { 7 | constructor(private prisma: PrismaService) {} 8 | 9 | async oneById(byId: Prisma.DonasiWhereUniqueInput): Promise { 10 | return this.prisma.donasi.findUnique({ 11 | where: byId, 12 | }); 13 | } 14 | 15 | async pagelist(params: { 16 | skip?: number; 17 | take?: number; 18 | cursor?: Prisma.DonasiWhereUniqueInput; 19 | where?: Prisma.DonasiWhereInput; 20 | orderBy?: Prisma.DonasiOrderByWithRelationInput; 21 | }): Promise { 22 | const { skip, take, cursor, where, orderBy } = params; 23 | return this.prisma.donasi.findMany({ 24 | skip, 25 | take, 26 | cursor, 27 | where, 28 | orderBy, 29 | }); 30 | } 31 | 32 | async create(data: Prisma.DonasiCreateInput): Promise { 33 | return this.prisma.donasi.create({ 34 | data, 35 | }); 36 | } 37 | 38 | async createMany( 39 | data: Prisma.DonasiCreateInput[] 40 | ): Promise { 41 | return await this.prisma.donasi.createMany({ 42 | data, 43 | skipDuplicates: true, 44 | }); 45 | } 46 | 47 | async update(params: { 48 | where: Prisma.DonasiWhereUniqueInput; 49 | data: Prisma.DonasiUpdateInput; 50 | }): Promise { 51 | const { where, data } = params; 52 | return this.prisma.donasi.update({ 53 | data, 54 | where, 55 | }); 56 | } 57 | 58 | async deleteUser(where: Prisma.DonasiWhereUniqueInput): Promise { 59 | return this.prisma.donasi.delete({ 60 | where, 61 | }); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /libs/services/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './services.module'; 2 | export * from './donasi/donasi.service'; 3 | -------------------------------------------------------------------------------- /libs/services/src/prisma/prisma.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; 2 | import { PrismaClient } from '@prisma/client'; 3 | 4 | @Injectable() 5 | export class PrismaService 6 | extends PrismaClient 7 | implements OnModuleInit, OnModuleDestroy { 8 | async onModuleInit() { 9 | await this.$connect(); 10 | } 11 | 12 | async onModuleDestroy() { 13 | await this.$disconnect(); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /libs/services/src/services.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { PrismaService } from './prisma/prisma.service'; 3 | import { DonasiService } from './donasi/donasi.service'; 4 | 5 | @Module({ 6 | controllers: [], 7 | providers: [PrismaService, DonasiService], 8 | exports: [PrismaService, DonasiService], 9 | }) 10 | export class ServicesModule {} 11 | -------------------------------------------------------------------------------- /libs/services/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "compilerOptions": { 4 | "module": "commonjs", 5 | "forceConsistentCasingInFileNames": true, 6 | "strict": true, 7 | "noImplicitOverride": true, 8 | "noPropertyAccessFromIndexSignature": true, 9 | "noImplicitReturns": true, 10 | "noFallthroughCasesInSwitch": true 11 | }, 12 | "files": [], 13 | "include": [], 14 | "references": [ 15 | { 16 | "path": "./tsconfig.lib.json" 17 | }, 18 | { 19 | "path": "./tsconfig.spec.json" 20 | } 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /libs/services/tsconfig.lib.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "declaration": true, 6 | "types": ["node"], 7 | "target": "es2021", 8 | "strictNullChecks": true, 9 | "noImplicitAny": true, 10 | "strictBindCallApply": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "noFallthroughCasesInSwitch": true 13 | }, 14 | "include": ["src/**/*.ts"], 15 | "exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"] 16 | } 17 | -------------------------------------------------------------------------------- /libs/services/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "module": "commonjs", 6 | "types": ["jest", "node"] 7 | }, 8 | "include": [ 9 | "jest.config.ts", 10 | "src/**/*.test.ts", 11 | "src/**/*.spec.ts", 12 | "src/**/*.d.ts" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /nx.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/nx/schemas/nx-schema.json", 3 | "namedInputs": { 4 | "default": ["{projectRoot}/**/*", "sharedGlobals"], 5 | "production": [ 6 | "default", 7 | "!{projectRoot}/.eslintrc.json", 8 | "!{projectRoot}/eslint.config.js", 9 | "!{projectRoot}/**/?(*.)+(spec|test).[jt]s?(x)?(.snap)", 10 | "!{projectRoot}/tsconfig.spec.json", 11 | "!{projectRoot}/jest.config.[jt]s", 12 | "!{projectRoot}/src/test-setup.[jt]s", 13 | "!{projectRoot}/test-setup.[jt]s" 14 | ], 15 | "sharedGlobals": [] 16 | }, 17 | "plugins": [ 18 | { 19 | "plugin": "@nx/webpack/plugin", 20 | "options": { 21 | "buildTargetName": "build", 22 | "serveTargetName": "serve", 23 | "previewTargetName": "preview" 24 | } 25 | }, 26 | { 27 | "plugin": "@nx/eslint/plugin", 28 | "options": { 29 | "targetName": "lint" 30 | } 31 | }, 32 | { 33 | "plugin": "@nx/jest/plugin", 34 | "options": { 35 | "targetName": "test" 36 | } 37 | }, 38 | { 39 | "plugin": "@nx/playwright/plugin", 40 | "options": { 41 | "targetName": "e2e" 42 | } 43 | } 44 | ], 45 | "generators": { 46 | "@nx/react": { 47 | "application": { 48 | "babel": true, 49 | "style": "css", 50 | "linter": "eslint", 51 | "bundler": "webpack" 52 | }, 53 | "component": { 54 | "style": "css" 55 | }, 56 | "library": { 57 | "style": "css", 58 | "linter": "eslint" 59 | } 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@offline-first3/source", 3 | "version": "0.0.0", 4 | "license": "MIT", 5 | "scripts": { 6 | "nx": "nx", 7 | "start": "nx serve", 8 | "build": "nx build", 9 | "test": "nx test", 10 | "lint": "nx workspace-lint && nx lint", 11 | "e2e": "nx e2e", 12 | "affected:apps": "nx affected:apps", 13 | "affected:libs": "nx affected:libs", 14 | "affected:build": "nx affected:build", 15 | "affected:e2e": "nx affected:e2e", 16 | "affected:test": "nx affected:test", 17 | "affected:lint": "nx affected:lint", 18 | "affected:dep-graph": "nx affected:dep-graph", 19 | "affected": "nx affected", 20 | "format": "nx format:write", 21 | "format:write": "nx format:write", 22 | "format:check": "nx format:check", 23 | "update": "nx migrate latest", 24 | "workspace-generator": "nx workspace-generator", 25 | "dep-graph": "nx dep-graph", 26 | "help": "nx help", 27 | "migrate:dev": "prisma migrate dev --preview-feature", 28 | "migrate:dev:create": "prisma migrate dev --create-only --preview-feature", 29 | "migrate:reset": "prisma migrate reset --preview-feature", 30 | "migrate:deploy": "npx prisma migrate deploy --preview-feature", 31 | "migrate:status": "npx prisma migrate status --preview-feature", 32 | "migrate:resolve": "npx prisma migrate resolve --preview-feature", 33 | "prisma:studio": "npx prisma studio", 34 | "prisma:generate": "npx prisma generate", 35 | "prisma:generate:watch": "npx prisma generate --watch" 36 | }, 37 | "private": true, 38 | "devDependencies": { 39 | "@babel/core": "^7.14.5", 40 | "@babel/preset-react": "^7.14.5", 41 | "@nestjs/schematics": "^10.0.1", 42 | "@nestjs/testing": "^10.0.2", 43 | "@nx/devkit": "18.0.7", 44 | "@nx/eslint": "18.0.7", 45 | "@nx/eslint-plugin": "18.0.7", 46 | "@nx/jest": "18.0.7", 47 | "@nx/js": "18.0.7", 48 | "@nx/nest": "^18.0.7", 49 | "@nx/node": "18.0.7", 50 | "@nx/playwright": "18.0.7", 51 | "@nx/react": "^18.0.7", 52 | "@nx/web": "18.0.7", 53 | "@nx/webpack": "18.0.7", 54 | "@nx/workspace": "18.0.7", 55 | "@playwright/test": "^1.36.0", 56 | "@pmmmwh/react-refresh-webpack-plugin": "^0.5.7", 57 | "@svgr/webpack": "^8.0.1", 58 | "@swc-node/register": "~1.8.0", 59 | "@swc/cli": "~0.1.62", 60 | "@swc/core": "~1.3.85", 61 | "@swc/helpers": "~0.5.2", 62 | "@testing-library/react": "14.0.0", 63 | "@types/jest": "^29.4.0", 64 | "@types/node": "~18.16.9", 65 | "@types/react": "18.2.33", 66 | "@types/react-dom": "18.2.14", 67 | "@typescript-eslint/eslint-plugin": "^6.13.2", 68 | "@typescript-eslint/parser": "^6.13.2", 69 | "babel-jest": "^29.4.1", 70 | "eslint": "~8.48.0", 71 | "eslint-config-prettier": "^9.0.0", 72 | "eslint-plugin-import": "2.27.5", 73 | "eslint-plugin-jsx-a11y": "6.7.1", 74 | "eslint-plugin-playwright": "^0.15.3", 75 | "eslint-plugin-react": "7.32.2", 76 | "eslint-plugin-react-hooks": "4.6.0", 77 | "jest": "^29.4.1", 78 | "jest-environment-jsdom": "^29.4.1", 79 | "jest-environment-node": "^29.4.1", 80 | "nx": "18.0.7", 81 | "prettier": "^2.6.2", 82 | "prisma": "^5.10.2", 83 | "react-refresh": "^0.10.0", 84 | "ts-jest": "^29.1.0", 85 | "ts-node": "10.9.1", 86 | "typescript": "~5.3.2", 87 | "url-loader": "^4.1.1", 88 | "webpack-cli": "^5.1.4" 89 | }, 90 | "dependencies": { 91 | "@apollo/client": "^3.9.5", 92 | "@apollo/server": "^4.10.0", 93 | "@emotion/react": "^11.11.4", 94 | "@mantine/core": "^6.0.21", 95 | "@mantine/form": "^6.0.21", 96 | "@nestjs/apollo": "^12.1.0", 97 | "@nestjs/common": "^10.0.2", 98 | "@nestjs/config": "^3.2.0", 99 | "@nestjs/core": "^10.0.2", 100 | "@nestjs/graphql": "^12.1.1", 101 | "@nestjs/platform-express": "^10.0.2", 102 | "@prisma/client": "^5.10.2", 103 | "axios": "^1.6.0", 104 | "graphql": "^16.8.1", 105 | "graphql-tools": "^9.0.1", 106 | "nanoid": "^5.0.6", 107 | "react": "18.2.0", 108 | "react-dom": "18.2.0", 109 | "react-router-dom": "6.11.2", 110 | "reflect-metadata": "^0.1.13", 111 | "rxdb": "^15.10.0", 112 | "rxjs": "^7.8.0", 113 | "tslib": "^2.3.0" 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | // This is your Prisma schema file, 2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema 3 | 4 | datasource db { 5 | provider = "postgresql" 6 | url = env("DATABASE_URL") 7 | } 8 | 9 | generator client { 10 | provider = "prisma-client-js" 11 | } 12 | 13 | model Donasi { 14 | sid Int @id @default(autoincrement()) 15 | id String @unique 16 | createdAt DateTime 17 | name String? 18 | phone String? 19 | amount Int 20 | syncedAt DateTime @default(now()) 21 | sync Boolean @default(true) 22 | } 23 | -------------------------------------------------------------------------------- /screenshot/1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/madipta/offline-first/3ca3fe0a205c7ec9099cce23bc52f28d16be1ac2/screenshot/1.png -------------------------------------------------------------------------------- /screenshot/2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/madipta/offline-first/3ca3fe0a205c7ec9099cce23bc52f28d16be1ac2/screenshot/2.png -------------------------------------------------------------------------------- /tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": false, 3 | "compilerOptions": { 4 | "rootDir": ".", 5 | "sourceMap": true, 6 | "declaration": false, 7 | "moduleResolution": "node", 8 | "emitDecoratorMetadata": true, 9 | "experimentalDecorators": true, 10 | "importHelpers": true, 11 | "target": "es2015", 12 | "module": "esnext", 13 | "lib": ["es2020", "dom"], 14 | "skipLibCheck": true, 15 | "skipDefaultLibCheck": true, 16 | "baseUrl": ".", 17 | "paths": { 18 | "@offline-first/gql": ["libs/gql/src/index.ts"], 19 | "@offline-first/services": ["libs/services/src/index.ts"] 20 | } 21 | }, 22 | "exclude": ["node_modules", "tmp"] 23 | } 24 | --------------------------------------------------------------------------------