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
3 changes: 3 additions & 0 deletions src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@
}

#App header section {
display: flex;
justify-content: space-around;
color: black;
background-color: #e0ffff;
}

Expand Down
68 changes: 65 additions & 3 deletions src/App.jsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,76 @@
import { useState } from 'react';
import messages from './data/messages.json';
import ChatLog from './components/ChatLog';
import ColorChoice from './components/ColorChoice';
import './App.css';

const numOfLikes = (messageData) => {
return messageData.reduce(
(count, message) => {
return message.liked ? count + 1 : count;
},
0
);
};
Comment on lines +7 to +14
Copy link
Copy Markdown

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.

I like that you wrapped your reduce call in a well-named helper function. This is one way that we can make using reduce a little more understandable for other programmers reading our code, since the syntax can be a little confusing.

Since the message data is passed in, we could even relocate this function completely out of this function. This is definitely business logic, not rendering logic. So it can be managed separately from our React components.


const App = () => {
const [messageData, setMessageData] = useState(messages);
const [messageColors, setMessageColors] = useState({ local: 'green', remote: 'blue' });

const localSender = messageData[0]?.sender;
const remoteSender = messageData.find(message => message.sender !== localSender)?.sender;
Comment on lines +20 to +21
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Picking out a local an remote sender works well in this example. Also think about how this could be generalized to a conversation with more than two participants (group chat)!


const handleLikeStatus = messageId => {
setMessageData((prevMessages) => (
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

👍 Nice use of the callback setter style. In this application, it doesn't really matter whether we use the callback style or the value style, but it's good practice to get in the habit of using the callback style.

prevMessages.map(message =>
message.id === messageId
? { ...message, liked: !message.liked }
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

: message
)
));
};

const getSenderType = (senderName) => (
senderName === localSender ? 'local' : 'remote'
);

const updateMessageColor = (sender, color) => {
const senderType = getSenderType(sender);
setMessageColors(prevColors => (
{ ...prevColors, [senderType]: color }
));
};

const totalLikes = numOfLikes(messageData);

return (
<div id="App">
<header>
<h1>Application title</h1>
<h1>
Chat Between{' '}
<span className={messageColors.local}>{localSender}</span>{' '}
and{' '}
<span className={messageColors.remote}>{remoteSender}</span>
Comment on lines +50 to +53
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

👍 Nice use of the calculated senders to build the header. I see that you also ran into the quirk that JSX really likes to "swallow" whitespace at the start and end of a line (different from plain HTML), requiring the insertion of explicit blanks here and there.

</h1>
<section>
<div>
<p className={messageColors.local}>{localSender}'s color:</p>
<ColorChoice sender={localSender} onUpdateColor={updateMessageColor} />
</div>
<h1>{totalLikes} ❤️s</h1>
<div>
<p className={messageColors.remote}>{remoteSender}'s color:</p>
<ColorChoice sender={remoteSender} onUpdateColor={updateMessageColor} />
</div>
</section>
Comment on lines +55 to +65
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Note that adding in the pickers here ends up cutting off the top of the first message. Some additional style tweaks might be necessary to ensure the messages don't start until below the header.

</header>
<main>
{/* Wave 01: Render one ChatEntry component
Wave 02: Render ChatLog component */}
<ChatLog
entries={messageData}
onToggleMessageLike={handleLikeStatus}
messageColors={messageColors}
getSenderType={getSenderType}
/>
</main>
</div>
);
Expand Down
3 changes: 2 additions & 1 deletion src/components/ChatEntry.css
Original file line number Diff line number Diff line change
Expand Up @@ -97,4 +97,5 @@ button {

.chat-entry.remote .entry-bubble:hover::before {
background-color: #a9f6f6;
}
}

38 changes: 29 additions & 9 deletions src/components/ChatEntry.jsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,41 @@
import PropTypes from 'prop-types';
import './ChatEntry.css';
import TimeStamp from './TimeStamp';
import { messageDataProtoTypes, messageColorsProtoTypes } from './sharedPropTypes';

const ChatEntry = ({
id,
sender,
body,
timeStamp,
liked,
onToggleLike,
messageColors,
getSenderType,
}) => {

const senderType = getSenderType && getSenderType(sender);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Passing down a "classifier" function like this does change where the logic is defined (and the classifier can be independently tested), but this still requires that ChatEntry know that classification of the message (and upon what to classify it; i.e., what to pass to the classifier) is required. Really, all the ChatEntry should need to worry about is "how do I render a message that's local, and one that isn't?".

So rather than passing down a classifier like this along with the raw message data, we can make a new type that in addition to the raw message data also includes per-message information needed for rendering, such as whether the message should be considered local or remote. This new data type serves as a "model" for the "view", which we can call a "ViewModel". The non-React business logic would be responsible for this conversion, leaving React only responsible for interpreting the results (e.g., whether the message viewmodel indicates the entry should be local). This helps us keep as much business logic out of the React rendering logic as possible.


const ChatEntry = () => {
return (
// Replace the outer tag name with a semantic element that fits our use case
<replace-with-relevant-semantic-element className="chat-entry local">
<h2 className="entry-name">Replace with name of sender</h2>
<section className={`chat-entry ${senderType}`}>
<h2 className="entry-name">{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>
<p className={messageColors?.[senderType] ?? ''}>{body}</p>
<p className="entry-time"><TimeStamp time={timeStamp} /></p>
Copy link
Copy Markdown

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={() => onToggleLike(id)}>{liked ? '❤️' : '🤍'}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Note that for any props that aren't required, we should check that a value was supplied before trying to use it. For example, trying to call a function reference if the value is undefined will result in an error.

</button>
</section>
</replace-with-relevant-semantic-element>
</section>
);
};

ChatEntry.propTypes = {
// Fill with correct proptypes
...messageDataProtoTypes,
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

👍 Great use of spreading to be able to include the base members from the shared entry proptypes, while allowing for additional props to be specified.

onToggleLike: PropTypes.func,
messageColors: PropTypes.shape(
messageColorsProtoTypes
),
getSenderType: PropTypes.func,
};

export default ChatEntry;
1 change: 1 addition & 0 deletions src/components/ChatEntry.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ describe('Wave 01: ChatEntry', () => {
);
});


test('renders without crashing and shows the sender', () => {
expect(screen.getByText(/Joe Biden/)).toBeInTheDocument();
});
Expand Down
2 changes: 2 additions & 0 deletions src/components/ChatLog.css
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,5 @@
margin: auto;
max-width: 50rem;
}


42 changes: 42 additions & 0 deletions src/components/ChatLog.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import PropTypes from 'prop-types';
import './ChatLog.css';
import ChatEntry from './ChatEntry';
import { messageDataProtoTypes, messageColorsProtoTypes } from './sharedPropTypes';

const ChatLog = ({
entries,
onToggleMessageLike,
messageColors,
getSenderType,
}) => {

return (
<div className='chat-log'>
{entries.map(message =>
Copy link
Copy Markdown

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={message.id}
id={message.id}
Comment on lines +17 to +18
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

👍 The key attribute is important for React to be able to detect certain kinds of data changes in an efficient manner. We're also using the id for our own id prop, so it might feel redundant to pass both, but one is for our logic and one is for React internals (we can't safely access the key value in any meaningful way).

sender={message.sender}
body={message.body}
timeStamp={message.timeStamp}
liked = {message.liked}
onToggleLike={onToggleMessageLike}
messageColors={messageColors}
getSenderType={getSenderType}
/>)}
</div>
);
};

ChatLog.propTypes = {
entries: PropTypes.arrayOf(PropTypes.shape({
...messageDataProtoTypes
})).isRequired,
onToggleMessageLike: PropTypes.func,
messageColors: PropTypes.shape(
messageColorsProtoTypes
),
getSenderType: PropTypes.func,
};

export default ChatLog;
9 changes: 9 additions & 0 deletions src/components/ColorChoice.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
.color-container {
display: flex;
flex-direction: row;
justify-content: center;
}

.button-size {
font-size: 2rem;
}
29 changes: 29 additions & 0 deletions src/components/ColorChoice.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import PropTypes from 'prop-types';
import './ColorChoice.css';

const COLORS = ['red', 'orange', 'yellow', 'green', 'blue', 'purple'];

const ColorChoice = ({ sender, onUpdateColor }) => {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

👍 Nice creation of a component to help with picking a color.

Notice that other than the name of the sender parameter, this component is independent of the other notions present in the app. The sender param itself is only used to send back to the change handler, acting as sort of an "id" for this control to identify which instance called the change handler. As such, consider using a more general name, such as id or name (similar to like a button control), or you might see such an "opaque" identifier refered to as a slug.

Passing an identifier like this requires then that the component know that it needs to pass that value along with the selected value to the callback function. That approach works fine, and is similar to what we did for handling changes to the message like. Some other possibilities could involve packaging up the name and new value into an event-like structure, similar to browser-native controls.

Or to avoid needing to have the control need to know that an additional value should be passed, we could instead write this to only call the callback with the color information, but pass different functions to different control instances so that the callback itself knows which sender it was related to. The App could have a function that creates such "bound" functions for us (a function that takes the name info it needs, and returns a function that takes a color value and already has the name info baked into it).

// in App
const makeColorHandlerForName = (name) => {
    // returned inner function is given to the color picker component
    return (color) => {
        // does whatever needs to be done to handle updating the color for name
        // both name and color are available to this function
    };
};

return (
<div className="color-container">
{COLORS.map((color, index) => {
return (
<button
key={index}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

We could even use the color itself as the key. key needn't be an integer. It just needs to be unique.

className={`button-size ${color}`}
onClick={() => onUpdateColor(sender, color)}
>
<p className='button-size'>&#9679;</p>
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Consider making a CONSTANT like const CIRCLE_GLYPH = '&#9679;'; and injecting it as { CIRCLE_GLYPH } to make this more readable without the magic number.

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

ColorChoice.propTypes = {
sender: PropTypes.string.isRequired,
onUpdateColor: PropTypes.func.isRequired
};

export default ColorChoice;
14 changes: 14 additions & 0 deletions src/components/sharedPropTypes.js
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

👍 Nice use of a shared file to store the proptypes for chat entries. This allows us to avoid duplicating them in ChatEntry and ChatLog.

Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import PropTypes from 'prop-types';

export const messageDataProtoTypes = {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I'd avoid using the naming ProtoTypes here, as it's close but not quite PropType and sounds like it references a prototype, which is a different concept in JavaScript. So even though this isn't a full PropType definition, one of the following would convey the intent of how it should be usede.

export const messageDataPropTypes = {

or

export const messageDataShape = {

id: PropTypes.number.isRequired,
sender: PropTypes.string.isRequired,
body: PropTypes.string.isRequired,
timeStamp: PropTypes.string.isRequired,
liked: PropTypes.bool.isRequired,
Comment on lines +4 to +8
Copy link
Copy Markdown

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.

};

export const messageColorsProtoTypes = {
local: PropTypes.string.isRequired,
remote: PropTypes.string.isRequired,
};