diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..21ae569 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,10 @@ +{ + "extends": [ + "react-app", + "react-app/jest" + ], + "rules": { + "testing-library/no-container": "off", + "testing-library/no-node-access": "off" + } + } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8b8f453..db1b606 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,3 +50,17 @@ jobs: run: npm i - name: Run eslint run: npm run lint + + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Install Node.js + uses: actions/setup-node@v4 + with: + node-version: 21 + cache: 'npm' + - name: Install modules + run: npm i + - name: Run tests + run: npm run test:ci diff --git a/package.json b/package.json index 6b7bebc..c2058f8 100644 --- a/package.json +++ b/package.json @@ -25,16 +25,12 @@ "start": "react-scripts start", "build": "react-scripts build", "test": "react-scripts test", + "test:ci": "react-scripts test --watchAll=false", "eject": "react-scripts eject", "lint": "eslint src/**/*.ts*", "fix": "npm run lint -- --fix", - "check-types": "tsc --noEmit" - }, - "eslintConfig": { - "extends": [ - "react-app", - "react-app/jest" - ] + "check-types": "tsc --noEmit", + "ci": "npm run lint && npm run check-types && npm run test:ci && npm run build" }, "browserslist": { "production": [ diff --git a/src/App.test.tsx b/src/App.test.tsx index af186ec..8e27742 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -1,16 +1,201 @@ -import { render, screen } from '@testing-library/react'; -import * as testData from './sampleData' +import {render, screen, waitFor} from '@testing-library/react'; import App from './App'; +import {IClient} from './client/IClient'; +import {ProjectData, CommentData, SectionData, EntityType, FileData} from './types'; +import {LocalStorageStore, StoreData} from './store/LocalStorageStore'; +import {MockLocalStorageDependency} from './store/MockLocalStorageDependency'; +import {LocalStorageClient} from './client/LocalStorageClient'; -test('renders learn react link', () => { - render( - - ); - const linkElement = screen.getByText(/learn react/i); - expect(linkElement).toBeInTheDocument(); +// import * as testData from './sampleData' + +window.alert = () => {}; + +const makeTestStore = (): StoreData => { + const initialProjects: ProjectData[] = [ + { + id: 'project-1', + }, + { + id: 'project-2', + }, + ]; + + const initialSections: SectionData[] = [ + { + id: 'section-1', + projectId: 'project-1', + chordProgression: ['C', 'Dm', 'F', 'G'], + description: 'This is the intro', + title: 'Intro', + numRevisions: 3, + } + ]; + + const initialFiles: FileData[] = [ + { + id: 'file-1', + projectId: 'project-1', + entityId: 'section-1', + entityType: EntityType.SECTION, + title: 'Bass.mp3', + }, + { + id: 'file-2', + projectId: 'project-1', + entityId: 'section-1', + entityType: EntityType.SECTION, + title: 'Chunky Monkey.mp3', + }, + ]; + + const initialComments: CommentData[] = [ + { + id: 'comment-1', + projectId: 'project-1', + message: 'Hey what\'s up', + entityType: EntityType.SECTION, + entityId: 'section-1', + username: 'username-1', + }, + { + id: 'comment-2', + projectId: 'project-1', + message: 'Yeah', + entityType: EntityType.FILE, + entityId: 'file-1', + username: 'username-1', + }, + { + id: 'comment-3', + projectId: 'project-1', + message: 'Yeah 3', + entityType: EntityType.FILE, + entityId: 'file-1', + username: 'username-1', + }, + ]; + + return { + projects: initialProjects, + sections: initialSections, + files: initialFiles, + comments: initialComments, + }; +}; + +describe('App', () => { + let client: IClient; + + beforeEach(() => { + const initialStore = makeTestStore(); + + const localStorageDependency = new MockLocalStorageDependency(initialStore); + const store = new LocalStorageStore(localStorageDependency); + client = new LocalStorageClient(store); + }); + + describe('initializing', () => { + it('should show "Loading"', async () => { + // this method is made blocking for this specific test + client.fetchFullDataForProject = (() => new Promise(r => setTimeout(r))); + + render( + + ); + + expect(screen.getByText(/Loading/)).toBeDefined(); + }); + + it('should show client error', async () => { + client.fetchFullDataForProject = jest.fn().mockResolvedValue(new Error('Some error')); + + render( + + ); + + await waitFor(() => { + expect(screen.queryByText(/Loading/)).toBeNull(); + }); + + expect(screen.getByText(/Some error/)).toBeDefined(); + }); + }); + + describe('initialized', () => { + it('should show the section title and description', async () => { + render( + + ); + + await waitFor(() => { + expect(screen.queryByText(/Loading/)).toBeNull(); + }); + + expect(screen.getByText(/Intro/)).toBeDefined(); + expect(screen.getByText(/This is the intro/)).toBeDefined(); + }); + + it('should show the chord progression', async () => { + const {container} = render( + + ); + + await waitFor(() => { + expect(screen.queryByText(/Loading/)).toBeNull(); + }); + + expect(container.querySelector('.chords')?.textContent).toEqual('CDmFG'); + }); + + it('should show files attached to the section', async () => { + const {container} = render( + + ); + + await waitFor(() => { + expect(screen.queryByText(/Loading/)).toBeNull(); + }); + + expect(container.querySelector('.files #file-1')?.textContent).toContain('Bass.mp3'); + expect(container.querySelector('.files #file-1')?.textContent).toContain('2 Comments'); + }); + + it('should show the comments on the section', async () => { + const {container} = render( + + ); + + await waitFor(() => { + expect(screen.queryByText(/Loading/)).toBeNull(); + }); + + expect(container.querySelector('.comments')?.textContent).toContain('1 Comment'); + expect(container.querySelector('.comments #comment-1')?.textContent).toContain('username-1'); + expect(container.querySelector('.comments #comment-1')?.textContent).toContain('Hey what\'s up'); + }); + }); }); diff --git a/src/App.tsx b/src/App.tsx index e33fe37..df11b80 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,38 +1,69 @@ +import {useState} from 'react'; + import './App.css'; import './css_reset.css' +import './index.css' import './section_view.css'; import * as types from './types'; -import { Files } from './Files'; -import { ChordProgression } from './ChordProgression'; -import { Comments } from './Comments'; -import { CreateComment } from './CreateComment'; -import { SectionTitle } from './SectionTitle'; -import { useState } from 'react'; +import {GlobalStoreProvider} from './hooks/useGlobalStore'; +import SectionPage from './SectionPage'; +import {IClient} from './client/IClient'; +import {ClientProvider} from './hooks/useClient'; +import {useMount} from './hooks/useMount'; +type AppProps = { + projectId: string; + sectionId: string; + client: IClient; +} +const App: React.FC = ({projectId, sectionId, client}) => { + const [initialProjectData, setInitialProjectData] = useState(null); + const [error, setError] = useState(''); -type AppProps = { - sectionData: types.SectionData, - chordProgression: types.ChordProgression, - files: types.File[], - comments: types.Comment[] -} + useMount(async () => { + const projectDataOrError = await client.fetchFullDataForProject(projectId); + + if (projectDataOrError instanceof Error) { + alert(projectDataOrError.message); + setError(projectDataOrError.message); + return; + } + + setInitialProjectData(projectDataOrError); + }); + + if (error) { + return ( +

+ {error} +

+ ); + } -const App:React.FC = ({sectionData, chordProgression, comments, files}) => { + if (!initialProjectData) { + return ( +

+ Loading +

+ ); + } - const [commentsAsState, setCommentsAsState] = useState(comments) - + const pageContent = ( + + ); - return ( -
- - - - - -
- ); + return ( + + + {pageContent} + + + ); } export default App; diff --git a/src/ChordProgression.tsx b/src/ChordProgression.tsx index e197661..c619531 100644 --- a/src/ChordProgression.tsx +++ b/src/ChordProgression.tsx @@ -9,7 +9,7 @@ export const ChordProgression: React.FC = ({ chordProgres return (
    - {chordProgression.map((chord, index) =>
  1. {chord}
  2. )} + {chordProgression.map((chord, index) =>
  3. {chord}
  4. )}
); diff --git a/src/Comments.tsx b/src/Comments.tsx index 1823f5a..3a3a91a 100644 --- a/src/Comments.tsx +++ b/src/Comments.tsx @@ -1,33 +1,32 @@ -import * as types from './types'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faFaceSmile } from '@fortawesome/free-solid-svg-icons'; -import {comments as initialComments} from './sampleData' -import { useEffect} from 'react'; +import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'; +import {faFaceSmile} from '@fortawesome/free-solid-svg-icons'; +import {EntityPointer} from './types'; +import {useGlobalStore} from './hooks/useGlobalStore'; +import {plural} from './utils'; -export const Comments: React.FC = ({ comments = initialComments, setComments }) => { +type CommentsProps = { + entityPointer: EntityPointer; +} - useEffect(() => { - // Attempt to load comments from localStorage - const storedCommentsJSON = localStorage.getItem('comments'); - const storedComments = storedCommentsJSON ? JSON.parse(storedCommentsJSON) : null; +export const Comments: React.FC = ({entityPointer}) => { + const globalStore = useGlobalStore(); + const comments = globalStore.getCommentsForEntity(entityPointer); - // Check if there are stored comments, otherwise use initial comments - setComments(storedComments || initialComments); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - - return ( -
- {comments.length} Comments -
- {comments.map((comment, index) => { - return <> -

{comments[index].name}: {comments[index].commentText}

- ; - })} -
-
- ); + return ( +
+ {comments.length} {plural('Comment', comments.length)} +
+ {comments.map(comment => ( +

+ + {comment.username}: {comment.message} +

+ ))} +
+
+ ); }; diff --git a/src/CreateComment.tsx b/src/CreateComment.tsx index 49947f9..8a0fb9a 100644 --- a/src/CreateComment.tsx +++ b/src/CreateComment.tsx @@ -1,59 +1,47 @@ -import {Comment} from './types'; +import {useActions} from './actions/useActions'; +import {EntityPointer} from './types'; import {useState} from 'react'; - type CreateCommentProps = { - comments: Comment[], - setComments: (comments: Comment[]) => void; + entityPointer: EntityPointer; } +export const CreateComment: React.FC = ({entityPointer}) => { + const actions = useActions(); + const [name, setName] = useState(''); + const [commentText, setCommentText] = useState(''); -export const CreateComment: React.FC = ({ comments, setComments }) => { - - const [name, setName] = useState(''); - const [commentText, setCommentText] = useState(''); - - const handleAddComment = () => { - const newComment = { name, commentText }; - - // Fetch existing comments from localStorage - const storedComments = localStorage.getItem('comments'); - const existingComments = storedComments ? JSON.parse(storedComments) : []; - - // Add the new comment to the array - const updatedComments = [...existingComments, newComment]; - - // Save the updated array back to localStorage - localStorage.setItem('comments', JSON.stringify(updatedComments)); - - // Update the local state to reflect the new list of comments - setComments(updatedComments); - } - - - return ( -
-
{ + const handleAddComment = async (e: React.FormEvent) => { e.preventDefault(); - handleAddComment(); + + await actions.addCommentToEntity(commentText, name, entityPointer); setCommentText(''); - }}> - setName(e.target.value)} - placeholder='Enter your name' - /> - setCommentText(e.target.value)} - placeholder='Type your thoughts here' - /> - -
-
- ); + } + + return ( +
+
+ setName(e.target.value)} + placeholder='Enter your name' + /> + setCommentText(e.target.value)} + placeholder='Type your thoughts here' + /> + +
+
+ ); }; diff --git a/src/Files.tsx b/src/Files.tsx index d9673d5..c87b9f7 100644 --- a/src/Files.tsx +++ b/src/Files.tsx @@ -1,20 +1,33 @@ +import {useGlobalStore} from './hooks/useGlobalStore'; import * as types from './types'; +import {plural} from './utils'; type FilesProps = { - files: types.File[] + files: types.FileData[] } -export const Files: React.FC = ({ files }) => { - return ( -
- + Files - {files.map((file) => ( -
- {file.title} -



- {file.numComments + ' '} - Comments -
))} -
- ); +export const Files: React.FC = ({files}) => { + const globalStore = useGlobalStore(); + + return ( +
+ + Files + {files.map((file) => { + const numComments = globalStore.getCommentsForFile(file.id).length; + + return ( +
+ {file.title} +



+ {numComments} + {' '} + {plural('Comment', numComments)} +
+ ); + })} +
+ ); }; diff --git a/src/SectionPage.tsx b/src/SectionPage.tsx new file mode 100644 index 0000000..b3a5b5e --- /dev/null +++ b/src/SectionPage.tsx @@ -0,0 +1,35 @@ +import * as types from './types'; +import {Files} from './Files'; +import {ChordProgression} from './ChordProgression'; +import {Comments} from './Comments'; +import {CreateComment} from './CreateComment'; +import {SectionTitle} from './SectionTitle'; +import {useGlobalStore} from './hooks/useGlobalStore'; + +type SectionPageProps = { + projectId: string; + sectionId: string; +} + +const SectionPage: React.FC = ({projectId, sectionId}) => { + const globalStore = useGlobalStore(); + const section = globalStore.getSection(sectionId); + const files = globalStore.getFilesForSection(sectionId); + + const sectionPointer: types.EntityPointer = { + entityId: sectionId, + entityType: types.EntityType.SECTION, + }; + + return ( +
+ + + + + +
+ ); +} + +export default SectionPage; diff --git a/src/SectionTitle.tsx b/src/SectionTitle.tsx index 588fe6a..b810bff 100644 --- a/src/SectionTitle.tsx +++ b/src/SectionTitle.tsx @@ -1,51 +1,70 @@ -import * as types from './types'; -import React, { useState, FormEvent } from 'react' +import {useActions} from './actions/useActions'; +import {useGlobalStore} from './hooks/useGlobalStore'; +import React, {useState, FormEvent} from 'react'; +import {SectionData} from './types'; +type SectionDataProps = { + sectionId: string; +} +export const SectionTitle: React.FC = ({sectionId}) => { + const actions = useActions(); + const globalStore = useGlobalStore(); + const section = globalStore.getSection(sectionId); -type SectionDataProps = { - sectionData: types.SectionData, -} + const [isEditingTitle, setIsEditingTitle] = useState(false); + const [draftTitle, setDraftTitle] = useState(section.title); + + const handleDraftTitleChange = (e: React.ChangeEvent) => { + setDraftTitle(e.target.value); + }; + + const handleToggleForm = () => { + console.log('button clicked'); + setIsEditingTitle(!isEditingTitle); + }; + + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + const newTitle = draftTitle; + const newSection: SectionData = { + ...section, + title: newTitle, + } + + actions.updateSection(sectionId, newSection); + // setDraftTitle(newTitle); + setIsEditingTitle(false); + }; -export const SectionTitle: React.FC = ({ sectionData }) => { - const [showForm, setShowForm] = useState(false); //shows the form for when you're entering a new title - const [currentTitle, setCurrentTitle] = useState(sectionData.name); - - let handleToggleForm = () => { - console.log('button clicked'); - setShowForm(!showForm); - }; - - // Handle form submission with FormData for TypeScript - const handleSubmit = (event: FormEvent) => { - event.preventDefault(); - const formData = new FormData(event.currentTarget); // Using event.currentTarget to reference the form - const newName = formData.get('newName') as string; - setCurrentTitle(newName); - setShowForm(false); - }; - - return ( -
-
-

{currentTitle}

-

{sectionData.description}

- -
- {showForm && ( -
- - - -
- )} -
- - -
-
- ); + return ( +
+
+

{section.title}

+

{section.description}

+ +
+ {isEditingTitle && ( +
+ + + +
+ )} +
+ + +
+
+ ); }; diff --git a/src/actions/useActions.ts b/src/actions/useActions.ts new file mode 100644 index 0000000..21f3ccb --- /dev/null +++ b/src/actions/useActions.ts @@ -0,0 +1,44 @@ +import {useClient} from '../hooks/useClient'; +import {useGlobalStore} from '../hooks/useGlobalStore'; +import {CommentData, EntityPointer, SectionData} from '../types'; + +type UseActionsHookValue = { + addCommentToEntity(text: string, username: string, entityPointer: EntityPointer): Promise; + updateSection(sectionId: string, section: SectionData): Promise; +} + +export const useActions = (): UseActionsHookValue => { + const globalStore = useGlobalStore(); + const client = useClient(); + + // TODO: this should be retrieved from globalState + // const projectId = globalStore.getCurrentProjectId() + const projectId = 'project-1'; + + const addCommentToEntity = async (message: string, username: string, entityPointer: EntityPointer) => { + const commentPayload: Omit = { + entityType: entityPointer.entityType, + entityId: entityPointer.entityId, + message, + projectId, + username, + }; + + const comment = await client.addComment(commentPayload); + globalStore.addComment(comment); + + return comment; + }; + + const updateSection = async (sectionId: string, section: SectionData) => { + const newSection = await client.updateSection(sectionId, section); + globalStore.updateSection(sectionId, newSection); + + return section; + }; + + return { + addCommentToEntity, + updateSection, + }; +} diff --git a/src/client/IClient.ts b/src/client/IClient.ts new file mode 100644 index 0000000..1b7e669 --- /dev/null +++ b/src/client/IClient.ts @@ -0,0 +1,8 @@ +import {CommentData, FullProjectData, SectionData} from '../types'; + +export interface IClient { + fetchFullDataForProject: (projectId: string) => Promise; + + addComment(comment: Omit): Promise; + updateSection(sectionId: string, section: SectionData): Promise; +} diff --git a/src/client/LocalStorageClient.ts b/src/client/LocalStorageClient.ts new file mode 100644 index 0000000..a1eca7e --- /dev/null +++ b/src/client/LocalStorageClient.ts @@ -0,0 +1,59 @@ +import {LocalStorageStore} from '../store/LocalStorageStore'; +import {CommentData, FullProjectData, SectionData} from '../types'; +import {IClient} from './IClient'; + +export class LocalStorageClient implements IClient { + private persistentStore: LocalStorageStore; + + constructor(persistentStore: LocalStorageStore) { + this.persistentStore = persistentStore; + } + + addComment = async (comment: Omit): Promise => { + const newComment: CommentData = { + ...comment, + id: Math.random().toString().slice(2), + }; + + const comments = await this.persistentStore.getAllComments(); + const newState = [...comments, newComment]; + this.persistentStore.setAllComments(newState); + + return newComment; + } + + // fetchFullDataForProject uses the local storage store to get all data for a given project + // it fetches the project data, sections, files, and comments for the given project + fetchFullDataForProject = async (projectId: string): Promise => { + const projects = (await this.persistentStore.getAllProjects()).filter(p => p.id === projectId); + const sections = (await this.persistentStore.getAllSections()).filter(s => s.projectId === projectId); + const files = (await this.persistentStore.getAllFiles()).filter(f => f.projectId === projectId); + const comments = (await this.persistentStore.getAllComments()).filter(c => c.projectId === projectId); + + if (!projects[0]) { + return new Error(`Error: No project found for projectId ${projectId}`); + } + + return { + project: projects[0], + comments, + files, + sections, + }; + } + + updateSection = async (sectionId: string, section: SectionData): Promise => { + const sections = await this.persistentStore.getAllSections(); + const newState = sections.map(s => { + if (s.id === sectionId) { + return section; + } + + return s; + }); + + this.persistentStore.setAllSections(newState); + + return section; + } +} diff --git a/src/hooks/useClient.tsx b/src/hooks/useClient.tsx new file mode 100644 index 0000000..5dc6b4e --- /dev/null +++ b/src/hooks/useClient.tsx @@ -0,0 +1,21 @@ +import {createContext, useContext} from 'react'; +import {IClient} from '../client/IClient'; + +const clientContext = createContext(null); + +type ClientProviderProps = React.PropsWithChildren<{ + client: IClient; +}>; + +export const useClient = (): IClient => { + const client = useContext(clientContext)!; + return client; +}; + +export const ClientProvider = (props: ClientProviderProps) => { + return ( + + {props.children} + + ); +}; diff --git a/src/hooks/useGlobalStore.tsx b/src/hooks/useGlobalStore.tsx new file mode 100644 index 0000000..4a2e9a7 --- /dev/null +++ b/src/hooks/useGlobalStore.tsx @@ -0,0 +1,87 @@ +import {createContext, useContext, useMemo, useState} from 'react'; +import {CommentData, EntityPointer, EntityType, FileData, FullProjectData, SectionData} from '../types'; +import {matchesEntityPointer} from '../utils'; + +type GlobalStoreContextValue = { + getFullProjectData(): FullProjectData; + setFullProjectData(project: FullProjectData): void; +} + +const globalStoreContext = createContext(null); + +type GlobalStoreProviderProps = React.PropsWithChildren<{ + initialProjectData: FullProjectData; +}>; + +type UseGlobalStoreHookValue = { + getCommentsForSection(sectionId: string): CommentData[]; + getCommentsForFile(fileId: string): CommentData[]; + getFilesForSection(sectionId: string): FileData[]; + getCommentsForEntity(entityPointer: EntityPointer): CommentData[]; + getSection(sectionId: string): SectionData; + + addComment(comment: CommentData): void; + updateSection(sectionId: string, section: SectionData): void; +} + +export const useGlobalStore = (): UseGlobalStoreHookValue => { + const globalStore = useContext(globalStoreContext)!; + const projectData = globalStore.getFullProjectData(); + + return useMemo(() => ({ + getCommentsForFile: (fileId) => projectData.comments.filter(c => matchesEntityPointer(c, EntityType.FILE, fileId)), + getCommentsForSection: (sectionId) => projectData.comments.filter(c => matchesEntityPointer(c, EntityType.SECTION, sectionId)), + getFilesForSection: (sectionId) => projectData.files.filter(f => matchesEntityPointer(f, EntityType.SECTION, sectionId)), + getCommentsForEntity: (entityPointer: EntityPointer) => projectData.comments.filter(c => matchesEntityPointer(c, entityPointer.entityType, entityPointer.entityId)), + + // error-prone how we assume the section exists + getSection: (sectionId) => projectData.sections.find(s => s.id === sectionId)!, + + addComment: (comment) => { + const state = globalStore.getFullProjectData(); + const newState: FullProjectData = { + ...state, + comments: [ + ...state.comments, + comment, + ], + }; + + globalStore.setFullProjectData(newState); + }, + + updateSection: (sectionId, updatedSection) => { + const state = globalStore.getFullProjectData(); + + const sections = state.sections.map(existingSection => { + if (existingSection.id === sectionId) { + return updatedSection; + } + + return existingSection; + }); + + const newState: FullProjectData = { + ...state, + sections, + }; + + globalStore.setFullProjectData(newState); + } + }), [projectData, globalStore]); +}; + +export const GlobalStoreProvider = (props: GlobalStoreProviderProps) => { + const [fullProjectData, setFullProjectData] = useState(props.initialProjectData); + + const value = useMemo(() => ({ + getFullProjectData: () => fullProjectData, + setFullProjectData, + }), [fullProjectData, setFullProjectData]); + + return ( + + {props.children} + + ); +}; diff --git a/src/hooks/useMount.ts b/src/hooks/useMount.ts new file mode 100644 index 0000000..7ae5596 --- /dev/null +++ b/src/hooks/useMount.ts @@ -0,0 +1,9 @@ +import {useEffect} from 'react' + +export const useMount = (callback: () => void) => { + useEffect(() => { + callback(); + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); +} diff --git a/src/index.tsx b/src/index.tsx index 14bf9ac..7b20240 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,23 +1,32 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; import './index.css'; -import * as testData from './sampleData' import App from './App'; import reportWebVitals from './reportWebVitals'; +import {LocalStorageStore} from './store/LocalStorageStore'; +import {LocalStorageClient} from './client/LocalStorageClient'; -const root = ReactDOM.createRoot( - document.getElementById('root') as HTMLElement -); -root.render( - - - -); +window.addEventListener('load', async () => { + const root = ReactDOM.createRoot( + document.getElementById('root') as HTMLElement + ); + + const localStore = new LocalStorageStore(localStorage); + const localClient = new LocalStorageClient(localStore); + + const projectId = 'project-1'; + const sectionId = 'section-1'; + + root.render( + + + + ); +}); // If you want to start measuring performance in your app, pass a function // to log results (for example: reportWebVitals(console.log)) diff --git a/src/logo.svg b/src/logo.svg deleted file mode 100644 index 9dfc1c0..0000000 --- a/src/logo.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/sampleData.ts b/src/sampleData.ts deleted file mode 100644 index 1a438b8..0000000 --- a/src/sampleData.ts +++ /dev/null @@ -1,45 +0,0 @@ - -import * as Types from './types' - -export const sectionData: Types.SectionData = { - name: 'Intro', - description: 'This intro section consists of a tuba quartet in the style of DJ Templeton & The Windsurfers', - numRevisions: 42, -}; - -export const currentChordProgression: Types.ChordProgression = ['C', 'Dm', 'F', 'G'] - -export const files: Types.File[] = [ - { - title: 'Bass.mp3', - numComments: 2, - id: 'change me to something better 0 ' - }, - { - title: 'Drums.mp3', - numComments: 2, - id: 'change me to something better 1' - }, - { - title: 'Yodeling.mp3', - numComments: 2, - id: 'change me to something better 2' - }, - { - title: 'Tuba.mp3', - numComments: 2, - id: 'change me to something better 3' - }, -] - - -export const comments: Types.Comment[] = [ - { - name: 'Sample User', - commentText: 'Hey! This is a comment' - }, - { - name: 'Sample User', - commentText: 'Hey! This is a comment' - }, -] diff --git a/src/store/LocalStorageStore.ts b/src/store/LocalStorageStore.ts new file mode 100644 index 0000000..5908afc --- /dev/null +++ b/src/store/LocalStorageStore.ts @@ -0,0 +1,207 @@ +import {CommentData, EntityType, FileData, ProjectData, SectionData} from '../types'; + +export interface LocalStorageDependency { + getItem(key: string): string | null; + setItem(key: string, value: string): void; + clear(): void; +} + +// TODO: versioning of the store allows for migrations +// const LOCAL_STORAGE_KEY_VERSION = 'version'; + +const LOCAL_STORAGE_KEY_PROJECTS = 'projects'; +const LOCAL_STORAGE_KEY_SECTIONS = 'sections'; +const LOCAL_STORAGE_KEY_FILES = 'files'; +const LOCAL_STORAGE_KEY_COMMENTS = 'comments'; + +export type StoreData = { + projects: ProjectData[]; + sections: SectionData[]; + files: FileData[]; + comments: CommentData[]; +} + +export class LocalStorageStore { + private ls: LocalStorageDependency; + + private currentData: StoreData; + + constructor(ls: LocalStorageDependency) { + this.ls = ls; + this.currentData = this.migrateLocalStorageStore(); + + if (!this.currentData.projects.length) { + this.initializeWithSampleData(); + } + + // this.clear(); + } + + clear = () => { + this.ls.clear(); + this.currentData = this.migrateLocalStorageStore(); + } + + initializeWithSampleData = () => { + this.clear(); + this.setAllProjects(initialProjects); + this.setAllSections(initialSections); + this.setAllFiles(initialFiles); + this.setAllComments(initialComments); + } + + getAllProjects = async (): Promise => { + return this.currentData.projects; + }; + + setAllProjects = async (allProjects: ProjectData[]): Promise => { + this.currentData = { + ...this.currentData, + projects: allProjects, + }; + + this.ls.setItem(LOCAL_STORAGE_KEY_PROJECTS, JSON.stringify(allProjects)); + }; + + getAllSections = async (): Promise => { + return this.currentData.sections; + }; + + setAllSections = async (allSections: SectionData[]): Promise => { + this.currentData = { + ...this.currentData, + sections: allSections, + }; + + this.ls.setItem(LOCAL_STORAGE_KEY_SECTIONS, JSON.stringify(allSections)); + }; + + getAllFiles = async (): Promise => { + return this.currentData.files; + }; + + setAllFiles = async (allFiles: FileData[]): Promise => { + this.currentData = { + ...this.currentData, + files: allFiles, + }; + + this.ls.setItem(LOCAL_STORAGE_KEY_FILES, JSON.stringify(allFiles)); + }; + + getAllComments = async (): Promise => { + return this.currentData.comments; + }; + + setAllComments = async (allComments: CommentData[]): Promise => { + this.currentData = { + ...this.currentData, + comments: allComments, + }; + + this.ls.setItem(LOCAL_STORAGE_KEY_COMMENTS, JSON.stringify(allComments)); + }; + + private migrateLocalStorageStore = (): StoreData => { + const store: StoreData = { + projects: [], + sections: [], + files: [], + comments: [], + }; + + const projectsStr = this.ls.getItem(LOCAL_STORAGE_KEY_PROJECTS); + if (projectsStr) { + store.projects = JSON.parse(projectsStr); + } else { + this.ls.setItem(LOCAL_STORAGE_KEY_PROJECTS, JSON.stringify(store.projects)); + } + + const sectionsStr = this.ls.getItem(LOCAL_STORAGE_KEY_SECTIONS); + if (sectionsStr) { + store.sections = JSON.parse(sectionsStr); + } else { + this.ls.setItem(LOCAL_STORAGE_KEY_SECTIONS, JSON.stringify(store.sections)); + } + + const filesStr = this.ls.getItem(LOCAL_STORAGE_KEY_FILES); + if (filesStr) { + store.files = JSON.parse(filesStr); + } else { + this.ls.setItem(LOCAL_STORAGE_KEY_FILES, JSON.stringify(store.files)); + } + + const commentsStr = this.ls.getItem(LOCAL_STORAGE_KEY_COMMENTS); + if (commentsStr) { + store.comments = JSON.parse(commentsStr); + } else { + this.ls.setItem(LOCAL_STORAGE_KEY_COMMENTS, JSON.stringify(store.comments)); + } + + return store; + } +} + +const initialProjects: ProjectData[] = [ + { + id: 'project-1', + }, + { + id: 'project-2', + }, +]; + +const initialSections: SectionData[] = [ + { + id: 'section-1', + projectId: 'project-1', + chordProgression: ['C', 'Dm', 'F', 'G'], + description: 'This is the intro', + title: 'Intro', + numRevisions: 3, + } +]; + +const initialFiles: FileData[] = [ + { + id: 'file-1', + projectId: 'project-1', + entityId: 'section-1', + entityType: EntityType.SECTION, + title: 'Bass.mp3', + }, + { + id: 'file-2', + projectId: 'project-1', + entityId: 'section-1', + entityType: EntityType.SECTION, + title: 'Chunky Monkey.mp3', + }, +]; + +const initialComments: CommentData[] = [ + { + id: 'comment-1', + projectId: 'project-1', + message: 'Hey what\'s up', + entityType: EntityType.SECTION, + entityId: 'section-1', + username: 'username-1', + }, + { + id: 'comment-2', + projectId: 'project-1', + message: 'Yeah', + entityType: EntityType.FILE, + entityId: 'file-1', + username: 'username-1', + }, + { + id: 'comment-3', + projectId: 'project-1', + message: 'Yeah 3', + entityType: EntityType.FILE, + entityId: 'file-1', + username: 'username-1', + }, +]; diff --git a/src/store/MockLocalStorageDependency.ts b/src/store/MockLocalStorageDependency.ts new file mode 100644 index 0000000..cfc9890 --- /dev/null +++ b/src/store/MockLocalStorageDependency.ts @@ -0,0 +1,24 @@ +import {LocalStorageDependency} from './LocalStorageStore'; + +export class MockLocalStorageDependency> implements LocalStorageDependency { + public currentData: T; + + constructor(data: T) { + this.currentData = data; + } + + getItem(key: string): string | null { + if (this.currentData[key]) { + return JSON.stringify(this.currentData[key]); + } + + return null; + } + + setItem(key: string, value: string): void { + this.currentData[key as keyof T] = JSON.parse(value); + } + clear(): void { + this.currentData = {} as T; + } +} diff --git a/src/types.ts b/src/types.ts index 9be5636..d70c88f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,29 +1,45 @@ - - -export type SectionData = { - name: string, - description: string, - numRevisions: number +export type ProjectData = { + id: string; } - - -export type ChordProgression = string[] - - -export type File = { - title: string, - numComments: number, - id: string, +export enum EntityType { + PROJECT = 'project', + SECTION = 'section', + FILE = 'file', } +export type EntityPointer = { + entityType: EntityType; + entityId: string; +} -export type Comment = { - name: string, - commentText: string +export type SectionData = { + id: string; + projectId: string; + title: string; + description: string; + numRevisions: number; + chordProgression: ChordProgression; } -export type commentsProps = { - comments: Comment[] - setComments: (comments: Comment[]) => void; +export type ChordProgression = string[] + +export type FileData = { + projectId: string; + id: string; + title: string; +} & EntityPointer; + +export type CommentData = { + projectId: string; + id: string; + username: string; + message: string; +} & EntityPointer; + +export type FullProjectData = { + project: ProjectData; + sections: SectionData[]; + comments: CommentData[]; + files: FileData[]; } diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..a2b81aa --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,13 @@ +import {EntityPointer, EntityType} from './types'; + +export const matchesEntityPointer = (entityPointer: EntityPointer, entityType: EntityType, entityId: string): boolean => { + return entityPointer.entityType === entityType && entityPointer.entityId === entityId; +} + +export const plural = (name: string, numItems: number) => { + if (numItems === 1) { + return name; + } + + return name + 's'; +}; diff --git a/src/setupTests.ts b/tests/setupTests.ts similarity index 100% rename from src/setupTests.ts rename to tests/setupTests.ts