refactor: remove advanced-checklist editor (moved to community plugins) (#1740)
This commit is contained in:
@@ -2,11 +2,6 @@ const BaseEditorStaticFiles = ['index.html', 'dist', 'package.json']
|
||||
const BaseThemeStaticFiles = ['dist', 'package.json']
|
||||
|
||||
const Editors = [
|
||||
{
|
||||
identifier: 'org.standardnotes.advanced-checklist',
|
||||
path: 'Editors/org.standardnotes.advanced-checklist',
|
||||
static_files: [...BaseEditorStaticFiles, 'build'],
|
||||
},
|
||||
{
|
||||
identifier: 'org.standardnotes.code-editor',
|
||||
path: 'Editors/org.standardnotes.code-editor',
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# TypeScript v1 declaration files
|
||||
typings/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Microbundle cache
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variables file
|
||||
.env
|
||||
.env.test
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
.cache/
|
||||
# Comment in the public line in if your project uses Gatsby and *not* Next.js
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# Serverless directories
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
.tern-port
|
||||
|
||||
#### Copied from Create React App ####
|
||||
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
/public/ext.json
|
||||
@@ -1,115 +0,0 @@
|
||||
# Change Log
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [0.2.5](https://github.com/standardnotes/app/compare/@standardnotes/advanced-checklist@0.2.4...@standardnotes/advanced-checklist@0.2.5) (2022-09-13)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/advanced-checklist
|
||||
|
||||
## [0.2.4](https://github.com/standardnotes/app/compare/@standardnotes/advanced-checklist@0.2.3...@standardnotes/advanced-checklist@0.2.4) (2022-08-23)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/advanced-checklist
|
||||
|
||||
## [0.2.3](https://github.com/standardnotes/app/compare/@standardnotes/advanced-checklist@0.2.2...@standardnotes/advanced-checklist@0.2.3) (2022-07-13)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* upgrade jest with types to latest version ([09e08ca](https://github.com/standardnotes/app/commit/09e08ca899ba8694cf43292e918c4c204c0d2cb9))
|
||||
* upgrade ts-jest in packages ([71e792d](https://github.com/standardnotes/app/commit/71e792da354ff90335b92758e196075a0f88d060))
|
||||
|
||||
## [0.2.2](https://github.com/standardnotes/app/compare/@standardnotes/advanced-checklist@0.2.1...@standardnotes/advanced-checklist@0.2.2) (2022-07-06)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **advanced checklist:** improve editor styles ([#1221](https://github.com/standardnotes/app/issues/1221)) ([f7ba658](https://github.com/standardnotes/app/commit/f7ba6588a7d062e3ec82e6413042ce5d8cd075f7))
|
||||
|
||||
## [0.2.1](https://github.com/standardnotes/app/compare/@standardnotes/advanced-checklist@0.2.0...@standardnotes/advanced-checklist@0.2.1) (2022-07-06)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/advanced-checklist
|
||||
|
||||
# [0.2.0](https://github.com/standardnotes/app/compare/@standardnotes/advanced-checklist@0.1.6...@standardnotes/advanced-checklist@0.2.0) (2022-07-06)
|
||||
|
||||
### Features
|
||||
|
||||
* add utils package ([aef4ceb](https://github.com/standardnotes/app/commit/aef4ceb7f85948f1f08b8b09a4db5d187daa371b))
|
||||
|
||||
## [0.1.6](https://github.com/standardnotes/app/compare/@standardnotes/advanced-checklist@0.1.5...@standardnotes/advanced-checklist@0.1.6) (2022-07-05)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **advanced checklist:** remove test for reorder icon ([#1209](https://github.com/standardnotes/app/issues/1209)) ([296aa9a](https://github.com/standardnotes/app/commit/296aa9aab2072d4aad68485c31c000ad2c3bf013))
|
||||
|
||||
## [0.1.5](https://github.com/standardnotes/app/compare/@standardnotes/advanced-checklist@0.1.4...@standardnotes/advanced-checklist@0.1.5) (2022-07-05)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **advanced checklist:** UI changes ([#1208](https://github.com/standardnotes/app/issues/1208)) ([1ce4cb3](https://github.com/standardnotes/app/commit/1ce4cb3c5c8f4e590fd67fdbd684b36ac6383bd9))
|
||||
|
||||
## [0.1.4](https://github.com/standardnotes/app/compare/@standardnotes/advanced-checklist@0.1.3...@standardnotes/advanced-checklist@0.1.4) (2022-07-04)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **advanced checklist:** animations and error handling ([#1200](https://github.com/standardnotes/app/issues/1200)) ([a0205a5](https://github.com/standardnotes/app/commit/a0205a5c7dd72184cb575195ced3132091072239))
|
||||
|
||||
## [0.1.3](https://github.com/standardnotes/app/compare/@standardnotes/advanced-checklist@0.1.2...@standardnotes/advanced-checklist@0.1.3) (2022-06-30)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* debounce saving task description/draft ([#1187](https://github.com/standardnotes/app/issues/1187)) ([47a0551](https://github.com/standardnotes/app/commit/47a0551967ca420a957e2123d56bd7f0c8a95c53))
|
||||
|
||||
## [0.1.2](https://github.com/standardnotes/app/compare/@standardnotes/advanced-checklist@0.1.1...@standardnotes/advanced-checklist@0.1.2) (2022-06-29)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **advanced checklist:** style and layout improvements ([#1182](https://github.com/standardnotes/app/issues/1182)) ([6c19adb](https://github.com/standardnotes/app/commit/6c19adba1902ef054f501d57f6e284fbf44ca28b))
|
||||
|
||||
## [0.1.1](https://github.com/standardnotes/app/compare/@standardnotes/advanced-checklist@0.1.0...@standardnotes/advanced-checklist@0.1.1) (2022-06-28)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/advanced-checklist
|
||||
|
||||
# [0.1.0](https://github.com/standardnotes/app/compare/@standardnotes/advanced-checklist@0.0.5...@standardnotes/advanced-checklist@0.1.0) (2022-06-28)
|
||||
|
||||
### Features
|
||||
|
||||
* **advanced checklist:** collapsible group sections ([#1167](https://github.com/standardnotes/app/issues/1167)) ([59e5324](https://github.com/standardnotes/app/commit/59e5324a29029d024811bf2bb63e08ae42d3b62b))
|
||||
* deprecated editors ([#1166](https://github.com/standardnotes/app/issues/1166)) ([60ca415](https://github.com/standardnotes/app/commit/60ca4150446f9a14bb6a31416686c6d07a7d0cd9))
|
||||
* **web:** tailwind css ([#1147](https://github.com/standardnotes/app/issues/1147)) ([b80038f](https://github.com/standardnotes/app/commit/b80038f607d7411912fa99366abf559a44874ef3))
|
||||
|
||||
## [0.0.5](https://github.com/standardnotes/app/compare/@standardnotes/advanced-checklist@0.0.5-alpha.0...@standardnotes/advanced-checklist@0.0.5) (2022-06-22)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/advanced-checklist
|
||||
|
||||
## [0.0.5-alpha.0](https://github.com/standardnotes/app/compare/@standardnotes/advanced-checklist@0.0.4...@standardnotes/advanced-checklist@0.0.5-alpha.0) (2022-06-22)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* components scripts ([#1136](https://github.com/standardnotes/app/issues/1136)) ([e80b4d0](https://github.com/standardnotes/app/commit/e80b4d0ffad495c758b593c30e1c4c754dda9b7e))
|
||||
|
||||
## 0.0.4 (2022-06-16)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/advanced-checklist
|
||||
|
||||
## 0.0.3 (2022-06-16)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/advanced-checklist
|
||||
|
||||
## [0.0.2](https://github.com/standardnotes/app/compare/@standardnotes/advanced-checklist@0.0.2-alpha.3...@standardnotes/advanced-checklist@0.0.2) (2022-06-16)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/advanced-checklist
|
||||
|
||||
## [0.0.2-alpha.3](https://github.com/standardnotes/app/compare/@standardnotes/advanced-checklist@0.0.2-alpha.2...@standardnotes/advanced-checklist@0.0.2-alpha.3) (2022-06-16)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/advanced-checklist
|
||||
|
||||
## 0.0.2-alpha.2 (2022-06-16)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/advanced-checklist
|
||||
|
||||
## 0.0.2-alpha.1 (2022-06-16)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/advanced-checklist
|
||||
|
||||
## 0.0.2-alpha.0 (2022-06-15)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/advanced-checklist
|
||||
@@ -1,80 +0,0 @@
|
||||
# advanced-checklist
|
||||
|
||||
A great way to manage short-term and long-term to-do's. You can mark tasks as completed, change their order, and edit the text naturally in place.
|
||||
|
||||
## Development
|
||||
|
||||
**Prerequisites:** Install [Node.js](https://nodejs.org/en/), [Yarn](https://classic.yarnpkg.com/en/docs/install/), and [Git](https://github.com/git-guides/install-git) on your computer.
|
||||
|
||||
The general instructions setting up an environment to develop Standard Notes extensions can be found [here](https://docs.standardnotes.org/extensions/local-setup). You can also follow these instructions:
|
||||
|
||||
1. Fork the [repository](https://github.com/standardnotes/advanced-checklist) on GitHub.
|
||||
1. [Clone](https://help.github.com/en/github/creating-cloning-and-archiving-repositories/cloning-a-repository) your fork of the repository.
|
||||
1. Run `cd advanced-checklist` to enter the `advanced-checklist` directory.
|
||||
1. Run `yarn install` to install the dependencies on your machine as they are described in `yarn.lock`.
|
||||
|
||||
### Testing in the browser
|
||||
|
||||
1. To run the app in development mode, run `yarn start` and visit http://localhost:8001. Press `ctrl/cmd + C` to exit development mode.
|
||||
|
||||
### Testing in the Standard Notes app
|
||||
|
||||
1. Create an `ext.json` in the `public` directory. You have three options:
|
||||
1. Use `sample.ext.json`.
|
||||
1. Create `ext.json` as a copy of `sample.ext.json`.
|
||||
1. Follow the instructions [here](https://docs.standardnotes.org/extensions/local-setup) with `url: "http://localhost:3000/index.html"`.
|
||||
1. Install http-server using `sudo npm install -g http-server` then run `yarn server` to serve the `./build` directory at http://localhost:3000.
|
||||
1. To build the app, run `yarn build`.
|
||||
1. Install the editor into the [web](https://app.standardnotes.org) or [desktop](https://standardnotes.org/download) app with `http://localhost:3000/sample.ext.json` or with your custom `ext.json`. Press `ctrl/cmd + C` to shut down the server.
|
||||
|
||||
### Deployment
|
||||
|
||||
1. To make the source code prettier, run `yarn pretty`.
|
||||
1. To the deploy the build into the `gh-pages` branch of your repository on GitHub, run `yarn deploy-stable`.
|
||||
1. To deploy the build into to the `dev` branch for testing, run `yarn deploy-dev`.
|
||||
1. To deploy the built into the `build` branch for distributing, run `yarn deploy-build` for distributing builds.
|
||||
|
||||
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
|
||||
|
||||
### Available Scripts
|
||||
|
||||
In the project directory, you can run:
|
||||
|
||||
#### `yarn start`
|
||||
|
||||
Runs the app in the development mode.\
|
||||
Open [http://localhost:8001](http://localhost:8001) to view it in the browser.
|
||||
|
||||
The page will reload if you make edits.\
|
||||
You will also see any lint errors in the console.
|
||||
|
||||
#### `yarn test`
|
||||
|
||||
Launches the test runner in the interactive watch mode.\
|
||||
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
|
||||
|
||||
#### `yarn build`
|
||||
|
||||
Builds the app for production to the `build` folder.\
|
||||
It correctly bundles React in production mode and optimizes the build for the best performance.
|
||||
|
||||
The build is minified and the filenames include the hashes.\
|
||||
Your app is ready to be deployed!
|
||||
|
||||
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
|
||||
|
||||
#### `yarn eject`
|
||||
|
||||
**Note: this is a one-way operation. Once you `eject`, you can’t go back!**
|
||||
|
||||
If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
|
||||
|
||||
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own.
|
||||
|
||||
You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it.
|
||||
|
||||
### Learn More
|
||||
|
||||
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
|
||||
|
||||
To learn React, check out the [React documentation](https://reactjs.org/).
|
||||
@@ -1,2 +0,0 @@
|
||||
const override = require('../cra-app.override')
|
||||
module.exports = override
|
||||
@@ -1,127 +0,0 @@
|
||||
{
|
||||
"name": "@standardnotes/advanced-checklist",
|
||||
"version": "0.2.5",
|
||||
"description": "A task editor with grouping functionality.",
|
||||
"author": "Standard Notes.",
|
||||
"keywords": [
|
||||
"Standard Notes",
|
||||
"Standard Notes Extensions"
|
||||
],
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"sn": {
|
||||
"main": "build/index.html"
|
||||
},
|
||||
"homepage": ".",
|
||||
"scripts": {
|
||||
"analyze": "source-map-explorer 'build/static/js/*.js'",
|
||||
"start": "react-app-rewired start",
|
||||
"test:coverage": "npm run test -- --coverage --watchAll --no-silent",
|
||||
"eject": "react-scripts eject",
|
||||
"components:compile": "react-app-rewired build",
|
||||
"test": "react-app-rewired test --watchAll=false --silent",
|
||||
"format": "prettier --write 'src/**/*.{html,css,scss,js,jsx,ts,tsx,json}' README.md"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app",
|
||||
"react-app/jest"
|
||||
]
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"lint-staged": {
|
||||
"README.md": [
|
||||
"prettier --write"
|
||||
],
|
||||
"src/**/*.{js,jsx,ts,tsx,json,css,scss,md}": [
|
||||
"prettier --write"
|
||||
]
|
||||
},
|
||||
"jest": {
|
||||
"collectCoverageFrom": [
|
||||
"src/**/*.{js,jsx,ts,tsx}",
|
||||
"!<rootDir>/node_modules/"
|
||||
],
|
||||
"coveragePathIgnorePatterns": [
|
||||
"<rootDir>/src/mockData.ts",
|
||||
"<rootDir>/src/app/hooks.ts",
|
||||
"<rootDir>/src/app/store.ts",
|
||||
"<rootDir>/src/app/listenerMiddleware.ts"
|
||||
],
|
||||
"coverageReporters": [
|
||||
"text",
|
||||
"html"
|
||||
],
|
||||
"coverageThreshold": {
|
||||
"global": {
|
||||
"branches": 90,
|
||||
"functions": 100,
|
||||
"lines": 100,
|
||||
"statements": 100
|
||||
}
|
||||
},
|
||||
"transform": {
|
||||
"^.+\\.(ts|tsx)$": "ts-jest"
|
||||
},
|
||||
"transformIgnorePatterns": [
|
||||
"[/\\\\]node_modules[/\\\\].+\\.(js|jsx|ts|tsx)$",
|
||||
"^.+\\.module\\.(css|sass|scss)$"
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"@standardnotes/utils": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@reach/alert-dialog": "0.16.2",
|
||||
"@reach/menu-button": "0.16.2",
|
||||
"@reach/visually-hidden": "0.16.0",
|
||||
"@react-hook/resize-observer": "^1.2.5",
|
||||
"@reduxjs/toolkit": "1.8.0",
|
||||
"@standardnotes/editor-kit": "2.2.5",
|
||||
"@standardnotes/stylekit": "5.23.0",
|
||||
"@testing-library/dom": "8.11.3",
|
||||
"@testing-library/jest-dom": "5.16.2",
|
||||
"@testing-library/react": "12.1.4",
|
||||
"@testing-library/user-event": "13.5.0",
|
||||
"@types/jest": "^28.1.5",
|
||||
"@types/lodash": "4.14.179",
|
||||
"@types/node": "17.0.21",
|
||||
"@types/react": "17.0.40",
|
||||
"@types/react-beautiful-dnd": "13.1.2",
|
||||
"@types/react-dom": "17.0.13",
|
||||
"@types/react-redux": "7.1.23",
|
||||
"@types/react-transition-group": "4.4.4",
|
||||
"@types/redux-mock-store": "1.0.3",
|
||||
"@types/styled-components": "5.1.24",
|
||||
"@types/uuid": "8.3.4",
|
||||
"gh-pages": "3.2.3",
|
||||
"lint-staged": "12.3.5",
|
||||
"node-sass": "*",
|
||||
"prettier": "*",
|
||||
"react": "17.0.2",
|
||||
"react-app-rewired": "^2.2.1",
|
||||
"react-beautiful-dnd": "13.1.0",
|
||||
"react-dom": "17.0.2",
|
||||
"react-redux": "7.2.8",
|
||||
"react-scripts": "5.0.0",
|
||||
"react-transition-group": "4.4.2",
|
||||
"redux": "4.1.2",
|
||||
"redux-mock-store": "1.5.4",
|
||||
"source-map-explorer": "2.5.2",
|
||||
"styled-components": "5.3.5",
|
||||
"ts-jest": "^28.0.5",
|
||||
"typescript": "4.6.2",
|
||||
"uuid": "8.3.2"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 3.8 KiB |
@@ -1,44 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta
|
||||
name="description"
|
||||
content="A great way to manage short-term and long-term to-do's. You can mark tasks as completed, change their order, and edit the text naturally in place."
|
||||
/>
|
||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
||||
<!--
|
||||
manifest.json provides metadata used when your web app is installed on a
|
||||
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
||||
-->
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||
<!--
|
||||
Notice the use of %PUBLIC_URL% in the tags above.
|
||||
It will be replaced with the URL of the `public` folder during the build.
|
||||
Only files inside the `public` folder can be referenced from the HTML.
|
||||
|
||||
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
|
||||
work correctly both with client-side routing and a non-root public URL.
|
||||
Learn how to configure a non-root public URL by running `npm run build`.
|
||||
-->
|
||||
<title>Advanced Checklist</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div class="sn-component" id="root"></div>
|
||||
<!--
|
||||
This HTML file is a template.
|
||||
If you open it directly in the browser, you will see an empty page.
|
||||
|
||||
You can add webfonts, meta tags, or analytics to this file.
|
||||
The build step will place the bundled scripts into the <body> tag.
|
||||
|
||||
To begin the development, run `npm start` or `yarn start`.
|
||||
To create a production bundle, use `npm run build` or `yarn build`.
|
||||
-->
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 5.2 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 9.4 KiB |
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"short_name": "Advanced Checklist",
|
||||
"name": "Advanced Checklist",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
},
|
||||
{
|
||||
"src": "logo192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192"
|
||||
},
|
||||
{
|
||||
"src": "logo512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
}
|
||||
],
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#ffffff"
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"identifier": "org.standardnotes.advanced-checklist-dev",
|
||||
"name": "Advanced Checklist - Dev",
|
||||
"content_type": "SN|Component",
|
||||
"area": "editor-editor",
|
||||
"version": "0.1.0",
|
||||
"description": "A task editor with grouping functionality.",
|
||||
"url": "http://localhost:3000/index.html",
|
||||
"download_url": "",
|
||||
"latest_url": "",
|
||||
"thumbnail_url": ""
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import useResizeObserver from '@react-hook/resize-observer'
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'
|
||||
import type { AppDispatch, RootState } from './store'
|
||||
|
||||
export const useAppDispatch = () => useDispatch<AppDispatch>()
|
||||
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector
|
||||
|
||||
export const useDidMount = (effect: React.EffectCallback, deps?: React.DependencyList) => {
|
||||
const [didMount, setDidMount] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (didMount) {
|
||||
effect()
|
||||
} else {
|
||||
setDidMount(true)
|
||||
}
|
||||
}, [deps, didMount, effect])
|
||||
}
|
||||
|
||||
export const useResize = (ref: React.RefObject<HTMLElement>, effect: (target: HTMLElement) => void) => {
|
||||
const [size, setSize] = useState<DOMRect>()
|
||||
|
||||
function isDeepEqual(prevSize?: DOMRect, nextSize?: DOMRect) {
|
||||
return JSON.stringify(prevSize) === JSON.stringify(nextSize)
|
||||
}
|
||||
|
||||
useResizeObserver(ref, ({ contentRect, target }) => {
|
||||
if (!isDeepEqual(size, contentRect)) {
|
||||
setSize(contentRect)
|
||||
effect(target as HTMLElement)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const useDebouncedCallback = (callback: () => void, waitMs: number = 500) => {
|
||||
const timeout = useRef<any>()
|
||||
|
||||
clearTimeout(timeout.current)
|
||||
|
||||
timeout.current = setTimeout(() => {
|
||||
callback()
|
||||
}, waitMs)
|
||||
}
|
||||
|
||||
export const usePrevious = (value: any) => {
|
||||
const ref = useRef<typeof value>()
|
||||
|
||||
useEffect(() => {
|
||||
ref.current = value
|
||||
}, [value])
|
||||
|
||||
return ref.current
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import { createListenerMiddleware, isAnyOf } from '@reduxjs/toolkit'
|
||||
import {
|
||||
deleteAllCompleted,
|
||||
openAllCompleted,
|
||||
taskAdded,
|
||||
taskDeleted,
|
||||
taskModified,
|
||||
tasksGroupAdded,
|
||||
tasksGroupCollapsed,
|
||||
tasksGroupDeleted,
|
||||
tasksGroupLastActive,
|
||||
tasksGroupMerged,
|
||||
tasksReordered,
|
||||
taskToggled,
|
||||
} from '../features/tasks/tasks-slice'
|
||||
|
||||
const listenerMiddleware = createListenerMiddleware()
|
||||
|
||||
/**
|
||||
* A list of actions that we want to listen to.
|
||||
* The groupName is obtained from the payload, and we use it to
|
||||
* dispatch the tasksGroupLastActive action.
|
||||
*/
|
||||
const actionsWithGroup = isAnyOf(
|
||||
taskAdded,
|
||||
taskModified,
|
||||
taskToggled,
|
||||
taskDeleted,
|
||||
openAllCompleted,
|
||||
deleteAllCompleted,
|
||||
tasksReordered,
|
||||
tasksGroupAdded,
|
||||
tasksGroupDeleted,
|
||||
tasksGroupMerged,
|
||||
tasksGroupCollapsed,
|
||||
)
|
||||
|
||||
listenerMiddleware.startListening({
|
||||
matcher: actionsWithGroup,
|
||||
effect: ({ payload }, listenerApi) => {
|
||||
const { groupName } = payload
|
||||
listenerApi.dispatch(tasksGroupLastActive({ groupName }))
|
||||
},
|
||||
})
|
||||
|
||||
export default listenerMiddleware.middleware
|
||||
@@ -1,16 +0,0 @@
|
||||
import { configureStore } from '@reduxjs/toolkit'
|
||||
|
||||
import settingsReducer from '../features/settings/settings-slice'
|
||||
import tasksReducer from '../features/tasks/tasks-slice'
|
||||
import listenerMiddleware from './listenerMiddleware'
|
||||
|
||||
export const store = configureStore({
|
||||
reducer: {
|
||||
tasks: tasksReducer,
|
||||
settings: settingsReducer,
|
||||
},
|
||||
middleware: (getDefaultMiddleware) => getDefaultMiddleware().prepend(listenerMiddleware),
|
||||
})
|
||||
|
||||
export type AppDispatch = typeof store.dispatch
|
||||
export type RootState = ReturnType<typeof store.getState>
|
||||
@@ -1,38 +0,0 @@
|
||||
import { ChangeEvent, forwardRef, MouseEvent } from 'react'
|
||||
|
||||
type CheckBoxInputProps = {
|
||||
checked?: boolean
|
||||
disabled?: boolean
|
||||
testId?: string
|
||||
onChange?: (event: ChangeEvent<HTMLInputElement>) => void
|
||||
onClick?: (event: MouseEvent<SVGElement>) => void
|
||||
}
|
||||
|
||||
export const CheckBoxInput = forwardRef<HTMLInputElement, CheckBoxInputProps>(
|
||||
({ checked, disabled, testId, onChange, onClick }, ref) => {
|
||||
return (
|
||||
<label className="checkbox-container">
|
||||
<input
|
||||
className="checkbox-state"
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
data-testid={testId}
|
||||
disabled={disabled}
|
||||
onChange={onChange}
|
||||
ref={ref}
|
||||
/>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlnsXlink="http://www.w3.org/1999/xlink"
|
||||
viewBox="3 2 22 20"
|
||||
className="checkbox-button"
|
||||
onClick={onClick}
|
||||
>
|
||||
<use xlinkHref="#checkbox-square" className="checkbox-square"></use>
|
||||
<use xlinkHref="#checkbox-mark" className="checkbox-mark"></use>
|
||||
<use xlinkHref="#checkbox-circle" className="checkbox-circle"></use>
|
||||
</svg>
|
||||
</label>
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -1,48 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
const ProgressBarBackground = styled.circle`
|
||||
fill: none;
|
||||
stroke: var(--sn-stylekit-neutral-color);
|
||||
`
|
||||
|
||||
const ProgressBarStroke = styled.circle`
|
||||
fill: none;
|
||||
stroke: var(--sn-stylekit-info-color);
|
||||
transition: all 0.5s;
|
||||
`
|
||||
|
||||
type CircularProgressBarProps = {
|
||||
size: number
|
||||
percentage: number
|
||||
}
|
||||
|
||||
export const CircularProgressBar: React.FC<CircularProgressBarProps> = ({ size, percentage }) => {
|
||||
const [progress, setProgress] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
setProgress(percentage)
|
||||
}, [percentage])
|
||||
|
||||
const viewBox = `0 0 ${size} ${size}`
|
||||
const strokeWidth = size * (0.1 / 100) * 100 + 1
|
||||
const radius = (size - strokeWidth) / 2
|
||||
const circumference = radius * Math.PI * 2
|
||||
const dash = (progress * circumference) / 100
|
||||
|
||||
return (
|
||||
<svg height={size} viewBox={viewBox} width={size} data-testid="circular-progress-bar">
|
||||
<ProgressBarBackground cx={size / 2} cy={size / 2} r={radius} strokeWidth={strokeWidth} />
|
||||
<ProgressBarStroke
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
strokeWidth={strokeWidth}
|
||||
transform={`rotate(-90 ${size / 2} ${size / 2})`}
|
||||
strokeDasharray={`${dash} ${circumference - dash}`}
|
||||
strokeLinecap="round"
|
||||
style={{ transition: 'all 0.5s' }}
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
import '@reach/dialog/styles.css'
|
||||
|
||||
import { AlertDialog, AlertDialogDescription, AlertDialogLabel } from '@reach/alert-dialog'
|
||||
import React, { useRef } from 'react'
|
||||
|
||||
import { sanitizeHtmlString } from '@standardnotes/utils'
|
||||
|
||||
type ConfirmDialogProps = {
|
||||
testId?: string
|
||||
title?: string
|
||||
confirmButtonText?: string
|
||||
confirmButtonStyle?: 'danger' | 'info'
|
||||
confirmButtonCb: () => void
|
||||
cancelButtonText?: string
|
||||
cancelButtonCb: () => void
|
||||
}
|
||||
|
||||
export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
|
||||
testId,
|
||||
title = '',
|
||||
confirmButtonText = 'Confirm',
|
||||
confirmButtonStyle = 'info',
|
||||
confirmButtonCb,
|
||||
cancelButtonText = 'Cancel',
|
||||
cancelButtonCb,
|
||||
children,
|
||||
}) => {
|
||||
const cancelRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
return (
|
||||
<AlertDialog data-testid={testId} onDismiss={cancelButtonCb} leastDestructiveRef={cancelRef}>
|
||||
<div className="sk-modal-content">
|
||||
<div className="sn-component">
|
||||
<div className="sk-panel">
|
||||
<div className="sk-panel-content">
|
||||
<div className="sk-panel-section">
|
||||
<AlertDialogLabel
|
||||
className="sk-h3 sk-panel-section-title"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: sanitizeHtmlString(title),
|
||||
}}
|
||||
/>
|
||||
|
||||
<AlertDialogDescription className="sk-panel-row">
|
||||
<p className="color-foreground">{children}</p>
|
||||
</AlertDialogDescription>
|
||||
|
||||
<div className="flex my-1">
|
||||
<button
|
||||
data-testid="cancel-dialog-button"
|
||||
className="sn-button small neutral"
|
||||
onClick={cancelButtonCb}
|
||||
ref={cancelRef}
|
||||
>
|
||||
{cancelButtonText}
|
||||
</button>
|
||||
<button
|
||||
data-testid="confirm-dialog-button"
|
||||
className={`sn-button small ml-2 ${confirmButtonStyle}`}
|
||||
onClick={confirmButtonCb}
|
||||
>
|
||||
{confirmButtonText}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import styled from 'styled-components'
|
||||
|
||||
export const GenericInlineText = styled.span`
|
||||
color: var(--sn-stylekit-paragraph-text-color);
|
||||
font-size: var(--sn-stylekit-font-size-p);
|
||||
margin: 0 10px;
|
||||
`
|
||||
@@ -1,26 +0,0 @@
|
||||
import styled from 'styled-components'
|
||||
|
||||
type Header1Props = {
|
||||
crossed: boolean
|
||||
}
|
||||
|
||||
const Header1 = styled.h1<Header1Props>`
|
||||
color: var(--sn-stylekit-editor-foreground-color);
|
||||
display: inline;
|
||||
font-size: 1.125rem !important;
|
||||
margin-right: 10px !important;
|
||||
text-decoration: ${({ crossed }) => (crossed ? 'line-through' : 'none')};
|
||||
`
|
||||
|
||||
type MainTitleProps = {
|
||||
highlight?: boolean
|
||||
crossed?: boolean
|
||||
}
|
||||
|
||||
export const MainTitle: React.FC<MainTitleProps> = ({ children, highlight = false, crossed = false, ...props }) => {
|
||||
return (
|
||||
<Header1 className={`sk-h1 ${highlight ? 'info' : ''}`} crossed={crossed} {...props}>
|
||||
{children}
|
||||
</Header1>
|
||||
)
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
type RoundButtonProps = {
|
||||
testId?: string
|
||||
onClick: () => void
|
||||
size?: 'normal' | 'small'
|
||||
}
|
||||
|
||||
export const RoundButton: React.FC<RoundButtonProps> = ({ testId, onClick, children, size = 'normal' }) => {
|
||||
return (
|
||||
<button data-testid={testId} className={`sn-icon-button ${size}`} onClick={onClick}>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import styled from 'styled-components'
|
||||
|
||||
export const SubTitle = styled.h3`
|
||||
color: var(--sn-stylekit-foreground-color);
|
||||
cursor: pointer;
|
||||
font-size: var(--sn-stylekit-font-size-h3);
|
||||
font-weight: 500;
|
||||
margin: 10px 0px;
|
||||
opacity: 0.55;
|
||||
|
||||
&::first-letter {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
`
|
||||
@@ -1,46 +0,0 @@
|
||||
import { ChangeEvent, forwardRef, KeyboardEvent } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
const StyledTextArea = styled.textarea`
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
color: inherit;
|
||||
font-size: 0.98rem;
|
||||
font-weight: 400;
|
||||
margin-left: 2px;
|
||||
outline: none;
|
||||
overflow: hidden;
|
||||
resize: none;
|
||||
width: 100%;
|
||||
`
|
||||
|
||||
type TextAreaInputProps = {
|
||||
value: string
|
||||
className?: string
|
||||
dir?: 'ltr' | 'rtl' | 'auto'
|
||||
disabled?: boolean
|
||||
spellCheck?: boolean
|
||||
testId?: string
|
||||
onChange?: (event: ChangeEvent<HTMLTextAreaElement>) => void
|
||||
onKeyPress?: (event: KeyboardEvent<HTMLTextAreaElement>) => void
|
||||
onKeyUp?: (event: KeyboardEvent<HTMLTextAreaElement>) => void
|
||||
}
|
||||
|
||||
export const TextAreaInput = forwardRef<HTMLTextAreaElement, TextAreaInputProps>(
|
||||
({ value, className, dir = 'auto', disabled, spellCheck, testId, onChange, onKeyPress, onKeyUp }, ref) => {
|
||||
return (
|
||||
<StyledTextArea
|
||||
className={className}
|
||||
data-testid={testId}
|
||||
dir={dir}
|
||||
disabled={disabled}
|
||||
onChange={onChange}
|
||||
onKeyPress={onKeyPress}
|
||||
onKeyUp={onKeyUp}
|
||||
ref={ref}
|
||||
spellCheck={spellCheck}
|
||||
value={value}
|
||||
/>
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -1,21 +0,0 @@
|
||||
$transition-duration: 750ms;
|
||||
|
||||
@keyframes strike {
|
||||
from {
|
||||
text-decoration: line-through 1px solid transparent;
|
||||
}
|
||||
to {
|
||||
text-decoration: line-through 1px solid var(--sn-stylekit-info-color);
|
||||
}
|
||||
}
|
||||
|
||||
.cross-out {
|
||||
animation-duration: $transition-duration;
|
||||
animation-fill-mode: forwards;
|
||||
animation-name: strike;
|
||||
animation-timing-function: linear;
|
||||
}
|
||||
|
||||
.no-text-decoration {
|
||||
text-decoration: none !important;
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
import './TextInput.scss'
|
||||
|
||||
import { ChangeEvent, FocusEvent, forwardRef, KeyboardEvent } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
type StyledInputProps = {
|
||||
textSize: 'normal' | 'big'
|
||||
}
|
||||
|
||||
const StyledInput = styled.input<StyledInputProps>`
|
||||
background-color: unset;
|
||||
border: none;
|
||||
color: var(--sn-stylekit-foreground-color);
|
||||
font-size: ${({ textSize }) => (textSize === 'big' ? '1.125rem' : 'var(--sn-stylekit-font-size-h3)')};
|
||||
font-weight: ${({ textSize }) => (textSize === 'big' ? '500' : '400')};
|
||||
height: auto;
|
||||
margin: 6px 0 6px 0;
|
||||
outline: none;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
|
||||
/* Remove default shadow for iOS mobile */
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
appearance: none;
|
||||
|
||||
::placeholder {
|
||||
color: var(--sn-stylekit-input-placeholder-color);
|
||||
}
|
||||
`
|
||||
|
||||
type TextInputProps = {
|
||||
value: string
|
||||
autoFocus?: boolean
|
||||
dir?: 'ltr' | 'rtl' | 'auto'
|
||||
disabled?: boolean
|
||||
enterKeyHint?: 'enter' | 'done' | 'go' | 'next' | 'previous' | 'search' | 'send'
|
||||
placeholder?: string
|
||||
spellCheck?: boolean
|
||||
testId?: string
|
||||
textSize?: 'normal' | 'big'
|
||||
onBlur?: (event: FocusEvent<HTMLInputElement>) => void
|
||||
onChange?: (event: ChangeEvent<HTMLInputElement>) => void
|
||||
onKeyPress?: (event: KeyboardEvent<HTMLInputElement>) => void
|
||||
}
|
||||
|
||||
export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(
|
||||
(
|
||||
{
|
||||
value,
|
||||
autoFocus,
|
||||
dir = 'auto',
|
||||
disabled,
|
||||
enterKeyHint,
|
||||
placeholder,
|
||||
spellCheck,
|
||||
testId,
|
||||
textSize = 'normal',
|
||||
onBlur,
|
||||
onChange,
|
||||
onKeyPress,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
return (
|
||||
<StyledInput
|
||||
type="text"
|
||||
autoFocus={autoFocus}
|
||||
data-testid={testId}
|
||||
dir={dir}
|
||||
disabled={disabled}
|
||||
enterKeyHint={enterKeyHint}
|
||||
onBlur={onBlur}
|
||||
onChange={onChange}
|
||||
onKeyPress={onKeyPress}
|
||||
placeholder={placeholder}
|
||||
ref={ref}
|
||||
spellCheck={spellCheck}
|
||||
textSize={textSize}
|
||||
value={value}
|
||||
/>
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -1,15 +0,0 @@
|
||||
import styled from 'styled-components'
|
||||
|
||||
export const WideButton = styled.button`
|
||||
align-items: center;
|
||||
background-color: var(--sn-stylekit-background-color);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--sn-stylekit-border-color);
|
||||
box-sizing: border-box;
|
||||
color: var(--sn-stylekit-paragraph-text-color);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
height: 36px;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
`
|
||||
@@ -1,14 +0,0 @@
|
||||
export const AddIcon = () => {
|
||||
return (
|
||||
<svg
|
||||
className="sn-icon small block"
|
||||
fill="none"
|
||||
height="14"
|
||||
viewBox="0 0 14 14"
|
||||
width="14"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M14 7C14 7.55228 13.5523 8 13 8H8V13C8 13.5523 7.55228 14 7 14C6.44772 14 6 13.5523 6 13V8H1C0.447715 8 0 7.55228 0 7C0 6.44772 0.447715 6 1 6H6V1C6 0.447715 6.44772 0 7 0C7.55228 0 8 0.447715 8 1V6H13C13.5523 6 14 6.44772 14 7Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
export const ChevronDownIcon = () => {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" className="sn-icon block">
|
||||
<path d="M6.17622 7.15015L10.0012 10.9751L13.8262 7.15015L15.0012 8.33348L10.0012 13.3335L5.00122 8.33348L6.17622 7.15015Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
export const ChevronUpIcon = () => {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" className="sn-icon block">
|
||||
<path d="M13.826 13.3335L10.001 9.5085L6.17597 13.3335L5.00097 12.1502L10.001 7.15017L15.001 12.1502L13.826 13.3335Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
export const DottedCircleIcon = () => {
|
||||
return (
|
||||
<svg
|
||||
className="sn-icon no-fill stroke-neutral-color block"
|
||||
fill="none"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<rect x="0.5" y="0.5" width="19" height="19" rx="9.5" strokeDasharray="2 2" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
export const MergeIcon = () => {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" className="sn-icon block">
|
||||
<path d="M8 17L12 13H15.2C15.6 14.2 16.7 15 18 15C19.7 15 21 13.7 21 12C21 10.3 19.7 9 18 9C16.7 9 15.6 9.8 15.2 11H12L8 7V3H3V8H6L10.2 12L6 16H3V21H8V17Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import styled from 'styled-components'
|
||||
|
||||
const Ellipsis = ({ ...props }) => (
|
||||
<span aria-hidden {...props}>
|
||||
…
|
||||
</span>
|
||||
)
|
||||
|
||||
/**
|
||||
* SVG icons don't work well with the MenuButton components.
|
||||
* So we create a text-based replacement for it.
|
||||
*/
|
||||
export const MoreIcon = styled(Ellipsis)`
|
||||
font-weight: 800;
|
||||
height: 18px;
|
||||
line-height: 10px;
|
||||
`
|
||||
@@ -1,7 +0,0 @@
|
||||
export const RenameIcon = () => {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" className="sn-icon block">
|
||||
<path d="M11.7167 7.5L12.5 8.28333L4.93333 15.8333H4.16667V15.0667L11.7167 7.5ZM14.7167 2.5C14.5083 2.5 14.2917 2.58333 14.1333 2.74167L12.6083 4.26667L15.7333 7.39167L17.2583 5.86667C17.5833 5.54167 17.5833 5 17.2583 4.69167L15.3083 2.74167C15.1417 2.575 14.9333 2.5 14.7167 2.5ZM11.7167 5.15833L2.5 14.375V17.5H5.625L14.8417 8.28333L11.7167 5.15833Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
type ReorderIconProps = {
|
||||
highlight?: boolean
|
||||
}
|
||||
|
||||
export const ReorderIcon: React.FC<ReorderIconProps> = ({ highlight = false }) => {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={`sn-icon block ${highlight ? 'info' : 'neutral'}`}
|
||||
data-testid="reorder-icon"
|
||||
>
|
||||
<path d="M17 5V6.66667H3V5H17ZM3 15H17V13.3333H3V15ZM3 10.8333H17V9.16667H3V10.8333Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
export const TrashIcon = () => {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" className="sn-icon block">
|
||||
<path d="M7.49992 2.5V3.33333H3.33325V5H4.16659V15.8333C4.16659 16.2754 4.34218 16.6993 4.65474 17.0118C4.9673 17.3244 5.39122 17.5 5.83325 17.5H14.1666C14.6086 17.5 15.0325 17.3244 15.3451 17.0118C15.6577 16.6993 15.8333 16.2754 15.8333 15.8333V5H16.6666V3.33333H12.4999V2.5H7.49992ZM5.83325 5H14.1666V15.8333H5.83325V5ZM7.49992 6.66667V14.1667H9.16658V6.66667H7.49992ZM10.8333 6.66667V14.1667H12.4999V6.66667H10.8333Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
export { AddIcon } from './AddIcon'
|
||||
export { ChevronDownIcon } from './ChevronDownIcon'
|
||||
export { ChevronUpIcon } from './ChevronUpIcon'
|
||||
export { DottedCircleIcon } from './DottedCircleIcon'
|
||||
export { MergeIcon } from './MergeIcon'
|
||||
export { MoreIcon } from './MoreIcon'
|
||||
export { RenameIcon } from './RenameIcon'
|
||||
export { ReorderIcon } from './ReorderIcon'
|
||||
export { TrashIcon } from './TrashIcon'
|
||||
@@ -1,10 +0,0 @@
|
||||
export * from './CheckBoxInput'
|
||||
export * from './CircularProgressBar'
|
||||
export * from './ConfirmDialog'
|
||||
export * from './GenericInlineText'
|
||||
export * from './MainTitle'
|
||||
export * from './RoundButton'
|
||||
export * from './SubTitle'
|
||||
export * from './TextAreaInput'
|
||||
export * from './TextInput'
|
||||
export * from './WideButton'
|
||||
@@ -1,114 +0,0 @@
|
||||
$transition-duration: 750ms;
|
||||
|
||||
.checkbox-container {
|
||||
display: block;
|
||||
padding-left: 22px;
|
||||
position: relative;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.checkbox-state {
|
||||
height: 0;
|
||||
left: 0;
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 0;
|
||||
}
|
||||
|
||||
.checkbox-button {
|
||||
bottom: 0;
|
||||
cursor: pointer;
|
||||
fill: var(--sn-stylekit-contrast-background-color);
|
||||
height: 18px;
|
||||
left: 0;
|
||||
margin: auto;
|
||||
position: absolute;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
stroke-width: 1;
|
||||
top: 0;
|
||||
width: 18px;
|
||||
}
|
||||
|
||||
.align-baseline {
|
||||
.checkbox-button {
|
||||
top: -10px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.checkbox-square,
|
||||
.checkbox-mark {
|
||||
cursor: pointer;
|
||||
transition: stroke-dashoffset $transition-duration cubic-bezier(0.9, 0, 0.5, 1);
|
||||
}
|
||||
|
||||
.checkbox-circle {
|
||||
animation-delay: 1s;
|
||||
animation: none $transition-duration linear;
|
||||
stroke-dasharray: 1 6;
|
||||
stroke-width: 0;
|
||||
stroke: var(--sn-stylekit-neutral-color);
|
||||
transform-origin: 13.5px 12.5px;
|
||||
transform: scale(0.4) rotate(0deg);
|
||||
}
|
||||
|
||||
.checkbox-square {
|
||||
stroke-dasharray: 56.1053, 56.1053;
|
||||
stroke-dashoffset: 0;
|
||||
stroke: var(--sn-stylekit-shadow-color);
|
||||
transition-delay: $transition-duration * 0.2;
|
||||
fill: var(--sn-stylekit-contrast-background-color);
|
||||
}
|
||||
|
||||
.checkbox-mark {
|
||||
stroke-dasharray: 9.8995, 9.8995;
|
||||
stroke-dashoffset: 9.8995;
|
||||
stroke: var(--sn-stylekit-neutral-color);
|
||||
transition-duration: $transition-duration * 0.4;
|
||||
}
|
||||
|
||||
.checkbox-circle {
|
||||
animation-delay: $transition-duration * 0.7;
|
||||
animation-duration: $transition-duration * 0.7;
|
||||
}
|
||||
|
||||
.checkbox-state:checked {
|
||||
~ .checkbox-button .checkbox-square {
|
||||
stroke-dashoffset: 56.1053;
|
||||
stroke: var(--sn-stylekit-info-color);
|
||||
transition-delay: 0s;
|
||||
}
|
||||
|
||||
~ .checkbox-button .checkbox-mark {
|
||||
stroke-dashoffset: 0;
|
||||
stroke: var(--sn-stylekit-info-color);
|
||||
transition-delay: $transition-duration * 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes explode {
|
||||
30% {
|
||||
stroke: var(--sn-stylekit-info-color);
|
||||
stroke-opacity: 1;
|
||||
stroke-width: 3;
|
||||
transform: scale(0.8) rotate(40deg);
|
||||
}
|
||||
100% {
|
||||
stroke: var(--sn-stylekit-neutral-color);
|
||||
stroke-opacity: 0;
|
||||
stroke-width: 0;
|
||||
transform: scale(1.1) rotate(60deg);
|
||||
}
|
||||
}
|
||||
|
||||
.explode {
|
||||
.checkbox-circle {
|
||||
animation: explode ease $transition-duration;
|
||||
animation-delay: $transition-duration;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import './CheckBoxElementsDefs.scss'
|
||||
|
||||
export const CheckBoxElementsDefs = () => {
|
||||
return (
|
||||
<svg viewBox="0 0 0 0" style={{ position: 'absolute', zIndex: -1, opacity: 0 }}>
|
||||
<defs>
|
||||
<path
|
||||
id="checkbox-square"
|
||||
d="M21 12.7v5c0 1.3-1 2.3-2.3 2.3H8.3C7 20 6 19 6 17.7V7.3C6 6 7 5 8.3 5h10.4C20 5 21 6 21 7.3v5.4"
|
||||
></path>
|
||||
<path id="checkbox-mark" d="M10 13l2 2 5-5"></path>
|
||||
<circle id="checkbox-circle" cx="13.5" cy="12.5" r="10"></circle>
|
||||
</defs>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export * from './CheckBoxElementsDefs'
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
export { ArrowVector } from './ArrowVector'
|
||||
@@ -1,296 +0,0 @@
|
||||
import { DEFAULT_SECTIONS, GroupModel, TaskModel } from '../features/tasks/tasks-slice'
|
||||
import {
|
||||
arrayDefault,
|
||||
arrayMoveImmutable,
|
||||
arrayMoveMutable,
|
||||
getPercentage,
|
||||
getPlainPreview,
|
||||
getTaskArrayFromGroupedTasks,
|
||||
groupTasksByCompletedStatus,
|
||||
parseMarkdownTasks,
|
||||
truncateText,
|
||||
} from './utils'
|
||||
|
||||
describe('arrayMoveMutable', () => {
|
||||
it('should not mutate array if there are no elements', () => {
|
||||
const theArray: any[] = []
|
||||
arrayMoveMutable(theArray, 0, 1)
|
||||
|
||||
expect(theArray).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('passing a negative number to fromIndex should use 0 instead', () => {
|
||||
const theArray = ['test', 'another test']
|
||||
arrayMoveMutable(theArray, -1, 1)
|
||||
|
||||
expect(theArray).toHaveLength(2)
|
||||
expect(theArray[0]).toBe('test')
|
||||
expect(theArray[1]).toBe('another test')
|
||||
})
|
||||
|
||||
test('passing a negative number to toIndex should use 0 instead', () => {
|
||||
const theArray = ['test', 'another test']
|
||||
arrayMoveMutable(theArray, 1, -1)
|
||||
|
||||
expect(theArray).toHaveLength(2)
|
||||
expect(theArray[0]).toBe('test')
|
||||
expect(theArray[1]).toBe('another test')
|
||||
})
|
||||
})
|
||||
|
||||
describe('arrayMoveImmutable', () => {
|
||||
it('should move the element to the desired position', () => {
|
||||
const theArray = ['test', 'testing']
|
||||
const newArray = arrayMoveImmutable(theArray, 0, 1)
|
||||
|
||||
expect(theArray).toHaveLength(2)
|
||||
expect(theArray[0]).toBe('test')
|
||||
expect(theArray[1]).toBe('testing')
|
||||
|
||||
expect(newArray).toHaveLength(2)
|
||||
expect(newArray[0]).toBe('testing')
|
||||
expect(newArray[1]).toBe('test')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getPercentage', () => {
|
||||
it('should return 0 if the first number is 0', () => {
|
||||
const percentage = getPercentage(0, 1)
|
||||
expect(percentage).toBe(0)
|
||||
})
|
||||
|
||||
it('should return 0 if the second number is 0', () => {
|
||||
const percentage = getPercentage(1, 0)
|
||||
expect(percentage).toBe(0)
|
||||
})
|
||||
|
||||
it('should swap first number with second number, if the later is greater', () => {
|
||||
const percentage = getPercentage(10, 1)
|
||||
expect(percentage).toBe(10)
|
||||
})
|
||||
|
||||
it('should trucate numbers up to two places', () => {
|
||||
expect(getPercentage(38.2, 125)).toBe(30.56)
|
||||
expect(getPercentage(67.55, 125)).toBe(54.04)
|
||||
expect(getPercentage(86.65, 125)).toBe(69.32)
|
||||
expect(getPercentage(98.85, 125)).toBe(79.08)
|
||||
})
|
||||
|
||||
it('should return the percentage of two numbers', () => {
|
||||
expect(getPercentage(4, 20)).toBe(20)
|
||||
expect(getPercentage(10, 10)).toBe(100)
|
||||
expect(getPercentage(10, 100)).toBe(10)
|
||||
expect(getPercentage(10, 40)).toBe(25)
|
||||
expect(getPercentage(15, 30)).toBe(50)
|
||||
})
|
||||
})
|
||||
|
||||
describe('groupTasksByCompletedStatus', () => {
|
||||
it('should return open tasks and completed tasks', () => {
|
||||
const tasks: TaskModel[] = [
|
||||
{
|
||||
id: 'test-1',
|
||||
description: 'Testing #1',
|
||||
completed: false,
|
||||
createdAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: 'test-2',
|
||||
description: 'Testing #2',
|
||||
createdAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: 'test-3',
|
||||
description: 'Testing #3',
|
||||
completed: true,
|
||||
createdAt: new Date(),
|
||||
},
|
||||
]
|
||||
|
||||
const { openTasks, completedTasks } = groupTasksByCompletedStatus(tasks)
|
||||
|
||||
expect(openTasks).toHaveLength(2)
|
||||
expect(openTasks[0]).toBe(tasks[0])
|
||||
expect(openTasks[1]).toBe(tasks[1])
|
||||
|
||||
expect(completedTasks).toHaveLength(1)
|
||||
expect(completedTasks[0]).toBe(tasks[2])
|
||||
})
|
||||
})
|
||||
|
||||
describe('getTaskArrayFromGroupedTasks', () => {
|
||||
it('should return an array of tasks', () => {
|
||||
const workTasks = [
|
||||
{
|
||||
id: 'test-b-1',
|
||||
description: 'Test #1',
|
||||
createdAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: 'test-b-2',
|
||||
description: 'Test #2',
|
||||
completed: true,
|
||||
createdAt: new Date(),
|
||||
},
|
||||
]
|
||||
|
||||
const personalTasks = [
|
||||
{
|
||||
id: 'test-c-1',
|
||||
description: 'Test #3',
|
||||
createdAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: 'test-c-2',
|
||||
description: 'Test #4',
|
||||
completed: true,
|
||||
createdAt: new Date(),
|
||||
},
|
||||
]
|
||||
|
||||
const groupedTasks: GroupModel[] = [
|
||||
{
|
||||
name: 'Work',
|
||||
sections: DEFAULT_SECTIONS,
|
||||
tasks: workTasks,
|
||||
},
|
||||
{
|
||||
name: 'Personal',
|
||||
sections: DEFAULT_SECTIONS,
|
||||
tasks: personalTasks,
|
||||
},
|
||||
]
|
||||
|
||||
const taskArray = getTaskArrayFromGroupedTasks(groupedTasks)
|
||||
|
||||
expect(taskArray).toHaveLength(workTasks.length + personalTasks.length)
|
||||
expect(taskArray).toStrictEqual([...workTasks, ...personalTasks])
|
||||
})
|
||||
})
|
||||
|
||||
describe('truncateText', () => {
|
||||
it('should return the text as-is', () => {
|
||||
const text = 'This is a simple text. It should not be truncated.'
|
||||
|
||||
expect(truncateText(text, 100)).toBe(text)
|
||||
})
|
||||
|
||||
it('should return the truncated text', () => {
|
||||
const text = 'This is a simple text. It should not be truncated.'
|
||||
const truncated = truncateText(text, 10)
|
||||
|
||||
expect(truncated).toHaveLength(13) // Includes ellipsis
|
||||
expect(truncated).toBe('This is a ...')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getPlainPreview', () => {
|
||||
it('should return a text preview in the format: {open tasks}/{all tasks}', () => {
|
||||
const workTasks = [
|
||||
{
|
||||
id: 'test-b-1',
|
||||
description: 'Test #1',
|
||||
createdAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: 'test-b-2',
|
||||
description: 'Test #2',
|
||||
completed: true,
|
||||
createdAt: new Date(),
|
||||
},
|
||||
]
|
||||
|
||||
const personalTasks = [
|
||||
{
|
||||
id: 'test-c-1',
|
||||
description: 'Test #3',
|
||||
createdAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: 'test-c-2',
|
||||
description: 'Test #4',
|
||||
completed: true,
|
||||
createdAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: 'test-c-3',
|
||||
description: 'Test #5',
|
||||
createdAt: new Date(),
|
||||
},
|
||||
]
|
||||
|
||||
const groupedTasks: GroupModel[] = [
|
||||
{
|
||||
name: 'Work',
|
||||
sections: DEFAULT_SECTIONS,
|
||||
tasks: workTasks,
|
||||
},
|
||||
{
|
||||
name: 'Personal',
|
||||
sections: DEFAULT_SECTIONS,
|
||||
tasks: personalTasks,
|
||||
},
|
||||
]
|
||||
|
||||
expect(getPlainPreview(groupedTasks)).toBe('2/5 tasks completed')
|
||||
expect(getPlainPreview([])).toBe('0/0 tasks completed')
|
||||
expect(getPlainPreview([{ name: 'Test', tasks: [], sections: [] }])).toBe('0/0 tasks completed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseMarkdownTasks', () => {
|
||||
it('should not return tasks if payload is not in correct format', () => {
|
||||
expect(parseMarkdownTasks('')).toBeUndefined()
|
||||
expect(parseMarkdownTasks(' ')).toBeUndefined()
|
||||
expect(parseMarkdownTasks('this is just a piece of text')).toBeUndefined()
|
||||
expect(parseMarkdownTasks(undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should not return tasks without descriptions', () => {
|
||||
const payload = '- [ ] '
|
||||
expect(parseMarkdownTasks(payload)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should return tasks from a payload with correct format', () => {
|
||||
const payload = `- [ ] Foo
|
||||
- [x] Bar
|
||||
- [ ] Foobar`
|
||||
|
||||
expect(parseMarkdownTasks(payload)).toMatchObject<GroupModel>({
|
||||
name: 'Checklist',
|
||||
tasks: [
|
||||
{
|
||||
id: expect.any(String),
|
||||
description: 'Foo',
|
||||
completed: false,
|
||||
createdAt: expect.any(Date),
|
||||
},
|
||||
{
|
||||
id: expect.any(String),
|
||||
description: 'Bar',
|
||||
completed: true,
|
||||
createdAt: expect.any(Date),
|
||||
},
|
||||
{
|
||||
id: expect.any(String),
|
||||
description: 'Foobar',
|
||||
completed: false,
|
||||
createdAt: expect.any(Date),
|
||||
},
|
||||
],
|
||||
sections: DEFAULT_SECTIONS,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('arrayDefault', () => {
|
||||
it('should fallback to default value', () => {
|
||||
expect(arrayDefault({ defaultValue: [] })).toEqual([])
|
||||
expect(arrayDefault({ value: undefined, defaultValue: [] })).toEqual([])
|
||||
expect(arrayDefault({ value: [], defaultValue: ['test'] })).toEqual(['test'])
|
||||
})
|
||||
|
||||
it('should return value', () => {
|
||||
expect(arrayDefault({ value: ['test'], defaultValue: [] })).toEqual(['test'])
|
||||
})
|
||||
})
|
||||
@@ -1,144 +0,0 @@
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { DEFAULT_SECTIONS, GroupModel, TaskModel } from '../features/tasks/tasks-slice'
|
||||
|
||||
export function arrayMoveMutable(array: any[], fromIndex: number, toIndex: number) {
|
||||
const startIndex = fromIndex < 0 ? array.length + fromIndex : fromIndex
|
||||
if (startIndex >= 0 && startIndex < array.length) {
|
||||
const endIndex = toIndex < 0 ? array.length + toIndex : toIndex
|
||||
const [item] = array.splice(fromIndex, 1)
|
||||
array.splice(endIndex, 0, item)
|
||||
}
|
||||
}
|
||||
|
||||
export function arrayMoveImmutable(array: any[], fromIndex: number, toIndex: number) {
|
||||
array = [...array]
|
||||
arrayMoveMutable(array, fromIndex, toIndex)
|
||||
return array
|
||||
}
|
||||
|
||||
export function getPercentage(numberA: number, numberB: number): number {
|
||||
if (numberA === 0 || numberB === 0) {
|
||||
return 0
|
||||
}
|
||||
const min = Math.min(numberA, numberB)
|
||||
const max = Math.max(numberA, numberB)
|
||||
const percentage = (min / max) * 100
|
||||
return Number(percentage.toFixed(2))
|
||||
}
|
||||
|
||||
export function groupTasksByCompletedStatus(tasks: TaskModel[]) {
|
||||
const openTasks = tasks.filter((task) => !task.completed)
|
||||
const completedTasks = tasks.filter((task) => task.completed)
|
||||
return {
|
||||
openTasks,
|
||||
completedTasks,
|
||||
}
|
||||
}
|
||||
|
||||
export function getTaskArrayFromGroupedTasks(groupedTasks: GroupModel[]): TaskModel[] {
|
||||
let taskArray: TaskModel[] = []
|
||||
|
||||
groupedTasks.forEach((group) => {
|
||||
taskArray = taskArray.concat(group.tasks)
|
||||
})
|
||||
|
||||
return taskArray
|
||||
}
|
||||
|
||||
export function truncateText(text: string, limit: number = 50) {
|
||||
if (text.length <= limit) {
|
||||
return text
|
||||
}
|
||||
return text.substring(0, limit) + '...'
|
||||
}
|
||||
|
||||
export function getPlainPreview(groupedTasks: GroupModel[]) {
|
||||
const allTasks = getTaskArrayFromGroupedTasks(groupedTasks)
|
||||
const { completedTasks } = groupTasksByCompletedStatus(allTasks)
|
||||
|
||||
return `${completedTasks.length}/${allTasks.length} tasks completed`
|
||||
}
|
||||
|
||||
function createTaskFromLine(rawTask: string): TaskModel | undefined {
|
||||
const IS_COMPLETED = /^- \[x\] /i
|
||||
const OPEN_PREFIX = '- [ ] '
|
||||
|
||||
const description = rawTask.replace(OPEN_PREFIX, '').replace(IS_COMPLETED, '')
|
||||
|
||||
if (description.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
return {
|
||||
id: uuidv4(),
|
||||
description,
|
||||
completed: IS_COMPLETED.test(rawTask),
|
||||
createdAt: new Date(),
|
||||
}
|
||||
}
|
||||
|
||||
export function parseMarkdownTasks(payload?: string): GroupModel | undefined {
|
||||
if (!payload) {
|
||||
return
|
||||
}
|
||||
|
||||
const IS_LEGACY_FORMAT = /^- \[[x ]\] .*/gim
|
||||
if (!IS_LEGACY_FORMAT.test(payload)) {
|
||||
return
|
||||
}
|
||||
|
||||
const lines = payload.split('\n')
|
||||
const tasks: TaskModel[] = []
|
||||
|
||||
lines
|
||||
.filter((line) => line.replace(/ /g, '').length > 0)
|
||||
.map((line) => createTaskFromLine(line))
|
||||
.forEach((item) => item && tasks.push(item))
|
||||
|
||||
if (tasks.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
return {
|
||||
name: 'Checklist',
|
||||
tasks,
|
||||
sections: DEFAULT_SECTIONS,
|
||||
}
|
||||
}
|
||||
|
||||
export function isJsonString(rawString: string) {
|
||||
try {
|
||||
JSON.parse(rawString)
|
||||
} catch (e) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export function isLastActiveGroup(allGroups: GroupModel[], groupName: string): boolean {
|
||||
if (allGroups.length === 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
const lastActiveGroup = allGroups.reduce((prev, current) => {
|
||||
if (!prev.lastActive) {
|
||||
return current
|
||||
}
|
||||
if (!current.lastActive) {
|
||||
return prev
|
||||
}
|
||||
return prev.lastActive > current.lastActive ? prev : current
|
||||
})
|
||||
|
||||
return lastActiveGroup.name === groupName
|
||||
}
|
||||
|
||||
export function arrayDefault({ value, defaultValue }: { value?: any[]; defaultValue: any[] }) {
|
||||
if (!value) {
|
||||
return defaultValue
|
||||
}
|
||||
if (value.length === 0) {
|
||||
return defaultValue
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import type { SettingsState } from './settings-slice'
|
||||
import reducer, { setCanEdit, setIsRunningOnMobile, setSpellCheckerEnabled } from './settings-slice'
|
||||
|
||||
it('should return the initial state', () => {
|
||||
return expect(
|
||||
reducer(undefined, {
|
||||
type: undefined,
|
||||
}),
|
||||
).toEqual({
|
||||
canEdit: true,
|
||||
isRunningOnMobile: false,
|
||||
spellCheckerEnabled: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle setting canEdit property', () => {
|
||||
const previousState: SettingsState = {
|
||||
canEdit: false,
|
||||
isRunningOnMobile: false,
|
||||
spellCheckerEnabled: false,
|
||||
}
|
||||
|
||||
expect(reducer(previousState, setCanEdit(true))).toEqual({
|
||||
...previousState,
|
||||
canEdit: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle setting isRunningOnMobile property', () => {
|
||||
const previousState: SettingsState = {
|
||||
canEdit: false,
|
||||
isRunningOnMobile: false,
|
||||
spellCheckerEnabled: false,
|
||||
}
|
||||
|
||||
expect(reducer(previousState, setIsRunningOnMobile(true))).toEqual({
|
||||
...previousState,
|
||||
isRunningOnMobile: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle setting spellCheckerEnabled property', () => {
|
||||
const previousState: SettingsState = {
|
||||
canEdit: false,
|
||||
isRunningOnMobile: false,
|
||||
spellCheckerEnabled: false,
|
||||
}
|
||||
|
||||
expect(reducer(previousState, setSpellCheckerEnabled(true))).toEqual({
|
||||
...previousState,
|
||||
spellCheckerEnabled: true,
|
||||
})
|
||||
})
|
||||
@@ -1,32 +0,0 @@
|
||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
|
||||
|
||||
export type SettingsState = {
|
||||
canEdit: boolean
|
||||
isRunningOnMobile: boolean
|
||||
spellCheckerEnabled: boolean
|
||||
}
|
||||
|
||||
const initialState: SettingsState = {
|
||||
canEdit: true,
|
||||
isRunningOnMobile: false,
|
||||
spellCheckerEnabled: true,
|
||||
}
|
||||
|
||||
const settingsSlice = createSlice({
|
||||
name: 'settings',
|
||||
initialState,
|
||||
reducers: {
|
||||
setCanEdit(state, action: PayloadAction<boolean>) {
|
||||
state.canEdit = action.payload
|
||||
},
|
||||
setIsRunningOnMobile(state, action: PayloadAction<boolean>) {
|
||||
state.isRunningOnMobile = action.payload
|
||||
},
|
||||
setSpellCheckerEnabled(state, action: PayloadAction<boolean>) {
|
||||
state.spellCheckerEnabled = action.payload
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export const { setCanEdit, setIsRunningOnMobile, setSpellCheckerEnabled } = settingsSlice.actions
|
||||
export default settingsSlice.reducer
|
||||
@@ -1,83 +0,0 @@
|
||||
import { fireEvent, screen } from '@testing-library/react'
|
||||
import { testRender } from '../../testUtils'
|
||||
|
||||
import { RootState } from '../../app/store'
|
||||
import CompletedTasksActions from './CompletedTasksActions'
|
||||
import { deleteAllCompleted, openAllCompleted } from './tasks-slice'
|
||||
|
||||
const group = 'default group'
|
||||
|
||||
it('renders two buttons', () => {
|
||||
testRender(<CompletedTasksActions groupName={group} />)
|
||||
|
||||
expect(screen.getByTestId('reopen-completed-button')).toHaveTextContent('Reopen Completed')
|
||||
expect(screen.getByTestId('delete-completed-button')).toHaveTextContent('Delete Completed')
|
||||
})
|
||||
|
||||
it('should not render buttons if can not edit', () => {
|
||||
const defaultState: Partial<RootState> = {
|
||||
settings: {
|
||||
canEdit: false,
|
||||
isRunningOnMobile: false,
|
||||
spellCheckerEnabled: true,
|
||||
},
|
||||
}
|
||||
|
||||
testRender(<CompletedTasksActions groupName={group} />, {}, defaultState)
|
||||
|
||||
expect(screen.queryByTestId('reopen-completed-button')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('delete-completed-button')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should dispatch openAllCompleted action', () => {
|
||||
const { mockStore } = testRender(<CompletedTasksActions groupName={group} />)
|
||||
|
||||
const reOpenCompletedButton = screen.getByTestId('reopen-completed-button')
|
||||
fireEvent.click(reOpenCompletedButton)
|
||||
|
||||
const confirmDialog = screen.getByTestId('reopen-all-tasks-dialog')
|
||||
expect(confirmDialog).toBeInTheDocument()
|
||||
expect(confirmDialog).toHaveTextContent(`Are you sure you want to reopen completed tasks in the '${group}' group?`)
|
||||
|
||||
const confirmButton = screen.getByTestId('confirm-dialog-button')
|
||||
fireEvent.click(confirmButton)
|
||||
|
||||
const dispatchedActions = mockStore.getActions()
|
||||
expect(dispatchedActions).toHaveLength(1)
|
||||
expect(dispatchedActions[0]).toMatchObject(openAllCompleted({ groupName: group }))
|
||||
})
|
||||
|
||||
it('should dispatch deleteCompleted action', () => {
|
||||
const { mockStore } = testRender(<CompletedTasksActions groupName={group} />)
|
||||
|
||||
const deleteCompletedButton = screen.getByTestId('delete-completed-button')
|
||||
fireEvent.click(deleteCompletedButton)
|
||||
|
||||
const confirmDialog = screen.getByTestId('delete-completed-tasks-dialog')
|
||||
expect(confirmDialog).toBeInTheDocument()
|
||||
expect(confirmDialog).toHaveTextContent(`Are you sure you want to delete completed tasks in the '${group}' group?`)
|
||||
|
||||
const confirmButton = screen.getByTestId('confirm-dialog-button')
|
||||
fireEvent.click(confirmButton)
|
||||
|
||||
const dispatchedActions = mockStore.getActions()
|
||||
expect(dispatchedActions).toHaveLength(1)
|
||||
expect(dispatchedActions[0]).toMatchObject(deleteAllCompleted({ groupName: group }))
|
||||
})
|
||||
|
||||
it('should dismiss dialogs', () => {
|
||||
const { mockStore } = testRender(<CompletedTasksActions groupName={group} />)
|
||||
|
||||
const reOpenCompletedButton = screen.getByTestId('reopen-completed-button')
|
||||
fireEvent.click(reOpenCompletedButton)
|
||||
|
||||
fireEvent.click(screen.getByTestId('cancel-dialog-button'))
|
||||
|
||||
const deleteCompletedButton = screen.getByTestId('delete-completed-button')
|
||||
fireEvent.click(deleteCompletedButton)
|
||||
|
||||
fireEvent.click(screen.getByTestId('cancel-dialog-button'))
|
||||
|
||||
const dispatchedActions = mockStore.getActions()
|
||||
expect(dispatchedActions).toHaveLength(0)
|
||||
})
|
||||
@@ -1,77 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import { useAppDispatch, useAppSelector } from '../../app/hooks'
|
||||
import { ConfirmDialog } from '../../common/components'
|
||||
|
||||
import { deleteAllCompleted, openAllCompleted } from './tasks-slice'
|
||||
|
||||
const ActionButton = styled.button`
|
||||
background-color: var(--sn-stylekit-contrast-background-color);
|
||||
border-radius: 4px;
|
||||
border-style: none;
|
||||
color: var(--sn-stylekit-paragraph-text-color);
|
||||
cursor: pointer;
|
||||
display: inline;
|
||||
font-size: var(--sn-stylekit-font-size-h6);
|
||||
font-weight: 500;
|
||||
height: 25px;
|
||||
margin-right: 10px;
|
||||
opacity: 0.96;
|
||||
padding: 4px 10px 4px;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
text-decoration: none;
|
||||
}
|
||||
`
|
||||
|
||||
type CompletedTasksActionsProps = {
|
||||
groupName: string
|
||||
}
|
||||
|
||||
const CompletedTasksActions: React.FC<CompletedTasksActionsProps> = ({ groupName }) => {
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
const canEdit = useAppSelector((state) => state.settings.canEdit)
|
||||
|
||||
const [showReopenDialog, setShowReopenDialog] = useState(false)
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||
|
||||
if (!canEdit) {
|
||||
return <></>
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-testid="completed-tasks-actions">
|
||||
<ActionButton onClick={() => setShowReopenDialog(true)} data-testid="reopen-completed-button">
|
||||
Reopen Completed
|
||||
</ActionButton>
|
||||
<ActionButton onClick={() => setShowDeleteDialog(true)} data-testid="delete-completed-button">
|
||||
Delete Completed
|
||||
</ActionButton>
|
||||
{showReopenDialog && (
|
||||
<ConfirmDialog
|
||||
testId="reopen-all-tasks-dialog"
|
||||
confirmButtonStyle="danger"
|
||||
confirmButtonCb={() => dispatch(openAllCompleted({ groupName }))}
|
||||
cancelButtonCb={() => setShowReopenDialog(false)}
|
||||
>
|
||||
Are you sure you want to reopen completed tasks in the '<strong>{groupName}</strong>' group?
|
||||
</ConfirmDialog>
|
||||
)}
|
||||
{showDeleteDialog && (
|
||||
<ConfirmDialog
|
||||
testId="delete-completed-tasks-dialog"
|
||||
confirmButtonStyle="danger"
|
||||
confirmButtonCb={() => dispatch(deleteAllCompleted({ groupName }))}
|
||||
cancelButtonCb={() => setShowDeleteDialog(false)}
|
||||
>
|
||||
Are you sure you want to delete completed tasks in the '<strong>{groupName}</strong>' group?
|
||||
</ConfirmDialog>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CompletedTasksActions
|
||||
@@ -1,126 +0,0 @@
|
||||
import { fireEvent, screen } from '@testing-library/react'
|
||||
import { RootState } from '../../app/store'
|
||||
|
||||
import { testRender } from '../../testUtils'
|
||||
import CreateGroup from './CreateGroup'
|
||||
import { tasksGroupAdded } from './tasks-slice'
|
||||
|
||||
const defaultTasksState = {
|
||||
tasks: {
|
||||
schemaVersion: '1.0.0',
|
||||
defaultSections: [],
|
||||
groups: [
|
||||
{
|
||||
name: 'test',
|
||||
tasks: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
it('renders a button by default', () => {
|
||||
testRender(<CreateGroup />)
|
||||
|
||||
expect(screen.queryByTestId('create-group-button')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('create-group-input')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders nothing if user can not edit', () => {
|
||||
const defaultState: Partial<RootState> = {
|
||||
settings: {
|
||||
canEdit: false,
|
||||
isRunningOnMobile: false,
|
||||
spellCheckerEnabled: true,
|
||||
},
|
||||
}
|
||||
|
||||
testRender(<CreateGroup />, {}, defaultState)
|
||||
|
||||
expect(screen.queryByTestId('create-group-button')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('create-group-input')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders an input box when the button is clicked', () => {
|
||||
testRender(<CreateGroup />, {}, defaultTasksState)
|
||||
|
||||
const button = screen.getByTestId('create-group-button')
|
||||
fireEvent.click(button)
|
||||
|
||||
expect(button).not.toBeInTheDocument()
|
||||
|
||||
const inputBox = screen.queryByTestId('create-group-input')
|
||||
expect(inputBox).toBeInTheDocument()
|
||||
expect(inputBox).toHaveTextContent('')
|
||||
})
|
||||
|
||||
it('changes the input box text', () => {
|
||||
testRender(<CreateGroup />)
|
||||
|
||||
const inputBox = screen.getByTestId('create-group-input') as HTMLInputElement
|
||||
fireEvent.change(inputBox, { target: { value: 'This is the new text' } })
|
||||
|
||||
expect(inputBox.value).toBe('This is the new text')
|
||||
})
|
||||
|
||||
test('pressing enter when input box is empty, should not create a new group', () => {
|
||||
const { mockStore } = testRender(<CreateGroup />, {}, defaultTasksState)
|
||||
|
||||
let button = screen.getByTestId('create-group-button')
|
||||
fireEvent.click(button)
|
||||
|
||||
const inputBox = screen.getByTestId('create-group-input')
|
||||
fireEvent.keyPress(inputBox, {
|
||||
key: 'Enter',
|
||||
code: 'Enter',
|
||||
charCode: 13,
|
||||
target: { value: '' },
|
||||
})
|
||||
|
||||
const dispatchedActions = mockStore.getActions()
|
||||
expect(dispatchedActions).toHaveLength(0)
|
||||
|
||||
button = screen.getByTestId('create-group-button')
|
||||
|
||||
expect(inputBox).not.toBeInTheDocument()
|
||||
expect(button).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('pressing enter should create a new group', () => {
|
||||
const groupName = 'My group name'
|
||||
const { mockStore } = testRender(<CreateGroup />, {}, defaultTasksState)
|
||||
|
||||
let button = screen.getByTestId('create-group-button')
|
||||
fireEvent.click(button)
|
||||
|
||||
const inputBox = screen.getByTestId('create-group-input')
|
||||
fireEvent.keyPress(inputBox, {
|
||||
key: 'Enter',
|
||||
code: 'Enter',
|
||||
charCode: 13,
|
||||
target: { value: groupName },
|
||||
})
|
||||
|
||||
const dispatchedActions = mockStore.getActions()
|
||||
expect(dispatchedActions).toHaveLength(1)
|
||||
expect(dispatchedActions[0]).toMatchObject(tasksGroupAdded({ groupName }))
|
||||
|
||||
button = screen.getByTestId('create-group-button')
|
||||
|
||||
expect(inputBox).not.toBeInTheDocument()
|
||||
expect(button).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('the create new group button is shown if the input box loses focus', () => {
|
||||
testRender(<CreateGroup />, {}, defaultTasksState)
|
||||
|
||||
let button = screen.getByTestId('create-group-button')
|
||||
fireEvent.click(button)
|
||||
|
||||
const inputBox = screen.getByTestId('create-group-input')
|
||||
fireEvent.blur(inputBox)
|
||||
|
||||
button = screen.getByTestId('create-group-button')
|
||||
|
||||
expect(inputBox).not.toBeInTheDocument()
|
||||
expect(button).toBeInTheDocument()
|
||||
})
|
||||
@@ -1,153 +0,0 @@
|
||||
import { ChangeEvent, createRef, FocusEvent, KeyboardEvent, useState } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import { useAppDispatch, useAppSelector } from '../../app/hooks'
|
||||
import { tasksGroupAdded } from './tasks-slice'
|
||||
|
||||
import { TextInput, WideButton } from '../../common/components'
|
||||
import { AddIcon } from '../../common/components/icons'
|
||||
import { ArrowVector } from '../../common/components/vectors'
|
||||
|
||||
const InputContainer = styled.div`
|
||||
background-color: var(--sn-stylekit-background-color);
|
||||
border: 1px solid var(--sn-stylekit-border-color);
|
||||
border-radius: 8px;
|
||||
box-sizing: border-box;
|
||||
padding: 16px;
|
||||
width: 100%;
|
||||
`
|
||||
|
||||
const TutorialContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-top: 9px;
|
||||
position: relative;
|
||||
`
|
||||
|
||||
const Tutorial = styled.div`
|
||||
align-items: center;
|
||||
align-content: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
z-index: 100;
|
||||
`
|
||||
|
||||
const TutorialText = styled.div`
|
||||
color: var(--sn-stylekit-paragraph-text-color);
|
||||
font-size: var(--sn-stylekit-font-size-h2);
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
width: 194px;
|
||||
`
|
||||
|
||||
const BaseEmptyContainer = styled.div`
|
||||
background-color: var(--sn-stylekit-border-color);
|
||||
border-radius: 8px;
|
||||
box-sizing: border-box;
|
||||
height: 66px;
|
||||
padding: 16px;
|
||||
margin-bottom: 9px;
|
||||
`
|
||||
|
||||
const EmptyContainer1 = styled(BaseEmptyContainer)`
|
||||
opacity: 0.8;
|
||||
`
|
||||
|
||||
const EmptyContainer2 = styled(BaseEmptyContainer)`
|
||||
opacity: 0.6;
|
||||
`
|
||||
|
||||
const EmptyContainer3 = styled(BaseEmptyContainer)`
|
||||
opacity: 0.4;
|
||||
`
|
||||
|
||||
const EmptyContainer4 = styled(BaseEmptyContainer)`
|
||||
opacity: 0.2;
|
||||
`
|
||||
|
||||
const CreateGroup: React.FC = () => {
|
||||
const inputRef = createRef<HTMLInputElement>()
|
||||
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
const [group, setGroup] = useState('')
|
||||
const [isCreateMode, setIsCreateMode] = useState(false)
|
||||
|
||||
const canEdit = useAppSelector((state) => state.settings.canEdit)
|
||||
const spellCheckerEnabled = useAppSelector((state) => state.settings.spellCheckerEnabled)
|
||||
const groupedTasks = useAppSelector((state) => state.tasks.groups)
|
||||
const taskGroupCount = groupedTasks.length
|
||||
|
||||
function toggleMode() {
|
||||
setIsCreateMode(!isCreateMode)
|
||||
}
|
||||
|
||||
function handleBlur(event: FocusEvent<HTMLInputElement>) {
|
||||
setIsCreateMode(false)
|
||||
setGroup('')
|
||||
}
|
||||
|
||||
function handleTextChange(event: ChangeEvent<HTMLInputElement>) {
|
||||
setGroup(event.target.value)
|
||||
}
|
||||
|
||||
function handleKeyPress(event: KeyboardEvent<HTMLInputElement>) {
|
||||
if (event.key === 'Enter') {
|
||||
const groupName = (event.target as HTMLInputElement).value
|
||||
if (groupName.length > 0) {
|
||||
dispatch(tasksGroupAdded({ groupName }))
|
||||
}
|
||||
|
||||
setIsCreateMode(false)
|
||||
setGroup('')
|
||||
}
|
||||
}
|
||||
|
||||
if (!canEdit) {
|
||||
return <></>
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{!isCreateMode && taskGroupCount > 0 ? (
|
||||
<WideButton data-testid="create-group-button" onClick={toggleMode}>
|
||||
<AddIcon />
|
||||
</WideButton>
|
||||
) : (
|
||||
<>
|
||||
<InputContainer>
|
||||
<TextInput
|
||||
testId="create-group-input"
|
||||
value={group}
|
||||
enterKeyHint={'go'}
|
||||
onBlur={handleBlur}
|
||||
onChange={handleTextChange}
|
||||
onKeyPress={handleKeyPress}
|
||||
placeholder="Name your task group and press enter"
|
||||
ref={inputRef}
|
||||
spellCheck={spellCheckerEnabled}
|
||||
textSize="big"
|
||||
autoFocus
|
||||
/>
|
||||
</InputContainer>
|
||||
{taskGroupCount === 0 && (
|
||||
<TutorialContainer>
|
||||
<Tutorial>
|
||||
<ArrowVector style={{ marginRight: 140, marginBottom: 12 }} />
|
||||
<TutorialText>Get started by naming your first task group</TutorialText>
|
||||
</Tutorial>
|
||||
<EmptyContainer1 />
|
||||
<EmptyContainer2 />
|
||||
<EmptyContainer3 />
|
||||
<EmptyContainer4 />
|
||||
</TutorialContainer>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default CreateGroup
|
||||
@@ -1,85 +0,0 @@
|
||||
import { fireEvent, screen } from '@testing-library/react'
|
||||
import { RootState } from '../../app/store'
|
||||
|
||||
import { testRender } from '../../testUtils'
|
||||
import CreateTask from './CreateTask'
|
||||
import { DEFAULT_SECTIONS, taskAdded } from './tasks-slice'
|
||||
|
||||
jest.mock('uuid', () => {
|
||||
return {
|
||||
v4: () => 'my-fake-uuid',
|
||||
}
|
||||
})
|
||||
|
||||
const defaultGroup = {
|
||||
name: 'My default group',
|
||||
tasks: [],
|
||||
sections: DEFAULT_SECTIONS,
|
||||
}
|
||||
|
||||
it('renders a button by default', () => {
|
||||
testRender(<CreateTask group={defaultGroup} />)
|
||||
|
||||
const inputBox = screen.queryByTestId('create-task-input')
|
||||
expect(inputBox).toBeInTheDocument()
|
||||
expect(inputBox).toHaveTextContent('')
|
||||
})
|
||||
|
||||
it('should not render input if can not edit', () => {
|
||||
const defaultState: Partial<RootState> = {
|
||||
settings: {
|
||||
canEdit: false,
|
||||
isRunningOnMobile: false,
|
||||
spellCheckerEnabled: true,
|
||||
},
|
||||
}
|
||||
|
||||
testRender(<CreateTask group={defaultGroup} />, {}, defaultState)
|
||||
|
||||
expect(screen.queryByTestId('create-task-input')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('changes the input box value', () => {
|
||||
testRender(<CreateTask group={defaultGroup} />)
|
||||
|
||||
const inputBox = screen.getByTestId('create-task-input') as HTMLInputElement
|
||||
fireEvent.change(inputBox, { target: { value: 'This is a simple task' } })
|
||||
|
||||
expect(inputBox.value).toBe('This is a simple task')
|
||||
})
|
||||
|
||||
test('pressing enter when input box is empty, should not create a new task', () => {
|
||||
const { mockStore } = testRender(<CreateTask group={defaultGroup} />)
|
||||
|
||||
const inputBox = screen.getByTestId('create-task-input')
|
||||
fireEvent.keyPress(inputBox, {
|
||||
key: 'Enter',
|
||||
code: 'Enter',
|
||||
charCode: 13,
|
||||
target: { value: '' },
|
||||
})
|
||||
|
||||
const dispatchedActions = mockStore.getActions()
|
||||
expect(dispatchedActions).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('pressing enter when input box is not empty, should create a new task', () => {
|
||||
const { mockStore } = testRender(<CreateTask group={defaultGroup} />)
|
||||
|
||||
const inputBox = screen.getByTestId('create-task-input')
|
||||
fireEvent.keyPress(inputBox, {
|
||||
key: 'Enter',
|
||||
code: 'Enter',
|
||||
charCode: 13,
|
||||
target: { value: 'My awesome task' },
|
||||
})
|
||||
|
||||
const dispatchedActions = mockStore.getActions()
|
||||
expect(dispatchedActions).toHaveLength(1)
|
||||
expect(dispatchedActions[0]).toMatchObject(
|
||||
taskAdded({
|
||||
task: { id: 'my-fake-uuid', description: 'My awesome task' },
|
||||
groupName: defaultGroup.name,
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -1,80 +0,0 @@
|
||||
import { ChangeEvent, createRef, KeyboardEvent, useState } from 'react'
|
||||
import styled from 'styled-components'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
|
||||
import { useAppDispatch, useAppSelector, useDebouncedCallback } from '../../app/hooks'
|
||||
import { GroupModel, taskAdded, tasksGroupDraft } from './tasks-slice'
|
||||
|
||||
import { TextInput } from '../../common/components'
|
||||
import { isLastActiveGroup } from '../../common/utils'
|
||||
|
||||
const Container = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
margin-bottom: 8px;
|
||||
`
|
||||
|
||||
type CreateTaskProps = {
|
||||
group: GroupModel
|
||||
}
|
||||
|
||||
const CreateTask: React.FC<CreateTaskProps> = ({ group }) => {
|
||||
const inputRef = createRef<HTMLInputElement>()
|
||||
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
const spellCheckerEnabled = useAppSelector((state) => state.settings.spellCheckerEnabled)
|
||||
const canEdit = useAppSelector((state) => state.settings.canEdit)
|
||||
const allGroups = useAppSelector((state) => state.tasks.groups)
|
||||
|
||||
const groupName = group.name
|
||||
const [taskDraft, setTaskDraft] = useState<string>(group.draft ?? '')
|
||||
|
||||
function onTextChange(event: ChangeEvent<HTMLInputElement>) {
|
||||
setTaskDraft(event.target.value)
|
||||
}
|
||||
|
||||
function handleKeyPress(event: KeyboardEvent<HTMLInputElement>) {
|
||||
if (event.key === 'Enter') {
|
||||
const rawString = (event.target as HTMLInputElement).value
|
||||
if (rawString.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
dispatch(taskAdded({ task: { id: uuidv4(), description: rawString }, groupName }))
|
||||
setTaskDraft('')
|
||||
}
|
||||
}
|
||||
|
||||
useDebouncedCallback(() => {
|
||||
const currentDraft = group.draft ?? ''
|
||||
if (currentDraft !== taskDraft) {
|
||||
dispatch(tasksGroupDraft({ groupName, draft: taskDraft }))
|
||||
}
|
||||
})
|
||||
|
||||
if (!canEdit) {
|
||||
return <></>
|
||||
}
|
||||
|
||||
const isLastActive = isLastActiveGroup(allGroups, groupName)
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<TextInput
|
||||
testId="create-task-input"
|
||||
disabled={!canEdit}
|
||||
enterKeyHint={'go'}
|
||||
onChange={onTextChange}
|
||||
onKeyPress={handleKeyPress}
|
||||
placeholder={'Type a task and press enter'}
|
||||
ref={inputRef}
|
||||
spellCheck={spellCheckerEnabled}
|
||||
value={taskDraft}
|
||||
autoFocus={isLastActive}
|
||||
/>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
export default CreateTask
|
||||
@@ -1,23 +0,0 @@
|
||||
import styled from 'styled-components'
|
||||
import { useAppSelector } from '../../app/hooks'
|
||||
|
||||
const Container = styled.div`
|
||||
background-color: var(--sn-stylekit-background-color);
|
||||
border: 1px solid var(--sn-stylekit-border-color);
|
||||
border-radius: 8px;
|
||||
box-sizing: border-box;
|
||||
height: 100%;
|
||||
padding: 16px;
|
||||
margin-bottom: 9px;
|
||||
`
|
||||
|
||||
const InvalidContentError: React.FC = () => {
|
||||
const lastError = useAppSelector((state) => state.tasks.lastError)
|
||||
if (!lastError) {
|
||||
return <></>
|
||||
}
|
||||
|
||||
return <Container>{lastError}</Container>
|
||||
}
|
||||
|
||||
export default InvalidContentError
|
||||
@@ -1,243 +0,0 @@
|
||||
import { fireEvent, screen } from '@testing-library/react'
|
||||
import { RootState } from '../../app/store'
|
||||
import { testRender } from '../../testUtils'
|
||||
|
||||
import MergeTaskGroups from './MergeTaskGroups'
|
||||
import { tasksGroupMerged } from './tasks-slice'
|
||||
|
||||
const handleClose = jest.fn()
|
||||
|
||||
it('renders the alert dialog when no groups are available to merge', () => {
|
||||
const defaultGroup = 'Test'
|
||||
const defaultState: Partial<RootState> = {
|
||||
tasks: {
|
||||
schemaVersion: '1.0.0',
|
||||
defaultSections: [],
|
||||
groups: [
|
||||
{
|
||||
name: 'Test',
|
||||
tasks: [
|
||||
{
|
||||
id: 'some-id',
|
||||
description: 'A simple task',
|
||||
completed: true,
|
||||
createdAt: new Date(),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
testRender(<MergeTaskGroups groupName={defaultGroup} handleClose={handleClose} />, {}, defaultState)
|
||||
|
||||
const alertDialog = screen.getByTestId('merge-task-group-dialog')
|
||||
expect(alertDialog).toBeInTheDocument()
|
||||
expect(alertDialog).toHaveTextContent(`There are no other groups to merge '${defaultGroup}' with.`)
|
||||
expect(alertDialog).not.toHaveTextContent(`Select which group you want to merge '${defaultGroup}' into:`)
|
||||
|
||||
// There shouldn't be any radio buttons
|
||||
expect(screen.queryAllByRole('radio')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('renders the alert dialog when there are groups available to merge', () => {
|
||||
const defaultGroup = 'Test'
|
||||
const defaultState: Partial<RootState> = {
|
||||
tasks: {
|
||||
schemaVersion: '1.0.0',
|
||||
defaultSections: [],
|
||||
groups: [
|
||||
{
|
||||
name: 'Test',
|
||||
tasks: [
|
||||
{
|
||||
id: 'some-id',
|
||||
description: 'A simple task',
|
||||
completed: true,
|
||||
createdAt: new Date(),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Testing',
|
||||
tasks: [
|
||||
{
|
||||
id: 'another-id',
|
||||
description: 'Another simple task',
|
||||
completed: false,
|
||||
createdAt: new Date(),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Tests',
|
||||
tasks: [
|
||||
{
|
||||
id: 'yet-another-id',
|
||||
description: 'Yet another simple task',
|
||||
completed: true,
|
||||
createdAt: new Date(),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
testRender(<MergeTaskGroups groupName={defaultGroup} handleClose={handleClose} />, {}, defaultState)
|
||||
|
||||
const alertDialog = screen.getByTestId('merge-task-group-dialog')
|
||||
expect(alertDialog).toBeInTheDocument()
|
||||
expect(alertDialog).toHaveTextContent(`Select which group you want to merge '${defaultGroup}' into:`)
|
||||
expect(alertDialog).not.toHaveTextContent(`There are no other groups to merge '${defaultGroup}' with.`)
|
||||
|
||||
const radioButtons = screen.queryAllByRole('radio')
|
||||
expect(radioButtons).toHaveLength(2)
|
||||
|
||||
const firstRadioButton = radioButtons[0]
|
||||
expect(firstRadioButton).not.toBeChecked()
|
||||
expect(firstRadioButton).toHaveAttribute('value', 'Testing')
|
||||
|
||||
const secondRadioButton = radioButtons[1]
|
||||
expect(secondRadioButton).not.toBeChecked()
|
||||
expect(secondRadioButton).toHaveAttribute('value', 'Tests')
|
||||
})
|
||||
|
||||
it('should close the dialog if no group is selected and the Merge button is clicked', () => {
|
||||
const defaultGroup = 'Test'
|
||||
const defaultState: Partial<RootState> = {
|
||||
tasks: {
|
||||
schemaVersion: '1.0.0',
|
||||
defaultSections: [],
|
||||
groups: [
|
||||
{
|
||||
name: 'Test',
|
||||
tasks: [
|
||||
{
|
||||
id: 'some-id',
|
||||
description: 'A simple task',
|
||||
completed: true,
|
||||
createdAt: new Date(),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Testing',
|
||||
tasks: [
|
||||
{
|
||||
id: 'another-id',
|
||||
description: 'Another simple task',
|
||||
completed: false,
|
||||
createdAt: new Date(),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Tests',
|
||||
tasks: [
|
||||
{
|
||||
id: 'yet-another-id',
|
||||
description: 'Yet another simple task',
|
||||
completed: true,
|
||||
createdAt: new Date(),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
const { mockStore } = testRender(
|
||||
<MergeTaskGroups groupName={defaultGroup} handleClose={handleClose} />,
|
||||
{},
|
||||
defaultState,
|
||||
)
|
||||
|
||||
const buttons = screen.queryAllByRole('button')
|
||||
expect(buttons).toHaveLength(2)
|
||||
|
||||
const mergeButton = buttons[1]
|
||||
expect(mergeButton).toHaveTextContent('Merge groups')
|
||||
|
||||
fireEvent.click(mergeButton)
|
||||
|
||||
const dispatchedActions = mockStore.getActions()
|
||||
expect(dispatchedActions).toHaveLength(0)
|
||||
expect(handleClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should dispatch the action to merge groups', () => {
|
||||
const defaultGroup = 'Test'
|
||||
const defaultState: Partial<RootState> = {
|
||||
tasks: {
|
||||
schemaVersion: '1.0.0',
|
||||
defaultSections: [],
|
||||
groups: [
|
||||
{
|
||||
name: 'Test',
|
||||
tasks: [
|
||||
{
|
||||
id: 'some-id',
|
||||
description: 'A simple task',
|
||||
completed: true,
|
||||
createdAt: new Date(),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Testing',
|
||||
tasks: [
|
||||
{
|
||||
id: 'another-id',
|
||||
description: 'Another simple task',
|
||||
completed: false,
|
||||
createdAt: new Date(),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Tests',
|
||||
tasks: [
|
||||
{
|
||||
id: 'yet-another-id',
|
||||
description: 'Yet another simple task',
|
||||
completed: true,
|
||||
createdAt: new Date(),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
const { mockStore } = testRender(
|
||||
<MergeTaskGroups groupName={defaultGroup} handleClose={handleClose} />,
|
||||
{},
|
||||
defaultState,
|
||||
)
|
||||
|
||||
const radioButtons = screen.queryAllByRole('radio')
|
||||
|
||||
let dispatchedActions = mockStore.getActions()
|
||||
expect(dispatchedActions).toHaveLength(0)
|
||||
expect(handleClose).toHaveBeenCalledTimes(0)
|
||||
|
||||
const firstRadioButton = radioButtons[0]
|
||||
fireEvent.click(firstRadioButton)
|
||||
|
||||
const buttons = screen.queryAllByRole('button')
|
||||
expect(buttons).toHaveLength(2)
|
||||
|
||||
const cancelButton = buttons[0]
|
||||
expect(cancelButton).toHaveTextContent('Cancel')
|
||||
|
||||
const mergeButton = buttons[1]
|
||||
expect(mergeButton).toHaveTextContent('Merge groups')
|
||||
|
||||
fireEvent.click(mergeButton)
|
||||
|
||||
dispatchedActions = mockStore.getActions()
|
||||
expect(dispatchedActions).toHaveLength(1)
|
||||
expect(dispatchedActions[0]).toMatchObject(tasksGroupMerged({ groupName: defaultGroup, mergeWith: 'Testing' }))
|
||||
expect(handleClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
@@ -1,90 +0,0 @@
|
||||
import '@reach/dialog/styles.css'
|
||||
|
||||
import { AlertDialog, AlertDialogDescription, AlertDialogLabel } from '@reach/alert-dialog'
|
||||
import React, { useRef, useState } from 'react'
|
||||
|
||||
import { useAppDispatch, useAppSelector } from '../../app/hooks'
|
||||
import { tasksGroupMerged } from './tasks-slice'
|
||||
|
||||
type MergeTaskGroupsProps = {
|
||||
groupName: string
|
||||
handleClose: () => void
|
||||
}
|
||||
|
||||
const MergeTaskGroups: React.FC<MergeTaskGroupsProps> = ({ groupName, handleClose }) => {
|
||||
const cancelRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
const groupedTasks = useAppSelector((state) => state.tasks.groups)
|
||||
const mergeableGroups = groupedTasks.filter((item) => item.name !== groupName)
|
||||
|
||||
const [mergeWith, setMergeWith] = useState<string>()
|
||||
|
||||
function handleChange(event: React.FormEvent<HTMLFieldSetElement>) {
|
||||
// @ts-ignore
|
||||
const selectedGroup = event.target.value
|
||||
setMergeWith(selectedGroup)
|
||||
}
|
||||
|
||||
function handleMergeGroups() {
|
||||
if (!mergeWith) {
|
||||
handleClose()
|
||||
return
|
||||
}
|
||||
|
||||
dispatch(tasksGroupMerged({ groupName, mergeWith }))
|
||||
handleClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<AlertDialog data-testid="merge-task-group-dialog" leastDestructiveRef={cancelRef}>
|
||||
<div className="sk-modal-content">
|
||||
<div className="sn-component">
|
||||
<div className="sk-panel">
|
||||
<div className="sk-panel-content">
|
||||
<div className="sk-panel-section">
|
||||
<AlertDialogLabel className="sk-h3 sk-panel-section-title">Merging task groups</AlertDialogLabel>
|
||||
|
||||
{mergeableGroups.length > 0 ? (
|
||||
<>
|
||||
<AlertDialogDescription className="sk-panel-row">
|
||||
<p className="color-foreground">
|
||||
Select which group you want to merge '<strong>{groupName}</strong>' into:
|
||||
</p>
|
||||
</AlertDialogDescription>
|
||||
<fieldset className="flex flex-col" onChange={handleChange}>
|
||||
{mergeableGroups.map((item) => (
|
||||
<label key={item.name} className="flex items-center mb-1">
|
||||
<input type="radio" value={item.name} checked={item.name === mergeWith} readOnly />
|
||||
{item.name}
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
</>
|
||||
) : (
|
||||
<AlertDialogDescription>
|
||||
<p className="color-foreground">
|
||||
There are no other groups to merge '<strong>{groupName}</strong>' with.
|
||||
</p>
|
||||
</AlertDialogDescription>
|
||||
)}
|
||||
|
||||
<div className="flex my-1 mt-4">
|
||||
<button className="sn-button small neutral" onClick={handleClose} ref={cancelRef}>
|
||||
{!mergeWith ? 'Close' : 'Cancel'}
|
||||
</button>
|
||||
<button className="sn-button small ml-2 info" onClick={handleMergeGroups}>
|
||||
Merge groups
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default MergeTaskGroups
|
||||
@@ -1,50 +0,0 @@
|
||||
import '@reach/dialog/styles.css'
|
||||
|
||||
import { AlertDialog, AlertDialogLabel } from '@reach/alert-dialog'
|
||||
import React, { useRef } from 'react'
|
||||
|
||||
import { useAppDispatch } from '../../app/hooks'
|
||||
import { tasksLegacyContentMigrated } from './tasks-slice'
|
||||
|
||||
const MigrateLegacyContent: React.FC = () => {
|
||||
const cancelRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
function handleMigrate() {
|
||||
dispatch(tasksLegacyContentMigrated({ continue: true }))
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
dispatch(tasksLegacyContentMigrated({ continue: false }))
|
||||
}
|
||||
|
||||
return (
|
||||
<AlertDialog data-testid="migrate-legacy-content-dialog" leastDestructiveRef={cancelRef}>
|
||||
<div className="sk-modal-content">
|
||||
<div className="sn-component">
|
||||
<div className="sk-panel">
|
||||
<div className="sk-panel-content">
|
||||
<div className="sk-panel-section">
|
||||
<AlertDialogLabel className="sk-h3 sk-panel-section-title">
|
||||
Are you sure you want to migrate legacy content to the new format?
|
||||
</AlertDialogLabel>
|
||||
|
||||
<div className="flex my-1">
|
||||
<button className="sn-button small neutral" onClick={handleCancel} ref={cancelRef}>
|
||||
Cancel
|
||||
</button>
|
||||
<button className="sn-button small ml-2 info" onClick={handleMigrate}>
|
||||
Migrate
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default MigrateLegacyContent
|
||||
@@ -1,138 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import NotePreview from './NotePreview'
|
||||
import { DEFAULT_SECTIONS, GroupModel } from './tasks-slice'
|
||||
|
||||
const workTasks = [
|
||||
{
|
||||
id: 'test-b-1',
|
||||
description: 'Test #1',
|
||||
createdAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: 'test-b-2',
|
||||
description: 'Test #2',
|
||||
completed: true,
|
||||
createdAt: new Date(),
|
||||
},
|
||||
]
|
||||
|
||||
const personalTasks = [
|
||||
{
|
||||
id: 'test-c-1',
|
||||
description: 'Test #3',
|
||||
createdAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: 'test-c-2',
|
||||
description: 'Test #4',
|
||||
completed: true,
|
||||
createdAt: new Date(),
|
||||
},
|
||||
]
|
||||
|
||||
const miscTasks = [
|
||||
{
|
||||
id: 'test-d-1',
|
||||
description: 'Test #5',
|
||||
createdAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: 'test-d-2',
|
||||
description: 'Test #6',
|
||||
createdAt: new Date(),
|
||||
},
|
||||
]
|
||||
|
||||
it('should render without tasks', () => {
|
||||
const groupedTasks: GroupModel[] = []
|
||||
|
||||
render(<NotePreview groupedTasks={groupedTasks} />)
|
||||
|
||||
const header = screen.getByText('0/0 tasks completed')
|
||||
expect(header).toBeVisible()
|
||||
|
||||
const progressBar = screen.getByTestId('circular-progress-bar')
|
||||
expect(progressBar).toBeVisible()
|
||||
|
||||
// eslint-disable-next-line testing-library/no-node-access
|
||||
const progressBarBackground = progressBar.firstChild
|
||||
expect(progressBarBackground).toHaveClass('background')
|
||||
|
||||
// eslint-disable-next-line testing-library/no-node-access
|
||||
const progressBarStroke = progressBar.lastChild
|
||||
expect(progressBarStroke).toHaveClass('progress p-0')
|
||||
|
||||
const groupList = screen.queryAllByTestId('group-summary')
|
||||
expect(groupList).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should render with tasks', () => {
|
||||
const groupedTasks = [
|
||||
{
|
||||
name: 'Work',
|
||||
tasks: workTasks,
|
||||
sections: DEFAULT_SECTIONS,
|
||||
},
|
||||
{
|
||||
name: 'Personal',
|
||||
tasks: personalTasks,
|
||||
sections: DEFAULT_SECTIONS,
|
||||
},
|
||||
]
|
||||
|
||||
render(<NotePreview groupedTasks={groupedTasks} />)
|
||||
|
||||
const header = screen.getByText('2/4 tasks completed')
|
||||
expect(header).toBeVisible()
|
||||
|
||||
const progressBar = screen.getByTestId('circular-progress-bar')
|
||||
expect(progressBar).toBeVisible()
|
||||
|
||||
// eslint-disable-next-line testing-library/no-node-access
|
||||
const progressBarBackground = progressBar.firstChild
|
||||
expect(progressBarBackground).toHaveClass('background')
|
||||
|
||||
// eslint-disable-next-line testing-library/no-node-access
|
||||
const progressBarStroke = progressBar.lastChild
|
||||
expect(progressBarStroke).toHaveClass('progress p-50')
|
||||
|
||||
const groupList = screen.getAllByTestId('group-summary')
|
||||
expect(groupList).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should render a summary of the remaining group(s)', () => {
|
||||
const groupedTasks = [
|
||||
{
|
||||
name: 'Work',
|
||||
tasks: workTasks,
|
||||
sections: DEFAULT_SECTIONS,
|
||||
},
|
||||
{
|
||||
name: 'Personal',
|
||||
tasks: personalTasks,
|
||||
sections: DEFAULT_SECTIONS,
|
||||
},
|
||||
{
|
||||
name: 'Misc',
|
||||
tasks: miscTasks,
|
||||
sections: DEFAULT_SECTIONS,
|
||||
},
|
||||
{
|
||||
name: 'Groceries',
|
||||
tasks: [
|
||||
{
|
||||
id: 'test-e-1',
|
||||
description: 'Test #7',
|
||||
createdAt: new Date(),
|
||||
},
|
||||
],
|
||||
sections: DEFAULT_SECTIONS,
|
||||
},
|
||||
]
|
||||
|
||||
render(<NotePreview groupedTasks={groupedTasks} />)
|
||||
|
||||
const remainingGroups = screen.getByTestId('groups-remaining')
|
||||
expect(remainingGroups).toHaveTextContent('And 1 other group')
|
||||
expect(remainingGroups).toBeVisible()
|
||||
})
|
||||
@@ -1,82 +0,0 @@
|
||||
import {
|
||||
getPercentage,
|
||||
getTaskArrayFromGroupedTasks,
|
||||
groupTasksByCompletedStatus,
|
||||
truncateText,
|
||||
} from '../../common/utils'
|
||||
import { GroupModel, TaskModel } from './tasks-slice'
|
||||
|
||||
const GROUPS_PREVIEW_LIMIT = 3
|
||||
const MAX_GROUP_DESCRIPTION_LENGTH = 30
|
||||
|
||||
const Title: React.FC = ({ children }) => {
|
||||
return <p className="ml-2 w-full font-medium">{children}</p>
|
||||
}
|
||||
|
||||
type GroupSummaryProps = {
|
||||
groups: GroupModel[]
|
||||
}
|
||||
|
||||
const GroupSummary: React.FC<GroupSummaryProps> = ({ groups }) => {
|
||||
const totalGroups = groups.length
|
||||
const groupsToPreview = groups.slice(0, Math.min(totalGroups, GROUPS_PREVIEW_LIMIT))
|
||||
if (groupsToPreview.length === 0) {
|
||||
return <></>
|
||||
}
|
||||
|
||||
const remainingGroups = totalGroups - groupsToPreview.length
|
||||
const groupNoun = remainingGroups > 1 ? 'groups' : 'group'
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="my-2">
|
||||
{groupsToPreview.map((group, index) => {
|
||||
const totalTasks = group.tasks.length
|
||||
const totalCompletedTasks = group.tasks.filter((task) => task.completed === true).length
|
||||
|
||||
return (
|
||||
<p data-testid="group-summary" key={`group-${group.name}`} className="mb-1">
|
||||
{truncateText(group.name, MAX_GROUP_DESCRIPTION_LENGTH)}
|
||||
<span className="px-2 neutral">
|
||||
{totalCompletedTasks}/{totalTasks}
|
||||
</span>
|
||||
</p>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{remainingGroups > 0 && (
|
||||
<p data-testid="groups-remaining">
|
||||
And {remainingGroups} other {groupNoun}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
type NotePreviewProps = {
|
||||
groupedTasks: GroupModel[]
|
||||
}
|
||||
|
||||
const NotePreview: React.FC<NotePreviewProps> = ({ groupedTasks }) => {
|
||||
const allTasks: TaskModel[] = getTaskArrayFromGroupedTasks(groupedTasks)
|
||||
const { completedTasks } = groupTasksByCompletedStatus(allTasks)
|
||||
const percentage = getPercentage(allTasks.length, completedTasks.length)
|
||||
const roundedPercentage = Math.floor(percentage / 10) * 10
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-grow items-center mb-3">
|
||||
<svg data-testid="circular-progress-bar" className="sk-circular-progress" viewBox="0 0 18 18">
|
||||
<circle className="background" />
|
||||
<circle className={`progress p-${roundedPercentage}`} />
|
||||
</svg>
|
||||
<Title>
|
||||
{completedTasks.length}/{allTasks.length} tasks completed
|
||||
</Title>
|
||||
</div>
|
||||
<GroupSummary groups={groupedTasks} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default NotePreview
|
||||
@@ -1,162 +0,0 @@
|
||||
import { fireEvent, screen } from '@testing-library/react'
|
||||
import { RootState } from '../../app/store'
|
||||
import { testRender } from '../../testUtils'
|
||||
|
||||
import RenameTaskGroups from './RenameTaskGroups'
|
||||
import { tasksGroupRenamed } from './tasks-slice'
|
||||
|
||||
const handleClose = jest.fn()
|
||||
|
||||
it('renders the alert dialog with an input box', () => {
|
||||
const defaultGroup = 'Test'
|
||||
const defaultState: Partial<RootState> = {
|
||||
tasks: {
|
||||
schemaVersion: '1.0.0',
|
||||
defaultSections: [],
|
||||
groups: [
|
||||
{
|
||||
name: defaultGroup,
|
||||
tasks: [
|
||||
{
|
||||
id: 'some-id',
|
||||
description: 'A simple task',
|
||||
completed: true,
|
||||
createdAt: new Date(),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
testRender(<RenameTaskGroups groupName={defaultGroup} handleClose={handleClose} />, {}, defaultState)
|
||||
|
||||
const alertDialog = screen.getByTestId('rename-task-group-dialog')
|
||||
expect(alertDialog).toBeInTheDocument()
|
||||
expect(alertDialog).toHaveTextContent(`Renaming group '${defaultGroup}':`)
|
||||
|
||||
const inputBox = screen.getByTestId('new-group-name-input')
|
||||
expect(inputBox).toBeInTheDocument()
|
||||
expect(inputBox).toHaveTextContent('')
|
||||
})
|
||||
|
||||
it('should dispatch the action to merge groups', () => {
|
||||
const defaultGroup = 'Test'
|
||||
const defaultState: Partial<RootState> = {
|
||||
tasks: {
|
||||
schemaVersion: '1.0.0',
|
||||
defaultSections: [],
|
||||
groups: [
|
||||
{
|
||||
name: defaultGroup,
|
||||
tasks: [
|
||||
{
|
||||
id: 'some-id',
|
||||
description: 'A simple task',
|
||||
completed: true,
|
||||
createdAt: new Date(),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Testing',
|
||||
tasks: [
|
||||
{
|
||||
id: 'another-id',
|
||||
description: 'Another simple task',
|
||||
completed: false,
|
||||
createdAt: new Date(),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
const { mockStore } = testRender(
|
||||
<RenameTaskGroups groupName={defaultGroup} handleClose={handleClose} />,
|
||||
{},
|
||||
defaultState,
|
||||
)
|
||||
|
||||
const newGroupName = 'My new group name'
|
||||
|
||||
const inputBox = screen.getByTestId('new-group-name-input') as HTMLInputElement
|
||||
|
||||
fireEvent.change(inputBox, { target: { value: newGroupName } })
|
||||
|
||||
expect(inputBox.value).toBe(newGroupName)
|
||||
|
||||
const buttons = screen.queryAllByRole('button')
|
||||
expect(buttons).toHaveLength(2)
|
||||
|
||||
const cancelButton = buttons[0]
|
||||
expect(cancelButton).toHaveTextContent('Cancel')
|
||||
|
||||
const mergeButton = buttons[1]
|
||||
expect(mergeButton).toHaveTextContent('Rename')
|
||||
|
||||
fireEvent.click(mergeButton)
|
||||
|
||||
const dispatchedActions = mockStore.getActions()
|
||||
expect(dispatchedActions).toHaveLength(1)
|
||||
expect(dispatchedActions[0]).toMatchObject(tasksGroupRenamed({ groupName: defaultGroup, newName: newGroupName }))
|
||||
expect(handleClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should dispatch the action to merge groups on Enter press', () => {
|
||||
const defaultGroup = 'Test'
|
||||
const defaultState: Partial<RootState> = {
|
||||
tasks: {
|
||||
schemaVersion: '1.0.0',
|
||||
defaultSections: [],
|
||||
groups: [
|
||||
{
|
||||
name: defaultGroup,
|
||||
tasks: [
|
||||
{
|
||||
id: 'some-id',
|
||||
description: 'A simple task',
|
||||
completed: true,
|
||||
createdAt: new Date(),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Testing',
|
||||
tasks: [
|
||||
{
|
||||
id: 'another-id',
|
||||
description: 'Another simple task',
|
||||
completed: false,
|
||||
createdAt: new Date(),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
const { mockStore } = testRender(
|
||||
<RenameTaskGroups groupName={defaultGroup} handleClose={handleClose} />,
|
||||
{},
|
||||
defaultState,
|
||||
)
|
||||
|
||||
const newGroupName = 'My new group name'
|
||||
|
||||
const inputBox = screen.getByTestId('new-group-name-input') as HTMLInputElement
|
||||
|
||||
fireEvent.change(inputBox, { target: { value: newGroupName } })
|
||||
fireEvent.keyPress(inputBox, {
|
||||
key: 'Enter',
|
||||
code: 'Enter',
|
||||
charCode: 13,
|
||||
target: { value: newGroupName },
|
||||
})
|
||||
|
||||
const dispatchedActions = mockStore.getActions()
|
||||
expect(dispatchedActions).toHaveLength(1)
|
||||
expect(dispatchedActions[0]).toMatchObject(tasksGroupRenamed({ groupName: defaultGroup, newName: newGroupName }))
|
||||
expect(handleClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
@@ -1,82 +0,0 @@
|
||||
import '@reach/dialog/styles.css'
|
||||
|
||||
import { AlertDialog, AlertDialogDescription, AlertDialogLabel } from '@reach/alert-dialog'
|
||||
import React, { KeyboardEvent, useRef, useState } from 'react'
|
||||
|
||||
import { useAppDispatch } from '../../app/hooks'
|
||||
import { TextInput } from '../../common/components'
|
||||
import { tasksGroupRenamed } from './tasks-slice'
|
||||
|
||||
type RenameTaskGroupsProps = {
|
||||
groupName: string
|
||||
handleClose: () => void
|
||||
}
|
||||
|
||||
const RenameTaskGroups: React.FC<RenameTaskGroupsProps> = ({ groupName, handleClose }) => {
|
||||
const cancelRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
const [renameTo, setRenameTo] = useState<string>(groupName)
|
||||
|
||||
function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
const newName = event.target.value
|
||||
setRenameTo(newName)
|
||||
}
|
||||
|
||||
function handleKeyPress(event: KeyboardEvent<HTMLInputElement>) {
|
||||
if (event.key === 'Enter') {
|
||||
const rawString = (event.target as HTMLInputElement).value
|
||||
rawString.length > 0 && handleRenameGroup()
|
||||
}
|
||||
}
|
||||
|
||||
function handleRenameGroup() {
|
||||
dispatch(tasksGroupRenamed({ groupName, newName: renameTo }))
|
||||
handleClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<AlertDialog data-testid="rename-task-group-dialog" leastDestructiveRef={cancelRef}>
|
||||
<div className="sk-modal-content">
|
||||
<div className="sn-component">
|
||||
<div className="sk-panel">
|
||||
<div className="sk-panel-content">
|
||||
<div className="sk-panel-section">
|
||||
<AlertDialogLabel className="sk-h3 sk-panel-section-title">
|
||||
Renaming group '<strong>{groupName}</strong>':
|
||||
</AlertDialogLabel>
|
||||
|
||||
<AlertDialogDescription>
|
||||
<TextInput
|
||||
testId="new-group-name-input"
|
||||
autoFocus
|
||||
onChange={handleChange}
|
||||
onKeyPress={handleKeyPress}
|
||||
placeholder="Enter new group name"
|
||||
value={renameTo}
|
||||
/>
|
||||
</AlertDialogDescription>
|
||||
|
||||
<div className="flex my-1 mt-4">
|
||||
<button className="sn-button small neutral" onClick={handleClose} ref={cancelRef}>
|
||||
{renameTo.length === 0 ? 'Close' : 'Cancel'}
|
||||
</button>
|
||||
<button
|
||||
className="sn-button small ml-2 info"
|
||||
disabled={renameTo.length === 0}
|
||||
onClick={handleRenameGroup}
|
||||
>
|
||||
Rename
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default RenameTaskGroups
|
||||
@@ -1,100 +0,0 @@
|
||||
import { fireEvent, screen } from '@testing-library/react'
|
||||
|
||||
import { RootState } from '../../app/store'
|
||||
import { testRender } from '../../testUtils'
|
||||
import TaskGroup from './TaskGroup'
|
||||
import { DEFAULT_SECTIONS } from './tasks-slice'
|
||||
|
||||
const defaultGroup = {
|
||||
name: 'default group',
|
||||
tasks: [
|
||||
{
|
||||
id: 'test-1',
|
||||
description: 'Testing',
|
||||
completed: false,
|
||||
createdAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: 'test-2',
|
||||
description: 'Testing',
|
||||
completed: false,
|
||||
createdAt: new Date(),
|
||||
},
|
||||
],
|
||||
sections: DEFAULT_SECTIONS,
|
||||
}
|
||||
|
||||
it('renders the group name', () => {
|
||||
testRender(<TaskGroup group={defaultGroup} isDragging={false} />)
|
||||
|
||||
expect(screen.getByText(defaultGroup.name)).toBeVisible()
|
||||
})
|
||||
|
||||
it('renders the number of completed tasks and total tasks', () => {
|
||||
testRender(<TaskGroup group={defaultGroup} isDragging={false} />)
|
||||
|
||||
const completedTasks = defaultGroup.tasks.filter((task) => task.completed).length
|
||||
const totalTasks = defaultGroup.tasks.length
|
||||
|
||||
expect(screen.getByTestId('task-group-stats')).toHaveTextContent(`${completedTasks}/${totalTasks}`)
|
||||
})
|
||||
|
||||
it('renders the circular progress bar', () => {
|
||||
testRender(<TaskGroup group={defaultGroup} isDragging={false} />)
|
||||
|
||||
expect(screen.getByTestId('circular-progress-bar')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not render a thematic break element', () => {
|
||||
testRender(<TaskGroup group={defaultGroup} isDragging={false} />)
|
||||
|
||||
expect(screen.queryByTestId('task-group-separator')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the element that is used to create a new task', () => {
|
||||
testRender(<TaskGroup group={defaultGroup} isDragging={false} />)
|
||||
|
||||
expect(screen.getByTestId('create-task-input')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the element that is used to display the list of tasks', () => {
|
||||
testRender(<TaskGroup group={defaultGroup} isDragging={false} />)
|
||||
|
||||
expect(screen.getByTestId('task-section-list')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('collapses the group', () => {
|
||||
testRender(<TaskGroup group={defaultGroup} isDragging={false} />)
|
||||
|
||||
const createTask = screen.getByTestId('create-task-input')
|
||||
const taskSectionList = screen.getByTestId('task-section-list')
|
||||
|
||||
expect(createTask).toBeVisible()
|
||||
expect(taskSectionList).toBeVisible()
|
||||
|
||||
const collapseButton = screen.getByTestId('collapse-task-group')
|
||||
fireEvent.click(collapseButton)
|
||||
|
||||
expect(createTask).not.toBeVisible()
|
||||
expect(taskSectionList).not.toBeVisible()
|
||||
})
|
||||
|
||||
it('shows group options', () => {
|
||||
testRender(<TaskGroup group={defaultGroup} isDragging={false} />)
|
||||
|
||||
expect(screen.getByTestId('task-group-options')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides group options if can not edit', () => {
|
||||
const defaultState: Partial<RootState> = {
|
||||
settings: {
|
||||
canEdit: false,
|
||||
isRunningOnMobile: false,
|
||||
spellCheckerEnabled: true,
|
||||
},
|
||||
}
|
||||
|
||||
testRender(<TaskGroup group={defaultGroup} isDragging={false} />, {}, defaultState)
|
||||
|
||||
expect(screen.queryByTestId('task-group-options')).not.toBeInTheDocument()
|
||||
})
|
||||
@@ -1,122 +0,0 @@
|
||||
import styled from 'styled-components'
|
||||
|
||||
import { useAppDispatch, useAppSelector } from '../../app/hooks'
|
||||
import { getPercentage } from '../../common/utils'
|
||||
import { GroupModel, tasksGroupCollapsed } from './tasks-slice'
|
||||
|
||||
import CreateTask from './CreateTask'
|
||||
import TaskSectionList from './TaskSectionList'
|
||||
|
||||
import TaskGroupOptions from './TaskGroupOptions'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { CircularProgressBar, GenericInlineText, MainTitle, RoundButton } from '../../common/components'
|
||||
import { ChevronDownIcon, ChevronUpIcon } from '../../common/components/icons'
|
||||
|
||||
const TaskGroupContainer = styled.div<{ isLast?: boolean }>`
|
||||
background-color: var(--sn-stylekit-background-color);
|
||||
border: 1px solid var(--sn-stylekit-border-color);
|
||||
border-radius: 4px;
|
||||
box-sizing: border-box;
|
||||
padding: 16px 18px;
|
||||
margin-bottom: ${({ isLast }) => (!isLast ? '9px' : '0px')};
|
||||
|
||||
@media only screen and (max-width: 600px) {
|
||||
padding: 8px 10px;
|
||||
}
|
||||
`
|
||||
|
||||
type TaskGroupProps = {
|
||||
group: GroupModel
|
||||
isDragging: boolean
|
||||
isLast?: boolean
|
||||
style?: React.CSSProperties
|
||||
innerRef?: (element?: HTMLElement | null | undefined) => any
|
||||
onDragStart?: React.DragEventHandler<any>
|
||||
onTransitionEnd?: React.TransitionEventHandler<any>
|
||||
}
|
||||
|
||||
const TaskGroup: React.FC<TaskGroupProps> = ({
|
||||
group,
|
||||
isDragging,
|
||||
isLast,
|
||||
style,
|
||||
innerRef,
|
||||
onDragStart,
|
||||
onTransitionEnd,
|
||||
...props
|
||||
}) => {
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
const groupName = group.name
|
||||
|
||||
const completedTasks = group.tasks.filter((task) => task.completed).length
|
||||
const totalTasks = group.tasks.length
|
||||
const percentageCompleted = getPercentage(completedTasks, totalTasks)
|
||||
|
||||
const [collapsed, setCollapsed] = useState<boolean>(!!group.collapsed)
|
||||
|
||||
const canEdit = useAppSelector((state) => state.settings.canEdit)
|
||||
|
||||
const allTasksCompleted = totalTasks > 0 && totalTasks === completedTasks
|
||||
|
||||
function handleCollapse() {
|
||||
setCollapsed(!collapsed)
|
||||
}
|
||||
|
||||
function handleClick() {
|
||||
if (!collapsed) {
|
||||
return
|
||||
}
|
||||
setCollapsed(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(tasksGroupCollapsed({ groupName, type: 'group', collapsed }))
|
||||
}, [collapsed, dispatch, groupName])
|
||||
|
||||
return (
|
||||
<TaskGroupContainer
|
||||
ref={innerRef}
|
||||
style={style}
|
||||
onDragStart={onDragStart}
|
||||
onTransitionEnd={onTransitionEnd}
|
||||
isLast={isLast}
|
||||
>
|
||||
<div className="flex items-center justify-between h-8">
|
||||
<div className="flex flex-grow items-center" onClick={handleClick}>
|
||||
<MainTitle crossed={allTasksCompleted && collapsed} highlight={isDragging} {...props}>
|
||||
{groupName}
|
||||
</MainTitle>
|
||||
<CircularProgressBar size={18} percentage={percentageCompleted} />
|
||||
<GenericInlineText data-testid="task-group-stats">
|
||||
{completedTasks}/{totalTasks}
|
||||
</GenericInlineText>
|
||||
</div>
|
||||
{!isDragging && (
|
||||
<div className="flex items-center">
|
||||
{canEdit && (
|
||||
<div className="ml-3">
|
||||
<TaskGroupOptions groupName={groupName} />
|
||||
</div>
|
||||
)}
|
||||
<div className="ml-3">
|
||||
<RoundButton testId="collapse-task-group" onClick={handleCollapse}>
|
||||
{!collapsed ? <ChevronUpIcon /> : <ChevronDownIcon />}
|
||||
</RoundButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!collapsed && (
|
||||
<>
|
||||
<CreateTask group={group} />
|
||||
<TaskSectionList group={group} />
|
||||
</>
|
||||
)}
|
||||
</TaskGroupContainer>
|
||||
)
|
||||
}
|
||||
|
||||
export default TaskGroup
|
||||
@@ -1,74 +0,0 @@
|
||||
import React from 'react'
|
||||
import { DragDropContext, Draggable, Droppable, DropResult } from 'react-beautiful-dnd'
|
||||
|
||||
import { useAppDispatch, useAppSelector } from '../../app/hooks'
|
||||
import { tasksGroupReordered } from './tasks-slice'
|
||||
|
||||
import TaskGroup from './TaskGroup'
|
||||
|
||||
const TaskGroupList: React.FC = () => {
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
const canEdit = useAppSelector((state) => state.settings.canEdit)
|
||||
const groupedTasks = useAppSelector((state) => state.tasks.groups)
|
||||
|
||||
function onDragEnd(result: DropResult) {
|
||||
const droppedOutsideList = !result.destination
|
||||
if (droppedOutsideList) {
|
||||
return
|
||||
}
|
||||
|
||||
const { source, destination } = result
|
||||
if (!destination) {
|
||||
return
|
||||
}
|
||||
|
||||
dispatch(
|
||||
tasksGroupReordered({
|
||||
swapGroupIndex: source.index,
|
||||
withGroupIndex: destination.index,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DragDropContext data-testid="task-group-list" onDragEnd={onDragEnd}>
|
||||
<Droppable droppableId={'droppable-task-group-list'} isDropDisabled={!canEdit}>
|
||||
{(provided) => (
|
||||
<div {...provided.droppableProps} ref={provided.innerRef}>
|
||||
{groupedTasks.map((group, index) => {
|
||||
return (
|
||||
<Draggable
|
||||
key={`draggable-${group.name}`}
|
||||
draggableId={`draggable-${group.name}`}
|
||||
index={index}
|
||||
isDragDisabled={!canEdit}
|
||||
>
|
||||
{({ innerRef, draggableProps, dragHandleProps }, { isDragging }) => {
|
||||
const { onTransitionEnd, ...restDraggableProps } = draggableProps
|
||||
return (
|
||||
<TaskGroup
|
||||
key={`group-${group.name}`}
|
||||
group={group}
|
||||
isDragging={isDragging}
|
||||
innerRef={innerRef}
|
||||
onTransitionEnd={onTransitionEnd}
|
||||
onDragStart={dragHandleProps?.onDragStart}
|
||||
isLast={groupedTasks.length - 1 === index}
|
||||
{...dragHandleProps}
|
||||
{...restDraggableProps}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
</Draggable>
|
||||
)
|
||||
})}
|
||||
{provided.placeholder}
|
||||
</div>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
)
|
||||
}
|
||||
|
||||
export default TaskGroupList
|
||||
@@ -1,135 +0,0 @@
|
||||
import { fireEvent, screen } from '@testing-library/react'
|
||||
import { testRender } from '../../testUtils'
|
||||
|
||||
import TaskGroupOptions from './TaskGroupOptions'
|
||||
import { tasksGroupDeleted } from './tasks-slice'
|
||||
|
||||
function clickButton(element: HTMLElement) {
|
||||
fireEvent.mouseDown(element)
|
||||
fireEvent.mouseUp(element)
|
||||
fireEvent.click(element)
|
||||
fireEvent.mouseMove(element)
|
||||
fireEvent.mouseUp(element)
|
||||
}
|
||||
|
||||
const groupName = 'default group'
|
||||
|
||||
it('renders an options menu', () => {
|
||||
testRender(<TaskGroupOptions groupName={groupName} />)
|
||||
|
||||
const optionsButton = screen.getByTestId('task-group-options')
|
||||
expect(optionsButton).toBeInTheDocument()
|
||||
|
||||
const deleteTaskGroup = screen.getByTestId('delete-task-group')
|
||||
const mergeTaskGroup = screen.getByTestId('merge-task-group')
|
||||
|
||||
expect(deleteTaskGroup).not.toBeVisible()
|
||||
expect(mergeTaskGroup).not.toBeVisible()
|
||||
|
||||
clickButton(optionsButton)
|
||||
|
||||
expect(deleteTaskGroup).toBeVisible()
|
||||
expect(mergeTaskGroup).toBeVisible()
|
||||
})
|
||||
|
||||
it('should dispatch tasksGroupDeleted action', () => {
|
||||
const { mockStore } = testRender(<TaskGroupOptions groupName={groupName} />)
|
||||
|
||||
const optionsButton = screen.getByTestId('task-group-options')
|
||||
fireEvent.click(optionsButton)
|
||||
|
||||
const deleteTaskGroup = screen.getByTestId('delete-task-group')
|
||||
clickButton(deleteTaskGroup)
|
||||
|
||||
const confirmDialog = screen.getByTestId('delete-task-group-dialog')
|
||||
expect(confirmDialog).toBeInTheDocument()
|
||||
expect(confirmDialog).toHaveTextContent(`Are you sure you want to delete the group '${groupName}'?`)
|
||||
|
||||
const confirmButton = screen.getByTestId('confirm-dialog-button')
|
||||
fireEvent.click(confirmButton)
|
||||
|
||||
const dispatchedActions = mockStore.getActions()
|
||||
expect(dispatchedActions).toHaveLength(1)
|
||||
expect(dispatchedActions[0]).toMatchObject(tasksGroupDeleted({ groupName }))
|
||||
})
|
||||
|
||||
it('should open the merge task group dialog', () => {
|
||||
testRender(<TaskGroupOptions groupName={groupName} />)
|
||||
|
||||
const optionsButton = screen.getByTestId('task-group-options')
|
||||
fireEvent.click(optionsButton)
|
||||
|
||||
const mergeTaskGroup = screen.getByTestId('merge-task-group')
|
||||
clickButton(mergeTaskGroup)
|
||||
|
||||
expect(screen.getByTestId('merge-task-group-dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should open the delete task group dialog', () => {
|
||||
testRender(<TaskGroupOptions groupName={groupName} />)
|
||||
|
||||
const optionsButton = screen.getByTestId('task-group-options')
|
||||
fireEvent.click(optionsButton)
|
||||
|
||||
const deleteTaskGroup = screen.getByTestId('delete-task-group')
|
||||
clickButton(deleteTaskGroup)
|
||||
|
||||
expect(screen.getByTestId('delete-task-group-dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should open the rename task group dialog', () => {
|
||||
testRender(<TaskGroupOptions groupName={groupName} />)
|
||||
|
||||
const optionsButton = screen.getByTestId('task-group-options')
|
||||
fireEvent.click(optionsButton)
|
||||
|
||||
const renameTaskGroup = screen.getByTestId('rename-task-group')
|
||||
clickButton(renameTaskGroup)
|
||||
|
||||
expect(screen.getByTestId('rename-task-group-dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should close the delete task group dialog', () => {
|
||||
testRender(<TaskGroupOptions groupName={groupName} />)
|
||||
|
||||
const optionsButton = screen.getByTestId('task-group-options')
|
||||
fireEvent.click(optionsButton)
|
||||
|
||||
const deleteTaskGroup = screen.getByTestId('delete-task-group')
|
||||
clickButton(deleteTaskGroup)
|
||||
|
||||
const cancelButton = screen.getByTestId('cancel-dialog-button')
|
||||
clickButton(cancelButton)
|
||||
|
||||
expect(screen.queryByTestId('trash-task-group-dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should close the merge task group dialog', () => {
|
||||
testRender(<TaskGroupOptions groupName={groupName} />)
|
||||
|
||||
const optionsButton = screen.getByTestId('task-group-options')
|
||||
fireEvent.click(optionsButton)
|
||||
|
||||
const mergeTaskGroup = screen.getByTestId('merge-task-group')
|
||||
clickButton(mergeTaskGroup)
|
||||
|
||||
const cancelButton = screen.queryAllByRole('button')[0]
|
||||
clickButton(cancelButton)
|
||||
|
||||
expect(screen.queryByTestId('merge-task-group-dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should close the rename task group dialog', () => {
|
||||
testRender(<TaskGroupOptions groupName={groupName} />)
|
||||
|
||||
const optionsButton = screen.getByTestId('task-group-options')
|
||||
fireEvent.click(optionsButton)
|
||||
|
||||
const renameTaskGroup = screen.getByTestId('rename-task-group')
|
||||
clickButton(renameTaskGroup)
|
||||
|
||||
const cancelButton = screen.queryAllByRole('button')[0]
|
||||
clickButton(cancelButton)
|
||||
|
||||
expect(screen.queryByTestId('rename-task-group-dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
@@ -1,66 +0,0 @@
|
||||
import { Menu, MenuButton, MenuItem, MenuList } from '@reach/menu-button'
|
||||
import VisuallyHidden from '@reach/visually-hidden'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { useAppDispatch } from '../../app/hooks'
|
||||
import { tasksGroupDeleted } from './tasks-slice'
|
||||
|
||||
import { MergeIcon, MoreIcon, RenameIcon, TrashIcon } from '../../common/components/icons'
|
||||
|
||||
import { ConfirmDialog } from '../../common/components'
|
||||
|
||||
import MergeTaskGroups from './MergeTaskGroups'
|
||||
import RenameTaskGroups from './RenameTaskGroups'
|
||||
|
||||
type TaskGroupOptionsProps = {
|
||||
groupName: string
|
||||
}
|
||||
|
||||
const TaskGroupOptions: React.FC<TaskGroupOptionsProps> = ({ groupName }) => {
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
const [showMergeDialog, setShowMergeDialog] = useState(false)
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||
const [showRenameDialog, setShowRenameDialog] = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Menu>
|
||||
<MenuButton data-testid="task-group-options" className="sn-icon-button">
|
||||
<VisuallyHidden>Options for '{groupName}' group</VisuallyHidden>
|
||||
<MoreIcon />
|
||||
</MenuButton>
|
||||
<MenuList>
|
||||
<MenuItem data-testid="delete-task-group" onSelect={() => setShowDeleteDialog(true)}>
|
||||
<TrashIcon />
|
||||
<span className="px-1">Delete group</span>
|
||||
</MenuItem>
|
||||
<MenuItem data-testid="merge-task-group" onSelect={() => setShowMergeDialog(true)}>
|
||||
<MergeIcon />
|
||||
<span className="px-1">Merge into another group</span>
|
||||
</MenuItem>
|
||||
<MenuItem data-testid="rename-task-group" onSelect={() => setShowRenameDialog(true)}>
|
||||
<RenameIcon />
|
||||
<span className="px-1">Rename</span>
|
||||
</MenuItem>
|
||||
</MenuList>
|
||||
</Menu>
|
||||
{showDeleteDialog && (
|
||||
<ConfirmDialog
|
||||
testId="delete-task-group-dialog"
|
||||
title="Delete group"
|
||||
confirmButtonText="Delete"
|
||||
confirmButtonStyle="danger"
|
||||
confirmButtonCb={() => dispatch(tasksGroupDeleted({ groupName }))}
|
||||
cancelButtonCb={() => setShowDeleteDialog(false)}
|
||||
>
|
||||
Are you sure you want to delete the group '<strong>{groupName}</strong>'?
|
||||
</ConfirmDialog>
|
||||
)}
|
||||
{showMergeDialog && <MergeTaskGroups groupName={groupName} handleClose={() => setShowMergeDialog(false)} />}
|
||||
{showRenameDialog && <RenameTaskGroups groupName={groupName} handleClose={() => setShowRenameDialog(false)} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default TaskGroupOptions
|
||||
@@ -1,47 +0,0 @@
|
||||
$transition-duration: 750ms;
|
||||
|
||||
@keyframes fadeOut {
|
||||
100% {
|
||||
opacity: 0;
|
||||
max-height: 0;
|
||||
}
|
||||
0% {
|
||||
opacity: 1;
|
||||
max-height: 100px;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
0% {
|
||||
opacity: 0;
|
||||
max-height: 0;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
max-height: 100px;
|
||||
}
|
||||
}
|
||||
|
||||
.fade-out {
|
||||
animation: fadeOut ease $transition-duration;
|
||||
animation-delay: 0s;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
|
||||
.fade-in {
|
||||
animation: fadeIn ease $transition-duration;
|
||||
animation-delay: 0s;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
|
||||
.completed {
|
||||
animation-delay: 1.8s;
|
||||
}
|
||||
|
||||
.opened {
|
||||
animation-delay: 1.2s;
|
||||
}
|
||||
|
||||
.hide {
|
||||
display: none;
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
|
||||
import { testRender } from '../../testUtils'
|
||||
import TaskItem from './TaskItem'
|
||||
import { taskDeleted, TaskModel, taskModified, taskToggled } from './tasks-slice'
|
||||
|
||||
const groupName = 'default group'
|
||||
const task: TaskModel = {
|
||||
id: 'test-1',
|
||||
description: 'Testing #1',
|
||||
completed: false,
|
||||
createdAt: new Date(),
|
||||
}
|
||||
|
||||
it('renders a check box and textarea input', async () => {
|
||||
testRender(<TaskItem groupName={groupName} task={task} />)
|
||||
|
||||
expect(screen.getByTestId('check-box-input')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('text-area-input')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('clicking the check box should toggle the task as open/completed', () => {
|
||||
jest.useFakeTimers()
|
||||
|
||||
const { mockStore } = testRender(<TaskItem groupName={groupName} task={task} />)
|
||||
|
||||
const checkBox = screen.getByTestId('check-box-input')
|
||||
fireEvent.click(checkBox)
|
||||
|
||||
jest.runAllTimers()
|
||||
|
||||
let dispatchedActions = mockStore.getActions()
|
||||
|
||||
expect(dispatchedActions).toHaveLength(1)
|
||||
expect(dispatchedActions[0]).toMatchObject(
|
||||
taskToggled({
|
||||
id: task.id,
|
||||
groupName,
|
||||
}),
|
||||
)
|
||||
|
||||
fireEvent.click(checkBox)
|
||||
|
||||
dispatchedActions = mockStore.getActions()
|
||||
|
||||
jest.runAllTimers()
|
||||
|
||||
expect(dispatchedActions).toHaveLength(2)
|
||||
expect(dispatchedActions[1]).toMatchObject(
|
||||
taskToggled({
|
||||
id: task.id,
|
||||
groupName,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test('changing the textarea input text should update the task description', async () => {
|
||||
jest.useFakeTimers()
|
||||
|
||||
const newTaskDescription = 'My new task'
|
||||
|
||||
const { mockStore } = testRender(<TaskItem groupName={groupName} task={task} />)
|
||||
|
||||
const textAreaInput = screen.getByTestId('text-area-input') as HTMLTextAreaElement
|
||||
fireEvent.change(textAreaInput, {
|
||||
target: { value: newTaskDescription },
|
||||
})
|
||||
fireEvent.keyUp(textAreaInput, {
|
||||
target: { value: newTaskDescription },
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(textAreaInput.value).toBe(newTaskDescription)
|
||||
})
|
||||
|
||||
jest.runAllTimers()
|
||||
|
||||
const dispatchedActions = mockStore.getActions()
|
||||
|
||||
expect(dispatchedActions).toHaveLength(1)
|
||||
expect(dispatchedActions[0]).toMatchObject(
|
||||
taskModified({
|
||||
task: {
|
||||
id: task.id,
|
||||
description: newTaskDescription,
|
||||
},
|
||||
groupName,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test('clearing the textarea input text should delete the task', () => {
|
||||
const { mockStore } = testRender(<TaskItem groupName={groupName} task={task} />)
|
||||
|
||||
const textAreaInput = screen.getByTestId('text-area-input')
|
||||
fireEvent.change(textAreaInput, {
|
||||
target: { value: '' },
|
||||
})
|
||||
fireEvent.keyUp(textAreaInput, {
|
||||
key: 'Enter',
|
||||
code: 'Enter',
|
||||
charCode: 13,
|
||||
target: { value: '' },
|
||||
})
|
||||
|
||||
const dispatchedActions = mockStore.getActions()
|
||||
|
||||
expect(dispatchedActions).toHaveLength(1)
|
||||
expect(dispatchedActions[0]).toMatchObject(
|
||||
taskDeleted({
|
||||
id: task.id,
|
||||
groupName,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test('pressing enter should not update the task description', () => {
|
||||
const { mockStore } = testRender(<TaskItem groupName={groupName} task={task} />)
|
||||
|
||||
const textAreaInput = screen.getByTestId('text-area-input')
|
||||
fireEvent.keyPress(textAreaInput, {
|
||||
key: 'Enter',
|
||||
code: 'Enter',
|
||||
charCode: 13,
|
||||
target: { value: 'This is a test' },
|
||||
})
|
||||
|
||||
const dispatchedActions = mockStore.getActions()
|
||||
|
||||
expect(dispatchedActions).toHaveLength(0)
|
||||
})
|
||||
@@ -1,146 +0,0 @@
|
||||
import './TaskItem.scss'
|
||||
|
||||
import { ChangeEvent, KeyboardEvent, MouseEvent, useRef, useState } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import { useAppDispatch, useAppSelector, useDebouncedCallback, useResize } from '../../app/hooks'
|
||||
import { taskDeleted, TaskModel, taskModified, taskToggled } from './tasks-slice'
|
||||
|
||||
import { CheckBoxInput, TextAreaInput } from '../../common/components'
|
||||
|
||||
const Container = styled.div`
|
||||
align-content: center;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
min-width: 10%;
|
||||
max-width: 90%;
|
||||
`
|
||||
|
||||
export type TaskItemProps = {
|
||||
task: TaskModel
|
||||
groupName: string
|
||||
}
|
||||
|
||||
const TaskItem: React.FC<TaskItemProps> = ({ task, groupName }) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const textAreaRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
const canEdit = useAppSelector((state) => state.settings.canEdit)
|
||||
const spellCheckEnabled = useAppSelector((state) => state.settings.spellCheckerEnabled)
|
||||
|
||||
const [completed, setCompleted] = useState(!!task.completed)
|
||||
const [description, setDescription] = useState(task.description)
|
||||
|
||||
function resizeTextArea(textarea: HTMLElement): void {
|
||||
if (!textarea) {
|
||||
return
|
||||
}
|
||||
|
||||
const heightOffset = 4
|
||||
/**
|
||||
* Set to 1px first to reset scroll height in case it shrunk.
|
||||
*/
|
||||
textarea.style.height = '1px'
|
||||
textarea.style.height = textarea.scrollHeight - heightOffset + 'px'
|
||||
|
||||
const singleLineHeight = 20
|
||||
const currentHeight = parseFloat(textarea.style.height)
|
||||
|
||||
const containerElement = containerRef.current
|
||||
|
||||
if (currentHeight > singleLineHeight) {
|
||||
containerElement?.classList.add('align-baseline')
|
||||
containerElement?.classList.remove('align-center')
|
||||
} else {
|
||||
containerElement?.classList.add('align-center')
|
||||
containerElement?.classList.remove('align-baseline')
|
||||
}
|
||||
}
|
||||
|
||||
function onCheckBoxToggle() {
|
||||
const newCompletedState = !completed
|
||||
setCompleted(newCompletedState)
|
||||
|
||||
const textarea = textAreaRef.current
|
||||
|
||||
if (newCompletedState) {
|
||||
textarea?.classList.add(...['cross-out', 'info-color'])
|
||||
} else {
|
||||
textarea?.classList.add('no-text-decoration')
|
||||
}
|
||||
|
||||
dispatch(taskToggled({ id: task.id, groupName }))
|
||||
}
|
||||
|
||||
function onCheckBoxClick({ currentTarget }: MouseEvent<SVGElement>) {
|
||||
const parentElement = containerRef.current?.parentElement
|
||||
|
||||
if (task.completed) {
|
||||
currentTarget.classList.remove('explode')
|
||||
parentElement?.classList.add('completed')
|
||||
} else {
|
||||
currentTarget.classList.add('explode')
|
||||
parentElement?.classList.add('opened')
|
||||
}
|
||||
}
|
||||
|
||||
function onTextChange(event: ChangeEvent<HTMLTextAreaElement>) {
|
||||
setDescription(event.target.value)
|
||||
}
|
||||
|
||||
function onKeyUp(event: KeyboardEvent<HTMLTextAreaElement>) {
|
||||
// Delete task if empty and enter pressed
|
||||
if (event.key === 'Enter') {
|
||||
if (description.length === 0) {
|
||||
dispatch(taskDeleted({ id: task.id, groupName }))
|
||||
event.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
const element = event.target as HTMLTextAreaElement
|
||||
resizeTextArea(element)
|
||||
}
|
||||
|
||||
function onKeyPress(event: KeyboardEvent<HTMLTextAreaElement>) {
|
||||
if (event.key === 'Enter') {
|
||||
// We want to disable any action on enter.
|
||||
event.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
useDebouncedCallback(() => {
|
||||
if (description !== task.description) {
|
||||
dispatch(taskModified({ task: { id: task.id, description }, groupName }))
|
||||
}
|
||||
})
|
||||
|
||||
useResize(textAreaRef, resizeTextArea)
|
||||
|
||||
return (
|
||||
<Container className="task-item" data-testid="task-item" ref={containerRef}>
|
||||
<CheckBoxInput
|
||||
testId="check-box-input"
|
||||
checked={completed}
|
||||
disabled={!canEdit}
|
||||
onChange={onCheckBoxToggle}
|
||||
onClick={onCheckBoxClick}
|
||||
/>
|
||||
<TextAreaInput
|
||||
testId="text-area-input"
|
||||
className="text-area-input"
|
||||
disabled={!canEdit || !!completed}
|
||||
onChange={onTextChange}
|
||||
onKeyPress={onKeyPress}
|
||||
onKeyUp={onKeyUp}
|
||||
ref={textAreaRef}
|
||||
spellCheck={spellCheckEnabled}
|
||||
value={description}
|
||||
/>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
export default TaskItem
|
||||
@@ -1,110 +0,0 @@
|
||||
import { screen, within } from '@testing-library/react'
|
||||
import { RootState } from '../../app/store'
|
||||
|
||||
import { testRender } from '../../testUtils'
|
||||
import { DEFAULT_SECTIONS, GroupModel } from './tasks-slice'
|
||||
import TaskSectionList from './TaskSectionList'
|
||||
|
||||
const defaultGroup: GroupModel = {
|
||||
name: 'default group',
|
||||
tasks: [
|
||||
{
|
||||
id: 'test-1',
|
||||
description: 'Testing #1',
|
||||
completed: false,
|
||||
createdAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: 'test-2',
|
||||
description: 'Testing #2',
|
||||
completed: false,
|
||||
createdAt: new Date(),
|
||||
},
|
||||
],
|
||||
sections: [
|
||||
{
|
||||
id: 'open-tasks',
|
||||
name: 'Open tasks',
|
||||
},
|
||||
{
|
||||
id: 'completed-tasks',
|
||||
name: 'Completed tasks',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
it('renders the open tasks container', async () => {
|
||||
testRender(<TaskSectionList group={defaultGroup} />)
|
||||
|
||||
const openTasksContainer = screen.getByTestId('open-tasks-section')
|
||||
expect(openTasksContainer).toBeInTheDocument()
|
||||
expect(openTasksContainer).toHaveTextContent('Open tasks')
|
||||
|
||||
const taskItems = within(openTasksContainer).getAllByTestId('task-item')
|
||||
expect(taskItems).toHaveLength(2)
|
||||
|
||||
const completedTasksActions = screen.queryByTestId('completed-tasks-actions')
|
||||
expect(completedTasksActions).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the completed tasks section', () => {
|
||||
const groupWithCompletedTask = defaultGroup
|
||||
groupWithCompletedTask.tasks.push({
|
||||
id: 'test-3',
|
||||
description: 'Testing #3',
|
||||
completed: true,
|
||||
createdAt: new Date(),
|
||||
})
|
||||
|
||||
testRender(<TaskSectionList group={groupWithCompletedTask} />)
|
||||
|
||||
const completedTasksSection = screen.getByTestId('completed-tasks-section')
|
||||
expect(completedTasksSection).toBeInTheDocument()
|
||||
expect(completedTasksSection).toHaveTextContent('Completed')
|
||||
|
||||
const taskItems = within(completedTasksSection).getAllByTestId('task-item')
|
||||
expect(taskItems).toHaveLength(1)
|
||||
|
||||
const completedTasksActions = screen.getByTestId('completed-tasks-actions')
|
||||
expect(completedTasksActions).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders default sections', () => {
|
||||
const defaultState: Partial<RootState> = {
|
||||
settings: {
|
||||
canEdit: true,
|
||||
isRunningOnMobile: false,
|
||||
spellCheckerEnabled: true,
|
||||
},
|
||||
tasks: {
|
||||
schemaVersion: '1.0.0',
|
||||
defaultSections: DEFAULT_SECTIONS,
|
||||
groups: [],
|
||||
},
|
||||
}
|
||||
|
||||
const group: GroupModel = {
|
||||
name: 'Test group',
|
||||
tasks: [
|
||||
...defaultGroup.tasks,
|
||||
{
|
||||
id: 'test-3',
|
||||
description: 'Testing #3',
|
||||
completed: true,
|
||||
createdAt: new Date(),
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
testRender(<TaskSectionList group={group} />, {}, defaultState)
|
||||
|
||||
const completedTasksSection = screen.getByTestId('completed-tasks-section')
|
||||
expect(completedTasksSection).toBeInTheDocument()
|
||||
expect(completedTasksSection).toHaveTextContent('Completed')
|
||||
|
||||
const taskItems = within(completedTasksSection).getAllByTestId('task-item')
|
||||
expect(taskItems).toHaveLength(1)
|
||||
|
||||
const completedTasksActions = screen.getByTestId('completed-tasks-actions')
|
||||
expect(completedTasksActions).toBeInTheDocument()
|
||||
})
|
||||
@@ -1,70 +0,0 @@
|
||||
import React from 'react'
|
||||
import { DragDropContext, DropResult } from 'react-beautiful-dnd'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import { useAppDispatch, useAppSelector } from '../../app/hooks'
|
||||
import { GroupModel, tasksReordered } from './tasks-slice'
|
||||
|
||||
import CompletedTasksActions from './CompletedTasksActions'
|
||||
import TasksSection from './TasksSection'
|
||||
|
||||
const Container = styled.div`
|
||||
position: relative;
|
||||
`
|
||||
|
||||
type TaskSectionListProps = {
|
||||
group: GroupModel
|
||||
}
|
||||
|
||||
const TaskSectionList: React.FC<TaskSectionListProps> = ({ group }) => {
|
||||
const dispatch = useAppDispatch()
|
||||
const defaultSections = useAppSelector((state) => state.tasks.defaultSections)
|
||||
|
||||
function onDragEnd(result: DropResult) {
|
||||
const droppedOutsideList = !result.destination
|
||||
if (droppedOutsideList) {
|
||||
return
|
||||
}
|
||||
|
||||
const { source, destination } = result
|
||||
if (!destination) {
|
||||
return
|
||||
}
|
||||
|
||||
dispatch(
|
||||
tasksReordered({
|
||||
groupName: group.name,
|
||||
swapTaskIndex: source.index,
|
||||
withTaskIndex: destination.index,
|
||||
isSameSection: source.droppableId === destination.droppableId,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const sections = group.sections ?? defaultSections
|
||||
|
||||
return (
|
||||
<Container data-testid="task-section-list">
|
||||
{sections.map((section) => {
|
||||
const tasks = group.tasks.filter((task) =>
|
||||
section.id === 'completed-tasks' ? task.completed === true : !task.completed,
|
||||
)
|
||||
return (
|
||||
<DragDropContext key={`${section.id}-section-dnd`} onDragEnd={onDragEnd}>
|
||||
<TasksSection
|
||||
testId={`${section.id}-section`}
|
||||
groupName={group.name}
|
||||
section={section}
|
||||
tasks={tasks}
|
||||
allTasks={group.tasks}
|
||||
>
|
||||
{section.id === 'completed-tasks' && tasks.length > 0 && <CompletedTasksActions groupName={group.name} />}
|
||||
</TasksSection>
|
||||
</DragDropContext>
|
||||
)
|
||||
})}
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
export default TaskSectionList
|
||||
@@ -1,10 +0,0 @@
|
||||
.completed-tasks-container {
|
||||
.task-item .checkbox-button * {
|
||||
stroke: var(--sn-stylekit-neutral-color) !important;
|
||||
}
|
||||
|
||||
.task-item .text-area-input {
|
||||
color: var(--sn-stylekit-neutral-color) !important;
|
||||
text-decoration: line-through 1px solid var(--sn-stylekit-neutral-color);
|
||||
}
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
import './TasksSection.scss'
|
||||
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Draggable, DraggingStyle, Droppable, NotDraggingStyle } from 'react-beautiful-dnd'
|
||||
import { CSSTransition, TransitionGroup } from 'react-transition-group'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import { useAppDispatch, useAppSelector, usePrevious } from '../../app/hooks'
|
||||
import { SubTitle } from '../../common/components'
|
||||
import { SectionModel, TaskModel, tasksGroupCollapsed } from './tasks-slice'
|
||||
|
||||
import TaskItem from './TaskItem'
|
||||
|
||||
const SectionHeader = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
|
||||
& > *:first-child {
|
||||
margin-right: 14px;
|
||||
}
|
||||
`
|
||||
|
||||
const InnerTasksContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
& > *:not(:last-child) {
|
||||
margin-bottom: 9px;
|
||||
}
|
||||
`
|
||||
|
||||
const OuterContainer = styled.div<{ addMargin: boolean; items: number; collapsed: boolean }>`
|
||||
margin-bottom: ${({ addMargin, items, collapsed }) => (addMargin && items > 0 && !collapsed ? '10px' : '0')};
|
||||
`
|
||||
|
||||
const ChildrenContainer = styled.div<{ addMargin: boolean; items: number }>`
|
||||
margin-top: ${({ addMargin, items }) => (addMargin && items > 0 ? '15px' : '0')};
|
||||
`
|
||||
|
||||
const Wrapper = styled.div`
|
||||
color: var(--sn-stylekit-foreground-color);
|
||||
`
|
||||
|
||||
const COMPLETED_TASK_TIMEOUT_MS = 1_800
|
||||
const OPEN_TASK_TIMEOUT_MS = 1_200
|
||||
|
||||
type TransitionTimeout = {
|
||||
enter: number
|
||||
exit: number
|
||||
}
|
||||
|
||||
function getTimeout(completed?: boolean): TransitionTimeout {
|
||||
return {
|
||||
enter: completed ? COMPLETED_TASK_TIMEOUT_MS : OPEN_TASK_TIMEOUT_MS,
|
||||
exit: !completed ? COMPLETED_TASK_TIMEOUT_MS : OPEN_TASK_TIMEOUT_MS,
|
||||
}
|
||||
}
|
||||
|
||||
const getItemStyle = (isDragging: boolean, draggableStyle?: DraggingStyle | NotDraggingStyle) => ({
|
||||
...draggableStyle,
|
||||
...(isDragging && {
|
||||
color: 'var(--sn-stylekit-info-color)',
|
||||
fontWeight: 500,
|
||||
}),
|
||||
})
|
||||
|
||||
type TasksSectionProps = {
|
||||
groupName: string
|
||||
tasks: TaskModel[]
|
||||
section: SectionModel
|
||||
allTasks?: TaskModel[]
|
||||
testId?: string
|
||||
}
|
||||
|
||||
const TasksSection: React.FC<TasksSectionProps> = ({ groupName, tasks, section, allTasks, testId, children }) => {
|
||||
const dispatch = useAppDispatch()
|
||||
const canEdit = useAppSelector((state) => state.settings.canEdit)
|
||||
const droppableId = `${section.id}-droppable`
|
||||
|
||||
const [collapsed, setCollapsed] = useState<boolean>(!!section.collapsed)
|
||||
|
||||
function handleCollapse() {
|
||||
setCollapsed(!collapsed)
|
||||
}
|
||||
|
||||
const prevTasks: TaskModel[] = usePrevious(allTasks)
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(tasksGroupCollapsed({ groupName, type: section.id, collapsed }))
|
||||
}, [collapsed, dispatch, groupName, section.id])
|
||||
|
||||
return (
|
||||
<OuterContainer
|
||||
data-testid={testId}
|
||||
addMargin={section.id === 'open-tasks'}
|
||||
items={tasks.length}
|
||||
collapsed={collapsed}
|
||||
>
|
||||
<Droppable droppableId={droppableId} isDropDisabled={!canEdit}>
|
||||
{(provided) => (
|
||||
<Wrapper>
|
||||
<SectionHeader>
|
||||
<SubTitle onClick={handleCollapse}>{section.name}</SubTitle>
|
||||
</SectionHeader>
|
||||
{!collapsed && (
|
||||
<InnerTasksContainer
|
||||
{...provided.droppableProps}
|
||||
className={`${section.id}-container`}
|
||||
ref={provided.innerRef}
|
||||
>
|
||||
<TransitionGroup component={null}>
|
||||
{tasks.map((task, index) => {
|
||||
const timeout = getTimeout(task.completed)
|
||||
return (
|
||||
<CSSTransition
|
||||
key={task.id}
|
||||
timeout={timeout}
|
||||
classNames={{
|
||||
enter: 'fade-in',
|
||||
enterActive: 'fade-in',
|
||||
enterDone: 'fade-in',
|
||||
exit: 'fade-out',
|
||||
exitActive: 'fade-out',
|
||||
exitDone: 'fade-out',
|
||||
}}
|
||||
onEnter={(node: HTMLElement) => {
|
||||
const exists = prevTasks.find((t) => t.id === task.id)
|
||||
exists && node.classList.add('hide')
|
||||
|
||||
const completed = !!task.completed
|
||||
completed && node.classList.add('explode')
|
||||
!completed && node.classList.remove('explode')
|
||||
}}
|
||||
onEntered={(node: HTMLElement) => {
|
||||
node.classList.remove('hide')
|
||||
setTimeout(() => node.classList.remove(...['fade-in', 'explode']), timeout.enter)
|
||||
}}
|
||||
>
|
||||
<Draggable
|
||||
key={`draggable-${task.id}`}
|
||||
draggableId={`draggable-${task.id}`}
|
||||
index={index}
|
||||
isDragDisabled={!canEdit}
|
||||
>
|
||||
{({ innerRef, draggableProps, dragHandleProps }, { isDragging }) => {
|
||||
const { style, ...restDraggableProps } = draggableProps
|
||||
return (
|
||||
<div
|
||||
style={getItemStyle(isDragging, style)}
|
||||
ref={innerRef}
|
||||
{...restDraggableProps}
|
||||
{...dragHandleProps}
|
||||
>
|
||||
<TaskItem key={`task-item-${task.id}`} task={task} groupName={groupName} />
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</Draggable>
|
||||
</CSSTransition>
|
||||
)
|
||||
})}
|
||||
</TransitionGroup>
|
||||
|
||||
{provided.placeholder}
|
||||
</InnerTasksContainer>
|
||||
)}
|
||||
<ChildrenContainer addMargin={section.id === 'completed-tasks'} items={tasks.length}>
|
||||
{children}
|
||||
</ChildrenContainer>
|
||||
</Wrapper>
|
||||
)}
|
||||
</Droppable>
|
||||
</OuterContainer>
|
||||
)
|
||||
}
|
||||
|
||||
export default TasksSection
|
||||
@@ -1,37 +0,0 @@
|
||||
import BaseMigration, { PartialData } from './BaseMigration'
|
||||
|
||||
class MockMigration extends BaseMigration {
|
||||
override get version() {
|
||||
return '0.0.0'
|
||||
}
|
||||
|
||||
override upgrade(data: PartialData) {
|
||||
return data
|
||||
}
|
||||
|
||||
override downgrade(data: PartialData) {
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
||||
describe('BaseMigration', () => {
|
||||
const mockMigration = new MockMigration()
|
||||
|
||||
it('should throw error if version is not in the semantic version scheme', () => {
|
||||
expect(() => {
|
||||
mockMigration.run({ schemaVersion: '0.0.0.0', groups: [], defaultSections: [] })
|
||||
}).toThrowError("'0.0.0.0' is not in the semantic version scheme: MAJOR.MINOR.PATCH")
|
||||
})
|
||||
|
||||
it('should throw error if version is not a number', () => {
|
||||
expect(() => {
|
||||
mockMigration.run({ schemaVersion: 'a.0.0', groups: [], defaultSections: [] })
|
||||
}).toThrowError('MAJOR version should be a number')
|
||||
expect(() => {
|
||||
mockMigration.run({ schemaVersion: '0.a.0', groups: [], defaultSections: [] })
|
||||
}).toThrowError('MINOR version should be a number')
|
||||
expect(() => {
|
||||
mockMigration.run({ schemaVersion: '0.0.a', groups: [], defaultSections: [] })
|
||||
}).toThrowError('PATCH version should be a number')
|
||||
})
|
||||
})
|
||||
@@ -1,63 +0,0 @@
|
||||
const SemanticVersionParts = ['MAJOR', 'MINOR', 'PATCH']
|
||||
|
||||
enum MigrationAction {
|
||||
Upgrade = 'up',
|
||||
Downgrade = 'down',
|
||||
Nothing = 'nothing',
|
||||
}
|
||||
|
||||
export type PartialData = {
|
||||
schemaVersion: string
|
||||
defaultSections?: any[]
|
||||
groups: any[]
|
||||
}
|
||||
|
||||
abstract class BaseMigration {
|
||||
protected abstract get version(): string
|
||||
protected abstract upgrade(data: PartialData): PartialData
|
||||
protected abstract downgrade(data: PartialData): PartialData
|
||||
|
||||
private parseVersion(version: string): number[] {
|
||||
const versionScheme = version.split('.')
|
||||
if (versionScheme.length !== SemanticVersionParts.length) {
|
||||
throw Error(`'${version}' is not in the semantic version scheme: ${SemanticVersionParts.join('.')}`)
|
||||
}
|
||||
return versionScheme.map((value, index) => {
|
||||
const number = Number(value)
|
||||
if (isNaN(number)) {
|
||||
throw Error(`${SemanticVersionParts[index]} version should be a number`)
|
||||
}
|
||||
return number
|
||||
})
|
||||
}
|
||||
|
||||
protected getAction(schemaVersion: string): MigrationAction {
|
||||
const fromVersion = this.parseVersion(schemaVersion)
|
||||
const toVersion = this.parseVersion(this.version)
|
||||
|
||||
for (let index = 0; index < fromVersion.length; index++) {
|
||||
if (fromVersion[index] < toVersion[index]) {
|
||||
return MigrationAction.Upgrade
|
||||
}
|
||||
if (fromVersion[index] > toVersion[index]) {
|
||||
return MigrationAction.Downgrade
|
||||
}
|
||||
}
|
||||
return MigrationAction.Nothing
|
||||
}
|
||||
|
||||
public run(data: PartialData): PartialData {
|
||||
const { schemaVersion } = data
|
||||
const migrationAction = this.getAction(schemaVersion)
|
||||
switch (migrationAction) {
|
||||
case MigrationAction.Upgrade:
|
||||
return this.upgrade(data)
|
||||
case MigrationAction.Downgrade:
|
||||
return this.downgrade(data)
|
||||
default:
|
||||
return data
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default BaseMigration
|
||||
@@ -1,150 +0,0 @@
|
||||
import { TasksState } from '../tasks-slice'
|
||||
import BaseMigration, { PartialData } from './BaseMigration'
|
||||
import MigrationService from './MigrationService'
|
||||
|
||||
class MockMigration extends BaseMigration {
|
||||
override get version() {
|
||||
return '1.0.123'
|
||||
}
|
||||
|
||||
override upgrade(data: PartialData) {
|
||||
return {
|
||||
...data,
|
||||
schemaVersion: this.version,
|
||||
}
|
||||
}
|
||||
|
||||
override downgrade(data: PartialData) {
|
||||
return {
|
||||
...data,
|
||||
schemaVersion: this.version,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('MigrationService', () => {
|
||||
it('should upgrade 1.0.0 to 1.0.123', () => {
|
||||
const testData: Partial<TasksState> = {
|
||||
schemaVersion: '1.0.0',
|
||||
groups: [
|
||||
{
|
||||
name: 'Test group #1',
|
||||
tasks: [
|
||||
{
|
||||
id: 'some-id',
|
||||
description: 'A simple task',
|
||||
completed: false,
|
||||
createdAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: 'another-id',
|
||||
description: 'Another simple task',
|
||||
completed: true,
|
||||
createdAt: new Date(),
|
||||
},
|
||||
],
|
||||
collapsed: true,
|
||||
},
|
||||
{
|
||||
name: 'Test group #2',
|
||||
tasks: [
|
||||
{
|
||||
id: 'yet-another-id',
|
||||
description: 'Yet another simple task',
|
||||
completed: true,
|
||||
createdAt: new Date(),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const migrationClasses = [MockMigration]
|
||||
const migrationService = new MigrationService(migrationClasses)
|
||||
const result = migrationService.performMigrations(testData as any)
|
||||
|
||||
expect(result).toEqual<Partial<TasksState>>({
|
||||
...testData,
|
||||
schemaVersion: '1.0.123',
|
||||
})
|
||||
})
|
||||
|
||||
it('should do nothing if latest version', () => {
|
||||
const testData: Partial<TasksState> = {
|
||||
schemaVersion: '1.0.123',
|
||||
groups: [
|
||||
{
|
||||
name: 'Test group #1',
|
||||
tasks: [
|
||||
{
|
||||
id: 'some-id',
|
||||
description: 'A simple task',
|
||||
completed: false,
|
||||
createdAt: new Date(),
|
||||
},
|
||||
],
|
||||
collapsed: true,
|
||||
},
|
||||
{
|
||||
name: 'Test group #2',
|
||||
tasks: [
|
||||
{
|
||||
id: 'yet-another-id',
|
||||
description: 'Yet another simple task',
|
||||
completed: true,
|
||||
createdAt: new Date(),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const migrationClasses = [MockMigration]
|
||||
const migrationService = new MigrationService(migrationClasses)
|
||||
const result = migrationService.performMigrations(testData as any)
|
||||
|
||||
expect(result).toBe(testData)
|
||||
})
|
||||
|
||||
it('should downgrade if version > 1.0.123', () => {
|
||||
const testData: Partial<TasksState> = {
|
||||
schemaVersion: '1.0.130',
|
||||
groups: [
|
||||
{
|
||||
name: 'Test group #1',
|
||||
tasks: [
|
||||
{
|
||||
id: 'some-id',
|
||||
description: 'A simple task',
|
||||
completed: false,
|
||||
createdAt: new Date(),
|
||||
},
|
||||
],
|
||||
collapsed: true,
|
||||
},
|
||||
{
|
||||
name: 'Test group #2',
|
||||
tasks: [
|
||||
{
|
||||
id: 'yet-another-id',
|
||||
description: 'Yet another simple task',
|
||||
completed: true,
|
||||
createdAt: new Date(),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const migrationClasses = [MockMigration]
|
||||
const migrationService = new MigrationService(migrationClasses)
|
||||
const result = migrationService.performMigrations(testData as any)
|
||||
|
||||
expect(result).toMatchObject(
|
||||
expect.objectContaining({
|
||||
schemaVersion: '1.0.123',
|
||||
groups: testData.groups,
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,25 +0,0 @@
|
||||
import { PartialData } from './BaseMigration'
|
||||
import { MigrationClasses } from './versions'
|
||||
|
||||
class MigrationService {
|
||||
private migrationClasses: any[]
|
||||
|
||||
constructor(migrationClasses?: any[]) {
|
||||
this.migrationClasses = migrationClasses ?? MigrationClasses
|
||||
}
|
||||
|
||||
private getMigrationInstances() {
|
||||
return this.migrationClasses.map((migrationClass) => {
|
||||
return new migrationClass()
|
||||
})
|
||||
}
|
||||
|
||||
public performMigrations(data: PartialData) {
|
||||
this.getMigrationInstances().forEach((migration) => {
|
||||
data = migration.run(data)
|
||||
})
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
||||
export default MigrationService
|
||||
@@ -1 +0,0 @@
|
||||
export const MigrationClasses: any[] = []
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,371 +0,0 @@
|
||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
|
||||
import { arrayDefault, arrayMoveImmutable, isJsonString, parseMarkdownTasks } from '../../common/utils'
|
||||
|
||||
export const LATEST_SCHEMA_VERSION = '1.0.0'
|
||||
export const DEFAULT_SECTIONS: SectionModel[] = [
|
||||
{
|
||||
id: 'open-tasks',
|
||||
name: 'Open',
|
||||
},
|
||||
{
|
||||
id: 'completed-tasks',
|
||||
name: 'Completed',
|
||||
},
|
||||
]
|
||||
|
||||
export type TasksState = {
|
||||
schemaVersion: string
|
||||
groups: GroupModel[]
|
||||
defaultSections: SectionModel[]
|
||||
initialized?: boolean
|
||||
legacyContent?: GroupModel
|
||||
lastError?: string
|
||||
}
|
||||
|
||||
const initialState: TasksState = {
|
||||
schemaVersion: LATEST_SCHEMA_VERSION,
|
||||
defaultSections: [],
|
||||
groups: [],
|
||||
}
|
||||
|
||||
export type TaskModel = {
|
||||
id: string
|
||||
description: string
|
||||
completed?: boolean
|
||||
createdAt: Date
|
||||
updatedAt?: Date
|
||||
completedAt?: Date
|
||||
}
|
||||
|
||||
export type SectionModel = {
|
||||
id: string
|
||||
name: string
|
||||
collapsed?: boolean
|
||||
}
|
||||
|
||||
export type GroupModel = {
|
||||
name: string
|
||||
collapsed?: boolean
|
||||
draft?: string
|
||||
lastActive?: Date
|
||||
tasks: TaskModel[]
|
||||
sections?: SectionModel[]
|
||||
}
|
||||
|
||||
const tasksSlice = createSlice({
|
||||
name: 'tasks',
|
||||
initialState,
|
||||
reducers: {
|
||||
taskAdded(
|
||||
state,
|
||||
action: PayloadAction<{
|
||||
task: { id: string; description: string }
|
||||
groupName: string
|
||||
}>,
|
||||
) {
|
||||
const { groupName, task } = action.payload
|
||||
const group = state.groups.find((item) => item.name === groupName)
|
||||
if (!group) {
|
||||
return
|
||||
}
|
||||
delete group.draft
|
||||
group.tasks.unshift({
|
||||
...task,
|
||||
completed: false,
|
||||
createdAt: new Date(),
|
||||
})
|
||||
},
|
||||
taskModified(
|
||||
state,
|
||||
action: PayloadAction<{
|
||||
task: { id: string; description: string }
|
||||
groupName: string
|
||||
}>,
|
||||
) {
|
||||
const { groupName, task } = action.payload
|
||||
const group = state.groups.find((item) => item.name === groupName)
|
||||
if (!group) {
|
||||
return
|
||||
}
|
||||
const currentTask = group.tasks.find((item) => item.id === task.id)
|
||||
if (!currentTask) {
|
||||
return
|
||||
}
|
||||
currentTask.description = task.description
|
||||
currentTask.updatedAt = new Date()
|
||||
},
|
||||
taskDeleted(state, action: PayloadAction<{ id: string; groupName: string }>) {
|
||||
const { id, groupName } = action.payload
|
||||
const group = state.groups.find((item) => item.name === groupName)
|
||||
if (!group) {
|
||||
return
|
||||
}
|
||||
group.tasks = group.tasks.filter((task) => task.id !== id)
|
||||
},
|
||||
taskToggled(state, action: PayloadAction<{ id: string; groupName: string }>) {
|
||||
const { id, groupName } = action.payload
|
||||
const group = state.groups.find((item) => item.name === groupName)
|
||||
if (!group) {
|
||||
return
|
||||
}
|
||||
const currentTask = group.tasks.find((task) => task.id === id)
|
||||
if (!currentTask) {
|
||||
return
|
||||
}
|
||||
currentTask.completed = !currentTask.completed
|
||||
currentTask.updatedAt = new Date()
|
||||
if (currentTask.completed) {
|
||||
currentTask.completedAt = new Date()
|
||||
} else {
|
||||
delete currentTask.completedAt
|
||||
}
|
||||
/**
|
||||
* Puts the recently toggled task on top
|
||||
*/
|
||||
const tasks = group.tasks.filter((task) => task.id !== id)
|
||||
group.tasks = [currentTask, ...tasks]
|
||||
},
|
||||
openAllCompleted(state, action: PayloadAction<{ groupName: string }>) {
|
||||
const { groupName } = action.payload
|
||||
const group = state.groups.find((item) => item.name === groupName)
|
||||
if (!group) {
|
||||
return
|
||||
}
|
||||
group.tasks.forEach((task) => {
|
||||
task.completed = false
|
||||
delete task.completedAt
|
||||
})
|
||||
},
|
||||
deleteAllCompleted(state, action: PayloadAction<{ groupName: string }>) {
|
||||
const { groupName } = action.payload
|
||||
const group = state.groups.find((item) => item.name === groupName)
|
||||
if (!group) {
|
||||
return
|
||||
}
|
||||
group.tasks = group.tasks.filter((task) => task.completed === false)
|
||||
},
|
||||
tasksReordered(
|
||||
state,
|
||||
action: PayloadAction<{
|
||||
groupName: string
|
||||
swapTaskIndex: number
|
||||
withTaskIndex: number
|
||||
isSameSection: boolean
|
||||
}>,
|
||||
) {
|
||||
const { groupName, swapTaskIndex, withTaskIndex, isSameSection } = action.payload
|
||||
if (!isSameSection) {
|
||||
return
|
||||
}
|
||||
const group = state.groups.find((item) => item.name === groupName)
|
||||
if (!group) {
|
||||
return
|
||||
}
|
||||
group.tasks = arrayMoveImmutable(group.tasks, swapTaskIndex, withTaskIndex)
|
||||
},
|
||||
tasksGroupAdded(
|
||||
state,
|
||||
action: PayloadAction<{
|
||||
groupName: string
|
||||
}>,
|
||||
) {
|
||||
const { groupName } = action.payload
|
||||
const group = state.groups.find((item) => item.name === groupName)
|
||||
if (group) {
|
||||
return
|
||||
}
|
||||
state.groups.push({
|
||||
name: groupName,
|
||||
tasks: [],
|
||||
})
|
||||
},
|
||||
tasksGroupReordered(
|
||||
state,
|
||||
action: PayloadAction<{
|
||||
swapGroupIndex: number
|
||||
withGroupIndex: number
|
||||
}>,
|
||||
) {
|
||||
const { swapGroupIndex, withGroupIndex } = action.payload
|
||||
state.groups = arrayMoveImmutable(state.groups, swapGroupIndex, withGroupIndex)
|
||||
},
|
||||
tasksGroupDeleted(
|
||||
state,
|
||||
action: PayloadAction<{
|
||||
groupName: string
|
||||
}>,
|
||||
) {
|
||||
const { groupName } = action.payload
|
||||
state.groups = state.groups.filter((item) => item.name !== groupName)
|
||||
},
|
||||
tasksGroupMerged(
|
||||
state,
|
||||
action: PayloadAction<{
|
||||
groupName: string
|
||||
mergeWith: string
|
||||
}>,
|
||||
) {
|
||||
const { groupName, mergeWith } = action.payload
|
||||
if (groupName === mergeWith) {
|
||||
return
|
||||
}
|
||||
const groupA = state.groups.find((item) => item.name === groupName)
|
||||
if (!groupA) {
|
||||
return
|
||||
}
|
||||
const groupB = state.groups.find((item) => item.name === mergeWith)
|
||||
if (!groupB) {
|
||||
return
|
||||
}
|
||||
groupA.name = mergeWith
|
||||
groupA.tasks = [...(groupB.tasks ?? []), ...groupA.tasks]
|
||||
state.groups = state.groups.filter((group) => group !== groupB)
|
||||
},
|
||||
tasksGroupRenamed(
|
||||
state,
|
||||
action: PayloadAction<{
|
||||
groupName: string
|
||||
newName: string
|
||||
}>,
|
||||
) {
|
||||
const { groupName, newName } = action.payload
|
||||
if (groupName === newName) {
|
||||
return
|
||||
}
|
||||
const groupA = state.groups.find((item) => item.name === groupName)
|
||||
if (!groupA) {
|
||||
return
|
||||
}
|
||||
groupA.name = newName
|
||||
},
|
||||
tasksGroupCollapsed(
|
||||
state,
|
||||
action: PayloadAction<{
|
||||
groupName: string
|
||||
type: 'group' | 'open-tasks' | 'completed-tasks' | string
|
||||
collapsed: boolean
|
||||
}>,
|
||||
) {
|
||||
const { groupName, type, collapsed } = action.payload
|
||||
const group = state.groups.find((item) => item.name === groupName)
|
||||
if (!group) {
|
||||
return
|
||||
}
|
||||
if (type === 'group') {
|
||||
group.collapsed = collapsed
|
||||
return
|
||||
}
|
||||
if (!group.sections) {
|
||||
group.sections = state.defaultSections.map((section) => ({ id: section.id, name: section.name }))
|
||||
}
|
||||
const section = group.sections.find((item) => item.id === type)
|
||||
if (!section) {
|
||||
return
|
||||
}
|
||||
section.collapsed = collapsed
|
||||
},
|
||||
tasksGroupDraft(
|
||||
state,
|
||||
action: PayloadAction<{
|
||||
groupName: string
|
||||
draft: string
|
||||
}>,
|
||||
) {
|
||||
const { groupName, draft } = action.payload
|
||||
const group = state.groups.find((item) => item.name === groupName)
|
||||
if (!group) {
|
||||
return
|
||||
}
|
||||
group.draft = draft
|
||||
},
|
||||
tasksGroupLastActive(
|
||||
state,
|
||||
action: PayloadAction<{
|
||||
groupName: string
|
||||
}>,
|
||||
) {
|
||||
const { groupName } = action.payload
|
||||
const group = state.groups.find((item) => item.name === groupName)
|
||||
if (!group) {
|
||||
return
|
||||
}
|
||||
group.lastActive = new Date()
|
||||
},
|
||||
tasksLegacyContentMigrated(state, { payload }: PayloadAction<{ continue: boolean }>) {
|
||||
if (!state.legacyContent) {
|
||||
return
|
||||
}
|
||||
|
||||
if (payload.continue) {
|
||||
state.initialized = true
|
||||
state.groups.push(state.legacyContent)
|
||||
delete state.lastError
|
||||
} else {
|
||||
state.initialized = false
|
||||
state.groups = []
|
||||
state.lastError =
|
||||
'The legacy content migration has been canceled by the user. ' +
|
||||
'Please reload this note to try again or switch to the Basic Checklist editor.'
|
||||
}
|
||||
|
||||
delete state.legacyContent
|
||||
},
|
||||
tasksLoaded(state, { payload }: PayloadAction<string>) {
|
||||
if (!payload && !state.initialized) {
|
||||
payload = '{}'
|
||||
}
|
||||
|
||||
try {
|
||||
const isJson = isJsonString(payload)
|
||||
if (!isJson) {
|
||||
const legacyContent = parseMarkdownTasks(payload)
|
||||
if (legacyContent) {
|
||||
state.legacyContent = legacyContent
|
||||
state.initialized = false
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const parsedState = JSON.parse(payload) as TasksState
|
||||
let newState: TasksState = {
|
||||
schemaVersion: parsedState?.schemaVersion ?? LATEST_SCHEMA_VERSION,
|
||||
defaultSections: arrayDefault({ value: parsedState?.defaultSections, defaultValue: DEFAULT_SECTIONS }),
|
||||
groups: parsedState?.groups ?? [],
|
||||
}
|
||||
|
||||
if (newState !== initialState) {
|
||||
state.schemaVersion = newState.schemaVersion
|
||||
state.groups = newState.groups
|
||||
state.defaultSections = newState.defaultSections
|
||||
state.initialized = true
|
||||
delete state.lastError
|
||||
}
|
||||
} catch (error: any) {
|
||||
state.initialized = false
|
||||
state.lastError = `An error has occurred while parsing the note's content: ${error}`
|
||||
return
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export const {
|
||||
taskAdded,
|
||||
taskModified,
|
||||
taskToggled,
|
||||
taskDeleted,
|
||||
openAllCompleted,
|
||||
deleteAllCompleted,
|
||||
tasksLoaded,
|
||||
tasksLegacyContentMigrated,
|
||||
tasksGroupAdded,
|
||||
tasksReordered,
|
||||
tasksGroupReordered,
|
||||
tasksGroupDeleted,
|
||||
tasksGroupMerged,
|
||||
tasksGroupRenamed,
|
||||
tasksGroupCollapsed,
|
||||
tasksGroupDraft,
|
||||
tasksGroupLastActive,
|
||||
} = tasksSlice.actions
|
||||
export default tasksSlice.reducer
|
||||
@@ -1,179 +0,0 @@
|
||||
import './stylesheets/main.scss'
|
||||
|
||||
import EditorKit, { EditorKitDelegate } from '@standardnotes/editor-kit'
|
||||
import React, { useCallback, useEffect, useRef } from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { renderToString } from 'react-dom/server'
|
||||
import { Provider } from 'react-redux'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import { useAppDispatch, useAppSelector } from './app/hooks'
|
||||
import { store } from './app/store'
|
||||
import { setCanEdit, setIsRunningOnMobile, setSpellCheckerEnabled } from './features/settings/settings-slice'
|
||||
import CreateGroup from './features/tasks/CreateGroup'
|
||||
import InvalidContentError from './features/tasks/InvalidContentError'
|
||||
import MigrateLegacyContent from './features/tasks/MigrateLegacyContent'
|
||||
import NotePreview from './features/tasks/NotePreview'
|
||||
import TaskGroupList from './features/tasks/TaskGroupList'
|
||||
import { tasksLoaded } from './features/tasks/tasks-slice'
|
||||
|
||||
import { CheckBoxElementsDefs } from './common/components/svg'
|
||||
import { getPlainPreview } from './common/utils'
|
||||
|
||||
const MainContainer = styled.div`
|
||||
margin: 6px;
|
||||
padding-bottom: 60px;
|
||||
`
|
||||
|
||||
const FloatingContainer = styled.div`
|
||||
background-color: var(--sn-stylekit-secondary-background-color);
|
||||
border-top: 1px solid var(--sn-stylekit-border-color);
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
position: fixed;
|
||||
width: 100%;
|
||||
`
|
||||
|
||||
const CenteredContainer = styled.div`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin: 0px;
|
||||
padding: 12px 16px;
|
||||
position: relative;
|
||||
width: 98%;
|
||||
`
|
||||
|
||||
const TaskEditor: React.FC = () => {
|
||||
const note = useRef<any>()
|
||||
const editorKit = useRef<EditorKit>()
|
||||
|
||||
const initialized = useAppSelector((state) => state.tasks.initialized)
|
||||
const groupedTasks = useAppSelector((state) => state.tasks.groups)
|
||||
const legacyContent = useAppSelector((state) => state.tasks.legacyContent)
|
||||
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
function isRunningOnMobile(): boolean {
|
||||
return editorKit.current!.isRunningInMobileApplication()
|
||||
}
|
||||
|
||||
const configureEditorKit = useCallback(() => {
|
||||
const editorKitDelegate: EditorKitDelegate = {
|
||||
setEditorRawText: (rawString: string) => {
|
||||
dispatch(tasksLoaded(rawString))
|
||||
},
|
||||
onNoteValueChange: async (currentNote: any) => {
|
||||
note.current = currentNote
|
||||
|
||||
const editable = !currentNote.content.appData['org.standardnotes.sn'].locked ?? true
|
||||
const spellCheckEnabled = currentNote.content.spellcheck
|
||||
|
||||
dispatch(setCanEdit(editable))
|
||||
dispatch(setSpellCheckerEnabled(spellCheckEnabled))
|
||||
dispatch(setIsRunningOnMobile(isRunningOnMobile()))
|
||||
},
|
||||
onNoteLockToggle: (locked: boolean) => {
|
||||
dispatch(setCanEdit(!locked))
|
||||
},
|
||||
}
|
||||
|
||||
editorKit.current = new EditorKit(editorKitDelegate, {
|
||||
mode: 'json',
|
||||
supportsFileSafe: false,
|
||||
})
|
||||
}, [dispatch])
|
||||
|
||||
useEffect(() => {
|
||||
configureEditorKit()
|
||||
}, [configureEditorKit])
|
||||
|
||||
const saveNote = useCallback(() => {
|
||||
const { initialized } = store.getState().tasks
|
||||
const currentNote = note.current
|
||||
if (!currentNote || !initialized) {
|
||||
return
|
||||
}
|
||||
|
||||
const canEdit = store.getState().settings.canEdit
|
||||
if (!canEdit) {
|
||||
return
|
||||
}
|
||||
|
||||
editorKit.current!.saveItemWithPresave(currentNote, () => {
|
||||
const { schemaVersion, groups, defaultSections } = store.getState().tasks
|
||||
currentNote.content.text = JSON.stringify({ schemaVersion, groups, defaultSections }, null, 2)
|
||||
|
||||
currentNote.content.preview_plain = getPlainPreview(groups)
|
||||
currentNote.content.preview_html = renderToString(<NotePreview groupedTasks={groups} />)
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = store.subscribe(() => initialized && saveNote())
|
||||
return unsubscribe
|
||||
})
|
||||
|
||||
/**
|
||||
* Prevents dragging and dropping files
|
||||
*/
|
||||
useEffect(() => {
|
||||
function rejectDragAndDrop(event: DragEvent) {
|
||||
event && event.preventDefault()
|
||||
}
|
||||
|
||||
window.addEventListener('drop', rejectDragAndDrop)
|
||||
window.addEventListener('dragover', rejectDragAndDrop)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('drop', rejectDragAndDrop)
|
||||
window.removeEventListener('dragover', rejectDragAndDrop)
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (legacyContent) {
|
||||
return (
|
||||
<MainContainer>
|
||||
<MigrateLegacyContent />
|
||||
</MainContainer>
|
||||
)
|
||||
}
|
||||
|
||||
if (!initialized) {
|
||||
return (
|
||||
<MainContainer>
|
||||
<InvalidContentError />
|
||||
</MainContainer>
|
||||
)
|
||||
}
|
||||
|
||||
if (groupedTasks.length === 0) {
|
||||
return (
|
||||
<MainContainer>
|
||||
<CreateGroup />
|
||||
</MainContainer>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<CheckBoxElementsDefs />
|
||||
<MainContainer>
|
||||
<TaskGroupList />
|
||||
</MainContainer>
|
||||
<FloatingContainer>
|
||||
<CenteredContainer>
|
||||
<CreateGroup />
|
||||
</CenteredContainer>
|
||||
</FloatingContainer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
ReactDOM.render(
|
||||
<React.StrictMode>
|
||||
<Provider store={store}>
|
||||
<TaskEditor />
|
||||
</Provider>
|
||||
</React.StrictMode>,
|
||||
document.getElementById('root'),
|
||||
)
|
||||
@@ -1 +0,0 @@
|
||||
/// <reference types="react-scripts" />
|
||||
@@ -1,5 +0,0 @@
|
||||
// jest-dom adds custom jest matchers for asserting on DOM nodes.
|
||||
// allows you to do things like:
|
||||
// expect(element).toHaveTextContent(/react/i)
|
||||
// learn more: https://github.com/testing-library/jest-dom
|
||||
import '@testing-library/jest-dom'
|
||||
@@ -1,175 +0,0 @@
|
||||
@import '~@standardnotes/stylekit/dist/stylekit.css';
|
||||
|
||||
body,
|
||||
html {
|
||||
background-color: var(--sn-stylekit-secondary-background-color);
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
* {
|
||||
// To prevent gray flash when focusing input on mobile Safari
|
||||
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
|
||||
font-family: var(--sn-stylekit-sans-serif-font);
|
||||
}
|
||||
|
||||
.sn-component {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@media screen and (max-width: 420px) {
|
||||
min-height: -webkit-fill-available;
|
||||
}
|
||||
|
||||
.ml-3 {
|
||||
margin-left: 0.75rem;
|
||||
}
|
||||
|
||||
.mr-3 {
|
||||
margin-right: 0.75rem;
|
||||
}
|
||||
|
||||
.sn-icon.small {
|
||||
height: 0.875rem;
|
||||
width: 0.875rem;
|
||||
}
|
||||
|
||||
.no-fill {
|
||||
fill: none;
|
||||
}
|
||||
|
||||
.stroke-info-color {
|
||||
stroke: var(--sn-stylekit-info-color);
|
||||
}
|
||||
|
||||
.stroke-neutral-color {
|
||||
stroke: var(--sn-stylekit-neutral-color);
|
||||
}
|
||||
|
||||
.fill-info-color {
|
||||
fill: var(--sn-stylekit-info-color);
|
||||
}
|
||||
|
||||
.info-color {
|
||||
color: var(--sn-stylekit-info-color);
|
||||
}
|
||||
|
||||
.sn-icon-button {
|
||||
border-width: 0;
|
||||
|
||||
&:hover {
|
||||
background-color: inherit;
|
||||
border-width: 1px;
|
||||
}
|
||||
|
||||
&.small {
|
||||
height: 1.25rem;
|
||||
min-width: 1.25rem;
|
||||
width: 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.pt-1px {
|
||||
padding-top: 1px;
|
||||
}
|
||||
|
||||
.align-baseline {
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.align-center {
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
:root {
|
||||
--reach-menu-button: 1;
|
||||
}
|
||||
|
||||
reach-portal {
|
||||
@import '~@standardnotes/stylekit/src/css/utils';
|
||||
@import '~@standardnotes/stylekit/src/css/sn';
|
||||
|
||||
div[data-reach-menu-list] {
|
||||
@extend .bg-default;
|
||||
@extend .border-main;
|
||||
@extend .rounded;
|
||||
@extend .box-shadow;
|
||||
@extend .min-w-55;
|
||||
@extend .mt-2;
|
||||
@extend .focus\:outline-none;
|
||||
@extend .origin-top-right;
|
||||
@extend .py-2;
|
||||
@extend .slide-down-animation;
|
||||
@extend .color-text;
|
||||
@extend .w-auto;
|
||||
}
|
||||
|
||||
div[data-reach-menu-item] {
|
||||
@extend .items-center;
|
||||
@extend .cursor-pointer;
|
||||
@extend .flex;
|
||||
@extend .hover\:bg-highlight;
|
||||
@extend .focus\:outline-none;
|
||||
@extend .focus\:shadow-none;
|
||||
@extend .px-2;
|
||||
@extend .selected\:bg-color;
|
||||
@extend .selected\:fg-color;
|
||||
@extend .m-h-32;
|
||||
}
|
||||
|
||||
div[data-reach-menu-item] > .sn-icon {
|
||||
@extend .mr-2;
|
||||
}
|
||||
|
||||
div[data-selected][data-reach-menu-item] {
|
||||
background-color: var(--sn-stylekit-contrast-background-color);
|
||||
}
|
||||
|
||||
div[data-reach-dialog-overlay] {
|
||||
align-items: center;
|
||||
background: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
overflow: unset;
|
||||
z-index: 1001;
|
||||
}
|
||||
|
||||
div[data-reach-dialog-overlay]::before {
|
||||
background-color: var(--sn-stylekit-contrast-background-color);
|
||||
bottom: 0px;
|
||||
content: '';
|
||||
left: 0px;
|
||||
opacity: 0.75;
|
||||
position: fixed;
|
||||
right: 0px;
|
||||
top: 0px;
|
||||
}
|
||||
|
||||
div[data-reach-dialog-content] {
|
||||
background: none;
|
||||
flex-basis: 0;
|
||||
margin: 0;
|
||||
max-width: 600px;
|
||||
min-width: 400px;
|
||||
overflow: unset;
|
||||
padding: 0;
|
||||
position: relative;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
div[data-reach-dialog-content] .sk-modal-content,
|
||||
div[data-reach-dialog-content] .sn-component,
|
||||
div[data-reach-dialog-content] .sk-panel {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
div[data-reach-alert-dialog-content] {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
fieldset {
|
||||
border: 0;
|
||||
padding-left: 0;
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { render as rtlRender, RenderOptions } from '@testing-library/react'
|
||||
import React from 'react'
|
||||
import { Provider } from 'react-redux'
|
||||
import configureStore from 'redux-mock-store'
|
||||
|
||||
import { RootState } from './app/store'
|
||||
|
||||
const defaultMockState: RootState = {
|
||||
tasks: {
|
||||
schemaVersion: '1.0.0',
|
||||
defaultSections: [],
|
||||
groups: [],
|
||||
},
|
||||
settings: {
|
||||
canEdit: true,
|
||||
isRunningOnMobile: false,
|
||||
spellCheckerEnabled: true,
|
||||
},
|
||||
}
|
||||
|
||||
export function testRender(ui: React.ReactElement, renderOptions?: RenderOptions, state?: Partial<RootState>) {
|
||||
const mockStore = configureStore()({
|
||||
...defaultMockState,
|
||||
...state,
|
||||
})
|
||||
function Wrapper({ children }: { children: React.ReactElement<any, string | React.JSXElementConstructor<any>> }) {
|
||||
return <Provider store={mockStore}>{children}</Provider>
|
||||
}
|
||||
return {
|
||||
component: rtlRender(ui, { wrapper: Wrapper, ...renderOptions }),
|
||||
mockStore,
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es6",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"typeRoots": ["./types", "./node_modules/@types", "../../node_modules/@types"]
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "types"]
|
||||
}
|
||||
Reference in New Issue
Block a user