Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ export default [{
"comma-dangle": ["warn", "only-multiline"],
"dot-notation": "warn",
"space-before-function-paren": "off",
"indent": ["warn", 2],
"indent": ["warn", 'tab'],

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What led you to change this? There are definitely arguments for using tabs rather than spaces, but in that case, what tab width are you using locally? GitHub assumes 8 spaces (!) for tabs, which would be deep even for Python, but is especially cumbersome in JS, where there is a lot of indentation due to nested anonymous functions.

"padded-blocks": "warn",
"no-trailing-spaces": "warn",
"array-bracket-spacing": "warn",
Expand Down
11 changes: 11 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"test": "vitest --run"
},
"dependencies": {
"date-fns": "^4.1.0",

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need to bring in another dependency.

"luxon": "^2.5.2",
"react": "^18.3.1",
"react-dom": "^18.3.1"
Expand Down
22 changes: 14 additions & 8 deletions src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -31,18 +31,24 @@
}

#App .widget {
display: inline-block;
line-height: 0.5em;
border-radius: 10px;
position: fixed;
top: 9.6vh;
width: 100%;
height: 9vh;
background-color: #e0ffff;
color: black;
font-size:0.8em;
padding-left: 1em;
padding-right: 1em;
}
font-size: 1.5em;
text-align: center;
display: flex; /* enables centering */
align-items: center; /* vertical centering */
justify-content: center; /* horizontal centering */
box-sizing: border-box;
z-index: 90;


#App #heartWidget {
font-size: 1.5em;
margin: 1em
margin: 0em
}

#App span {
Expand Down
38 changes: 27 additions & 11 deletions src/App.jsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,33 @@
import './App.css';
import entriesData from './data/messages.json';
import ChatLog from './components/ChatLog';
import { useState } from 'react';

const App = () => {
return (
<div id="App">
<header>
<h1>Application title</h1>
</header>
<main>
{/* Wave 01: Render one ChatEntry component
Wave 02: Render ChatLog component */}
</main>
</div>
);
const [entries, setEntries] = useState(entriesData);
const toggleLike = (id) => {
const updatedEntries = entries.map(entry => {
if (entry.id === id) {
return { ...entry, liked: !entry.liked } ;
} else{
return entry;
}
});
setEntries(updatedEntries);
Comment on lines +9 to +16

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this case, calculating the next version of the message data using the current state variable and passing that updated version to the state setter shouldn't cause any problems, but we still generally prefer using the callback style of setters. Using that approach, we pass a function to the setter whose parameter will receive the latest state value, and which returns the new value to use for the state.

    setEntries(entries => entries.map(entry => {
      if (entry.id === id) {
        return {...entry, liked: !entry.liked};
      } else {
        return entry;
      };
    }));

We showed this approach in class, but technically, we're mixing a few responsibilities here. rather than this function needing to know how to change the liked status itself, we could move this update logic to a helper function. This would better mirror how we eventually update records when there's an API call involved.

In this project, our messages are very simple objects, but if we had more involved operations, it could be worthwhile to create an actual class with methods to work with them, or at least have a set of dedicated helper functions to centralize any such mutation logic.

};
const totalLikes = entries.filter((entry) => entry.liked).length;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice job determining the total likes based on the like data of each message. We don't need an additional piece of state to track this, since it can be derived from the existing state we are tracking.

Great idea to filter down the data to the liked messages and use the list length as the count. This is a really understandable alternative to the more canonical reduce approach. Compared to reduce, it does make a list potentially as long as the message list, but since we know a similar copying gets done anyway during a render (building the ChatEntry components) on the whole it's not any more costly.


return (
<div id="App">
<header>
<h1>Chat between {entries[0].sender} and {entries[1].sender}</h1>

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice way to read the participant names from the messages. Notice that this assumes there are only two participants in this conversation, and that they are found in the first two messages. What would happen for other conversation situations?

  • no one has sent a message yet
  • only one participant has sent a message
  • a single participant sends multiple messages before getting a response
  • there are more than two participants in this conversation

Some of these cases might not really be workable given the limited data we're working with, but it's worth thinking about what this could look like for a more complete application.

</header>
<section id="heartWidget"className="widget">{totalLikes}&nbsp;❤️{totalLikes !== 1 ? 's' : ''}</section>

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

section tags are recommended to have a heading within them. Use of an h2 around the liked count would avoid validation warnings.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 Nice logic to handle pluralization.

<main>
<ChatLog entries={entries} onToggleLike={toggleLike} />
</main>
</div>
);
};

export default App;
45 changes: 32 additions & 13 deletions src/components/ChatEntry.jsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,39 @@
import './ChatEntry.css';
import PropTypes from 'prop-types';
import TimeStamp from './TimeStamp';

const ChatEntry = () => {
return (
<div className="chat-entry local">
<h2 className="entry-name">Replace with name of sender</h2>
<section className="entry-bubble">
<p>Replace with body of ChatEntry</p>
<p className="entry-time">Replace with TimeStamp component</p>
<button className="like">🤍</button>
</section>
</div>
);

const ChatEntry = (props) => {
const localSender = props.sender === 'Vladimir';
const entryClass = `chat-entry ${localSender ? 'local' : 'remote'}`;
Comment on lines +7 to +8

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice logic to decide whether to treat a message as local or remote. How could we generalize this so that it didn't need to look explicitly for Vladimir? This project only passes in a single conversation, but ideally, our components should work in other situations.

In the general case, does the ChatEntry itself have enough information as it is to "know" which messages are local and which are remote?

// const likedStatus = () => {
// props.liked ? 'liked' : 'not-liked';

return (
<div className={entryClass} key={props.id}>
<h2 className="entry-name">{props.sender}</h2>
<section className="entry-bubble">
<p>{props.body}</p>
<p className="entry-time"><TimeStamp time={props.timeStamp} /></p>

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice use of the supplied TimeStamp. We pass in the timeStamp string from the message data and it takes care of the rest. All we had to do was confirm the name and type of the prop it was expecting (which we could do through its PropTypes) and we're all set!

<button className="like" onClick={() => props.onToggleLike(props.id)}>

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 Passing the id of this message lets the logic defined up in the App find the message to update in its data.

{props.liked ? '❤️' : '🤍'}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 Nice use of a ternary to pick the emoji to show for the liked status. Consider moving even something small like this to a helper function external to the component, so that the behavior of the emoji-picking logic can be tested easily outside of React.

</button>
</section>
</div>
);
};

ChatEntry.propTypes = {
// Fill with correct proptypes
id: PropTypes.number.isRequired,
sender: PropTypes.string.isRequired,
body: PropTypes.string.isRequired,
timeStamp: PropTypes.string.isRequired,
liked: PropTypes.bool.isRequired,
onToggleLike: PropTypes.func, //had to remove isRequired to pass the test from Waves 1 and 2

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The id, sender, body, timeStamp, and liked props are always passed (they're defined explicitly in the data and also provided in the test) so we can (and should) mark them isRequired.

The remaining props were up to you, and the tests don't know about them. As a result, using isRequired would cause a warning when running any tests that only pass the known props. As a result, either leaving the prop not marked isRequired or updating the tests to pass a dummy function is required to avoid the warnings.

If we do leave props marked not isRequired, we should either have logic to avoid using those values if they are missing, or make allowance for using a default value as you did, though note the warning that React reports below.

};

ChatEntry.defaultProps = { // added dafault props so it stays always defined
onToggleLike: () => {},
};
Comment on lines +35 to 37

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

React reports a warning about defaultProps being slated for removal, and to prefer to use JS native default paramaters instead (which can work with destructured object arguments).


export default ChatEntry;
export default ChatEntry;
27 changes: 27 additions & 0 deletions src/components/ChatLog.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// import entries from './data/messages.json';
import PropTypes from 'prop-types';
import ChatEntry from './ChatEntry';

const ChatLog = ({ entries, onToggleLike }) => {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ChatEntry uses a single props param, but ChatLog uses a destructured object. Personally, I prefer the destructured style, since it makes the expected component attributes a bit more clear. And it's fine to use a mixture of styles in this project, but in general, try to pick one style or the other.

return (
<div id="ChatLog">
{entries.map((entry) => (

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice use of map to convert from the message data into ChatEntry components. We could perform this mapping storing the result into a variable we use in the JSX result, we could make a helper function that we call as part of the return, or we can perform the map expression as part of the return JSX as you did here. I often like having relatively simple maps directly in the JSX since it helps me see the overall structure of the component, though it can make debugging a little more tricky. But any of those approaches will work fine.

<ChatEntry key={entry.id} {...entry} onToggleLike={onToggleLike} />

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔎 While spreading to pass props can reduce perceived redundancy, React style practices generally recommend passing the props explicitly. Spreading can lead to unintended data being passed, and obscures what values are being provided to the component. It's not a problem for a small example like this project, but it can make it harder to keep track of data in larger applications. PropTypes (or TS types going forward) can help us catch cases where a breaking change might occur, but unless working on a team with a style guide that favors using spreading, prefer to explicitly break out the props.

))}
</div>
);
};
ChatLog.propTypes = {
entries: PropTypes.arrayOf(
PropTypes.shape({
id: PropTypes.number.isRequired,
sender: PropTypes.string.isRequired,
body: PropTypes.string.isRequired,
timeStamp: PropTypes.string.isRequired,
liked: PropTypes.bool.isRequired
})
).isRequired,
onToggleLike: PropTypes.func.isRequired,
Comment on lines +15 to +24

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar to the props for ChatEntry, here, the entries prop is included in the tests, but the like toggle is not, resulting in prop warnings (unless we update the tests to reflect our custom props).

Again, if we were to leave this as not required so as to avoid the test warnings, we'd want to be sure that all the script logic in our component worked properly even in the absence of this value.

};

export default ChatLog;
120 changes: 60 additions & 60 deletions src/components/ChatLog.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,70 +3,70 @@ import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';

const LOG = [
{
id: 1,
sender: 'Vladimir',
body: 'why are you arguing with me',
timeStamp: '2018-05-29T22:49:06+00:00',
liked: false,
},
{
id: 2,
sender: 'Estragon',
body: 'Because you are wrong.',
timeStamp: '2018-05-29T22:49:33+00:00',
liked: false,
},
{
id: 3,
sender: 'Vladimir',
body: 'because I am what',
timeStamp: '2018-05-29T22:50:22+00:00',
liked: false,
},
{
id: 4,
sender: 'Estragon',
body: 'A robot.',
timeStamp: '2018-05-29T22:52:21+00:00',
liked: false,
},
{
id: 5,
sender: 'Vladimir',
body: 'Notabot',
timeStamp: '2019-07-23T22:52:21+00:00',
liked: false,
},
{
id: 1,
sender: 'Vladimir',
body: 'why are you arguing with me',
timeStamp: '2018-05-29T22:49:06+00:00',
liked: false,
},
{
id: 2,
sender: 'Estragon',
body: 'Because you are wrong.',
timeStamp: '2018-05-29T22:49:33+00:00',
liked: false,
},
{
id: 3,
sender: 'Vladimir',
body: 'because I am what',
timeStamp: '2018-05-29T22:50:22+00:00',
liked: false,
},
{
id: 4,
sender: 'Estragon',
body: 'A robot.',
timeStamp: '2018-05-29T22:52:21+00:00',
liked: false,
},
{
id: 5,
sender: 'Vladimir',
body: 'Notabot',
timeStamp: '2019-07-23T22:52:21+00:00',
liked: false,
},
];

describe('Wave 02: ChatLog', () => {
beforeEach(() => {
render(<ChatLog entries={LOG} />);
});
beforeEach(() => {
render(<ChatLog entries={LOG} />);
});

test('renders without crashing and shows all the names', () => {
[
{
name: 'Vladimir',
numChats: 3,
},
{
name: 'Estragon',
numChats: 2,
},
].forEach((person) => {
const elementList = screen.getAllByText(new RegExp(person.name));
expect(elementList.length).toEqual(person.numChats);
test('renders without crashing and shows all the names', () => {
[
{
name: 'Vladimir',
numChats: 3,
},
{
name: 'Estragon',
numChats: 2,
},
].forEach((person) => {
const elementList = screen.getAllByText(new RegExp(person.name));
expect(elementList.length).toEqual(person.numChats);

elementList.forEach((element) => {
expect(element).toBeInTheDocument();
});
});
});
elementList.forEach((element) => {
expect(element).toBeInTheDocument();
});
});
});

test('renders an empty list without crashing', () => {
const element = render(<ChatLog entries={[]} />);
expect(element).not.toBeNull();
});
test('renders an empty list without crashing', () => {
const element = render(<ChatLog entries={[]} />);
expect(element).not.toBeNull();
});
});