Browse Source

Implement inferring of Sequelize models to TypeScript types, delete functionality

Use basic infer map of Sequelize models. Implement local and server-side deletion of notes.
feature/transactions
TheoryOfNekomata 3 years ago
parent
commit
853d19d9a4
7 changed files with 359 additions and 140 deletions
  1. +260
    -124
      src/pages/notes.tsx
  2. +6
    -1
      src/services/Controller.ts
  3. +13
    -3
      src/services/Folder.ts
  4. +12
    -2
      src/services/Note.ts
  5. +31
    -7
      src/services/Storage.ts
  6. +33
    -3
      src/utilities/Instance.ts
  7. +4
    -0
      src/utilities/Response.ts

+ 260
- 124
src/pages/notes.tsx View File

@@ -7,7 +7,7 @@ import generateId from '../utilities/Id'
import Link from 'next/link'
import { formatDate } from '../utilities/Date'
import { useRouter } from 'next/router'
import { Trash2, FilePlus, FolderPlus } from 'react-feather'
import { Trash2, FilePlus, FolderPlus, FileText, GitBranch, User } from 'react-feather'

const Navbar = styled('aside')({
width: 360,
@@ -22,6 +22,43 @@ const Navbar = styled('aside')({
},
})

const PrimaryNavItems = styled('nav')({
height: '100%',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
alignItems: 'stretch',
'@media (min-width: 1080px)': {
width: '4rem',
},
})

const SecondaryNavItems = styled('nav')({
height: '100%',
position: 'relative',
'::before': {
content: "''",
display: 'block',
top: 0,
left: 0,
width: '100%',
height: '100%',
position: 'absolute',
zIndex: -1,
backgroundColor: 'white',
opacity: 0.5,
},
'@media (min-width: 1080px)': {
flex: 'auto',
},
})

const SecondaryNavItemsOverflow = styled('div')({
overflow: 'auto',
width: '100%',
height: '100%',
})

const Main = styled('main')({
margin: '2rem 0',
'@media (min-width: 1080px)': {
@@ -40,11 +77,17 @@ const Container = styled('div')({
},
})

const NavbarContainer = styled('span')({
const NavbarItems = styled('div')({
display: 'flex',
width: '100%',
height: '100%',
})

const NavbarContainer = styled('div')({
display: 'block',
width: '100%',
height: '100%',
margin: '0 0 0 auto',
padding: '0 1rem',
boxSizing: 'border-box',
maxWidth: 360,
})
@@ -59,7 +102,6 @@ const TitleInput = styled('input')({
fontSize: '3rem',
fontWeight: 'bold',
outline: 0,
marginBottom: '2rem',
})

const NoteLink = styled('a')({
@@ -127,6 +169,30 @@ const BinIcon = styled(Trash2)({
marginRight: '0.5rem',
})

const NavbarItemContent = styled('span')({
padding: '0 1rem',
boxSizing: 'border-box',
})

const PrimaryNavItem = styled('a')({
width: '4rem',
height: '4rem',
display: 'grid',
placeContent: 'center',
color: 'inherit',
})

const PostMeta = styled('small')({
opacity: 0.5,
height: '1.25rem',
display: 'block',
lineHeight: 1.25,
})

const PostPrimary = styled('div')({
marginBottom: '2rem',
})

type NoteInstance = { id: string, title: string, content?: object, updatedAt: string, }

const Notes = ({ id: idProp }) => {
@@ -145,7 +211,7 @@ const Notes = ({ id: idProp }) => {
timeoutRef.current = window.setTimeout(async () => {
const newNote = await Storage.saveNote({
...stateRef.current,
updatedAt: new Date().toISOString(),
updatedAt: (stateRef.current.updatedAt = new Date().toISOString()),
})
if (router.query.id !== id) {
await router.push(`/notes/${id}`, undefined, { shallow: true })
@@ -174,6 +240,14 @@ const Notes = ({ id: idProp }) => {
autoSave()
}

const deleteNote = note => async () => {
setNotes(notes.filter(n => n.id !== note.id))
const result = await Storage.deleteNote(note)
if (!result) {
setNotes(notes)
}
}

React.useEffect(() => {
const loadNotes = async () => {
const theNotes = await Storage.loadNotes()
@@ -210,121 +284,168 @@ const Notes = ({ id: idProp }) => {
<link rel="icon" href="/favicon.ico" />
</Head>
<Navbar>
<Link
href={{
pathname: '/profile',
}}
passHref
>
<NoteLink>
<NavbarContainer>
<NoteLinkPrimary>
<NewFolderIcon />
<NoteLinkTitle>
Personal
</NoteLinkTitle>
</NoteLinkPrimary>
</NavbarContainer>
</NoteLink>
</Link>
<Link
href={{
pathname: '/folders/new',
}}
passHref
>
<NoteLink>
<NavbarContainer>
<NoteLinkPrimary>
<NewFolderIcon />
<NoteLinkTitle>
Create Folder
</NoteLinkTitle>
</NoteLinkPrimary>
</NavbarContainer>
</NoteLink>
</Link>
<Link
href={{
pathname: '/notes',
}}
passHref
>
<NoteLink>
<NavbarContainer>
<NoteLinkPrimary>
<NewIcon />
<NoteLinkTitle>
Create Note
</NoteLinkTitle>
</NoteLinkPrimary>
</NavbarContainer>
</NoteLink>
</Link>
{
Array.isArray(notes!)
&& notes.map(n => (
<LinkContainer
key={n.id}
>
{
n.id === id
&& (
<NoteLinkBackground />
)
}
<Link
href={{
pathname: '/notes/[id]',
query: { id: n.id },
}}
passHref
>
<NoteLink>
<NavbarContainer>
<NoteLinkPrimary>
<NoteLinkTitle
style={{ opacity: n.title.length > 0 ? 1 : 0.5, }}
<NavbarContainer>
<NavbarItems>
<PrimaryNavItems>
<div>
<Link
href={{
pathname: '/notes',
}}
passHref
>
<PrimaryNavItem>
<FileText />
</PrimaryNavItem>
</Link>
<Link
href={{
pathname: '/graph',
}}
passHref
>
<PrimaryNavItem>
<GitBranch />
</PrimaryNavItem>
</Link>
</div>
<div>
<Link
href={{
pathname: '/me',
}}
passHref
>
<PrimaryNavItem>
<User />
</PrimaryNavItem>
</Link>
</div>
</PrimaryNavItems>
<SecondaryNavItems>
<SecondaryNavItemsOverflow>
<Link
href={{
pathname: '/profile',
}}
passHref
>
<NoteLink>
<NavbarItemContent>
<NoteLinkPrimary>
<NewFolderIcon />
<NoteLinkTitle>
Personal
</NoteLinkTitle>
</NoteLinkPrimary>
</NavbarItemContent>
</NoteLink>
</Link>
<Link
href={{
pathname: '/folders/new',
}}
passHref
>
<NoteLink>
<NavbarItemContent>
<NoteLinkPrimary>
<NewFolderIcon />
<NoteLinkTitle>
Create Folder
</NoteLinkTitle>
</NoteLinkPrimary>
</NavbarItemContent>
</NoteLink>
</Link>
<Link
href={{
pathname: '/notes',
}}
passHref
>
<NoteLink>
<NavbarItemContent>
<NoteLinkPrimary>
<NewIcon />
<NoteLinkTitle>
Create Note
</NoteLinkTitle>
</NoteLinkPrimary>
</NavbarItemContent>
</NoteLink>
</Link>
{
Array.isArray(notes!)
&& notes.map(n => (
<LinkContainer
key={n.id}
>
{
n.id === id
&& (
<NoteLinkBackground />
)
}
<Link
href={{
pathname: '/notes/[id]',
query: { id: n.id },
}}
replace
passHref
>
{n.title.length > 0 ? n.title : '(untitled)'}
</NoteLinkTitle>
</NoteLinkPrimary>
{' '}
<small>
<time
dateTime={new Date(n.updatedAt).toISOString()}
>
Last updated {formatDate(new Date(n.updatedAt))}
</time>
</small>
</NavbarContainer>
</NoteLink>
</Link>
<NoteActions>
<NoteAction>
<Trash2 />
</NoteAction>
</NoteActions>
</LinkContainer>
))
}
<Link
href={{
pathname: '/bin',
}}
passHref
>
<NoteLink>
<NavbarContainer>
<NoteLinkPrimary>
<BinIcon />
<NoteLinkTitle>
View Binned Notes
</NoteLinkTitle>
</NoteLinkPrimary>
</NavbarContainer>
</NoteLink>
</Link>
<NoteLink>
<NavbarItemContent>
<NoteLinkPrimary>
<NoteLinkTitle
style={{ opacity: n.title.length > 0 ? 1 : 0.5, }}
>
{n.title.length > 0 ? n.title : '(untitled)'}
</NoteLinkTitle>
</NoteLinkPrimary>
{' '}
<PostMeta>
<time
dateTime={new Date(n.updatedAt).toISOString()}
>
Last updated {formatDate(new Date(n.updatedAt))}
</time>
</PostMeta>
</NavbarItemContent>
</NoteLink>
</Link>
<NoteActions>
<NoteAction
onClick={deleteNote(n)}
>
<Trash2 />
</NoteAction>
</NoteActions>
</LinkContainer>
))
}
<Link
href={{
pathname: '/bin',
}}
passHref
>
<NoteLink>
<NavbarItemContent>
<NoteLinkPrimary>
<BinIcon />
<NoteLinkTitle>
View Binned Notes
</NoteLinkTitle>
</NoteLinkPrimary>
</NavbarItemContent>
</NoteLink>
</Link>
</SecondaryNavItemsOverflow>
</SecondaryNavItems>
</NavbarItems>
</NavbarContainer>
</Navbar>
<Main>
<Container>
@@ -332,11 +453,26 @@ const Notes = ({ id: idProp }) => {
Array.isArray(notes!)
&& (
<React.Fragment>
<TitleInput
placeholder="Title"
value={title}
onChange={handleTitleChange}
/>
<PostPrimary>
<TitleInput
placeholder="Title"
value={title}
onChange={handleTitleChange}
/>
<PostMeta>
{
stateRef.current.updatedAt
&& router.query.id
&& (
<time
dateTime={new Date(stateRef.current.updatedAt).toISOString()}
>
Last updated {formatDate(new Date(stateRef.current.updatedAt))}
</time>
)
}
</PostMeta>
</PostPrimary>
<Editor
autoFocus={false}
key={id}


+ 6
- 1
src/services/Controller.ts View File

@@ -37,6 +37,7 @@ export const item = (Model, Service) => async (req, res) => {
const methodHandlers = {
'GET': Service.getSingle(repository),
'PUT': Service.save(repository)(req.body),
'DELETE': Service.remove(repository)
}

const { [req.method as keyof typeof methodHandlers]: handler = null } = methodHandlers
@@ -50,7 +51,11 @@ export const item = (Model, Service) => async (req, res) => {
try {
const { status, data, } = await handler(id)
res.statusCode = status
res.json(data)
if (data) {
res.json(data)
return
}
res.end()
} catch (err) {
console.log('ERROR', err)
const { status, data, } = err


+ 13
- 3
src/services/Folder.ts View File

@@ -1,7 +1,8 @@
import Model from '../../models/Folder'
import FolderModel from '../../models/Folder'
import Instance from '../utilities/Instance'
import * as Response from '../utilities/Response'
type ModelInstance = Instance<typeof Model.rawAttributes>

type Folder = Instance<typeof FolderModel>

export const getSingle = repository => async (id: string) => {
const instance = await repository.findByPk(id)
@@ -20,7 +21,7 @@ export const getMultiple = repository => async (query: Record<string, unknown>)
})
}

export const save = repository => (body: Partial<ModelInstance>) => async (id: string, idColumnName = 'id') => {
export const save = repository => (body: Partial<Folder>) => async (id: string, idColumnName = 'id') => {
const [newInstance, created] = await repository.findOrCreate({
where: { [idColumnName]: id },
defaults: {
@@ -44,3 +45,12 @@ export const save = repository => (body: Partial<ModelInstance>) => async (id: s
data: updatedInstance.toJSON()
})
}

export const remove = repository => async (id: string) => {
const instanceDAO = repository.findByPk(id)
if (instanceDAO === null) {
throw new Response.NotFound({ message: 'Not found.' })
}
await instanceDAO.destroy()
return new Response.Destroyed()
}

+ 12
- 2
src/services/Note.ts View File

@@ -1,7 +1,8 @@
import Model from '../../models/Note'
import Instance from '../utilities/Instance'
import InferType from '../utilities/Instance'
import * as Response from '../utilities/Response'
type ModelInstance = Instance<typeof Model.rawAttributes>

type ModelInstance = InferType<typeof Model>

export const getSingle = repository => async (id: string) => {
const instanceDAO = await repository.findByPk(id)
@@ -66,3 +67,12 @@ export const save = repository => (body: Partial<ModelInstance>) => async (id: s
}
})
}

export const remove = repository => async (id: string) => {
const instanceDAO = await repository.findByPk(id)
if (instanceDAO === null) {
throw new Response.NotFound({ message: 'Not found.' })
}
await instanceDAO.destroy()
return new Response.Destroyed()
}

+ 31
- 7
src/services/Storage.ts View File

@@ -1,13 +1,13 @@
import {addTime, TimeDivision} from '../utilities/Date'
import { addTime, TimeDivision } from '../utilities/Date'
import * as Serialization from '../utilities/Serialization'
import LocalStorage from './LocalStorage'

type LoadItemParams = {
type StorageParams = {
id: string,
url: string,
}

type LoadItems = <T extends Record<string, unknown>>(params: LoadItemParams) => () => Promise<T[]>
type LoadItems = <T extends Record<string, unknown>>(params: StorageParams) => () => Promise<T[]>

const loadItems: LoadItems = <T extends Record<string, unknown>>(params) => async (): Promise<T[]> => {
const { id, url, } = params
@@ -39,7 +39,7 @@ const loadItems: LoadItems = <T extends Record<string, unknown>>(params) => asyn
return localData.items
}

type SaveItem = <T extends Record<string, unknown>>(p: LoadItemParams) => (item: T) => Promise<T>
type SaveItem = <T extends Record<string, unknown>>(p: StorageParams) => (item: T) => Promise<T>

const saveItem: SaveItem = <T extends Record<string, unknown>>(params) => async (item) => {
const { id: storageId, url } = params
@@ -50,10 +50,9 @@ const saveItem: SaveItem = <T extends Record<string, unknown>>(params) => async
)
const localData = storage.getItem(storageId, {
expiry: addTime(new Date(), 30)(TimeDivision.DAYS).getTime(),
items: []
items: [],
})
console.log(localData)
const localItems = localData.items
const { items: localItems } = localData
const { id: itemId, ...theBody } = item
const theItems: T[] = (
localItems.some(i => i.id === itemId)
@@ -76,7 +75,32 @@ const saveItem: SaveItem = <T extends Record<string, unknown>>(params) => async
return responseBody as T
}

type DeleteItem = <T extends Record<string, unknown>>(p: StorageParams) => (item: T) => Promise<boolean>

const deleteItem: DeleteItem = <T extends Record<string, unknown>>(params) => async (item) => {
const { id: storageId, url } = params
const storage = new LocalStorage(
window.localStorage,
Serialization.serialize,
Serialization.deserialize
)

const localData = storage.getItem(storageId, {
expiry: addTime(new Date(), 30)(TimeDivision.DAYS).getTime(),
items: [],
})
const { items: localItems } = localData
const { id: itemId } = item
const theItems: T[] = localItems.filter(i => i.id !== itemId)
storage.setItem(storageId, { ...localData, items: theItems })
const response = await window.fetch(`${url}/${itemId}`, {
method: 'delete',
})
return response.status === 204
}

export const loadNotes = loadItems({ id: 'notes', url: '/api/notes' })
export const loadFolders = loadItems({ id: 'folders', url: '/api/folders' })
export const saveNote = saveItem({ id: 'notes', url: '/api/notes' })
export const saveFolder = saveItem({ id: 'folders', url: '/api/folders' })
export const deleteNote = deleteItem({ id: 'notes', url: '/api/notes' })

+ 33
- 3
src/utilities/Instance.ts View File

@@ -1,5 +1,35 @@
type Instance<T> = {
[key in keyof T]: unknown
import * as Sequelize from 'sequelize'

type ModelAttribute = {
allowNull?: boolean,
primaryKey?: boolean,
type: Sequelize.DataType,
}

type Model = {
tableName?: string,
modelName?: string,
options?: {
timestamps?: boolean,
paranoid?: boolean,
createdAt?: string | boolean,
updatedAt?: string | boolean,
deletedAt?: string | boolean,
},
rawAttributes: Record<string, ModelAttribute>,
}

type InferType<V extends Sequelize.DataType> = (
V extends typeof Sequelize.STRING ? string :
V extends typeof Sequelize.TEXT ? string :
V extends typeof Sequelize.DATE ? Date :
V extends typeof Sequelize.DATEONLY ? Date :
V extends typeof Sequelize.UUIDV4 ? string :
unknown
)

type InferProps<M extends Model> = {
[K in keyof M['rawAttributes']]-?: InferType<M['rawAttributes'][K]['type']>
}

export default Instance
export default InferProps

+ 4
- 0
src/utilities/Response.ts View File

@@ -43,3 +43,7 @@ export class Retrieved<T extends Record<string, unknown>> implements Response {
this.data = params.data
}
}

export class Destroyed implements Response {
public readonly status = 204
}

Loading…
Cancel
Save