refactor: remove advanced-checklist editor (moved to community plugins) (#1740)

This commit is contained in:
Mo
2022-10-05 06:43:48 -05:00
committed by GitHub
parent ec41d082ff
commit 8ef5a33994
145 changed files with 42 additions and 7918 deletions

View File

@@ -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',

View File

@@ -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

View File

@@ -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

View File

@@ -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 cant go back!**
If you arent 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 youre on your own.
You dont have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldnt feel obligated to use this feature. However we understand that this tool wouldnt be useful if you couldnt 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/).

View File

@@ -1,2 +0,0 @@
const override = require('../cra-app.override')
module.exports = override

View File

@@ -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

View File

@@ -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

View File

@@ -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"
}

View File

@@ -1,3 +0,0 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

View File

@@ -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": ""
}

View File

@@ -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
}

View File

@@ -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

View File

@@ -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>

View File

@@ -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>
)
},
)

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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;
`

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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;
}
`

View File

@@ -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}
/>
)
},
)

View File

@@ -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;
}

View File

@@ -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}
/>
)
},
)

View File

@@ -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%;
`

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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;
`

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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'

View File

@@ -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'

View File

@@ -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;
}
}

View File

@@ -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>
)
}

View File

@@ -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'])
})
})

View File

@@ -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
}

View File

@@ -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,
})
})

View File

@@ -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

View File

@@ -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)
})

View File

@@ -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

View File

@@ -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()
})

View File

@@ -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

View File

@@ -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,
}),
)
})

View File

@@ -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

View File

@@ -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

View File

@@ -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)
})

View File

@@ -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

View File

@@ -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

View File

@@ -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()
})

View File

@@ -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

View File

@@ -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)
})

View File

@@ -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

View File

@@ -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()
})

View File

@@ -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

View File

@@ -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

View File

@@ -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()
})

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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)
})

View File

@@ -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

View File

@@ -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()
})

View File

@@ -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

View File

@@ -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);
}
}

View File

@@ -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

View File

@@ -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')
})
})

View File

@@ -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

View File

@@ -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,
}),
)
})
})

View File

@@ -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

View File

@@ -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

View File

@@ -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'),
)

View File

@@ -1 +0,0 @@
/// <reference types="react-scripts" />

View File

@@ -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'

View File

@@ -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;
}
}

View File

@@ -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,
}
}

View File

@@ -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"]
}