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 new file mode 100644 index 0000000..db1b606 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,66 @@ +name: CI + +on: + push: + branches: + - main + pull_request: + branches: + - '**' + workflow_dispatch: + +jobs: + build: + 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: Build app + run: npm run build + types: + 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: Check Types + run: npm run check-types + + lint: + 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 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/.gitignore b/.gitignore index 4d29575..84d9f63 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,5 @@ npm-debug.log* yarn-debug.log* yarn-error.log* + +_ignore diff --git a/.gitpod.yml b/.gitpod.yml new file mode 100644 index 0000000..c4b48c3 --- /dev/null +++ b/.gitpod.yml @@ -0,0 +1,3 @@ +tasks: + - init: npm install && npm run build + command: npm run start diff --git a/README.md b/README.md index b87cb00..9cddb47 100644 --- a/README.md +++ b/README.md @@ -1,46 +1,19 @@ -# Getting Started with Create React App +## Section-View: a new tool for musical composition -This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). -## Available Scripts -In the project directory, you can run: +### Section view is a collaborative pre-DAW music application -### `npm start` +## Tech Used: ![JAVASCRIPT BADGE](https://img.shields.io/static/v1?label=|&message=JAVASCRIPT&color=3c7f5d&style=plastic&logo=javascript) ![React Badge](https://img.shields.io/static/v1?label=|&message=REACT&color=61DAFB&style=plastic&logo=react)![TypeScript Badge](https://img.shields.io/static/v1?label=|&message=TypeScript&color=3178C6&style=plastic&logo=typescript) -Runs the app in the development mode.\ -Open [http://localhost:3000](http://localhost:3000) to view it in the browser. -The page will reload if you make edits.\ -You will also see any lint errors in the console. -### `npm test` -Launches the test runner in the interactive watch mode.\ -See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. -### `npm run build` +## Optimizations -Builds the app for production to the `build` folder.\ -It correctly bundles React in production mode and optimizes the build for the best performance. +This project is constantly improving and growing. We're working on extending the scope of the project as you read this -The build is minified and the filenames include the hashes.\ -Your app is ready to be deployed! +## Lessons Learned: -See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. - -### `npm run eject` - -**Note: this is a one-way operation. Once you `eject`, you can’t go back!** - -If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. - -Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. - -You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. - -## Learn More - -You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). - -To learn React, check out the [React documentation](https://reactjs.org/). +Learned quite a bit about managing complexity and writing scalable and maintainable code diff --git a/package-lock.json b/package-lock.json index 00f513c..a3253ef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,11 @@ "name": "section-view", "version": "0.1.0", "dependencies": { + "@fortawesome/fontawesome-svg-core": "^6.5.2", + "@fortawesome/free-brands-svg-icons": "^6.5.2", + "@fortawesome/free-regular-svg-icons": "^6.5.2", + "@fortawesome/free-solid-svg-icons": "^6.5.2", + "@fortawesome/react-fontawesome": "^0.2.0", "@testing-library/jest-dom": "^5.17.0", "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", @@ -2397,6 +2402,75 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@fortawesome/fontawesome-common-types": { + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.5.2.tgz", + "integrity": "sha512-gBxPg3aVO6J0kpfHNILc+NMhXnqHumFxOmjYCFfOiLZfwhnnfhtsdA2hfJlDnj+8PjAs6kKQPenOTKj3Rf7zHw==", + "hasInstallScript": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/fontawesome-svg-core": { + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.5.2.tgz", + "integrity": "sha512-5CdaCBGl8Rh9ohNdxeeTMxIj8oc3KNBgIeLMvJosBMdslK/UnEB8rzyDRrbKdL1kDweqBPo4GT9wvnakHWucZw==", + "hasInstallScript": true, + "dependencies": { + "@fortawesome/fontawesome-common-types": "6.5.2" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/free-brands-svg-icons": { + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/@fortawesome/free-brands-svg-icons/-/free-brands-svg-icons-6.5.2.tgz", + "integrity": "sha512-zi5FNYdmKLnEc0jc0uuHH17kz/hfYTg4Uei0wMGzcoCL/4d3WM3u1VMc0iGGa31HuhV5i7ZK8ZlTCQrHqRHSGQ==", + "hasInstallScript": true, + "dependencies": { + "@fortawesome/fontawesome-common-types": "6.5.2" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/free-regular-svg-icons": { + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-6.5.2.tgz", + "integrity": "sha512-iabw/f5f8Uy2nTRtJ13XZTS1O5+t+anvlamJ3zJGLEVE2pKsAWhPv2lq01uQlfgCX7VaveT3EVs515cCN9jRbw==", + "hasInstallScript": true, + "dependencies": { + "@fortawesome/fontawesome-common-types": "6.5.2" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/free-solid-svg-icons": { + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.5.2.tgz", + "integrity": "sha512-QWFZYXFE7O1Gr1dTIp+D6UcFUF0qElOnZptpi7PBUMylJh+vFmIedVe1Ir6RM1t2tEQLLSV1k7bR4o92M+uqlw==", + "hasInstallScript": true, + "dependencies": { + "@fortawesome/fontawesome-common-types": "6.5.2" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/react-fontawesome": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@fortawesome/react-fontawesome/-/react-fontawesome-0.2.0.tgz", + "integrity": "sha512-uHg75Rb/XORTtVt7OS9WoK8uM276Ufi7gCzshVWkUJbHhh3svsUUeqXerrM96Wm7fRiDzfKRwSoahhMIkGAYHw==", + "dependencies": { + "prop-types": "^15.8.1" + }, + "peerDependencies": { + "@fortawesome/fontawesome-svg-core": "~1 || ~6", + "react": ">=16.3" + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.11.14", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", diff --git a/package.json b/package.json index 62e3a52..c2058f8 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,11 @@ "version": "0.1.0", "private": true, "dependencies": { + "@fortawesome/fontawesome-svg-core": "^6.5.2", + "@fortawesome/free-brands-svg-icons": "^6.5.2", + "@fortawesome/free-regular-svg-icons": "^6.5.2", + "@fortawesome/free-solid-svg-icons": "^6.5.2", + "@fortawesome/react-fontawesome": "^0.2.0", "@testing-library/jest-dom": "^5.17.0", "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", @@ -20,13 +25,12 @@ "start": "react-scripts start", "build": "react-scripts build", "test": "react-scripts test", - "eject": "react-scripts eject" - }, - "eslintConfig": { - "extends": [ - "react-app", - "react-app/jest" - ] + "test:ci": "react-scripts test --watchAll=false", + "eject": "react-scripts eject", + "lint": "eslint src/**/*.ts*", + "fix": "npm run lint -- --fix", + "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 2a68616..8e27742 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -1,9 +1,201 @@ -import React from 'react'; -import { render, screen } from '@testing-library/react'; +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 a53698a..df11b80 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,26 +1,69 @@ -import React from 'react'; -import logo from './logo.svg'; +import {useState} from 'react'; + import './App.css'; +import './css_reset.css' +import './index.css' +import './section_view.css'; +import * as types from './types'; +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(''); + + 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} +

+ ); + } + + if (!initialProjectData) { + return ( +

+ Loading +

+ ); + } + + const pageContent = ( + + ); -function App() { - return ( -
-
- logo -

- Edit src/App.tsx and save to reload. -

- - Learn React - -
-
- ); + return ( + + + {pageContent} + + + ); } export default App; diff --git a/src/ChordProgression.tsx b/src/ChordProgression.tsx new file mode 100644 index 0000000..c619531 --- /dev/null +++ b/src/ChordProgression.tsx @@ -0,0 +1,16 @@ +import * as types from './types'; + +type ChordProgressionProps = { + chordProgression: types.ChordProgression +} + + +export const ChordProgression: React.FC = ({ chordProgression }) => { + return ( +
+
    + {chordProgression.map((chord, index) =>
  1. {chord}
  2. )} +
+
+ ); +}; diff --git a/src/Comments.tsx b/src/Comments.tsx new file mode 100644 index 0000000..3a3a91a --- /dev/null +++ b/src/Comments.tsx @@ -0,0 +1,32 @@ +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'; + +type CommentsProps = { + entityPointer: EntityPointer; +} + +export const Comments: React.FC = ({entityPointer}) => { + const globalStore = useGlobalStore(); + const comments = globalStore.getCommentsForEntity(entityPointer); + + return ( +
+ {comments.length} {plural('Comment', comments.length)} +
+ {comments.map(comment => ( +

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

+ ))} +
+
+ ); +}; diff --git a/src/CreateComment.tsx b/src/CreateComment.tsx new file mode 100644 index 0000000..8a0fb9a --- /dev/null +++ b/src/CreateComment.tsx @@ -0,0 +1,47 @@ + +import {useActions} from './actions/useActions'; +import {EntityPointer} from './types'; +import {useState} from 'react'; + +type CreateCommentProps = { + entityPointer: EntityPointer; +} + +export const CreateComment: React.FC = ({entityPointer}) => { + const actions = useActions(); + + const [name, setName] = useState(''); + const [commentText, setCommentText] = useState(''); + + const handleAddComment = async (e: React.FormEvent) => { + e.preventDefault(); + + await actions.addCommentToEntity(commentText, name, entityPointer); + setCommentText(''); + } + + 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 new file mode 100644 index 0000000..c87b9f7 --- /dev/null +++ b/src/Files.tsx @@ -0,0 +1,33 @@ +import {useGlobalStore} from './hooks/useGlobalStore'; +import * as types from './types'; +import {plural} from './utils'; + +type FilesProps = { + files: types.FileData[] +} + +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 new file mode 100644 index 0000000..b810bff --- /dev/null +++ b/src/SectionTitle.tsx @@ -0,0 +1,70 @@ +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); + + 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); + }; + + 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/css_reset.css b/src/css_reset.css new file mode 100644 index 0000000..e29c0f5 --- /dev/null +++ b/src/css_reset.css @@ -0,0 +1,48 @@ +/* http://meyerweb.com/eric/tools/css/reset/ + v2.0 | 20110126 + License: none (public domain) +*/ + +html, body, div, span, applet, object, iframe, +h1, h2, h3, h4, h5, h6, p, blockquote, pre, +a, abbr, acronym, address, big, cite, code, +del, dfn, em, img, ins, kbd, q, s, samp, +small, strike, strong, sub, sup, tt, var, +b, u, i, center, +dl, dt, dd, ol, ul, li, +fieldset, form, label, legend, +table, caption, tbody, tfoot, thead, tr, th, td, +article, aside, canvas, details, embed, +figure, figcaption, footer, header, hgroup, +menu, nav, output, ruby, section, summary, +time, mark, audio, video { + margin: 0; + padding: 0; + border: 0; + font-size: 100%; + font: inherit; + vertical-align: baseline; +} +/* HTML5 display-role reset for older browsers */ +article, aside, details, figcaption, figure, +footer, header, hgroup, menu, nav, section { + display: block; +} +body { + line-height: 1; +} +ol, ul { + list-style: none; +} +blockquote, q { + quotes: none; +} +blockquote:before, blockquote:after, +q:before, q:after { + content: ''; + content: none; +} +table { + border-collapse: collapse; + border-spacing: 0; +} 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 032464f..7b20240 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -3,15 +3,30 @@ import ReactDOM from 'react-dom/client'; import './index.css'; 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/section_view.css b/src/section_view.css new file mode 100644 index 0000000..7b8a5c8 --- /dev/null +++ b/src/section_view.css @@ -0,0 +1,168 @@ + + +* { + box-sizing: border-box; +} + +html {font-size: 62.5%;} + + +.root { + display: flex; + background-color: rgb(95, 193, 208); + justify-content: center; + flex-direction: column; + min-height: 100vh; +} + + + +div { + border: 1px solid black; + border-collapse: collapse; + +} + + + + + + + +/* SECTION TITLE STYLES */ + + +.section-title { + flex-grow: 1; + display: flex; +} + +.section-title h1 { + font-size: 4rem; +} + +.section-title p { + font-size: 2rem; +} + +.section-title .revisions { +flex-grow: 1; +display: flex; + justify-content: center; + align-items: center; +} + +.section-title .text { + flex-grow: 9; + display: flex; + justify-content: center; + align-items: center; + flex-direction: column; +} + + +/* CHORD PROGRESSION STYLES */ + + +.chords { + flex-grow: 2; + display: flex; + justify-content: space-evenly; + align-items: center; + flex-direction: row; +} + +.chords ol li{ + font-size: 7rem; + display: inline; + margin: 3rem; +} + + + +/* FILES STYLES */ + + +.files { + flex-grow: 3; + display: flex; + justify-content: space-around; + align-items: center; + flex-direction: row; +} + + +.files div { + display: inline; + margin: 3rem; + padding: 3rem; + border: 0.3rem solid rgb(0, 0, 0); + font-size: 3.5rem; +} + + + +.files span { + font-size: 3rem; +} + +.files span::first-letter { + font-size: 5rem; +} + + +/* STLYES FOR COMMENTS */ + + +.comments { + flex-grow: 3; + display: flex; + justify-content: flex-start; + align-items: flex-start; + flex-direction: column; + font-size: 2.5rem; +} + + +.comments span { + font-size: 7rem; + padding: 4rem; + margin-left: auto; + margin-right: auto; +} + +.comments p { + padding: 3rem 0; + padding-left: 3rem; +} + +.display-comments { + background-color: rgb(73, 229, 253); + margin-left: 5rem; +} + + + + + + +/* STYLES FOR SUBMITTING A COMMENT */ + + + +.submit { + flex-grow: 2; + display: flex; + justify-content: center; + align-items: center; +} + +.submit * { + margin: 5rem; +} + +.submit textarea { + width: 45%; + height: 80%; + background-color: rgb(73, 229, 253); +} 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 new file mode 100644 index 0000000..d70c88f --- /dev/null +++ b/src/types.ts @@ -0,0 +1,45 @@ +export type ProjectData = { + id: string; +} + +export enum EntityType { + PROJECT = 'project', + SECTION = 'section', + FILE = 'file', +} + +export type EntityPointer = { + entityType: EntityType; + entityId: string; +} + +export type SectionData = { + id: string; + projectId: string; + title: string; + description: string; + numRevisions: number; + chordProgression: ChordProgression; +} + +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