//==============================================================================
// Default Types
//==============================================================================
// Taken from TypeScript private declared type within Actions
export type WithPayload = T & {
payload: P
}
================================================
FILE: src/client/utils/constants.ts
================================================
import { Folder, NotesSortKey, DirectionText } from '@/utils/enums'
export const folderMap: Record = {
[Folder.ALL]: 'All Notes',
[Folder.FAVORITES]: 'Favorites',
[Folder.SCRATCHPAD]: 'Scratchpad',
[Folder.TRASH]: 'Trash',
[Folder.CATEGORY]: 'Category',
}
export const iconColor = 'rgba(255, 255, 255, 0.25)'
export const shortcutMap = [
{ action: 'Create a new note', key: 'N' },
{ action: 'Delete a note', key: 'U' },
{ action: 'Create a category', key: 'C' },
{ action: 'Download a note', key: 'O' },
{ action: 'Sync all notes', key: 'L' },
{ action: 'Markdown preview', key: 'P' },
{ action: 'Toggle theme', key: 'K' },
{ action: 'Search notes', key: 'F' },
{ action: 'Prettify a note', key: 'I' },
]
export const notesSortOptions = [
{ value: NotesSortKey.TITLE, label: 'Title' },
{ value: NotesSortKey.CREATED_DATE, label: 'Date Created' },
{ value: NotesSortKey.LAST_UPDATED, label: 'Last Updated' },
]
export const directionTextOptions = [
{ value: DirectionText.LEFT_TO_RIGHT, label: 'Left to right' },
{ value: DirectionText.RIGHT_TO_LEFT, label: 'Right to left' },
]
================================================
FILE: src/client/utils/enums.ts
================================================
export enum Folder {
ALL = 'ALL',
CATEGORY = 'CATEGORY',
FAVORITES = 'FAVORITES',
SCRATCHPAD = 'SCRATCHPAD',
TRASH = 'TRASH',
}
export enum Shortcuts {
NEW_NOTE = 'ctrl+alt+n',
NEW_CATEGORY = 'ctrl+alt+c',
DELETE_NOTE = 'ctrl+alt+u',
SYNC_NOTES = 'ctrl+alt+l',
DOWNLOAD_NOTES = 'ctrl+alt+o',
PREVIEW = 'alt+ctrl+p',
TOGGLE_THEME = 'alt+ctrl+k',
SEARCH = 'alt+ctrl+f',
PRETTIFY = 'ctrl+alt+i',
}
export enum ContextMenuEnum {
CATEGORY = 'CATEGORY',
NOTE = 'NOTE',
}
export enum NotesSortKey {
LAST_UPDATED = 'lastUpdated',
TITLE = 'title',
CREATED_DATE = 'created_date',
}
export enum DirectionText {
LEFT_TO_RIGHT = 'ltr',
RIGHT_TO_LEFT = 'rtl',
}
export enum Errors {
INVALID_LINKED_NOTE_ID = '',
}
================================================
FILE: src/client/utils/helpers.ts
================================================
import dayjs from 'dayjs'
import { v4 as uuid } from 'uuid'
import JSZip from 'jszip'
import { Action } from 'redux'
import * as clipboard from 'clipboard-polyfill/text'
import { LabelText } from '@resources/LabelText'
import { Folder, NotesSortKey } from '@/utils/enums'
import { folderMap } from '@/utils/constants'
import { NoteItem, CategoryItem, WithPayload } from '@/types'
export const getActiveNote = (notes: NoteItem[], activeNoteId: string) =>
notes.find((note) => note.id === activeNoteId)
export const getShortUuid = (uuid: string) => {
return uuid.substr(0, 6)
}
export const getActiveNoteFromShortUuid = (notes: NoteItem[], shortUuid: string) => {
const uuidWithoutHash = shortUuid.replace('{{', '').replace('}}', '')
return notes.find((note) => note.id.startsWith(uuidWithoutHash))
}
export const getActiveCategory = (categories: CategoryItem[], activeCategoryId: string) =>
categories.find(({ id }) => id === activeCategoryId)
export const getNoteTitle = (text: string): string => {
// Remove whitespace from both ends
// Get the first n characters
// Remove # from the title in the case of using markdown headers in your title
const noteText = text.trim().match(/[^#]{1,45}/)
// Get the first line of text after any newlines
// In the future, this should break on a full word
return noteText ? noteText[0].trim().split(/\r?\n/)[0] : LabelText.NEW_NOTE
}
export const noteWithFrontmatter = (note: NoteItem, category?: CategoryItem): string =>
`---
title: ${getNoteTitle(note.text)}
created: ${note.created}
lastUpdated: ${note.lastUpdated}
category: ${category?.name ?? ''}
---
${note.text}`
// Downloads a single note as a markdown file or a group of notes as a zip file.
export const downloadNotes = (notes: NoteItem[], categories: CategoryItem[]): void => {
if (notes.length === 1) {
const pom = document.createElement('a')
pom.setAttribute(
'href',
`data:text/plain;charset=utf-8,${encodeURIComponent(
noteWithFrontmatter(
notes[0],
categories.find((category: CategoryItem) => category.id === notes[0].category)
)
)}`
)
pom.setAttribute('download', `${getNoteTitle(notes[0].text)}.md`)
if (document.createEvent) {
const event = document.createEvent('MouseEvents')
event.initEvent('click', true, true)
pom.dispatchEvent(event)
} else {
pom.click()
}
} else {
const zip = new JSZip()
notes.forEach((note) =>
zip.file(
`${getNoteTitle(note.text)} (${note.id.substring(0, 6)}).md`,
noteWithFrontmatter(
note,
categories.find((category: CategoryItem) => category.id === note.category)
)
)
)
zip.generateAsync({ type: 'blob' }).then(
(content) => {
var downloadUrl = window.URL.createObjectURL(content)
var a = document.createElement('a')
a.href = downloadUrl
a.download = 'notes.zip'
document.body.appendChild(a)
a.click()
URL.revokeObjectURL(downloadUrl)
},
(err) => {
// TODO: error generating zip file.
// Generate a popup?
}
)
}
}
export const backupNotes = (notes: NoteItem[], categories: CategoryItem[]) => {
const pom = document.createElement('a')
const json = JSON.stringify({ notes, categories })
const blob = new Blob([json], { type: 'application/json' })
const downloadUrl = window.URL.createObjectURL(blob)
pom.href = downloadUrl
pom.download = `takenote-backup-${dayjs().format('YYYY-MM-DD')}.json`
document.body.appendChild(pom)
// @ts-ignore
if (!window.Cypress) {
pom.click()
URL.revokeObjectURL(downloadUrl)
}
}
const newNote = (categoryId?: string, folder?: Folder): NoteItem => ({
id: uuid(),
text: '',
created: dayjs().format(),
lastUpdated: dayjs().format(),
category: categoryId,
favorite: folder === Folder.FAVORITES,
})
export const newNoteHandlerHelper = (
activeFolder: Folder,
previewMarkdown: boolean,
activeNote: NoteItem | undefined,
activeCategoryId: string,
swapFolder: (
folder: Folder
) => WithPayload<{ folder: string; sortOrderKey?: NotesSortKey }, Action>,
togglePreviewMarkdown: () => WithPayload>,
addNote: (note: NoteItem) => WithPayload>,
updateActiveNote: (
noteId: string,
multiSelect: boolean
) => WithPayload<
{
noteId: string
multiSelect: boolean
},
Action
>,
updateSelectedNotes: (
noteId: string,
multiSelect: boolean
) => WithPayload<
{
noteId: string
multiSelect: boolean
},
Action
>
) => {
if ([Folder.TRASH, Folder.SCRATCHPAD].indexOf(activeFolder) !== -1) {
swapFolder(Folder.ALL)
}
if (previewMarkdown) {
togglePreviewMarkdown()
}
if ((activeNote && activeNote.text !== '') || !activeNote) {
const note = newNote(
activeCategoryId,
activeFolder === Folder.TRASH ? Folder.ALL : activeFolder
)
addNote(note)
updateSelectedNotes(note.id, false)
updateActiveNote(note.id, false)
}
}
export const shouldOpenContextMenu = (clicked: Element) => {
if (!clicked.parentElement) return
const elementContainsClass = (className: string) => clicked.classList.contains(className)
const parentContainsClass = (className: string) =>
clicked.parentElement!.classList.contains(className)
return (
(clicked instanceof Element &&
// If the element is explicitly a context menu action
elementContainsClass('context-menu-action')) ||
// If the element is an item of the context menu
(!elementContainsClass('nav-item') &&
!elementContainsClass('options-context-menu') &&
!elementContainsClass('nav-item-icon') &&
!parentContainsClass('nav-item-icon')) ||
// Or if it's a sub-element of the context menu
(clicked.tagName === 'circle' && parentContainsClass('context-menu-action'))
)
}
export const getWebsiteTitle = (activeFolder: Folder, activeCategory?: CategoryItem) => {
// Show category name if category is active
if (activeFolder === Folder.CATEGORY && activeCategory) {
return `${activeCategory.name} | TakeNote`
} else {
// Show main folder name otherwise
return `${folderMap[activeFolder]} | TakeNote`
}
}
export const determineAppClass = (
darkTheme: boolean,
sidebarVisible: boolean,
activeFolder: Folder
) => {
let className = 'app'
if (darkTheme) className += ' dark'
return className
}
export const determineCategoryClass = (
category: CategoryItem,
isDragging: boolean,
activeCategoryId: string
) => {
if (category.draggedOver) {
return 'category-list-each dragged-over'
} else if (category.id === activeCategoryId) {
return 'category-list-each active'
} else if (isDragging) {
return 'category-list-each dragging'
} else {
return 'category-list-each'
}
}
export const debounceEvent = (cb: T, wait = 20) => {
let h = 0
const callable = (...args: any) => {
clearTimeout(h)
h = window.setTimeout(() => cb(...args), wait)
}
return (callable)
}
export const isDraftNote = (note: NoteItem) => {
return !note.scratchpad && note.text === ''
}
export const getDayJsLocale = (languagetoken: string): string => {
try {
require('dayjs/locale/' + languagetoken + '.js')
return languagetoken
} catch (error) {
if (languagetoken.includes('-'))
return getDayJsLocale(languagetoken.substring(0, languagetoken.lastIndexOf('-')))
return 'en'
}
}
export const getNoteBarConf = (
activeFolder: Folder
): {
minSize?: number
maxSize?: number
defaultSize?: number
allowResize?: boolean
resizerStyle?: React.CSSProperties
} => {
switch (activeFolder) {
case Folder.SCRATCHPAD:
return {
minSize: 0,
maxSize: 0,
defaultSize: 0,
allowResize: false,
resizerStyle: { display: 'none' },
}
default:
return {
minSize: 200,
maxSize: 600,
defaultSize: 330,
}
}
}
export const copyToClipboard = (text: string) => {
clipboard.writeText(text)
}
================================================
FILE: src/client/utils/history.ts
================================================
import { createBrowserHistory } from 'history'
export default createBrowserHistory()
================================================
FILE: src/client/utils/hooks.ts
================================================
import mousetrap from 'mousetrap'
import { useEffect, useRef } from 'react'
import 'mousetrap-global-bind'
const noop = () => {}
export function useInterval(callback: () => void, delay: number | null) {
const savedCallback = useRef(noop)
// Remember the latest callback
useEffect(() => {
savedCallback.current = callback
}, [callback])
// Set up the interval
useEffect(() => {
const tick = () => savedCallback.current()
if (delay) {
const id = setInterval(tick, delay)
return () => clearInterval(id)
}
}, [delay])
}
export function useKey(key: string, action: () => void) {
const actionRef = useRef(noop)
actionRef.current = action
useEffect(() => {
mousetrap.bindGlobal(key, (event: Event) => {
event.preventDefault()
if (actionRef.current) {
actionRef.current()
}
})
return () => mousetrap.unbind(key)
}, [key])
}
export function useBeforeUnload(handler: Function = () => {}) {
if (process.env.NODE_ENV !== 'production' && typeof handler !== 'function') {
throw new TypeError(`Expected "handler" to be a function, not ${typeof handler}.`)
}
const handlerRef = useRef(handler)
// Remember the latest callback
useEffect(() => {
handlerRef.current = handler
}, [handler])
// Set up the before unload event
useEffect(() => {
const handleBeforeunload = (event: BeforeUnloadEvent) => {
let returnValue
if (typeof handlerRef.current === 'function') {
returnValue = handlerRef.current(event)
}
if (event.defaultPrevented) {
event.returnValue = ''
}
if (typeof returnValue === 'string') {
event.returnValue = returnValue
return returnValue
}
}
window.addEventListener('beforeunload', handleBeforeunload)
return () => {
window.removeEventListener('beforeunload', handleBeforeunload)
}
}, [])
}
================================================
FILE: src/client/utils/notesSortStrategies.ts
================================================
import { NoteItem } from '@/types'
import { getNoteTitle } from './helpers'
import { NotesSortKey } from './enums'
export interface NotesSortStrategy {
sort: (a: NoteItem, b: NoteItem) => number
}
const withFavorites = (sortFunction: NotesSortStrategy['sort']) => (a: NoteItem, b: NoteItem) => {
if (a.favorite && !b.favorite) return -1
if (!a.favorite && b.favorite) return 1
return sortFunction(a, b)
}
const createdDate: NotesSortStrategy = {
sort: (a: NoteItem, b: NoteItem): number => {
const dateA = new Date(a.created)
const dateB = new Date(b.created)
return dateA < dateB ? 1 : -1
},
}
const lastUpdated: NotesSortStrategy = {
sort: (a: NoteItem, b: NoteItem): number => {
const dateA = new Date(a.lastUpdated)
const dateB = new Date(b.lastUpdated)
// the first note in the list should consistently sort after if it is created at the same time
return dateA < dateB ? 1 : -1
},
}
const title: NotesSortStrategy = {
sort: (a: NoteItem, b: NoteItem): number => {
const titleA = getNoteTitle(a.text)
const titleB = getNoteTitle(b.text)
if (titleA === titleB) return 0
return titleA > titleB ? 1 : -1
},
}
export const sortStrategyMap: { [key in NotesSortKey]: NotesSortStrategy } = {
[NotesSortKey.LAST_UPDATED]: lastUpdated,
[NotesSortKey.TITLE]: title,
[NotesSortKey.CREATED_DATE]: createdDate,
}
export const getNotesSorter = (notesSortKey: NotesSortKey) =>
withFavorites(sortStrategyMap[notesSortKey].sort)
================================================
FILE: src/client/utils/reactMarkdownPlugins.ts
================================================
import visit from 'unist-util-visit'
// This regexp will match any string starting with a # followed by 6 alphanumeric chars
// #k5b4m3, #j4n7k3, etc (substring of a note's UUID)
const noteUuidRegexp = /\{\{[a-z0-9]{6}\}\}/
const extractText = (string: string, start: number, end: number) => {
const startLine = string.slice(0, start).split('\n')
const endLine = string.slice(0, end).split('\n')
return {
type: 'text',
value: string.slice(start, end),
position: {
start: {
line: startLine.length,
column: startLine[startLine.length - 1].length + 1,
},
end: {
line: endLine.length,
column: endLine[endLine.length - 1].length + 1,
},
},
}
}
export const uuidPlugin = () => {
function transformer(tree: any) {
visit(tree, 'text', (node: any, position: any, parent: any) => {
const definition = []
let lastIndex = 0
let match
if ((match = noteUuidRegexp.exec(node.value)) !== null) {
const value = match[0]
const type = 'uuid'
if (match.index !== lastIndex) {
definition.push(extractText(node.value, lastIndex, match.index))
}
definition.push({
type,
value,
})
lastIndex = match.index + value.length
}
if (lastIndex !== node.value.length) {
const text = extractText(node.value, lastIndex, node.value.length)
definition.push(text)
}
if (!parent) return
const last = parent.children.slice(position + 1)
parent.children = parent.children.slice(0, position)
parent.children = parent.children.concat(definition)
parent.children = parent.children.concat(last)
})
}
return transformer
}
================================================
FILE: src/resources/LabelText.ts
================================================
// Default Labels
export enum LabelText {
ADD_CATEGORY = 'Add category',
COLLAPSE_CATEGORY = 'Collapse Category List',
NOTES = 'Notes',
CREATE_NEW_NOTE = 'Create new note',
DELETE_PERMANENTLY = 'Delete permanently',
DOWNLOAD = 'Download',
FAVORITES = 'Favorites',
SCRATCHPAD = 'Scratchpad',
MARK_AS_FAVORITE = 'Mark as favorite',
MOVE_TO_TRASH = 'Move to trash',
NEW_CATEGORY = 'New category...',
NEW_NOTE = 'New note',
REMOVE_CATEGORY = 'Remove category',
REMOVE_FAVORITE = 'Remove favorite',
MOVE_CATEGORY = 'Move category',
RESTORE_FROM_TRASH = 'Restore from trash',
SETTINGS = 'Settings',
SYNC_NOTES = 'Sync notes',
TRASH = 'Trash',
WELCOME_TO_TAKENOTE = 'Welcome to Takenote!',
RENAME = 'Rename category',
ADD_CONTENT_NOTE = 'Please add content to this new note to access the menu options.',
DOWNLOAD_ALL_NOTES = 'Download all notes',
BACKUP_ALL_NOTES = 'Export backup',
IMPORT_BACKUP = 'Import backup',
TOGGLE_FAVORITE = 'Toggle favorite',
COPY_REFERENCE_TO_NOTE = 'Copy reference to note',
}
================================================
FILE: src/resources/TestID.ts
================================================
// data-testid
export enum TestID {
ACTION_BUTTON = 'action-button',
ADD_CATEGORY_BUTTON = 'add-category-button',
CATEGORY_EDIT = 'category-edit',
CATEGORY_COLLAPSE_BUTTON = 'category-collapse-button',
CATEGORY_LIST_DIV = 'category-list-div',
CATEGORY_OPTIONS_NAV = 'category-options-nav',
CATEGORY_OPTION_RENAME = 'category-options-rename',
CATEGORY_OPTION_DELETE_PERMANENTLY = 'category-option-delete-permanently',
EDIT_CATEGORY = 'edit-category',
EMPTY_TRASH_BUTTON = 'empty-trash-button',
FOLDER_NOTES = 'notes',
FOLDER_FAVORITES = 'favorites',
FOLDER_TRASH = 'trash',
NEW_CATEGORY_FORM = 'new-category-form',
NEW_CATEGORY_INPUT = 'new-category-label',
NOTE_LIST = 'note-list',
NOTE_LINK_ERROR = 'note-link-error',
NOTE_LINK_SUCCESS = 'note-link-success',
NOTE_LIST_ITEM = 'note-list-item-',
NOTE_OPTIONS_DIV = 'note-options-div-',
NOTE_OPTIONS_NAV = 'note-options-nav',
NOTE_OPTION_DELETE_PERMANENTLY = 'note-option-delete-permanently',
NOTE_OPTION_DOWNLOAD = 'note-option-download',
NOTE_OPTION_FAVORITE = 'note-option-favorite',
NOTE_OPTION_REMOVE_CATEGORY = 'note-option-remove-category',
NOTE_OPTION_RESTORE_FROM_TRASH = 'note-option-restore-from-trash',
NOTE_OPTION_TRASH = 'note-option-trash',
NOTE_SEARCH = 'note-search',
NOTE_TITLE = 'note-title-',
MOVE_TO_CATEGORY = 'note-options-move-to-category-select',
REMOVE_CATEGORY = 'remove-category',
MOVE_CATEGORY = 'move-category',
SIDEBAR_ACTION_CREATE_NEW_NOTE = 'sidebar-action-create-new-note',
SIDEBAR_ACTION_SETTINGS = 'sidebar-action-settings',
TOPBAR_ACTION_SYNC_NOTES = 'topbar-action-sync-notes',
SCRATCHPAD = 'scratchpad',
NOTE_OPTION_ADD_CONTENT_NOTE = 'note-option-add-content-note',
SETTINGS_MODAL_DOWNLOAD_NOTES = 'settings-modal-download-notes',
DARK_MODE_TOGGLE = 'dark-mode-toggle',
MARKDOWN_PREVIEW_TOGGLE = 'markdown-preview-toggle',
ACTIVE_LINE_HIGHLIGHT_TOGGLE = 'active-line-highlight-toggle',
DISPLAY_LINE_NUMS_TOGGLE = 'display-line-nums-toggle',
SCROLL_PAST_END_TOGGLE = 'scroll-past-end-toggle',
SORT_BY_DROPDOWN = 'sort-by-dropdown',
TEXT_DIRECTION_DROPDOWN = 'text-direction-dropdown',
UPLOAD_SETTINGS_BACKUP = 'upload-settings-backup',
UUID_MENU_BAR_COPY_ICON = 'uuid-menu-bar-copy-icon',
PREVIEW_MODE = 'preview-mode',
COPY_REFERENCE_TO_NOTE = 'copy-reference-to-note',
EMPTY_EDITOR = 'empty-editor',
ICON_BUTTON = 'icon-button',
ICON_BUTTON_UPLOADER = 'icon-button-uploader',
LAST_SYNCED_NOTIFICATION_SYNCING = 'last-synced-notification-syncing',
LAST_SYNCED_NOTIFICATION_UNSAVED = 'last-synced-notification-unsaved',
LAST_SYNCED_NOTIFICATION_DATE = 'last-synced-notification-date',
}
================================================
FILE: src/server/handlers/auth.ts
================================================
import { Request, Response } from 'express'
import axios from 'axios'
import * as dotenv from 'dotenv'
import { welcomeNote } from '../utils/data/welcomeNote'
import { scratchpadNote } from '../utils/data/scratchpadNote'
import { thirtyDayCookie } from '../utils/constants'
import { SDK } from '../utils/helpers'
import { Method } from '../utils/enums'
dotenv.config()
const clientId = process.env.CLIENT_ID
const clientSecret = process.env.CLIENT_SECRET
export default {
/**
* GitHub OAuth flow
* @url https://developer.github.com/apps/building-oauth-apps/authorizing-oauth-apps/
*
* 1. When the user hits the `/authorize` endpoint on GitHub, they
* will be prompted to log in (if not already logged in) and redirected to
* this `/callback` endpoint with a code.
*
* 2. The code will be exchanged for an access token on the `/access_token`
* endpoint and the user will be redirected to the app.
*
* Client-side persistence
* @url https://www.taniarascia.com/full-stack-cookies-localstorage-react-express/
*
* A thirty day, secure, HTTP only, and same-site cookie will be set on the app
* containing the access token with repo scope for accessing any GitHub data
* and determining login state.
*
* Refresh tokens
* @url https://developer.github.com/apps/migrating-oauth-apps-to-github-apps/
*
* From the GitHub docs:
* > An OAuth token does not expire until the person who authorized the OAuth
* > App revokes the token.
*
* Therefore, there is no refresh token set up and the only option is to
* log in again.
*/
callback: async (request: Request, response: Response) => {
const { code } = request.query
try {
// Obtain access token
const { data } = await axios({
method: 'post',
url: `https://github.com/login/oauth/access_token?client_id=${clientId}&client_secret=${clientSecret}&code=${code}`,
headers: {
accept: 'application/json',
},
})
const accessToken = data.access_token
// Set cookie
response.cookie('githubAccessToken', accessToken, thirtyDayCookie)
// Redirect to the app when logged in
response.redirect('/app')
} catch (error) {
console.log(error) // eslint-disable-line
// Redirect to the main page if something went wrong
response.redirect('/')
}
},
/**
* Authentication
*
* If an access token cookie exists, attempt to determine the currently logged
* in user. If the access token is in some way incorrect, expired, etc., throw
* an error.
*
* After successful login, check if it's the first time logging in by seeing if a repo
* named `takenote-data` exists. If it doesn't, create it.
*/
login: async (request: Request, response: Response) => {
const { accessToken } = response.locals
try {
const { data } = await SDK(Method.GET, '/user', accessToken)
const username = data.login
const isFirstTimeLoggingIn = await firstTimeLoginCheck(username, accessToken)
if (isFirstTimeLoggingIn) {
await createTakeNoteDataRepo(username, accessToken)
await createInitialCommit(username, accessToken)
}
response.status(200).send(data)
} catch (error) {
response.status(400).send({ message: error.message })
}
},
logout: async (request: Request, response: Response) => {
response.clearCookie('githubAccessToken')
response.status(200).send({ message: 'Logged out' })
},
}
async function firstTimeLoginCheck(username: string, accessToken: string): Promise {
try {
await SDK(Method.GET, `/repos/${username}/takenote-data`, accessToken)
// If repo already exists, we assume it's the takenote data repo and can move on
return false
} catch (error) {
// If repo doesn't exist, we'll try to create it
return true
}
}
async function createTakeNoteDataRepo(username: string, accessToken: string): Promise {
const takenoteDataRepo = {
name: 'takenote-data',
description: 'Database of notes for TakeNote',
private: true,
visibility: 'private',
has_issues: false,
has_projects: false,
has_wiki: false,
is_template: false,
auto_init: false,
allow_squash_merge: false,
allow_rebase_merge: false,
}
try {
await SDK(Method.POST, `/user/repos`, accessToken, takenoteDataRepo)
} catch (error) {
throw new Error(error)
}
}
async function createInitialCommit(username: string, accessToken: string): Promise {
const noteCommit = {
message: 'Initial commit',
content: Buffer.from(JSON.stringify([scratchpadNote, welcomeNote], null, 2)).toString('base64'),
branch: 'master',
}
try {
await SDK(
Method.PUT,
`/repos/${username}/takenote-data/contents/notes.json`,
accessToken,
noteCommit
)
} catch (error) {
throw new Error(error)
}
}
================================================
FILE: src/server/handlers/sync.ts
================================================
import { Request, Response } from 'express'
import dayjs from 'dayjs'
import { SDK } from '../utils/helpers'
import { Method } from '../utils/enums'
export default {
sync: async (request: Request, response: Response) => {
const { accessToken, userData } = response.locals
const {
body: { notes, categories },
} = request
const username = userData.login
const repo = 'takenote-data'
try {
// Get a reference
// https://docs.github.com/en/free-pro-team@latest/rest/reference/git#update-a-reference
const ref = await SDK(
Method.GET,
`/repos/${username}/${repo}/git/refs/heads/master`,
accessToken
)
// Create blobs
// https://docs.github.com/en/free-pro-team@latest/rest/reference/git#create-a-blob
const [noteBlob, categoryBlob] = await Promise.all([
SDK(Method.POST, `/repos/${username}/${repo}/git/blobs`, accessToken, {
content: JSON.stringify(notes, null, 2),
}),
SDK(Method.POST, `/repos/${username}/${repo}/git/blobs`, accessToken, {
content: JSON.stringify(categories, null, 2),
}),
])
// Create tree path
const treeItems = [
{
path: 'notes.json',
sha: noteBlob.data.sha,
mode: '100644',
type: 'blob',
},
{
path: 'categories.json',
sha: categoryBlob.data.sha,
mode: '100644',
type: 'blob',
},
]
// Create tree
// https://docs.github.com/en/free-pro-team@latest/rest/reference/git#create-a-tree
const tree = await SDK(Method.POST, `/repos/${username}/${repo}/git/trees`, accessToken, {
tree: treeItems,
base_tree: ref.data.object.sha,
})
// Create commit
// https://docs.github.com/en/free-pro-team@latest/rest/reference/git#create-a-commit
const commit = await SDK(Method.POST, `/repos/${username}/${repo}/git/commits`, accessToken, {
message: 'TakeNote update ' + dayjs(Date.now()).format('h:mm A M/D/YYYY'),
tree: tree.data.sha,
parents: [ref.data.object.sha],
})
// Update a reference
// https://docs.github.com/en/free-pro-team@latest/rest/reference/git#update-a-reference
await SDK(Method.POST, `/repos/${username}/${repo}/git/refs/heads/master`, accessToken, {
sha: commit.data.sha,
force: true,
})
response.status(200).send({ message: 'Successly commited to takenote-data' })
} catch (error) {
response
.status(400)
.send({ message: error.message || 'Something went wrong while syncing data' })
}
},
getNotes: async (request: Request, response: Response) => {
const { accessToken, userData } = response.locals
const username = userData.login
const repo = 'takenote-data'
try {
const { data } = await SDK(
Method.GET,
`/repos/${username}/${repo}/contents/notes.json`,
accessToken
)
const notes = Buffer.from(data.content, 'base64').toString()
try {
JSON.parse(notes)
} catch (error) {
response.status(400).send({ message: error.message || 'Must be valid JSON.' })
}
response.status(200).send(notes)
} catch (error) {
response
.status(400)
.send({ message: error.message || 'Something went wrong while fetching note data' })
}
},
getCategories: async (request: Request, response: Response) => {
const { accessToken, userData } = response.locals
const username = userData.login
const repo = 'takenote-data'
try {
const { data } = await SDK(
Method.GET,
`/repos/${username}/${repo}/contents/categories.json`,
accessToken
)
const categories = Buffer.from(data.content, 'base64').toString()
try {
JSON.parse(categories)
} catch (error) {
response.status(400).send({ message: error.message || 'Must be valid JSON.' })
}
response.status(200).send(categories)
} catch (error) {
response
.status(400)
.send({ message: error.message || 'Something went wrong while fetching category data' })
}
},
}
================================================
FILE: src/server/index.ts
================================================
import initializeServer from './initializeServer'
import router from './router'
const app = initializeServer(router)
app.listen(5000, () => console.log(`Listening on port ${5000}`)) // eslint-disable-line
================================================
FILE: src/server/initializeServer.ts
================================================
import path from 'path'
import express, { Router } from 'express'
import cookieParser from 'cookie-parser'
import cors from 'cors'
import helmet from 'helmet'
import compression from 'compression'
export default function initializeServer(router: Router) {
const app = express()
const isProduction = process.env.NODE_ENV === 'production'
const origin = { origin: isProduction ? false : '*' }
app.set('trust proxy', 1)
app.use(express.json())
app.use(cookieParser())
app.use(cors(origin))
app.use(helmet())
app.use(compression())
app.use((request, response, next) => {
response.header('Content-Security-Policy', "img-src 'self' *.githubusercontent.com")
return next()
})
app.use(express.static(path.join(__dirname, '../../dist/')))
app.use('/api', router)
app.get('*', (request, response) => {
response.sendFile(path.join(__dirname, '../../dist/index.html'))
})
return app
}
================================================
FILE: src/server/middleware/checkAuth.ts
================================================
import { Request, Response, NextFunction } from 'express'
const checkAuth = async (request: Request, response: Response, next: NextFunction) => {
const accessToken = request.cookies?.githubAccessToken
if (accessToken) {
response.locals.accessToken = accessToken
next()
} else {
response.status(403).send({ message: 'Forbidden Resource', status: 403 })
}
}
export default checkAuth
================================================
FILE: src/server/middleware/getUser.ts
================================================
import { Request, Response, NextFunction } from 'express'
import { SDK } from '../utils/helpers'
import { Method } from '../utils/enums'
const getUser = async (request: Request, response: Response, next: NextFunction) => {
const { accessToken } = response.locals
try {
const { data } = await SDK(Method.GET, '/user', accessToken)
response.locals.userData = data
next()
} catch (error) {
response.status(403).send({ message: 'Forbidden Resource', status: 403 })
}
}
export default getUser
================================================
FILE: src/server/router/auth.ts
================================================
import express from 'express'
import * as dotenv from 'dotenv'
import authHandler from '../handlers/auth'
import checkAuth from '../middleware/checkAuth'
const router = express.Router()
dotenv.config()
router.get('/callback', authHandler.callback)
router.get('/login', checkAuth, authHandler.login)
router.get('/logout', authHandler.logout)
export default router
================================================
FILE: src/server/router/index.ts
================================================
import express from 'express'
import authRoutes from './auth'
import syncRoutes from './sync'
const router = express.Router()
router.use('/auth', authRoutes)
router.use('/sync', syncRoutes)
export default router
================================================
FILE: src/server/router/sync.ts
================================================
import express from 'express'
import syncHandler from '../handlers/sync'
import checkAuth from '../middleware/checkAuth'
import getUser from '../middleware/getUser'
const router = express.Router()
router.post('/', checkAuth, getUser, syncHandler.sync)
router.get('/notes', checkAuth, getUser, syncHandler.getNotes)
router.get('/categories', checkAuth, getUser, syncHandler.getCategories)
export default router
================================================
FILE: src/server/utils/constants.ts
================================================
const isProduction = process.env.NODE_ENV === 'production'
export const thirtyDayCookie = {
maxAge: 60 * 60 * 1000 * 24 * 30,
secure: isProduction,
httpOnly: true,
sameSite: true,
}
================================================
FILE: src/server/utils/data/scratchpadNote.ts
================================================
import { v4 as uuid } from 'uuid'
import dayjs from 'dayjs'
export const scratchpadNote = {
id: uuid(),
text: `# Scratchpad
The easiest note to find.`,
category: '',
scratchpad: true,
favorite: false,
created: dayjs().format(),
lastUpdated: dayjs().format(),
}
================================================
FILE: src/server/utils/data/welcomeNote.ts
================================================
import { v4 as uuid } from 'uuid'
import dayjs from 'dayjs'
const markdown = `# Welcome to Takenote!
TakeNote is a free, open-source notes app for the web. It is a demo project only, and does not integrate with any database or cloud. Your notes are saved in local storage and will not be permanently persisted, but are available for download.
View the source on [Github](https://github.com/taniarascia/takenote).
## Features
- **Plain text notes** - take notes in an IDE-like environment that makes no assumptions
- **Markdown preview** - view rendered HTML of the notes
- **Linked notes** - use \`{{uuid}}\` syntax to link to notes within other notes
- **Syntax highlighting** - light and dark mode available (based on the beautiful [New Moon theme](https://taniarascia.github.io/new-moon/))
- **Keyboard shortcuts** - use the keyboard for all common tasks - creating notes and categories, toggling settings, and other options
- **Drag and drop** - drag a note or multiple notes to categories, favorites, or trash
- **Multi-cursor editing** - supports multiple cursors and other [Codemirror](https://codemirror.net/) options
- **Search notes** - easily search all notes, or notes within a category
- **Prettify notes** - use Prettier on the fly for your Markdown
- **No WYSIWYG** - made for developers, by developers
- **No database** - notes are only stored in the browser's local storage and are available for download and export to you alone
- **No tracking or analytics** - 'nuff said
- **GitHub integration** - self-hosted option is available for auto-syncing to a GitHub repository (not available in the demo)
`
export const welcomeNote = {
id: uuid(),
text: markdown,
category: '',
favorite: false,
created: dayjs().format(),
lastUpdated: dayjs().format(),
}
================================================
FILE: src/server/utils/enums.ts
================================================
export enum Method {
GET = 'GET',
POST = 'POST',
PUT = 'PUT',
PATCH = 'PATCH',
DELETE = 'DELETE',
}
================================================
FILE: src/server/utils/helpers.ts
================================================
import axios from 'axios'
import { Method } from './enums'
export function SDK(method: Method, path: string, accessToken: string, data?: Object) {
const apiHost = 'https://api.github.com'
return axios({
method,
url: `${apiHost}${path}`,
data,
headers: {
Authorization: `token ${accessToken}`,
},
})
}
================================================
FILE: tests/__mocks__/styleMock.ts
================================================
// __mocks__/styleMock.js
// @ts-ignore
module.exports = {}
================================================
FILE: tests/e2e/integration/category.test.ts
================================================
// category.spec.ts
// Tests for manipulating note categories
import {
addCategory,
assertCategoryDoesNotExist,
assertCategoryExists,
assertCategoryOptionsOpened,
assertCategoryOrder,
navigateToCategory,
selectMoveToCategoryOption,
startEditingCategory,
renameCategory,
defocusCategory,
moveCategory,
openCategoryContextMenu,
clickCategoryOptionRename,
clickCategoryOptionDelete,
collapseCategoryList,
assertCategoryListExists,
assertCategoryListDoesNotExists,
} from '../utils/testCategoryHelperUtils'
import { dynamicTimeCategoryName } from '../utils/testHelperEnums'
import {
defaultInit,
navigateToNotes,
assertCurrentFolderOrCategory,
} from '../utils/testHelperUtils'
import {
assertNoteListLengthEquals,
clickNoteOptions,
createXUniqueNotes,
} from '../utils/testNotesHelperUtils'
describe('Categories', () => {
defaultInit()
it('should hide the category list on click of category', () => {
addCategory(dynamicTimeCategoryName)
collapseCategoryList()
assertCategoryListDoesNotExists()
})
it('should show category list on add new category', () => {
collapseCategoryList()
addCategory(dynamicTimeCategoryName)
assertCategoryListExists()
})
it('creates a new category with the current time', () => {
// Skipping for now due to
addCategory(dynamicTimeCategoryName)
})
it('should add a note to new category', () => {
// add a category
addCategory(dynamicTimeCategoryName)
// navigate back to All Notes create a new note, and move it to that category
navigateToNotes()
createXUniqueNotes(1)
clickNoteOptions()
selectMoveToCategoryOption(dynamicTimeCategoryName)
// make sure it ended up in the category
navigateToCategory(dynamicTimeCategoryName)
assertNoteListLengthEquals(1)
})
it('should rename existing category after defocusing edit state', () => {
const originalCategoryName = 'Category'
const newCategoryName = 'Renamed Category'
addCategory(originalCategoryName)
startEditingCategory(originalCategoryName)
renameCategory(originalCategoryName, newCategoryName)
defocusCategory(newCategoryName)
assertCategoryExists(newCategoryName)
})
it('should change category order', () => {
const firstCategory = 'Source Category'
const secondCategory = 'Destination Category'
addCategory(firstCategory)
addCategory(secondCategory)
moveCategory(firstCategory, secondCategory)
assertCategoryOrder(firstCategory, 3)
moveCategory(secondCategory, firstCategory)
assertCategoryOrder(secondCategory, 3)
})
it('should open context menu with right click', () => {
const categoryName = 'Context Menu'
addCategory(categoryName)
openCategoryContextMenu(categoryName)
assertCategoryOptionsOpened()
})
it('should allow category rename through context menu', () => {
const originalCategoryName = 'Category CM'
const newCategoryName = 'Renamed Category CM'
addCategory(originalCategoryName)
openCategoryContextMenu(originalCategoryName)
clickCategoryOptionRename()
renameCategory(originalCategoryName, newCategoryName)
defocusCategory(newCategoryName)
assertCategoryExists(newCategoryName)
})
it('should allow category permanent delete through context menu', () => {
addCategory(dynamicTimeCategoryName)
openCategoryContextMenu(dynamicTimeCategoryName)
clickCategoryOptionDelete()
assertCategoryDoesNotExist(dynamicTimeCategoryName)
})
it('should redirect to notes after deleting the category you are in', () => {
addCategory(dynamicTimeCategoryName)
navigateToCategory(dynamicTimeCategoryName)
openCategoryContextMenu(dynamicTimeCategoryName)
clickCategoryOptionDelete()
assertCurrentFolderOrCategory('Notes')
})
})
================================================
FILE: tests/e2e/integration/note.test.ts
================================================
// note.test.ts
// Tests for managing notes (create, trash, favorite, etc)
import { LabelText } from '@resources/LabelText'
import { TestID } from '@resources/TestID'
import { Errors } from '@/utils/enums'
import {
defaultInit,
getNoteCount,
navigateToNotes,
navigateToFavorites,
navigateToTrash,
testIDShouldContain,
testIDShouldNotExist,
clickDynamicTestID,
} from '../utils/testHelperUtils'
import {
assertNewNoteCreated,
assertNoteEditorCharacterCount,
assertNoteEditorLineCount,
assertNoteListLengthEquals,
assertNoteListLengthGTE,
assertNoteListTitleAtIndex,
assertNoteOptionsOpened,
assertNotesSelected,
clickCreateNewNote,
createXUniqueNotes,
clickEmptyTrash,
clickNoteOptionDeleteNotePermanently,
clickNoteOptionFavorite,
clickNoteOptionRestoreFromTrash,
clickNoteOptionTrash,
clickNoteOptions,
clickNoteOptionCopyLinkedNoteMarkdown,
clickSyncNotes,
typeNoteEditor,
typeNoteSearch,
clearNoteSearch,
openNoteContextMenu,
holdKeyAndClickNoteAtIndex,
trashAllNotes,
dragAndDrop,
} from '../utils/testNotesHelperUtils'
import {
addCategory,
selectMoveToCategoryOption,
navigateToCategory,
} from '../utils/testCategoryHelperUtils'
import { dynamicTimeCategoryName } from '../utils/testHelperEnums'
describe('Manage notes test', () => {
defaultInit()
before(() => {
// Delete welcome note
clickNoteOptions()
clickNoteOptionTrash()
})
beforeEach(() => {
navigateToNotes()
createXUniqueNotes(1)
trashAllNotes()
clearNoteSearch()
createXUniqueNotes(1)
})
it('should try to create a few new notes', () => {
clickCreateNewNote()
assertNoteListLengthEquals(2)
assertNewNoteCreated()
clickCreateNewNote()
assertNoteListLengthEquals(2)
assertNewNoteCreated()
clickCreateNewNote()
assertNoteListLengthEquals(2)
assertNewNoteCreated()
})
it('should link to another vote if a valid uuid is provided', () => {
createXUniqueNotes(3)
holdKeyAndClickNoteAtIndex(1, 'meta')
clickDynamicTestID(TestID.UUID_MENU_BAR_COPY_ICON)
const id = cy.task('getClipboard')
clickCreateNewNote()
cy.get('.CodeMirror textarea').invoke('val', `test ${id}`)
clickDynamicTestID(TestID.PREVIEW_MODE)
cy.get('a').should('exist')
})
it('should not link to another vote if an invalid uuid is provided', () => {
createXUniqueNotes(3)
holdKeyAndClickNoteAtIndex(1, 'meta')
clickCreateNewNote()
cy.get('.CodeMirror textarea').invoke('val', 'test {{z1x2c3}}')
clickDynamicTestID(TestID.PREVIEW_MODE)
cy.get('span.error').should('contain', Errors.INVALID_LINKED_NOTE_ID)
})
it('should copy note linking syntax from context menu', () => {
createXUniqueNotes(1)
holdKeyAndClickNoteAtIndex(0, 'meta')
openNoteContextMenu()
clickNoteOptionCopyLinkedNoteMarkdown()
cy.task('getClipboard').should('match', /\{\{[a-z0-9]{6}\}\}/)
})
it('should update a note', () => {
const sampleText = 'Sample note text.'
clickCreateNewNote()
// add some text to the editor
typeNoteEditor(sampleText)
assertNoteEditorLineCount(1)
assertNoteEditorCharacterCount(sampleText.length)
typeNoteEditor('{enter}123')
assertNoteEditorLineCount(2)
assertNoteEditorCharacterCount(sampleText.length + 3)
typeNoteEditor('{backspace}{backspace}{backspace}{backspace}')
assertNoteEditorLineCount(1)
assertNoteEditorCharacterCount(sampleText.length)
// // clean up state
// clickNoteOptions()
// clickNoteOptionTrash()
// clickCreateNewNote()
// navigateToTrash()
// clickEmptyTrash()
})
it('should open options', () => {
clickNoteOptions()
assertNoteOptionsOpened()
})
it('should open context menu through right click', () => {
openNoteContextMenu()
assertNoteOptionsOpened()
})
it('should add a note to favorites', () => {
// make sure favorites is empty
navigateToFavorites()
assertNoteListLengthEquals(0)
// favorite the note in All Notes
navigateToNotes()
clickNoteOptions()
testIDShouldContain(TestID.NOTE_OPTION_FAVORITE, LabelText.MARK_AS_FAVORITE)
clickNoteOptionFavorite()
// assert there is 1 favorited note
navigateToFavorites()
assertNoteListLengthEquals(1)
// assert button now says 'Remove'
clickNoteOptions()
testIDShouldContain(TestID.NOTE_OPTION_FAVORITE, LabelText.REMOVE_FAVORITE)
clickNoteOptionFavorite()
// assert favorites is empty
assertNoteListLengthEquals(0)
})
it('should add a note to favorites through context menu', () => {
// make sure favorites is empty
navigateToFavorites()
assertNoteListLengthEquals(0)
// favorite the note in All Notes
navigateToNotes()
openNoteContextMenu()
testIDShouldContain(TestID.NOTE_OPTION_FAVORITE, LabelText.MARK_AS_FAVORITE)
clickNoteOptionFavorite()
// assert there is 1 favorited note
navigateToFavorites()
assertNoteListLengthEquals(1)
// assert button now says 'Remove'
openNoteContextMenu()
testIDShouldContain(TestID.NOTE_OPTION_FAVORITE, LabelText.REMOVE_FAVORITE)
clickNoteOptionFavorite()
// assert favorites is empty
assertNoteListLengthEquals(0)
})
it('should send a note to trash', () => {
// make sure trash is currently empty
navigateToTrash()
assertNoteListLengthEquals(0)
// navigate back to All Notes and move the note to trash
navigateToNotes()
clickNoteOptions()
testIDShouldContain(TestID.NOTE_OPTION_TRASH, LabelText.MOVE_TO_TRASH)
clickNoteOptionTrash()
testIDShouldNotExist(TestID.NOTE_OPTION_TRASH)
// make sure the new note is in the trash
navigateToTrash()
assertNoteListLengthEquals(1)
clickEmptyTrash()
})
it('should empty notes in trash', () => {
// move note to trash
clickNoteOptions()
clickNoteOptionTrash()
// make sure there is a note in the trash and empty it
navigateToTrash()
assertNoteListLengthGTE(1)
clickEmptyTrash()
assertNoteListLengthEquals(0)
// assert the empty trash button is gone
testIDShouldNotExist(TestID.EMPTY_TRASH_BUTTON)
})
it('should send a note to trash through context menu', () => {
// make sure trash is currently empty
navigateToTrash()
assertNoteListLengthEquals(0)
// navigate back to All Notes and move the note to trash
navigateToNotes()
openNoteContextMenu()
testIDShouldContain(TestID.NOTE_OPTION_TRASH, LabelText.MOVE_TO_TRASH)
clickNoteOptionTrash()
testIDShouldNotExist(TestID.NOTE_OPTION_TRASH)
// make sure there is a note in the trash and empty it
navigateToTrash()
assertNoteListLengthEquals(1)
clickEmptyTrash()
assertNoteListLengthEquals(0)
})
it('should delete the active note in the trash permanently', () => {
// move note to trash
clickNoteOptions()
clickNoteOptionTrash()
// navigate to trash and delete the active note permanently
navigateToTrash()
clickNoteOptions()
testIDShouldContain(TestID.NOTE_OPTION_DELETE_PERMANENTLY, LabelText.DELETE_PERMANENTLY)
clickNoteOptionDeleteNotePermanently()
assertNoteListLengthEquals(0)
// assert the empty trash button is gone
testIDShouldNotExist(TestID.EMPTY_TRASH_BUTTON)
})
it('should delete the active note in the trash permanently through context menu', () => {
// move note to trash
openNoteContextMenu()
clickNoteOptionTrash()
// navigate to trash and delete the active note permanently
navigateToTrash()
openNoteContextMenu()
testIDShouldContain(TestID.NOTE_OPTION_DELETE_PERMANENTLY, LabelText.DELETE_PERMANENTLY)
clickNoteOptionDeleteNotePermanently()
assertNoteListLengthEquals(0)
// assert the empty trash button is gone
testIDShouldNotExist(TestID.EMPTY_TRASH_BUTTON)
})
it('should restore the active note in the trash', function () {
getNoteCount('allNoteStartCount')
// move note to trash
clickNoteOptions()
clickNoteOptionTrash()
cy.then(() => assertNoteListLengthEquals(this.allNoteStartCount - 1))
// navigate to trash and restore the active note
navigateToTrash()
getNoteCount('trashStartCount')
clickNoteOptions()
testIDShouldContain(TestID.NOTE_OPTION_RESTORE_FROM_TRASH, LabelText.RESTORE_FROM_TRASH)
clickNoteOptionRestoreFromTrash()
cy.then(() => assertNoteListLengthEquals(this.trashStartCount - 1))
// assert the empty trash button is gone
testIDShouldNotExist(TestID.EMPTY_TRASH_BUTTON)
// make sure the note is back in All Notes
navigateToNotes()
cy.then(() => assertNoteListLengthEquals(this.allNoteStartCount))
})
it('should restore the active note in the trash through context menu', function () {
getNoteCount('allNoteStartCount')
// move note to trash
openNoteContextMenu()
clickNoteOptionTrash()
cy.then(() => assertNoteListLengthEquals(this.allNoteStartCount - 1))
// navigate to trash and restore the active note
navigateToTrash()
getNoteCount('trashStartCount')
openNoteContextMenu()
testIDShouldContain(TestID.NOTE_OPTION_RESTORE_FROM_TRASH, LabelText.RESTORE_FROM_TRASH)
clickNoteOptionRestoreFromTrash()
cy.then(() => assertNoteListLengthEquals(this.trashStartCount - 1))
// assert the empty trash button is gone
testIDShouldNotExist(TestID.EMPTY_TRASH_BUTTON)
// make sure the note is back in All Notes
navigateToNotes()
cy.then(() => assertNoteListLengthEquals(this.allNoteStartCount))
})
it('should sync some notes', function () {
const noteOneTitle = 'note 1'
const noteTwoTitle = 'same note title'
const noteThreeTitle = 'same note title'
const noteFourTitle = 'note 4'
Cypress.on('window:before:unload', (event: BeforeUnloadEvent) =>
expect(event.returnValue).to.equal('')
)
// start with a refresh so we know our current saved state
cy.reload()
getNoteCount('allNoteStartCount')
// create a new note and refresh without syncing
clickCreateNewNote()
typeNoteEditor(noteOneTitle)
cy.reload()
cy.then(() => assertNoteListLengthEquals(this.allNoteStartCount))
// create a few new notes
clickCreateNewNote()
typeNoteEditor(noteOneTitle)
clickCreateNewNote()
typeNoteEditor(noteTwoTitle)
clickCreateNewNote()
typeNoteEditor(noteThreeTitle)
clickCreateNewNote()
typeNoteEditor(noteFourTitle)
clickSyncNotes()
// make sure notes persisted
cy.reload()
cy.then(() => assertNoteListLengthEquals(this.allNoteStartCount + 4))
// make sure order is correct
assertNoteListTitleAtIndex(3, noteOneTitle)
assertNoteListTitleAtIndex(2, noteTwoTitle)
assertNoteListTitleAtIndex(1, noteThreeTitle)
assertNoteListTitleAtIndex(0, noteFourTitle)
})
it('should search some notes', function () {
const noteOneTitle = 'note 1'
const noteTwoTitle = 'same note title'
const noteThreeTitle = 'same note title'
const noteFourTitle = 'note 4'
// start with a refresh so we know our current saved state
cy.reload()
getNoteCount('allNoteStartCount')
// create a few new notes
clickCreateNewNote()
typeNoteEditor(noteOneTitle)
clickCreateNewNote()
typeNoteEditor(noteTwoTitle)
clickCreateNewNote()
typeNoteEditor(noteThreeTitle)
clickCreateNewNote()
typeNoteEditor(noteFourTitle)
// make sure notes are filtered
typeNoteSearch('note title')
cy.then(() => assertNoteListLengthEquals(2))
})
it('should select multiple notes via ctrl/cmd + click', () => {
createXUniqueNotes(2)
// Select notes
holdKeyAndClickNoteAtIndex(0, 'meta')
holdKeyAndClickNoteAtIndex(1, 'meta')
assertNotesSelected(2)
})
it('should send multiple selected notes to trash via context menu', () => {
createXUniqueNotes(1)
holdKeyAndClickNoteAtIndex(0, 'meta')
holdKeyAndClickNoteAtIndex(1, 'meta')
openNoteContextMenu()
clickNoteOptionTrash()
assertNoteListLengthEquals(0)
navigateToTrash()
assertNoteListLengthEquals(2)
})
it('should send multiple selected notes to favorites via context menu', () => {
createXUniqueNotes(1)
holdKeyAndClickNoteAtIndex(0, 'meta')
holdKeyAndClickNoteAtIndex(1, 'meta')
openNoteContextMenu()
clickNoteOptionFavorite()
assertNoteListLengthEquals(2)
navigateToFavorites()
assertNoteListLengthEquals(2)
})
it('should remove multiple selected notes from favorites via context menu', () => {
createXUniqueNotes(1)
holdKeyAndClickNoteAtIndex(0, 'meta')
holdKeyAndClickNoteAtIndex(1, 'meta')
openNoteContextMenu()
clickNoteOptionFavorite()
assertNoteListLengthEquals(2)
navigateToFavorites()
assertNoteListLengthEquals(2)
holdKeyAndClickNoteAtIndex(1, 'meta')
openNoteContextMenu()
clickNoteOptionFavorite()
assertNoteListLengthEquals(0)
navigateToNotes()
assertNoteListLengthEquals(2)
})
it('should send multiple selected notes to favorites when clicking on an already favorited selected note via context menu', () => {})
it('should remove multiple selected notes from favorites when clicking on a not yet favorited selected note via context menu', () => {})
it('should send multiple selected notes to a category via context menu', () => {
// add a category
addCategory(dynamicTimeCategoryName)
// navigate back to All Notes create a new note, and move it to that category
navigateToNotes()
createXUniqueNotes(1)
holdKeyAndClickNoteAtIndex(0, 'meta')
holdKeyAndClickNoteAtIndex(1, 'meta')
openNoteContextMenu()
selectMoveToCategoryOption(dynamicTimeCategoryName)
assertNoteListLengthEquals(2)
navigateToCategory(dynamicTimeCategoryName)
assertNoteListLengthEquals(2)
})
it('should restore multiple selected notes from trash', () => {
createXUniqueNotes(1)
holdKeyAndClickNoteAtIndex(0, 'meta')
holdKeyAndClickNoteAtIndex(1, 'meta')
openNoteContextMenu()
clickNoteOptionTrash()
navigateToTrash()
holdKeyAndClickNoteAtIndex(0, 'meta')
openNoteContextMenu()
clickNoteOptionRestoreFromTrash()
assertNoteListLengthEquals(0)
navigateToNotes()
assertNoteListLengthEquals(2)
})
it('should permanently delete multiple selected notes from trash', () => {
createXUniqueNotes(1)
holdKeyAndClickNoteAtIndex(0, 'meta')
holdKeyAndClickNoteAtIndex(1, 'meta')
openNoteContextMenu()
clickNoteOptionTrash()
navigateToTrash()
holdKeyAndClickNoteAtIndex(0, 'meta')
openNoteContextMenu()
clickNoteOptionDeleteNotePermanently()
assertNoteListLengthEquals(0)
navigateToNotes()
assertNoteListLengthEquals(0)
})
it('should send a not selected note to favorites with drag & drop', () => {
createXUniqueNotes(2)
holdKeyAndClickNoteAtIndex(0, 'meta')
holdKeyAndClickNoteAtIndex(1, 'meta')
dragAndDrop('[data-testid=note-list-item-2]', '[data-testid=favorites]')
cy.get('[data-testid=favorites]').click()
cy.get('[data-testid=note-list]').within(() => {
cy.get('.note-list-each').should('have.length', 1)
})
})
it('should send a not selected note to trash with drag & drop', () => {
createXUniqueNotes(2)
holdKeyAndClickNoteAtIndex(0, 'meta')
holdKeyAndClickNoteAtIndex(1, 'meta')
dragAndDrop('[data-testid=note-list-item-2]', '[data-testid=trash]')
cy.get('[data-testid=trash]').click()
cy.get('[data-testid=note-list]').within(() => {
cy.get('.note-list-each').should('have.length', 1)
})
})
it('should send a not selected note to a category with drag & drop', () => {
createXUniqueNotes(2)
addCategory(dynamicTimeCategoryName)
holdKeyAndClickNoteAtIndex(0, 'meta')
holdKeyAndClickNoteAtIndex(1, 'meta')
dragAndDrop('[data-testid=note-list-item-2]', '[data-testid=category-list-div]')
cy.get('[data-testid=category-list-div]').click()
cy.get('[data-testid=note-list]').within(() => {
cy.get('.note-list-each').should('have.length', 1)
})
})
it('should send multiple notes to favorites with drag & drop', () => {
createXUniqueNotes(2)
addCategory(dynamicTimeCategoryName)
holdKeyAndClickNoteAtIndex(0, 'meta')
holdKeyAndClickNoteAtIndex(1, 'meta')
dragAndDrop('[data-testid=note-list-item-0]', '[data-testid=favorites]')
cy.get('[data-testid=favorites]').click()
cy.get('[data-testid=note-list]').within(() => {
cy.get('.note-list-each').should('have.length', 2)
})
})
it('should send multiple notes to favorites with drag & drop', () => {
createXUniqueNotes(2)
addCategory(dynamicTimeCategoryName)
holdKeyAndClickNoteAtIndex(0, 'meta')
holdKeyAndClickNoteAtIndex(1, 'meta')
dragAndDrop('[data-testid=note-list-item-0]', '[data-testid=trash]')
cy.get('[data-testid=trash]').click()
cy.get('[data-testid=note-list]').within(() => {
cy.get('.note-list-each').should('have.length', 2)
})
})
it('should send multiple notes to a category with drag & drop', () => {
createXUniqueNotes(2)
addCategory(dynamicTimeCategoryName)
holdKeyAndClickNoteAtIndex(0, 'meta')
holdKeyAndClickNoteAtIndex(1, 'meta')
dragAndDrop('[data-testid=note-list-item-0]', '[data-testid=category-list-div]')
cy.get('[data-testid=category-list-div]').click()
cy.get('[data-testid=note-list]').within(() => {
cy.get('.note-list-each').should('have.length', 2)
})
})
it('should send a not selected trashed note to notes with drag & drop', () => {
createXUniqueNotes(2)
holdKeyAndClickNoteAtIndex(0, 'meta')
dragAndDrop('[data-testid=note-list-item-0]', '[data-testid=trash]')
cy.get('[data-testid=trash]').click()
cy.get('[data-testid=note-list]').within(() => {
cy.get('.note-list-each').should('have.length', 1)
})
holdKeyAndClickNoteAtIndex(0, 'meta')
dragAndDrop('[data-testid=note-list-item-0]', '[data-testid=notes]')
cy.get('[data-testid=notes]').click()
cy.get('[data-testid=note-list]').within(() => {
cy.get('.note-list-each').should('have.length', 3)
})
})
it('should send multiple not selected trashed notes to notes with drag & drop', () => {
createXUniqueNotes(2)
holdKeyAndClickNoteAtIndex(0, 'meta')
holdKeyAndClickNoteAtIndex(1, 'meta')
dragAndDrop('[data-testid=note-list-item-0]', '[data-testid=trash]')
cy.get('[data-testid=trash]').click()
cy.get('[data-testid=note-list]').within(() => {
cy.get('.note-list-each').should('have.length', 2)
})
holdKeyAndClickNoteAtIndex(0, 'meta')
dragAndDrop('[data-testid=note-list-item-0]', '[data-testid=notes]')
cy.get('[data-testid=notes]').click()
cy.get('[data-testid=note-list]').within(() => {
cy.get('.note-list-each').should('have.length', 3)
})
})
it('should not move a note that is already in Notes when dragged & dropped on Notes', () => {
createXUniqueNotes(2)
holdKeyAndClickNoteAtIndex(0, 'meta')
dragAndDrop('[data-testid=note-list-item-0]', '[data-testid=notes]')
cy.get('[data-testid=note-list]').within(() => {
cy.get('.note-list-each').should('have.length', 3)
})
})
it('should not move multiple notes that are already in Notes when dragged & dropped on Notes', () => {
createXUniqueNotes(2)
holdKeyAndClickNoteAtIndex(0, 'meta')
holdKeyAndClickNoteAtIndex(2, 'meta')
dragAndDrop('[data-testid=note-list-item-0]', '[data-testid=notes]')
cy.get('[data-testid=note-list]').within(() => {
cy.get('.note-list-each').should('have.length', 3)
})
})
it('should not create a new draft note if one already exists', () => {
clickCreateNewNote()
cy.get('[data-testid=trash]').click()
clickCreateNewNote()
cy.get('[data-testid=note-list]').within(() => {
cy.get('.note-list-each').should('have.length', 2)
})
})
})
================================================
FILE: tests/e2e/integration/settings.test.ts
================================================
// settings.test.ts
// Tests for functionality available in the settings menu
import { TestID } from '@resources/TestID'
import { getNoteTitle } from '@/utils/helpers'
import { NoteItem, CategoryItem } from '@/types'
import {
addCategory,
assertCategoryExists,
clickCategoryOptionDelete,
openCategoryContextMenu,
} from '../utils/testCategoryHelperUtils'
import { defaultInit, assertNoteContainsText } from '../utils/testHelperUtils'
import {
clickCreateNewNote,
createXUniqueNotes,
clickNoteOptionFavorite,
typeNoteEditor,
openNoteContextMenu,
holdKeyAndClickNoteAtIndex,
trashAllNotes,
assertNoteListLengthEquals,
} from '../utils/testNotesHelperUtils'
import {
navigateToSettings,
assertSettingsMenuIsOpen,
assertSettingsMenuIsClosed,
closeSettingsByClickingX,
closeSettingsByClickingOutsideWindow,
toggleDarkMode,
toggleMarkdownPreview,
toggleLineNumbers,
toggleLineHighlight,
assertDarkModeActive,
assertDarkModeInactive,
assertMarkdownPreviewActive,
assertMarkdownPreviewInactive,
assertLineNumbersActive,
assertLineNumbersInactive,
selectOptionInSortByDropdown,
assertLineHighlightActive,
assertLineHighlightInactive,
clickSettingsTab,
getDownloadedBackup,
} from '../utils/testSettingsUtils'
describe('Settings', () => {
defaultInit()
before(() => {})
beforeEach(() => {
navigateToSettings()
})
afterEach(() => {
closeSettingsByClickingOutsideWindow()
})
describe('Preferences', () => {
const generateAndConfigureSomeNotes = () => {
const noteTitle = 'note 10'
const noteTitleAbc = 'B'
createXUniqueNotes(5)
holdKeyAndClickNoteAtIndex(0, 'meta')
holdKeyAndClickNoteAtIndex(1, 'meta')
openNoteContextMenu()
clickNoteOptionFavorite()
clickCreateNewNote()
typeNoteEditor(noteTitleAbc)
clickCreateNewNote()
typeNoteEditor(noteTitle)
}
it('should open settings menu', () => {
assertSettingsMenuIsOpen()
})
it('should close settings menu on clicking X', () => {
closeSettingsByClickingX()
assertSettingsMenuIsClosed()
navigateToSettings()
})
it('should close settings menu on clicking outside of window', () => {
closeSettingsByClickingOutsideWindow()
assertSettingsMenuIsClosed()
navigateToSettings()
})
it('should toggle preferences: active line highlight [off]', () => {
toggleLineHighlight()
closeSettingsByClickingOutsideWindow()
assertLineHighlightInactive()
navigateToSettings()
})
it('should toggle preferences: active line highlight [on]', () => {
toggleLineHighlight()
closeSettingsByClickingOutsideWindow()
assertLineHighlightActive()
navigateToSettings()
})
it('should toggle preferences: dark mode [on]', () => {
toggleDarkMode()
assertDarkModeActive()
})
it('should toggle preferences: dark mode [off]', () => {
toggleDarkMode()
assertDarkModeInactive()
})
it('should toggle preferences: markdown preview [on]', () => {
toggleMarkdownPreview()
assertMarkdownPreviewActive()
})
it('should toggle preferences: markdown preview [off]', () => {
toggleMarkdownPreview()
assertMarkdownPreviewInactive()
})
it('should toggle preferences: line numbers [on]', () => {
toggleLineNumbers()
assertLineNumbersActive()
})
it('should toggle preferences: line numbers [off]', () => {
toggleLineNumbers()
assertLineNumbersInactive()
closeSettingsByClickingX()
generateAndConfigureSomeNotes()
navigateToSettings()
})
it('should change sort order: last updated', () => {
selectOptionInSortByDropdown('Last Updated')
closeSettingsByClickingX()
assertNoteContainsText('note-list-item-2', 'note 10')
navigateToSettings()
})
it('should change sort order: title (alphabetical)', () => {
selectOptionInSortByDropdown('Title')
closeSettingsByClickingX()
assertNoteContainsText('note-list-item-2', 'B')
navigateToSettings()
})
it('should change sort order: date created', () => {
selectOptionInSortByDropdown('Date Created')
closeSettingsByClickingX()
assertNoteContainsText('note-list-item-2', 'note 10')
navigateToSettings()
})
})
describe('Data Management', () => {
before(() => {
trashAllNotes()
})
beforeEach(() => {
clickSettingsTab('data management')
})
it('should download backup', () => {
cy.findByRole('button', { name: /export backup/i }).click()
getDownloadedBackup().then((result) => {
const data = JSON.parse(result as string)
expect(data.notes).to.have.length(1)
expect(data.notes[0].text).to.include('Scratchpad')
})
})
it('should import backup', () => {
const categories = ['test_category_1', 'test_category_2']
closeSettingsByClickingOutsideWindow()
createXUniqueNotes(2)
categories.forEach((category) => {
addCategory(category)
})
navigateToSettings()
clickSettingsTab('data management')
cy.findByRole('button', { name: /export backup/i }).click()
getDownloadedBackup().then((result) => {
closeSettingsByClickingOutsideWindow()
trashAllNotes()
categories.forEach((category) => {
openCategoryContextMenu(category)
clickCategoryOptionDelete()
})
navigateToSettings()
clickSettingsTab('data management')
cy.findByTestId(TestID.UPLOAD_SETTINGS_BACKUP).attachFile({
fileContent: result as Blob,
filePath: '',
fileName: 'backup',
mimeType: 'application/json',
encoding: 'utf-8',
})
const backupData = JSON.parse(result as string) as {
categories: CategoryItem[]
notes: NoteItem[]
}
clickSettingsTab('preferences')
selectOptionInSortByDropdown('Title')
clickSettingsTab('data management')
closeSettingsByClickingX()
backupData.categories.forEach(({ name }) => {
assertCategoryExists(name)
})
assertNoteListLengthEquals(2)
backupData.notes.slice(1).forEach(({ text }, index) => {
assertNoteContainsText(TestID.NOTE_LIST_ITEM + index, getNoteTitle(text))
})
navigateToSettings()
})
})
})
})
================================================
FILE: tests/e2e/plugins/cy-ts-preprocessor.js
================================================
const path = require('path')
const wp = require('@cypress/webpack-preprocessor')
const webpackOptions = {
resolve: {
extensions: ['.tsx', '.ts', '.js'],
alias: {
'@': path.resolve(__dirname, '../../../src/client'),
'@resources': path.resolve(__dirname, '../../../src/resources'),
},
// Polyfills
fallback: {
path: require.resolve('path-browserify'), // Needed for cypress-file-upload
stream: require.resolve('stream-browserify'), // Needed to import utils from client folder
},
},
module: {
rules: [
{
test: /\.ts(x?)$/,
exclude: [/node_modules/],
use: [
{
loader: 'ts-loader',
options: {
transpileOnly: true,
},
},
],
},
],
},
}
const options = { webpackOptions }
module.exports = wp(options)
================================================
FILE: tests/e2e/plugins/index.js
================================================
const clipboardy = require('clipboardy')
const cypressTypeScriptPreprocessor = require('./cy-ts-preprocessor')
module.exports = (on) => {
on('file:preprocessor', cypressTypeScriptPreprocessor)
on('task', {
getClipboard() {
return clipboardy.readSync()
},
})
}
================================================
FILE: tests/e2e/support/commands.js
================================================
import '@testing-library/cypress/add-commands'
import 'cypress-file-upload'
Cypress.Commands.add('dragAndDrop', { prevSubject: 'element' }, (subject, target) => {
Cypress.log({
name: 'DRAGNDROP',
message: `Dragging element ${subject} to ${target}`,
consoleProps: () => {
return {
subject: subject,
target: target,
}
},
})
const BUTTON_INDEX = 0
const SLOPPY_CLICK_THRESHOLD = 10
cy.contains(target).then(($target) => {
const coordsDrop = $target[0].getBoundingClientRect()
cy.get(subject)
.first()
.then((subject) => {
const coordsDrag = subject[0].getBoundingClientRect()
cy.wrap(subject)
.trigger('mousedown', {
button: BUTTON_INDEX,
clientX: coordsDrag.x,
clientY: coordsDrag.y,
force: true,
})
.trigger('mousemove', {
button: BUTTON_INDEX,
clientX: coordsDrag.x + SLOPPY_CLICK_THRESHOLD,
clientY: coordsDrag.y,
force: true,
})
cy.get('body')
.trigger('mousemove', {
button: BUTTON_INDEX,
clientX: coordsDrag.x,
clientY: coordsDrop.y,
force: true,
})
.wait(0.2 * 1000)
.trigger('mouseup')
})
})
})
================================================
FILE: tests/e2e/support/index.js
================================================
import './commands'
// Since before unload alert hangs console tests, the alert has to be disabled
// Check https://github.com/cypress-io/cypress/issues/2118 for more info
Cypress.on('window:before:load', function (win) {
const original = win.EventTarget.prototype.addEventListener
win.EventTarget.prototype.addEventListener = function () {
if (arguments && arguments[0] === 'beforeunload') return
return original.apply(this, arguments)
}
Object.defineProperty(win, 'onbeforeunload', {
get: function () {},
set: function () {},
})
})
================================================
FILE: tests/e2e/tsconfig.json
================================================
{
"extends": "../../tsconfig.json",
"include": ["../node_modules/cypress", "**/*.ts"],
"compilerOptions": {
"types": ["cypress", "@types/testing-library__cypress", "cypress-file-upload"],
"isolatedModules": false,
"noEmit": false
}
}
================================================
FILE: tests/e2e/utils/testCategoryHelperUtils.ts
================================================
// testCategoryHelperUtils.ts
// Utility functions for use in category tests
import { TestID } from '@resources/TestID'
import {
getTestID,
wrapWithTestIDTag,
testIDShouldExist,
clickTestID,
testIDShouldNotExist,
} from './testHelperUtils'
const addCategory = (categoryName: string) => {
getTestID(TestID.ADD_CATEGORY_BUTTON).click()
getTestID(TestID.NEW_CATEGORY_INPUT).type(categoryName)
getTestID(TestID.NEW_CATEGORY_FORM).submit()
cy.contains(categoryName)
}
const collapseCategoryList = () => {
getTestID(TestID.CATEGORY_COLLAPSE_BUTTON).click()
}
const assertCategoryDoesNotExist = (categoryName: string) => {
cy.findByText(categoryName).should('not.exist')
}
const assertCategoryExists = (categoryName: string) => {
cy.contains(wrapWithTestIDTag(TestID.CATEGORY_LIST_DIV), categoryName).should('exist')
}
const assertCategoryListExists = () => {
testIDShouldExist(TestID.CATEGORY_LIST_DIV)
}
const assertCategoryListDoesNotExists = () => {
testIDShouldNotExist(TestID.CATEGORY_LIST_DIV)
}
const assertCategoryOrder = (categoryName: string, position: number) => {
cy.get('.category-list > div').eq(position).contains(categoryName)
}
const assertCategoryOptionsOpened = () => {
testIDShouldExist(TestID.CATEGORY_OPTIONS_NAV)
}
const defocusCategory = (categoryName: string) => {
getTestID(TestID.CATEGORY_EDIT).blur()
}
const navigateToCategory = (categoryName: string) => {
cy.get('.category-list').contains(categoryName).click()
}
const moveCategory = (categoryName: string, targetName: string) => {
cy.contains(categoryName)
.parent()
.find(wrapWithTestIDTag(TestID.MOVE_CATEGORY))
// @ts-ignore
.dragAndDrop(targetName)
.wait(1 * 1000)
}
const renameCategory = (oldCategoryName: string, newCategoryName: string) => {
getTestID(TestID.CATEGORY_EDIT).focus().clear().type(newCategoryName)
}
const openCategoryContextMenu = (categoryName: string) => {
cy.contains(categoryName).parent().rightclick()
}
const selectMoveToCategoryOption = (categoryName: string) => {
getTestID(TestID.MOVE_TO_CATEGORY).select(categoryName)
}
const startEditingCategory = (categoryName: string) => {
cy.get('.category-list')
.contains(wrapWithTestIDTag(TestID.CATEGORY_LIST_DIV), categoryName)
.dblclick()
}
const clickCategoryOptionRename = () => {
clickTestID(TestID.CATEGORY_OPTION_RENAME)
}
const clickCategoryOptionDelete = () => {
clickTestID(TestID.CATEGORY_OPTION_DELETE_PERMANENTLY)
}
export {
addCategory,
assertCategoryExists,
assertCategoryDoesNotExist,
assertCategoryOrder,
assertCategoryOptionsOpened,
defocusCategory,
navigateToCategory,
moveCategory,
renameCategory,
selectMoveToCategoryOption,
startEditingCategory,
openCategoryContextMenu,
clickCategoryOptionRename,
clickCategoryOptionDelete,
collapseCategoryList,
assertCategoryListExists,
assertCategoryListDoesNotExists,
}
================================================
FILE: tests/e2e/utils/testHelperEnums.ts
================================================
// testHelperEnums.ts
// Default enumerated values that can be used in tests
const entryPoint = '/app'
const dynamicTimeCategoryName = `Cy${Date.now()}`
export { entryPoint, dynamicTimeCategoryName }
================================================
FILE: tests/e2e/utils/testHelperUtils.ts
================================================
// testHelperUtils.ts
// Utility functions used by all test specs
import { LabelText } from '@resources/LabelText'
import { TestID } from '@resources/TestID'
import { entryPoint } from './testHelperEnums'
const assertCurrentFolderOrCategory = (folderOrCategoryName: string) => {
cy.get('.active').should('have.text', folderOrCategoryName)
}
const assertNoteContainsText = (testID: string, text: string) => {
cy.get(wrapWithTestIDTag(testID)).click().should('contain.text', text)
}
// takes a built string instead of a TestID .. prefer clickTestID() when possible
const clickDynamicTestID = (dynamicTestID: string) => {
cy.get(wrapWithTestIDTag(dynamicTestID)).click()
}
// optional second parameter to click at supported areas (e.g. 'right' 'left') default is 'center'
const clickTestID = (testIDEnum: TestID) => {
cy.get(wrapWithTestIDTag(testIDEnum)).click()
}
const selectOptionTestID = (testIDEnum: TestID, text: string) => {
cy.get(wrapWithTestIDTag(testIDEnum)).select(text)
}
const defaultInit = () => {
before(() => {
cy.visit(entryPoint)
// wait for things to settle .. like waiting for Welcome Note to resolve
// increasing due to occasional flaky starts
cy.wait(200)
})
}
const getDynamicTestID = (testID: string) => {
return cy.get(wrapWithTestIDTag(testID))
}
const getTestID = (testIDEnum: TestID) => {
return cy.get(wrapWithTestIDTag(testIDEnum))
}
// sets the specified alias for the current folder note count, must be accessed
// through 'this' asynchronously (for example, .then())
// note: test retrieving aliased variable must use regular 'function(){}' syntax for proper 'this' scope
const getNoteCount = (noteCountAlias: string) => {
getTestID(TestID.NOTE_LIST).children().its('length').as(noteCountAlias)
}
const navigateToNotes = () => {
clickTestID(TestID.FOLDER_NOTES)
}
const navigateToFavorites = () => {
clickTestID(TestID.FOLDER_FAVORITES)
}
const navigateToTrash = () => {
clickTestID(TestID.FOLDER_TRASH)
}
const testIDShouldContain = (testIDEnum: TestID, textEnum: LabelText) => {
cy.get(wrapWithTestIDTag(testIDEnum)).should('contain', textEnum)
}
const testIDShouldExist = (testIDEnum: TestID) => {
cy.get(wrapWithTestIDTag(testIDEnum)).should('exist')
}
const testIDShouldNotExist = (testIDEnum: TestID) => {
cy.get(wrapWithTestIDTag(testIDEnum)).should('not.exist')
}
const wrapWithTestIDTag = (testIDEnum: TestID | string) => {
return '[data-testid="' + testIDEnum + '"]'
}
export {
clickDynamicTestID,
clickTestID,
getDynamicTestID,
getNoteCount,
getTestID,
defaultInit,
navigateToNotes,
navigateToFavorites,
navigateToTrash,
testIDShouldContain,
testIDShouldExist,
testIDShouldNotExist,
wrapWithTestIDTag,
assertCurrentFolderOrCategory,
selectOptionTestID,
assertNoteContainsText,
}
================================================
FILE: tests/e2e/utils/testNotesHelperUtils.ts
================================================
// testNotesHelperUtils.ts
// Utility functions for use in note tests
import { LabelText } from '@resources/LabelText'
import { TestID } from '@resources/TestID'
import {
clickDynamicTestID,
clickTestID,
getDynamicTestID,
getTestID,
testIDShouldExist,
navigateToTrash,
} from './testHelperUtils'
const assertNewNoteCreated = () => {
getDynamicTestID(TestID.NOTE_LIST_ITEM + '0').should('contain', LabelText.NEW_NOTE)
}
const assertNoteEditorCharacterCount = (expectedCharacterCount: number) => {
// all lines in the code editor should be descendants of the CodeMirror-code class
cy.get('.CodeMirror-code').each((element) => {
expect(element.text().length).to.equal(expectedCharacterCount)
})
}
const assertNoteEditorLineCount = (expectedLineCount: number) => {
cy.get('.CodeMirror-code').children().should('have.length', expectedLineCount)
}
const assertNoteListLengthEquals = (expectedLength: number) => {
getTestID(TestID.NOTE_LIST).children().should('have.length', expectedLength)
}
const assertNoteListLengthGTE = (expectedLength: number) => {
getTestID(TestID.NOTE_LIST).children().should('have.length.gte', expectedLength)
}
const assertNoteListTitleAtIndex = (noteIndex: number, expectedTitle: string) => {
getDynamicTestID(TestID.NOTE_TITLE + noteIndex)
.children()
.contains(expectedTitle)
}
const assertNoteOptionsOpened = () => {
testIDShouldExist(TestID.NOTE_OPTIONS_NAV)
}
const assertNotesSelected = (expectedSelectedNotesCount: number) => {
cy.get('.selected').should('have.length', expectedSelectedNotesCount)
}
const trashAllNotes = () => {
getTestID(TestID.NOTE_LIST)
.children()
.each((el, noteIndex) => {
if (el.hasClass('selected')) return
cy.get('body').type(`{meta}`, { release: false })
getDynamicTestID(TestID.NOTE_LIST_ITEM + noteIndex).click()
})
openNoteContextMenu()
clickNoteOptionTrash()
navigateToTrash()
clickEmptyTrash()
}
const createXUniqueNotes = (numberOfUniqueNotes: number) => {
for (let i = 0; i < numberOfUniqueNotes; i++) {
clickCreateNewNote()
typeNoteEditor(`note ${i}`)
}
}
const clickCreateNewNote = () => {
clickTestID(TestID.SIDEBAR_ACTION_CREATE_NEW_NOTE)
}
const clickEmptyTrash = () => {
clickTestID(TestID.EMPTY_TRASH_BUTTON)
}
const clickNoteAtIndex = (noteIndex: number) => {
getDynamicTestID(TestID.NOTE_LIST_ITEM + noteIndex).click()
}
const holdKeyAndClickNoteAtIndex = (
noteIndex: number,
key: 'alt' | 'ctrl' | 'meta' | 'shift' | null = null
) => {
key && cy.get('body').type(`{${key}}`, { release: false })
getDynamicTestID(TestID.NOTE_LIST_ITEM + noteIndex).click()
}
// click a note with the specified index
const clickNoteOptions = (noteIndex: number = 0) => {
clickDynamicTestID(TestID.NOTE_OPTIONS_DIV + noteIndex)
}
const openNoteContextMenu = (noteIndex: number = 0) => {
cy.get('.note-list > div').eq(noteIndex).rightclick()
}
const clickNoteOptionDeleteNotePermanently = () => {
clickTestID(TestID.NOTE_OPTION_DELETE_PERMANENTLY)
}
const clickNoteOptionFavorite = () => {
clickTestID(TestID.NOTE_OPTION_FAVORITE)
}
const clickNoteOptionRestoreFromTrash = () => {
clickTestID(TestID.NOTE_OPTION_RESTORE_FROM_TRASH)
}
const clickNoteOptionTrash = () => {
clickTestID(TestID.NOTE_OPTION_TRASH)
}
const clickNoteOptionCopyLinkedNoteMarkdown = () => {
clickTestID(TestID.COPY_REFERENCE_TO_NOTE)
}
const clickSyncNotes = () => {
clickTestID(TestID.TOPBAR_ACTION_SYNC_NOTES)
}
const typeNoteEditor = (contentToType: string) => {
// force = true, cypress doesn't support typing in hidden elements
cy.get('.CodeMirror textarea').type(contentToType, { force: true })
}
const typeNoteSearch = (contentToType: string) => {
getTestID(TestID.NOTE_SEARCH).type(contentToType, { force: true })
}
const clearNoteSearch = () => {
getTestID(TestID.NOTE_SEARCH).clear()
}
const dragAndDrop = (subject: string, element: string) => {
const dt = new DataTransfer()
cy.get(subject).trigger('dragstart', { dataTransfer: dt })
cy.get(element).trigger('drop', { dataTransfer: dt })
}
export {
assertNewNoteCreated,
assertNoteEditorCharacterCount,
assertNoteEditorLineCount,
assertNoteListLengthEquals,
assertNoteListLengthGTE,
assertNoteListTitleAtIndex,
assertNoteOptionsOpened,
assertNotesSelected,
clickCreateNewNote,
createXUniqueNotes,
clickEmptyTrash,
clickNoteAtIndex,
clickNoteOptionDeleteNotePermanently,
clickNoteOptionFavorite,
clickNoteOptionRestoreFromTrash,
clickNoteOptionTrash,
clickNoteOptions,
clickNoteOptionCopyLinkedNoteMarkdown,
clickSyncNotes,
typeNoteEditor,
typeNoteSearch,
clearNoteSearch,
openNoteContextMenu,
holdKeyAndClickNoteAtIndex,
trashAllNotes,
dragAndDrop,
}
================================================
FILE: tests/e2e/utils/testSettingsUtils.ts
================================================
// testHelperUtils.ts
// Utility functions for use in settings tests
import { TestID } from '@resources/TestID'
import { clickTestID, selectOptionTestID } from './testHelperUtils'
const clickSettingsTab = (name: string) => {
cy.findByRole('button', { name: new RegExp(name, 'i') }).click()
}
const navigateToSettings = () => {
cy.findByRole('button', { name: /settings/i }).click()
}
const assertSettingsMenuIsOpen = () => {
cy.get('.settings-modal').should('exist')
}
const assertSettingsMenuIsClosed = () => {
cy.get('.settings-modal').should('not.exist')
}
const closeSettingsByClickingX = () => {
cy.get('.close-button').click()
}
const closeSettingsByClickingOutsideWindow = () => {
cy.get('.dimmer').click('topLeft')
}
const toggleDarkMode = () => {
clickTestID(TestID.DARK_MODE_TOGGLE)
}
const toggleMarkdownPreview = () => {
clickTestID(TestID.MARKDOWN_PREVIEW_TOGGLE)
}
const toggleLineNumbers = () => {
clickTestID(TestID.DISPLAY_LINE_NUMS_TOGGLE)
}
const toggleLineHighlight = () => {
clickTestID(TestID.ACTIVE_LINE_HIGHLIGHT_TOGGLE)
}
const selectOptionInSortByDropdown = (text: string) => {
selectOptionTestID(TestID.SORT_BY_DROPDOWN, text)
}
const assertDarkModeActive = () => {
cy.get('.settings-modal').should('have.css', 'background-color', 'rgb(51, 51, 51)')
}
const assertDarkModeInactive = () => {
cy.get('.settings-modal').should('have.css', 'background-color', 'rgb(255, 255, 255)')
}
const assertMarkdownPreviewActive = () => {
cy.get('h1').should('exist')
}
const assertMarkdownPreviewInactive = () => {
cy.get('h1').should('not.exist')
}
const assertLineNumbersActive = () => {
cy.get('.CodeMirror-activeline > .CodeMirror-gutter-wrapper > .CodeMirror-linenumber').should(
'exist'
)
}
const assertLineNumbersInactive = () => {
cy.get('.CodeMirror-activeline > .CodeMirror-gutter-wrapper > .CodeMirror-linenumber').should(
'not.exist'
)
}
const assertLineHighlightActive = () => {
cy.get('div.CodeMirror-activeline').should('exist')
}
const assertLineHighlightInactive = () => {
cy.get('div.CodeMirror-activeline').should('not.exist')
}
const getDownloadedBackup = () => {
return cy.get('a[download]:last-child').then(
(anchor) =>
new Cypress.Promise((resolve) => {
// Use XHR to get the blob that corresponds to the object URL.
const xhr = new XMLHttpRequest()
xhr.open('GET', anchor.prop('href'), true)
xhr.responseType = 'blob'
// Once loaded, use FileReader to get the string back from the blob.
xhr.onload = () => {
if (xhr.status === 200) {
const blob = xhr.response
const reader = new FileReader()
reader.onload = () => {
// Once we have a string, resolve the promise to let
// the Cypress chain continue, e.g. to assert on the result.
resolve(reader.result)
}
reader.readAsText(blob)
}
}
xhr.send()
})
)
}
export {
navigateToSettings,
assertSettingsMenuIsOpen,
assertSettingsMenuIsClosed,
closeSettingsByClickingX,
closeSettingsByClickingOutsideWindow,
toggleDarkMode,
toggleMarkdownPreview,
toggleLineNumbers,
toggleLineHighlight,
assertDarkModeActive,
assertDarkModeInactive,
assertMarkdownPreviewActive,
assertMarkdownPreviewInactive,
assertLineNumbersActive,
assertLineNumbersInactive,
selectOptionInSortByDropdown,
assertLineHighlightActive,
assertLineHighlightInactive,
clickSettingsTab,
getDownloadedBackup,
}
================================================
FILE: tests/unit/client/components/AppSidebar/ActionButton.test.tsx
================================================
import React from 'react'
import { render } from '@testing-library/react'
import '@testing-library/jest-dom'
import 'jest-extended'
import { Camera } from 'react-feather'
import { TestID } from '@resources/TestID'
import { ActionButton, ActionButtonProps } from '@/components/AppSidebar/ActionButton'
test('Sample test', () => {
expect(true).toBeTruthy()
})
describe('', () => {
it('renders the ActionButton component', () => {
const enabledProps: ActionButtonProps = {
handler: jest.fn,
label: 'Test',
dataTestID: TestID.ACTION_BUTTON,
text: 'text',
icon: Camera,
}
const component = render()
expect(component).toBeTruthy()
})
})
================================================
FILE: tests/unit/client/components/AppSidebar/AddCategoryButton.test.tsx
================================================
import React from 'react'
import { render } from '@testing-library/react'
import '@testing-library/jest-dom'
import 'jest-extended'
import { TestID } from '@resources/TestID'
import {
AddCategoryButton,
AddCategoryButtonProps,
} from '@/components/AppSidebar/AddCategoryButton'
describe('', () => {
it('renders the AddCategoryButton component', () => {
const enabledProps: AddCategoryButtonProps = {
handler: jest.fn,
label: 'Test',
dataTestID: TestID.ADD_CATEGORY_BUTTON,
}
const component = render()
expect(component).toBeTruthy()
})
})
================================================
FILE: tests/unit/client/components/AppSidebar/AddCategoryForm.test.tsx
================================================
import React from 'react'
import { render } from '@testing-library/react'
import '@testing-library/jest-dom'
import 'jest-extended'
import { TestID } from '@resources/TestID'
import { AddCategoryForm, AddCategoryFormProps } from '@/components/AppSidebar/AddCategoryForm'
describe('', () => {
it('renders the AddCategoryForm component', () => {
const enabledProps: AddCategoryFormProps = {
submitHandler: jest.fn,
changeHandler: jest.fn,
resetHandler: jest.fn,
editingCategoryId: 'Category-id',
tempCategoryName: 'Category-id',
dataTestID: TestID.NEW_CATEGORY_INPUT,
}
const component = render()
expect(component).toBeTruthy()
})
})
================================================
FILE: tests/unit/client/components/AppSidebar/CollapseCategoryButton.test.tsx
================================================
import React from 'react'
import { render } from '@testing-library/react'
import '@testing-library/jest-dom'
import 'jest-extended'
import { TestID } from '@resources/TestID'
import { CollapseCategoryListButton } from '@/components/AppSidebar/CollapseCategoryButton'
describe('', () => {
it('renders the CollapseCategoryButton component', () => {
const enabledProps: CollapseCategoryListButton = {
handler: jest.fn,
label: 'Test',
dataTestID: TestID.CATEGORY_COLLAPSE_BUTTON,
isCategoryListOpen: true,
showIcon: true,
}
const component = render()
expect(component).toBeTruthy()
})
})
================================================
FILE: tests/unit/client/components/AppSidebar/FolderOption.test.tsx
================================================
import React from 'react'
import { render } from '@testing-library/react'
import '@testing-library/jest-dom'
import 'jest-extended'
import { TestID } from '@resources/TestID'
import { FolderOption, FolderOptionProps } from '@/components/AppSidebar/FolderOption'
import { Folder } from '@/utils/enums'
describe('', () => {
it('renders the FolderOption component', () => {
const enabledProps: FolderOptionProps = {
swapFolder: jest.fn,
addNoteType: jest.fn,
text: 'Test',
dataTestID: TestID.FOLDER_NOTES,
active: true,
folder: Folder.CATEGORY,
}
const component = render()
expect(component).toBeTruthy()
})
})
================================================
FILE: tests/unit/client/components/AppSidebar/ScratchpadOption.test.tsx
================================================
import React from 'react'
import { render } from '@testing-library/react'
import '@testing-library/jest-dom'
import 'jest-extended'
import { ScratchpadOption, ScratchpadOptionProps } from '@/components/AppSidebar/ScratchpadOption'
describe('', () => {
it('renders the ScratchpadOption component', () => {
const enabledProps: ScratchpadOptionProps = {
swapFolder: jest.fn,
active: true,
}
const component = render()
expect(component).toBeTruthy()
})
})
================================================
FILE: tests/unit/client/components/LastSyncedNotification.test.tsx
================================================
import React from 'react'
import { render } from '@testing-library/react'
import dayjs from 'dayjs'
import '@testing-library/jest-dom'
import 'jest-extended'
import { TestID } from '@resources/TestID'
import {
LastSyncedNotification,
LastSyncedNotificationProps,
} from '@/components/LastSyncedNotification'
describe('', () => {
it('renders the Tab component', () => {
const enabledProps: LastSyncedNotificationProps = {
datetime: '',
pending: false,
syncing: true,
}
const component = render()
expect(component).toBeTruthy()
})
it('Should display syncing ', () => {
const enabledProps: LastSyncedNotificationProps = {
datetime: '',
pending: false,
syncing: true,
}
const { getByTestId } = render()
expect(getByTestId(TestID.LAST_SYNCED_NOTIFICATION_SYNCING).innerHTML).toBe('Syncing...')
})
it('Should display Unsaved change ', () => {
const enabledProps: LastSyncedNotificationProps = {
datetime: '',
pending: true,
syncing: false,
}
const { getByTestId } = render()
expect(getByTestId(TestID.LAST_SYNCED_NOTIFICATION_UNSAVED).innerHTML).toBe('Unsaved changes')
})
it('Should display date ', () => {
const enabledProps: LastSyncedNotificationProps = {
datetime: Date(),
pending: false,
syncing: false,
}
const { getByTestId } = render()
expect(getByTestId(TestID.LAST_SYNCED_NOTIFICATION_DATE).innerHTML).toBe(
dayjs(Date()).format('LT on L')
)
})
})
================================================
FILE: tests/unit/client/components/NoteList/NoteListButton.test.tsx
================================================
import React from 'react'
import { render } from '@testing-library/react'
import '@testing-library/jest-dom'
import 'jest-extended'
import { TestID } from '@resources/TestID'
import { NoteListButton, NoteListButtonProps } from '@/components/NoteList/NoteListButton'
describe('', () => {
it('renders the NoteListButton component', () => {
const enabledProps: NoteListButtonProps = {
handler: jest.fn,
label: 'Test',
dataTestID: TestID.EMPTY_TRASH_BUTTON,
}
const component = render()
expect(component).toBeTruthy()
})
it('renders the NoteListButton component as disabled', () => {
const disabledProps: NoteListButtonProps = {
handler: jest.fn,
label: 'Test',
disabled: true,
dataTestID: TestID.EMPTY_TRASH_BUTTON,
}
const component = render()
const button = component.queryByTestId(TestID.EMPTY_TRASH_BUTTON)
expect(button).toBeDisabled()
})
})
================================================
FILE: tests/unit/client/components/NoteList/SearchBar.test.tsx
================================================
import React, { createRef } from 'react'
import { render, fireEvent } from '@testing-library/react'
import '@testing-library/jest-dom'
import 'jest-extended'
import { TestID } from '@resources/TestID'
import { SearchBar, SearchBarProps } from '@/components/NoteList/SearchBar'
describe('', () => {
it('renders the SearchBar component', () => {
const enabledProps: SearchBarProps = {
searchRef: createRef() as React.MutableRefObject,
searchNotes: jest.fn,
}
const component = render()
expect(component).toBeTruthy()
})
it('renders the SearchBar and searches for text', () => {
const enabledProps: SearchBarProps = {
searchRef: createRef() as React.MutableRefObject,
searchNotes: jest.fn,
}
const component = render()
expect(component).toBeTruthy()
const { getByTestId } = component
fireEvent.change(getByTestId(TestID.NOTE_SEARCH), {
target: { value: 'welcome' },
})
})
})
================================================
FILE: tests/unit/client/components/SettingsModal/IconButton.test.tsx
================================================
import React from 'react'
import { render } from '@testing-library/react'
import '@testing-library/jest-dom'
import 'jest-extended'
import { Camera } from 'react-feather'
import { TestID } from '@resources/TestID'
import { IconButton, IconButtonProps } from '@/components/SettingsModal/IconButton'
describe('', () => {
it('renders the IconButton component', () => {
const enabledProps: IconButtonProps = {
handler: jest.fn,
dataTestID: TestID.ICON_BUTTON,
icon: Camera,
text: 'takeNote',
}
const component = render()
expect(component).toBeTruthy()
})
it('renders the IconButton component as disabled', () => {
const disabledProps: IconButtonProps = {
handler: jest.fn,
dataTestID: TestID.ICON_BUTTON,
disabled: true,
icon: Camera,
text: 'takeNote',
}
const component = render()
const button = component.queryByTestId(TestID.ICON_BUTTON)
expect(button).toBeDisabled()
})
})
================================================
FILE: tests/unit/client/components/SettingsModal/IconButtonUploader.test.tsx
================================================
import React from 'react'
import { render } from '@testing-library/react'
import '@testing-library/jest-dom'
import 'jest-extended'
import { Camera } from 'react-feather'
import { TestID } from '@resources/TestID'
import {
IconButtonUploader,
IconButtonUploaderProps,
} from '@/components/SettingsModal/IconButtonUploader'
describe('', () => {
it('renders the IconButtonUploader component', () => {
const enabledProps: IconButtonUploaderProps = {
handler: jest.fn,
dataTestID: TestID.ICON_BUTTON_UPLOADER,
icon: Camera,
text: 'takeNote',
accept: 'takeNote',
}
const component = render()
expect(component).toBeTruthy()
})
})
================================================
FILE: tests/unit/client/components/Switch.test.tsx
================================================
import React from 'react'
import { render } from '@testing-library/react'
import { Switch, SwitchProps } from '@/components/Switch'
describe('', () => {
it('renders the Switch component', () => {
const enabledProps: SwitchProps = {
toggle: jest.fn(),
checked: false,
testId: 'fake-test-id-for-testing',
}
const component = render()
expect(component).toBeTruthy()
})
})
================================================
FILE: tests/unit/client/components/editor/EditorEmpty.test.tsx
================================================
import React from 'react'
import { render, screen } from '@testing-library/react'
import '@testing-library/jest-dom'
import 'jest-extended'
import { TestID } from '@resources/TestID'
import { EmptyEditor } from '@/components/Editor/EmptyEditor'
describe('', () => {
it('renders the EmptyEditor component', () => {
const component = render()
expect(component).toBeTruthy()
})
it('renders the EmptyEditor component and its texts', () => {
const component = render()
const createNoteText = component.queryByTestId(TestID.EMPTY_EDITOR)
expect(createNoteText).toBeValid()
expect(component.getByText('Create a note')).toBeInstanceOf(Node)
expect(component.getByText('CTRL')).toBeInstanceOf(Node)
expect(component.getByText('ALT')).toBeInstanceOf(Node)
expect(component.getByText('N')).toBeInstanceOf(Node)
})
})
================================================
FILE: tests/unit/client/components/editor/PreviewEditor.test.tsx
================================================
import React from 'react'
import { render } from '@testing-library/react'
import '@testing-library/jest-dom'
import 'jest-extended'
import { PreviewEditor, PreviewEditorProps } from '@/components/Editor/PreviewEditor'
import NoteLink, { NoteLinkProps } from '@/components/Editor/NoteLink'
import { NoteItem } from '@/types'
import { Errors } from '@/utils/enums'
import { TestID } from '@resources/TestID'
import { TempStateProvider } from '@/contexts/TempStateContext'
import { renderWithRouter } from '../../testHelpers'
const wrap = (props: PreviewEditorProps) => renderWithRouter()
describe('', () => {
it('renders the PreviewEditor component', () => {
const props: PreviewEditorProps = {
noteText: 'texts for testing',
directionText: 'testing',
notes: [],
}
const component = wrap(props)
expect(component).toBeTruthy()
})
it('test', () => {
const noteItemProps: NoteItem = {
id: 'test-note',
text: 'Test note',
created: Date(),
lastUpdated: Date(),
}
const props: NoteLinkProps = {
uuid: '{{test-note}}',
notes: [noteItemProps],
handleNoteLinkClick: jest.fn,
}
const component = render(
)
expect(component).toBeTruthy()
const { getByTestId } = component
expect(getByTestId(TestID.NOTE_LINK_SUCCESS).innerHTML).toMatch(noteItemProps.text)
})
it('test2', () => {
const noteItemProps: NoteItem = {
id: '2',
text: 'Test note',
created: Date(),
lastUpdated: Date(),
}
const props: NoteLinkProps = {
uuid: 'test-note',
notes: [noteItemProps],
handleNoteLinkClick: jest.fn,
}
const component = render(
)
expect(component).toBeTruthy()
const { getByTestId } = component
expect(getByTestId(TestID.NOTE_LINK_ERROR).innerHTML).toMatch(
'<invalid note id provided>'
)
})
})
================================================
FILE: tests/unit/client/containers/ContextMenuOptions.test.tsx
================================================
import React from 'react'
import { TestID } from '@resources/TestID'
import { ContextMenuOptions, ContextMenuOptionsProps } from '@/containers/ContextMenuOptions'
import { ContextMenuEnum } from '@/utils/enums'
import { renderWithRouter } from '../testHelpers'
const wrap = (props: ContextMenuOptionsProps) => renderWithRouter()
describe('', () => {
it('renders the ContextMenuOptions', () => {
const props: ContextMenuOptionsProps = {
clickedItem: {
id: '1',
text: 'text',
created: '01/02/2019',
lastUpdated: '01/02/2019',
},
type: ContextMenuEnum.NOTE,
}
const component = wrap(props)
const nav = component.getByTestId('note-options-nav')
expect(nav).toBeTruthy()
})
it('displays correct default options', () => {
const props: ContextMenuOptionsProps = {
clickedItem: {
id: '1',
text: 'text',
created: '01/02/2019',
lastUpdated: '01/02/2019',
},
type: ContextMenuEnum.NOTE,
}
const component = wrap(props)
const addToFavorites = component.queryByTestId(TestID.NOTE_OPTION_FAVORITE)
const removeCategory = component.queryByTestId(TestID.NOTE_OPTION_REMOVE_CATEGORY)
const download = component.queryByTestId(TestID.NOTE_OPTION_DOWNLOAD)
const deletePermanently = component.queryByTestId(TestID.NOTE_OPTION_DELETE_PERMANENTLY)
const restoreFromTrash = component.queryByTestId(TestID.NOTE_OPTION_RESTORE_FROM_TRASH)
expect(addToFavorites).toBeTruthy()
expect(download).toBeTruthy()
expect(removeCategory).toBeFalsy()
expect(deletePermanently).toBeFalsy()
expect(restoreFromTrash).toBeFalsy()
})
it('displays correct trash options', () => {
const props: ContextMenuOptionsProps = {
clickedItem: {
id: '1',
text: 'text',
created: '01/02/2019',
lastUpdated: '01/02/2019',
trash: true,
},
type: ContextMenuEnum.NOTE,
}
const component = wrap(props)
const addToFavorites = component.queryByTestId(TestID.NOTE_OPTION_FAVORITE)
const removeCategory = component.queryByTestId(TestID.NOTE_OPTION_REMOVE_CATEGORY)
const download = component.queryByTestId(TestID.NOTE_OPTION_DOWNLOAD)
const deletePermanently = component.queryByTestId(TestID.NOTE_OPTION_DELETE_PERMANENTLY)
const restoreFromTrash = component.queryByTestId(TestID.NOTE_OPTION_RESTORE_FROM_TRASH)
expect(addToFavorites).toBeFalsy()
expect(deletePermanently).toBeTruthy()
expect(restoreFromTrash).toBeTruthy()
expect(removeCategory).toBeFalsy()
expect(download).toBeTruthy()
})
it('displays correct category options', () => {
const props: ContextMenuOptionsProps = {
clickedItem: {
id: '1',
text: 'text',
created: '01/02/2019',
lastUpdated: '01/02/2019',
category: '2',
},
type: ContextMenuEnum.NOTE,
}
const component = wrap(props)
const addToFavorites = component.queryByTestId(TestID.NOTE_OPTION_FAVORITE)
const removeCategory = component.queryByTestId(TestID.NOTE_OPTION_REMOVE_CATEGORY)
const download = component.queryByTestId(TestID.NOTE_OPTION_DOWNLOAD)
const deletePermanently = component.queryByTestId(TestID.NOTE_OPTION_DELETE_PERMANENTLY)
const restoreFromTrash = component.queryByTestId(TestID.NOTE_OPTION_RESTORE_FROM_TRASH)
expect(addToFavorites).toBeTruthy()
expect(deletePermanently).toBeFalsy()
expect(restoreFromTrash).toBeFalsy()
expect(removeCategory).toBeTruthy()
expect(download).toBeTruthy()
})
})
================================================
FILE: tests/unit/client/containers/TakeNoteApp.test.tsx
================================================
import React from 'react'
import { mocked } from 'ts-jest/utils'
import { waitFor } from '@testing-library/react'
import { name, internet, lorem } from 'faker'
import { getAuth, getCategories, getSettings, getNotes, getSync } from '@/selectors'
import { Folder, NotesSortKey } from '@/utils/enums'
import { TakeNoteApp } from '@/containers/TakeNoteApp'
import { renderWithRouter } from '../testHelpers'
jest.mock('@/selectors')
const mockedGetNotes = mocked(getNotes, true)
const mockedGetSettings = mocked(getSettings, true)
const mockedGetCategories = mocked(getCategories, true)
const mockedGetSync = mocked(getSync, true)
const mockedGetAuth = mocked(getAuth, true)
const wrap = () => renderWithRouter()
describe('', () => {
test('should see empty editor if there are no active notes', async () => {
mockedGetNotes.mockImplementation(() => {
return {
activeCategoryId: '',
activeFolder: Folder.ALL,
activeNoteId: '',
selectedNotesIds: [],
error: '',
loading: false,
notes: [],
searchValue: '',
}
})
mockedGetSettings.mockImplementation(() => {
return {
isOpen: false,
loading: false,
previewMarkdown: false,
darkTheme: false,
sidebarVisible: true,
notesSortKey: NotesSortKey.LAST_UPDATED,
codeMirrorOptions: {
mode: 'gfm',
theme: 'base16-light',
lineNumbers: false,
lineWrapping: true,
styleActiveLine: { nonEmpty: true },
viewportMargin: Infinity,
keyMap: 'default',
dragDrop: false,
scrollPastEnd: true,
},
}
})
mockedGetCategories.mockImplementation(() => {
return {
categories: [],
error: '',
loading: false,
editingCategory: {
id: '',
tempName: '',
},
}
})
mockedGetSync.mockImplementation(() => {
return {
error: '',
syncing: false,
lastSynced: '',
pendingSync: false,
}
})
mockedGetAuth.mockImplementation(() => {
return {
loading: false,
currentUser: {
bio: lorem.words(),
name: name.findName(),
avatar_url: internet.url(),
},
isAuthenticated: true,
error: '',
}
})
const component = wrap()
await waitFor(() => component.getByTestId('empty-editor'))
})
})
================================================
FILE: tests/unit/client/slices/auth.test.ts
================================================
import { PayloadAction } from '@reduxjs/toolkit'
import reducer, {
initialState,
login,
loginError,
loginSuccess,
logout,
logoutSuccess,
} from '@/slices/auth'
describe('authSlice', () => {
it('should return the initial state on first run', () => {
const nextState = initialState
const action = {} as PayloadAction
const result = reducer(undefined, action)
expect(result).toEqual(nextState)
})
it('should set the loading true on login', () => {
const nextState = { ...initialState, loading: true }
const result = reducer(initialState, login())
expect(result).toEqual(nextState)
})
it('should set the currentUser to payload, isAuthenticated to true and loading to false on loginSuccess', () => {
const payload = { currentUserName: 'test' }
const nextState = {
...initialState,
loading: false,
isAuthenticated: true,
currentUser: payload,
}
const result = reducer(initialState, loginSuccess(payload))
expect(result).toEqual(nextState)
})
it('should set the error to payload, isAuthenticated to false and loading to false on loginError', () => {
const payload = 'error text'
const nextState = {
...initialState,
loading: false,
isAuthenticated: false,
error: payload,
}
const result = reducer(initialState, loginError(payload))
expect(result).toEqual(nextState)
})
it('should set the loading true on logout', () => {
const nextState = { ...initialState, loading: true }
const result = reducer(initialState, logout())
expect(result).toEqual(nextState)
})
it('should set isAuthenticated to false, currentUser to empty object and loading to false on logoutSuccess', () => {
const initialStateBeforeLogout = {
isAuthenticated: true,
loading: false,
currentUser: { name: 'test' },
error: '',
}
const nextState = {
...initialState,
loading: false,
}
const result = reducer(initialStateBeforeLogout, logoutSuccess())
expect(result).toEqual(nextState)
})
})
================================================
FILE: tests/unit/client/slices/category.test.ts
================================================
import { PayloadAction } from '@reduxjs/toolkit'
import reducer, {
initialState,
addCategory,
updateCategory,
deleteCategory,
categoryDragEnter,
categoryDragLeave,
setCategoryEdit,
loadCategories,
loadCategoriesError,
loadCategoriesSuccess,
swapCategories,
} from '@/slices/category'
describe('categorySlice', () => {
test('should return initial state on first run', () => {
const nextState = initialState
const action = {} as PayloadAction
const result = reducer(undefined, action)
expect(result).toEqual(nextState)
})
test('should add passed payload in the existing category list on addCategory', () => {
const payload = { id: '123', name: 'note 0', draggedOver: false }
const nextState = { ...initialState, categories: [payload] }
const result = reducer(initialState, addCategory(payload))
expect(result).toEqual(nextState)
})
test('should update the category name on updateCategory', () => {
const payload = { id: '123', name: 'note 0 renamed', draggedOver: false }
const nextState = { ...initialState, categories: [payload] }
const initialStateBeforeUpdateCategory = {
...initialState,
categories: [
{
id: '123',
name: 'note 0',
draggedOver: false,
},
],
}
const result = reducer(initialStateBeforeUpdateCategory, updateCategory(payload))
expect(result).toEqual(nextState)
})
test('should delete the category name on deleteCategory', () => {
const initialStateBeforeDeleteCategory = {
...initialState,
categories: [
{
id: '123',
name: 'note 0',
draggedOver: false,
},
{
id: '456',
name: 'note 1',
draggedOver: false,
},
],
}
const nextState = {
...initialState,
categories: [
{
id: '123',
name: 'note 0',
draggedOver: false,
},
],
}
const result = reducer(initialStateBeforeDeleteCategory, deleteCategory('456'))
expect(result).toEqual(nextState)
})
test('should set draggedOver to true on categoryDragEnter', () => {
const payload = { id: '123', name: 'note 0 renamed', draggedOver: false }
const initialStateBeforeCategoryDragEnter = {
...initialState,
categories: [
{
id: '123',
name: 'note 0',
draggedOver: false,
},
],
}
const nextState = {
...initialState,
categories: [
{
id: '123',
name: 'note 0',
draggedOver: true,
},
],
}
const result = reducer(initialStateBeforeCategoryDragEnter, categoryDragEnter(payload))
expect(result).toEqual(nextState)
})
test('should set draggedOver to false on categoryDragLeave', () => {
const payload = { id: '123', name: 'note 0 renamed', draggedOver: false }
const initialStateBeforeCategoryDragLeave = {
...initialState,
categories: [
{
id: '123',
name: 'note 0',
draggedOver: true,
},
],
}
const nextState = {
...initialState,
categories: [
{
id: '123',
name: 'note 0',
draggedOver: false,
},
],
}
const result = reducer(initialStateBeforeCategoryDragLeave, categoryDragLeave(payload))
expect(result).toEqual(nextState)
})
test('should swap categories', () => {
const payload = {
categoryId: 0,
destinationId: 2,
}
const initialStateBeforeSwapCategories = {
...initialState,
categories: [
{
id: '1',
name: 'note 0',
draggedOver: false,
},
{
id: '2',
name: 'note 1',
draggedOver: false,
},
{
id: '3',
name: 'note 2',
draggedOver: false,
},
],
}
const nextState = {
...initialState,
categories: [
{
id: '2',
name: 'note 1',
draggedOver: false,
},
{
id: '3',
name: 'note 2',
draggedOver: false,
},
{
id: '1',
name: 'note 0',
draggedOver: false,
},
],
}
const result = reducer(initialStateBeforeSwapCategories, swapCategories(payload))
expect(result).toEqual(nextState)
})
test('should set editing category to payload on setCategoryEdit', () => {
const payload = {
id: '123',
tempName: 'tempName',
}
const nextState = {
...initialState,
editingCategory: payload,
}
const result = reducer(initialState, setCategoryEdit(payload))
expect(result).toEqual(nextState)
})
test('should set loading true on loadCategories', () => {
const nextState = {
...initialState,
loading: true,
}
const result = reducer(initialState, loadCategories())
expect(result).toEqual(nextState)
})
test('should set loading false and error to payload on loadCategoriesError', () => {
const payload = 'test error'
const nextState = {
...initialState,
loading: false,
error: payload,
}
const result = reducer(initialState, loadCategoriesError(payload))
expect(result).toEqual(nextState)
})
test('should set loading false and categories to payload on loadCategoriesSuccess', () => {
const payload = [{ id: '123', name: 'note 0', draggedOver: false }]
const nextState = {
...initialState,
loading: false,
categories: payload,
}
const result = reducer(initialState, loadCategoriesSuccess(payload))
expect(result).toEqual(nextState)
})
})
================================================
FILE: tests/unit/client/slices/note.test.ts
================================================
import { PayloadAction } from '@reduxjs/toolkit'
import dayjs from 'dayjs'
import reducer, {
addNote,
initialState,
updateNote,
deleteNotes,
addCategoryToNote,
removeCategoryFromNotes,
updateActiveNote,
updateActiveCategoryId,
swapFolder,
assignFavoriteToNotes,
toggleFavoriteNotes,
assignTrashToNotes,
toggleTrashNotes,
unassignTrashFromNotes,
updateSelectedNotes,
permanentlyEmptyTrash,
pruneNotes,
searchNotes,
loadNotes,
loadNotesError,
loadNotesSuccess,
} from '@/slices/note'
import { Folder } from '@/utils/enums'
function createNote({
id,
category,
text,
favorite,
scratchpad,
trash,
}: {
id: string
category?: string
text?: string
favorite?: boolean
scratchpad?: boolean
trash?: boolean
}) {
return {
id,
text: text ?? `sample note - ${id}`,
created: dayjs().format(),
lastUpdated: dayjs().format(),
category,
favorite,
scratchpad,
trash,
}
}
describe('noteSlice', () => {
test('should return initial state on first run', () => {
const nextState = initialState
const action = {} as PayloadAction
const result = reducer(undefined, action)
expect(result).toEqual(nextState)
})
describe('addNote', () => {
test('should add note if there are no notes in draft state on addNote', () => {
const payload = createNote({ id: '1', category: '1' })
const nextState = { ...initialState, notes: [payload] }
const result = reducer(initialState, addNote(payload))
expect(result).toEqual(nextState)
})
test('should not add note if there is any note in draft state on addNote', () => {
const payload = createNote({ id: '1', category: '1' })
const initialStateBeforeAddNote = {
...initialState,
notes: [
createNote({ id: '1', category: '1' }),
createNote({ id: '1', category: '1', text: '' }),
],
}
const nextState = { ...initialStateBeforeAddNote }
const result = reducer(initialStateBeforeAddNote, addNote(payload))
expect(result).toEqual(nextState)
})
})
test('should update note content and lastUpdated on updateNote', () => {
const payload = createNote({ id: '1', category: '1' })
const initialStateBeforeUpdateNote = {
...initialState,
notes: [createNote({ id: '1', category: '1' })],
}
const nextState = { ...initialStateBeforeUpdateNote, notes: [payload] }
const result = reducer(initialStateBeforeUpdateNote, updateNote(payload))
expect(result).toEqual(nextState)
})
describe('deleteNotes', () => {
test('should deleteNotes from notes list and set activeNoteId and selectedNotesIds on deleteNotes', () => {
const payload = ['1', '4']
const notes = [
createNote({ id: '1', category: '1' }),
createNote({ id: '2', category: '1' }),
createNote({ id: '3' }),
createNote({ id: '4', category: '1' }),
]
const initialStateBeforeDeleteNotes = {
...initialState,
notes: notes,
activeCategoryId: '1',
}
const nextState = {
...initialStateBeforeDeleteNotes,
notes: [notes[1], notes[2]],
activeNoteId: '',
selectedNotesIds: [''],
}
const result = reducer(initialStateBeforeDeleteNotes, deleteNotes(payload))
expect(result).toEqual(nextState)
})
})
describe('addCategory', () => {
test('should add Category to the existing single note', () => {
const payload = {
categoryId: '3',
noteId: '2',
}
const note = createNote({ id: '2' })
const initialStateBeforeAddingCategoryToNote = {
...initialState,
notes: [note],
}
const nextState = {
...initialStateBeforeAddingCategoryToNote,
notes: [
{
...note,
category: '3',
},
],
}
const result = reducer(initialStateBeforeAddingCategoryToNote, addCategoryToNote(payload))
expect(result).toEqual(nextState)
})
test('should add Category to the requested note and selected notes', () => {
const payload = {
categoryId: '3',
noteId: '2',
}
const notes = [createNote({ id: '1' }), createNote({ id: '2' }), createNote({ id: '3' })]
const initialStateBeforeAddingCategoryToNote = {
...initialState,
notes,
selectedNotesIds: ['1', '2'],
}
const nextState = {
...initialStateBeforeAddingCategoryToNote,
notes: [
{
...notes[0],
category: '3',
},
{
...notes[1],
category: '3',
},
notes[2],
],
}
const result = reducer(initialStateBeforeAddingCategoryToNote, addCategoryToNote(payload))
expect(result).toEqual(nextState)
})
})
describe('removeCategory', () => {
test('should remove Category from the notes', () => {
const payload = '1'
const notes = [
createNote({ id: '1', category: '1' }),
createNote({ id: '2', category: '1' }),
createNote({ id: '3', category: '2' }),
createNote({ id: '4', category: '3' }),
]
const initialStateBeforeRemovingCategoryFromNotes = {
...initialState,
notes: notes,
}
const nextState = {
...initialStateBeforeRemovingCategoryFromNotes,
notes: [
{
...notes[0],
category: '',
},
{
...notes[1],
category: '',
},
notes[2],
notes[3],
],
}
const result = reducer(
initialStateBeforeRemovingCategoryFromNotes,
removeCategoryFromNotes(payload)
)
expect(result).toEqual(nextState)
})
})
describe('updateActiveNote', () => {
const notes = [createNote({ id: '1', category: '3' }), createNote({ id: '2' })]
test('should update active note id on updateActiveNote when multiSelect is false', () => {
const payload = {
multiSelect: false,
noteId: '2',
}
const initialStateBeforeUpdatingActiveNote = {
...initialState,
notes,
}
const nextState = { ...initialStateBeforeUpdatingActiveNote, activeNoteId: '2' }
const result = reducer(initialStateBeforeUpdatingActiveNote, updateActiveNote(payload))
expect(result).toEqual(nextState)
})
test('should update active note id on updateActiveNote when multiSelect is true', () => {
const payload = {
multiSelect: true,
noteId: '2',
}
const initialStateBeforeUpdatingActiveNote = {
...initialState,
notes,
selectedNotesIds: ['1', '2'],
}
const nextState = { ...initialStateBeforeUpdatingActiveNote, activeNoteId: '2' }
const result = reducer(initialStateBeforeUpdatingActiveNote, updateActiveNote(payload))
expect(result).toEqual(nextState)
})
})
describe('updateActiveCategory', () => {
test('should update active category id , active note id, selected note ids and filter the draft notes on updateActiveCategoryId', () => {
const payload = '3'
const initialStateBeforeUpdatingActiveCategoryId = {
...initialState,
notes: [
createNote({ id: '1', category: '3' }),
createNote({ id: '4', category: '3', text: '' }),
createNote({ id: '7', category: '3' }),
],
}
const nextState = {
...initialStateBeforeUpdatingActiveCategoryId,
activeFolder: Folder.CATEGORY,
activeCategoryId: '3',
activeNoteId: '1',
selectedNotesIds: ['1'],
notes: [createNote({ id: '1', category: '3' }), createNote({ id: '7', category: '3' })],
}
const result = reducer(
initialStateBeforeUpdatingActiveCategoryId,
updateActiveCategoryId(payload)
)
expect(result).toEqual(nextState)
})
})
describe('swapFolder', () => {
test('should swap folders and set FAVORITES folder as active folder', () => {
const payload = Folder.FAVORITES
const notes = [
createNote({ id: '1', category: '3' }),
createNote({ id: '2', favorite: true }),
createNote({ id: '4', category: '3', text: '' }),
createNote({ id: '7', category: '3', favorite: true }),
]
const initialStateBeforeUpdatingActiveFolder = {
...initialState,
notes: notes,
}
const nextState = {
...initialStateBeforeUpdatingActiveFolder,
activeFolder: Folder.FAVORITES,
activeCategoryId: '',
activeNoteId: '2',
selectedNotesIds: ['2'],
notes: [notes[0], notes[1], notes[3]],
}
const result = reducer(
initialStateBeforeUpdatingActiveFolder,
swapFolder({ folder: payload })
)
expect(result).toEqual(nextState)
})
test('should swap folders and set SCRATCHPAD folder as active folder', () => {
const payload = Folder.SCRATCHPAD
const notes = [
createNote({ id: '1', category: '3' }),
createNote({ id: '2', scratchpad: true }),
createNote({ id: '4', category: '3', text: '' }),
createNote({ id: '7', category: '3', scratchpad: true }),
]
const initialStateBeforeUpdatingActiveFolder = {
...initialState,
notes: notes,
}
const nextState = {
...initialStateBeforeUpdatingActiveFolder,
activeFolder: Folder.SCRATCHPAD,
activeCategoryId: '',
activeNoteId: '2',
selectedNotesIds: ['2'],
notes: [notes[0], notes[1], notes[3]],
}
const result = reducer(
initialStateBeforeUpdatingActiveFolder,
swapFolder({ folder: payload })
)
expect(result).toEqual(nextState)
})
test('should swap folders and set TRASH folder as active folder', () => {
const payload = Folder.TRASH
const notes = [
createNote({ id: '1', category: '3' }),
createNote({ id: '2', trash: true }),
createNote({ id: '4', category: '3', text: '' }),
createNote({ id: '7', category: '3', trash: true }),
]
const initialStateBeforeUpdatingActiveFolder = {
...initialState,
notes: notes,
}
const nextState = {
...initialStateBeforeUpdatingActiveFolder,
activeFolder: Folder.TRASH,
activeCategoryId: '',
activeNoteId: '2',
selectedNotesIds: ['2'],
notes: [notes[0], notes[1], notes[3]],
}
const result = reducer(
initialStateBeforeUpdatingActiveFolder,
swapFolder({ folder: payload })
)
expect(result).toEqual(nextState)
})
})
describe('assignFavorite', () => {
test('should assign Favorite To Notes', () => {
const payload = '2'
const notes = [
createNote({ id: '1', category: '3', favorite: true }),
createNote({ id: '2', category: '3' }),
]
const initialStateBeforeAssigningFavoriteToNotes = {
...initialState,
notes: notes,
}
const nextState = {
...initialStateBeforeAssigningFavoriteToNotes,
notes: [notes[0], { ...notes[1], favorite: true }],
}
const result = reducer(
initialStateBeforeAssigningFavoriteToNotes,
assignFavoriteToNotes(payload)
)
expect(result).toEqual(nextState)
})
test('should assign Favorite To Notes which are in selectedNotesIds', () => {
const payload = '2'
const notes = [createNote({ id: '1', category: '3' }), createNote({ id: '2', category: '3' })]
const initialStateBeforeAssigningFavoriteToNotes = {
...initialState,
notes,
selectedNotesIds: ['2', '1'],
}
const nextState = {
...initialStateBeforeAssigningFavoriteToNotes,
notes: [
{ ...notes[0], favorite: true },
{
...notes[1],
favorite: true,
},
],
selectedNotesIds: ['2', '1'],
}
const result = reducer(
initialStateBeforeAssigningFavoriteToNotes,
assignFavoriteToNotes(payload)
)
expect(result).toEqual(nextState)
})
})
describe('toggleFavoriteNotes', () => {
test('should toggle Favorite notes for selected ids', () => {
const payload = '1'
const notes = [
createNote({ id: '1', category: '3', favorite: true }),
createNote({ id: '2' }),
createNote({ id: '3' }),
]
const initialStateBeforeTogglingFavoriteToNotes = {
...initialState,
notes: notes,
selectedNotesIds: ['2', '1'],
}
const nextState = {
...initialStateBeforeTogglingFavoriteToNotes,
notes: [
{
...notes[0],
favorite: false,
},
{
...notes[1],
favorite: true,
},
notes[2],
],
selectedNotesIds: ['2', '1'],
}
const result = reducer(
initialStateBeforeTogglingFavoriteToNotes,
toggleFavoriteNotes(payload)
)
expect(result).toEqual(nextState)
})
test('should toggle Favorite notes only for passed id when there are no selectedNotesIds', () => {
const payload = '1'
const notes = [
createNote({ id: '1', category: '3', favorite: true }),
createNote({ id: '2' }),
createNote({ id: '3' }),
]
const initialStateBeforeTogglingFavoriteToNotes = {
...initialState,
notes: notes,
selectedNotesIds: [],
}
const nextState = {
...initialStateBeforeTogglingFavoriteToNotes,
notes: [
{
...notes[0],
favorite: false,
},
notes[1],
notes[2],
],
selectedNotesIds: [],
}
const result = reducer(
initialStateBeforeTogglingFavoriteToNotes,
toggleFavoriteNotes(payload)
)
expect(result).toEqual(nextState)
})
})
describe('assignTrash', () => {
const notes = [
createNote({ id: '1', category: '3', favorite: true }),
createNote({ id: '2' }),
createNote({ id: '3' }),
]
test('should assign trash to all selected ids', () => {
const payload = '1'
const initialStateBeforeAssigningTrashToNotes = {
...initialState,
notes,
selectedNotesIds: ['2', '1'],
}
const nextState = {
...initialStateBeforeAssigningTrashToNotes,
notes: [
{
...notes[0],
trash: true,
},
{
...notes[1],
trash: true,
},
{
...notes[2],
},
],
selectedNotesIds: [''],
activeNoteId: '',
}
const result = reducer(initialStateBeforeAssigningTrashToNotes, assignTrashToNotes(payload))
expect(result).toEqual(nextState)
})
test('should assign trash to given payload id', () => {
const payload = '3'
const initialStateBeforeAssigningTrashToNotes = {
...initialState,
notes,
selectedNotesIds: ['2', '1'],
}
const nextState = {
...initialStateBeforeAssigningTrashToNotes,
notes: [
notes[0],
notes[1],
{
...notes[2],
trash: true,
},
],
selectedNotesIds: [''],
activeNoteId: '',
}
const result = reducer(initialStateBeforeAssigningTrashToNotes, assignTrashToNotes(payload))
expect(result).toEqual(nextState)
})
})
describe('toggleTrash', () => {
test('should toggle all selected trash notes', () => {
const payload = '2'
const notes = [
createNote({ id: '1', category: '3', favorite: true }),
createNote({ id: '2', trash: true }),
createNote({ id: '3', trash: false }),
]
const initialStateBeforeTogglingTrashToNotes = {
...initialState,
notes,
selectedNotesIds: ['2', '3'],
}
const nextState = {
...initialStateBeforeTogglingTrashToNotes,
notes: [notes[0], { ...notes[1], trash: false }, { ...notes[2], trash: true }],
selectedNotesIds: ['1'],
activeNoteId: '1',
}
const result = reducer(initialStateBeforeTogglingTrashToNotes, toggleTrashNotes(payload))
expect(result).toEqual(nextState)
})
test('should toggle only payload id trash notes', () => {})
const payload = '2'
const notes = [
createNote({ id: '1', category: '3', favorite: true }),
createNote({ id: '2', trash: false }),
createNote({ id: '3' }),
]
const initialStateBeforeTogglingTrashToNotes = {
...initialState,
notes,
selectedNotesIds: ['3'],
}
const nextState = {
...initialStateBeforeTogglingTrashToNotes,
notes: [
notes[0],
{
...notes[1],
trash: true,
},
notes[2],
],
selectedNotesIds: [''],
activeNoteId: '',
}
const result = reducer(initialStateBeforeTogglingTrashToNotes, toggleTrashNotes(payload))
expect(result).toEqual(nextState)
})
describe('unassignTrash', () => {
test('should unassign all selected notes from trash', () => {
const payload = '2'
const notes = [
createNote({ id: '1', category: '3', favorite: true }),
createNote({ id: '2', trash: true }),
createNote({ id: '3', trash: false }),
]
const initialStateBeforeUnassigningTrashFromNotes = {
...initialState,
notes,
selectedNotesIds: ['2', '3'],
}
const nextState = {
...initialStateBeforeUnassigningTrashFromNotes,
notes: [
notes[0],
{
...notes[1],
trash: false,
},
notes[2],
],
selectedNotesIds: ['2', '3'],
activeNoteId: '',
}
const result = reducer(
initialStateBeforeUnassigningTrashFromNotes,
unassignTrashFromNotes(payload)
)
expect(result).toEqual(nextState)
})
test('should unassign only payload id trash note', () => {
const payload = '2'
const notes = [
createNote({ id: '1', category: '3', favorite: true }),
createNote({ id: '2', trash: true }),
createNote({ id: '3', trash: false }),
]
const initialStateBeforeUnassigningTrashFromNotes = {
...initialState,
notes,
selectedNotesIds: ['3'],
}
const nextState = {
...initialStateBeforeUnassigningTrashFromNotes,
notes: [
notes[0],
{
...notes[1],
trash: false,
},
notes[2],
],
selectedNotesIds: ['3'],
activeNoteId: '',
}
const result = reducer(
initialStateBeforeUnassigningTrashFromNotes,
unassignTrashFromNotes(payload)
)
expect(result).toEqual(nextState)
})
})
describe('updateSelectedNotes', () => {
test('should update selected notes ids when multiSelect is false', () => {
const payload = {
noteId: '2',
multiSelect: false,
}
const initialStateBeforeUpdatingSelectedNotes = {
...initialState,
selectedNotesIds: [],
notes: [createNote({ id: '2' })],
}
const nextState = {
...initialStateBeforeUpdatingSelectedNotes,
selectedNotesIds: ['2'],
}
const result = reducer(initialStateBeforeUpdatingSelectedNotes, updateSelectedNotes(payload))
expect(result).toEqual(nextState)
})
test('should update selected notes ids when multiSelect is true', () => {
const payload = {
noteId: '3',
multiSelect: true,
}
const initialStateBeforeUpdatingSelectedNotes = {
...initialState,
selectedNotesIds: ['2'],
notes: [createNote({ id: '2' }), createNote({ id: '3' })],
}
const nextState = {
...initialStateBeforeUpdatingSelectedNotes,
selectedNotesIds: ['2', '3'],
}
const result = reducer(initialStateBeforeUpdatingSelectedNotes, updateSelectedNotes(payload))
expect(result).toEqual(nextState)
})
test('should update selected notes ids when multiSelect is true and selectedNotesIds are more than 1', () => {
const payload = {
noteId: '4',
multiSelect: true,
}
const initialStateBeforeUpdatingSelectedNotes = {
...initialState,
selectedNotesIds: ['2', '3'],
notes: [createNote({ id: '2' }), createNote({ id: '3' }), createNote({ id: '4' })],
}
const nextState = {
...initialStateBeforeUpdatingSelectedNotes,
selectedNotesIds: ['2', '3', '4'],
}
const result = reducer(initialStateBeforeUpdatingSelectedNotes, updateSelectedNotes(payload))
expect(result).toEqual(nextState)
})
})
test('should permanently empty trash', () => {
const initialStateBeforeEmptyingTRash = {
...initialState,
notes: [createNote({ id: '2', trash: true }), createNote({ id: '3' })],
}
const nextState = {
...initialStateBeforeEmptyingTRash,
notes: [createNote({ id: '3' })],
}
const result = reducer(initialStateBeforeEmptyingTRash, permanentlyEmptyTrash())
expect(result).toEqual(nextState)
})
test('should prune notes', () => {
const initialStateBeforePrune = {
...initialState,
notes: [
createNote({ id: '1', trash: true, text: '' }),
createNote({ id: '2', trash: true, text: '' }),
createNote({ id: '3' }),
],
selectedNotesIds: ['2'],
}
const nextState = {
...initialStateBeforePrune,
notes: [createNote({ id: '2', trash: true, text: '' }), createNote({ id: '3' })],
}
const result = reducer(initialStateBeforePrune, pruneNotes())
expect(result).toEqual(nextState)
})
test('should set searchValue to payload on searchNotes', () => {
const payload = 'searchText'
const nextState = {
...initialState,
searchValue: payload,
}
const result = reducer(initialState, searchNotes(payload))
expect(result).toEqual(nextState)
})
test('should set loading true on loadNotes', () => {
const nextState = {
...initialState,
loading: true,
}
const result = reducer(initialState, loadNotes())
expect(result).toEqual(nextState)
})
test('should set loading false and error to payload on loadNotesError', () => {
const payload = 'error'
const nextState = {
...initialState,
loading: false,
error: payload,
}
const result = reducer(initialState, loadNotesError(payload))
expect(result).toEqual(nextState)
})
test('should set value for notes, activeNoteId and selectedNotesIds on loadNotesSuccess', () => {
const payload = [createNote({ id: '2', text: '' })]
const nextState = {
...initialState,
loading: false,
notes: payload,
activeNoteId: '2',
selectedNotesIds: ['2'],
}
const result = reducer(initialState, loadNotesSuccess({ notes: payload }))
expect(result).toEqual(nextState)
})
})
================================================
FILE: tests/unit/client/slices/settings.test.ts
================================================
import { PayloadAction } from '@reduxjs/toolkit'
import reducer, {
initialState,
toggleSettingsModal,
togglePreviewMarkdown,
updateCodeMirrorOption,
toggleDarkTheme,
} from '@/slices/settings'
describe('settings slice', () => {
it('should return the initial state on first run', () => {
const nextState = initialState
const action = {} as PayloadAction
const result = reducer(undefined, action)
expect(result).toEqual(nextState)
})
it('should toggle open state', () => {
const nextState = { ...initialState, isOpen: true }
const result = reducer(initialState, toggleSettingsModal())
expect(result).toEqual(nextState)
})
it('should update code mirror option', () => {
const payload = { key: 'key123', value: 'mirror' }
const state = {
...initialState,
codeMirrorOptions: {
...initialState.codeMirrorOptions,
[payload.key]: payload.value,
},
}
const result = reducer(initialState, updateCodeMirrorOption(payload))
expect(result).toEqual(state)
})
it('should toggle preview markdown state', () => {
const nextState = { ...initialState, previewMarkdown: !initialState.previewMarkdown }
const result = reducer(initialState, togglePreviewMarkdown())
expect(result).toEqual(nextState)
})
it('should toggle dark theme state', () => {
const nextState = { ...initialState, darkTheme: !initialState.darkTheme }
const result = reducer(initialState, toggleDarkTheme())
expect(result).toEqual(nextState)
})
})
================================================
FILE: tests/unit/client/slices/sync.test.ts
================================================
import { PayloadAction } from '@reduxjs/toolkit'
import reducer, { initialState, setPendingSync, sync, syncError, syncSuccess } from '@/slices/sync'
describe('SycSlice', () => {
test('should return initial state on first run', () => {
const nextState = initialState
const action = {} as PayloadAction
const result = reducer(undefined, action)
expect(result).toEqual(nextState)
})
test('should set pendingSync to true on setPendingSync', () => {
const nextState = { ...initialState, pendingSync: true }
const result = reducer(undefined, setPendingSync())
expect(result).toEqual(nextState)
})
test('should set syncing to true on sync', () => {
const payload = {
categories: [],
notes: [],
}
const nextState = { ...initialState, syncing: true }
const result = reducer(initialState, sync(payload))
expect(result).toEqual(nextState)
})
test('should set syncing to false and error to payload on syncError', () => {
const payload = 'test error'
const nextState = { ...initialState, syncing: false, error: payload }
const result = reducer(initialState, syncError(payload))
expect(result).toEqual(nextState)
})
test('should set syncing to false, pendingSync to false and lastSynced to payload on syncSuccess', () => {
const payload = 'lastUpdated'
const nextState = {
...initialState,
syncing: false,
lastSynced: payload,
pendingSync: false,
error: '',
}
const result = reducer(initialState, syncSuccess(payload))
expect(result).toEqual(nextState)
})
})
================================================
FILE: tests/unit/client/testHelpers.tsx
================================================
import { render } from '@testing-library/react'
import { createMemoryHistory, MemoryHistory } from 'history'
import React, { ReactNode } from 'react'
import { Provider } from 'react-redux'
import { MemoryRouter } from 'react-router-dom'
import createSagaMiddleware from 'redux-saga'
import { configureStore, getDefaultMiddleware } from '@reduxjs/toolkit'
import rootSaga from '@/sagas'
import rootReducer from '@/slices'
interface RenderWithRouterOptions {
route: string
history: MemoryHistory
}
export const renderWithRouter = (
ui: ReactNode,
{
route = '/',
history = createMemoryHistory({ initialEntries: [route] }),
}: RenderWithRouterOptions = {} as RenderWithRouterOptions
) => {
const sagaMiddleware = createSagaMiddleware()
const store = configureStore({
reducer: rootReducer,
middleware: [sagaMiddleware, ...getDefaultMiddleware({ thunk: false })],
})
sagaMiddleware.run(rootSaga)
return {
...render(
{ui}
),
history,
}
}
================================================
FILE: tests/unit/client/utils/index.test.ts
================================================
import dayjs from 'dayjs'
import { getNoteTitle, getWebsiteTitle, getActiveNoteFromShortUuid } from '@/utils/helpers'
import { Folder } from '@/utils/enums'
import { NoteItem, CategoryItem } from '@/types'
describe('Utilities', () => {
describe('getNoteTitle', () => {
test(`should return 45 characters`, () => {
const note = `This is your world. This is gonna be a happy little seascape. I'm gonna start with a little Alizarin crimson and a touch of Prussian blue`
expect(getNoteTitle(note)).toEqual(note.slice(0, 45).trim())
})
test(`should trim both ends`, () => {
const note = ` This is your world. This is gonna be a happy `
expect(getNoteTitle(note)).toEqual(`This is your world. This is gonna be a happy`)
})
test(`should only return the first line`, () => {
const note = `Something
and something else`
expect(getNoteTitle(note)).toEqual(`Something`)
})
test(`should not display a hash`, () => {
const note = `# Something
and something else`
expect(getNoteTitle(note)).toEqual(`Something`)
})
test(`should ignore newlines in the beginning`, () => {
const note = `
Something
and something else`
expect(getNoteTitle(note)).toEqual(`Something`)
})
})
describe('getWebsiteTitle', () => {
test(`should display the folder name followed by the app name`, () => {
expect(getWebsiteTitle(Folder.ALL)).toEqual(`All Notes | TakeNote`)
expect(getWebsiteTitle(Folder.FAVORITES)).toEqual(`Favorites | TakeNote`)
expect(getWebsiteTitle(Folder.TRASH)).toEqual(`Trash | TakeNote`)
})
test(`should display the category name followed by the app name`, () => {
const category = {
id: '123',
name: 'Recipes',
draggedOver: false,
}
expect(getWebsiteTitle(Folder.CATEGORY, category)).toEqual(`Recipes | TakeNote`)
})
})
const newNote = (id: string): NoteItem => ({
id: id,
text: '',
created: dayjs().format(),
lastUpdated: dayjs().format(),
})
describe('getActiveNoteFromShortUuid', () => {
test(`should get active note from short`, () => {
const activeNoteId = '6ec0bd7f-11c0-43da-975e-2a8ad9ebae0b'
const shortActiveNoteId = '6ec0bd'
const othernoteId = '710b962e-041c-11e1-9234-0123456789ab'
const activenote: NoteItem = newNote(activeNoteId)
const othernote: NoteItem = newNote(othernoteId)
const notes = [activenote, othernote]
expect(getActiveNoteFromShortUuid(notes, shortActiveNoteId)).toEqual(activenote)
})
test(`should get active note from short with braces`, () => {
const activeNoteId = '6ec0bd7f-11c0-43da-975e-2a8ad9ebae0b'
const shortActiveNoteId = '{{6ec0bd}}'
const otherNoteId = '710b962e-041c-11e1-9234-0123456789ab'
const activeNote: NoteItem = newNote(activeNoteId)
const otherNote: NoteItem = newNote(otherNoteId)
const notes = [activeNote, otherNote]
expect(getActiveNoteFromShortUuid(notes, shortActiveNoteId)).toEqual(activeNote)
})
test(`should not get active note if not present`, () => {
const shortActiveNoteId = '6ec0bd'
const oneNoteId = '109156be-c4fb-41ea-b1b4-efe1671c5836'
const otherNoteId = '710b962e-041c-11e1-9234-0123456789ab'
const oneNote: NoteItem = newNote(oneNoteId)
const otherNote: NoteItem = newNote(otherNoteId)
const notes = [oneNote, otherNote]
expect(getActiveNoteFromShortUuid(notes, shortActiveNoteId)).toEqual(undefined)
})
})
})
================================================
FILE: tests/unit/server/middleware/checkAuth.test.ts
================================================
import checkAuth from '../../../../src/server/middleware/checkAuth'
describe(`checkAuth middleware`, () => {
let requestMock: any
let responseMock: any
const nextMock = jest.fn()
const statusSend = jest.fn()
beforeEach(() => {
responseMock = {
locals: {},
status: jest.fn(() => {
return { send: statusSend }
}),
clearCookie: jest.fn(),
}
})
afterEach(() => jest.resetAllMocks())
test(`should pass saved cookies to locals`, async () => {
requestMock = {
cookies: {
githubAccessToken: 'test access token',
},
}
await checkAuth(requestMock, responseMock, nextMock)
expect(responseMock.locals.accessToken).toEqual('test access token')
})
test(`should exit with an error if no access token cookie`, async () => {
requestMock = {
cookies: {},
}
await checkAuth(requestMock, responseMock, nextMock)
expect(statusSend).toBeCalledWith({ message: 'Forbidden Resource', status: 403 })
})
})
================================================
FILE: tsconfig.json
================================================
{
"compilerOptions": {
"baseUrl": ".",
"outDir": "./dist",
"strict": true,
"forceConsistentCasingInFileNames": true,
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noImplicitAny": true,
"allowJs": false,
"target": "es5",
"module": "commonjs",
"lib": ["dom", "dom.iterable", "esnext"],
"jsx": "react",
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"sourceMap": true,
"paths": {
// Allow `@/` to map to `src/client/`
"@/*": ["./src/client/*"],
"@resources/*": ["./src/resources/*"]
}
},
"include": ["./src/**/*", "./tests/unit/**/*"],
"exclude": ["./dist", "node_modules", "./config"]
}