Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: remove default timestamp formatting props from DateSeparator, EventComponent, MessageTimestamp #2442

Merged
merged 1 commit into from
Jul 9, 2024
Merged
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
60 changes: 24 additions & 36 deletions docusaurus/docs/React/guides/date-time-formatting.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ The following components provided by the SDK display datetime:

The datetime format customization can be done on multiple levels:

1. Override the default component prop values
1. Component prop values
2. Supply custom formatting function
3. Format date via i18n

Expand All @@ -29,11 +29,11 @@ All the mentioned components accept timestamp formatter props:
```ts
export type TimestampFormatterOptions = {
/* If true, call the `Day.js` calendar function to get the date string to display (e.g. "Yesterday at 3:58 PM"). */
calendar?: boolean | null;
calendar?: boolean;
/* Object specifying date display formats for dates formatted with calendar extension. Active only if calendar prop enabled. */
calendarFormats?: Record<string, string> | null;
calendarFormats?: Record<string, string>;
/* Overrides the default timestamp format if calendar is disabled. */
format?: string | null;
format?: string;
};
```

Expand All @@ -54,7 +54,7 @@ If calendar formatting is enabled, the dates are formatted with time-relative wo
If any of the `calendarFormats` keys are missing, then the underlying library will fall back to hard-coded english equivalents
:::

If `calendar` formatting is enabled, the `format` prop would be ignored. So to apply the `format` string, the `calendar` has to be disabled (applies to `DateSeparator` and `MessageTimestamp`.
If `calendar` formatting is enabled, the `format` prop would be ignored. So to apply the `format` string, the `calendar` has to be disabled (applies to `DateSeparator` and `MessageTimestamp`).

All the components can be overridden through `Channel` component context:

Expand Down Expand Up @@ -117,44 +117,32 @@ Until now, the datetime values could be customized within the `Channel` componen

The default datetime formatting configuration is stored in the JSON translation files. The default translation keys are namespaced with prefix `timestamp/` followed by the component name. For example, the message date formatting can be targeted via `timestamp/MessageTimestamp`, because the underlying component is called `MessageTimestamp`.

##### Overriding the prop defaults

The default date and time rendering components in the SDK were created with default prop values that override the configuration parameters provided over JSON translations. Therefore, if we wanted to configure the formatting from JSON translation files, we need to nullify the prop defaults first. An example follows:
You can change the default configuration by passing an object to `translationsForLanguage` `Streami18n` option with all or some of the relevant translation keys:

```tsx
import {
DateSeparatorProps,
DateSeparator,
EventComponentProps,
EventComponent,
MessageTimestampProps,
MessageTimestamp,
} from 'stream-chat-react';

const CustomDateSeparator = (props: DateSeparatorProps) => (
<DateSeparator {...props} calendar={null} /> // calendarFormats, neither format have default value
);
import { Chat, Streami18n } from 'stream-chat-react';

const SystemMessage = (props: EventComponentProps) => (
<EventComponent {...props} format={null} /> // calendar neither calendarFormats have default value
);

const CustomMessageTimestamp = (props: MessageTimestampProps) => (
<MessageTimestamp {...props} calendar={null} format={null} /> // calendarFormats do not have default value
);
```

Now we can apply custom configuration in all the translation JSON files. It could look similar to the following key-value pair example.
const i18n = new Streami18n({
language: 'de',
translationsForLanguage: {
'timestamp/DateSeparator': '{{ timestamp | timestampFormatter(calendar: false) }}',
'timestamp/MessageTimestamp':
'{{ timestamp | timestampFormatter(calendar: true; calendarFormats: {"lastDay": "[gestern um] LT", "lastWeek": "[letzten] dddd [um] LT", "nextDay": "[morgen um] LT", "nextWeek": "dddd [um] LT", "sameDay": "[heute um] LT", "sameElse": "L"}) }}',
},
});

```json
{
"timestamp/SystemMessage": "{{ timestamp | timestampFormatter(format: YYYY) }}"
}
const ChatApp = ({ chatClient, children }) => {
return (
<Chat client={chatClient} i18nInstance={i18n}>
{children}
</Chat>
);
};
```

##### Understanding the formatting syntax

Once the default prop values are nullified, we override the default formatting rules in the JSON translation value. We can take a look at an example of German translation for SystemMessage:
Once the default prop values are nullified, we override the default formatting rules. We can take a look at an example of German translation for SystemMessage (below a JSON example - note the escaped quotes):

```
"timestamp/SystemMessage": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: {\"lastDay\": \"[gestern um] LT\", \"lastWeek\": \"[letzten] dddd [um] LT\", \"nextDay\": \"[morgen um] LT\", \"nextWeek\": \"dddd [um] LT\", \"sameDay\": \"[heute um] LT\", \"sameElse\": \"L\"}) }}",
Expand All @@ -166,7 +154,7 @@ Let's dissect the example:
- variable `timestamp` is the name of variable which value will be inserted into the string
- value separator `|` signals the separation between the interpolated value and the formatting function name
- `timestampFormatter` is the name of the formatting function that is used to convert the `timestamp` value into desired format
- the `timestampFormatter` can be passed the same parameters as the React components (`calendar`, `calendarFormats`, `format`) as if the function was called with these values. The values can be simple scalar values as well as objects (note `calendarFormats` should be an object)
- the `timestampFormatter` can be passed the same parameters as the React components (`calendar`, `calendarFormats`, `format`) as if the function was called with these values. The values can be simple scalar values as well as objects (note `calendarFormats` should be an object). The params should be separated by semicolon `;`.

:::note
The described rules follow the formatting rules required by the i18n library used under the hood - `i18next`. You can learn more about the rules in [the formatting section of the `i18next` documentation](https://www.i18next.com/translation-function/formatting#basic-usage).
Expand Down
15 changes: 15 additions & 0 deletions docusaurus/docs/React/release-guides/upgrade-to-v12.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,21 @@ title: Upgrade to v12
keywords: [migration guide, upgrade, v12, breaking changes]
---

## Date & time formatting

The components that display date and time are:

- `DateSeparator` - separates message groups in message lists
- `EventComponent` - displays system messages
- `MessageTimestamp` - displays the creation timestamp for a message in a message list

These components had previously default values for props like `format` or `calendar`. This setup required for a custom formatting to be set up via i18n service, the default values had to be nullified. For a better developer experience we decided to remove the default prop values and rely on default configuration provided via i18n translations. The value `null` is not a valid value for `format`, `calendar` or `calendarFormats` props.

:::important
**Action required**<br/>
If you are not using the default translations provided with the SDK, make sure to follow the [date & time formatting guide](../guides/date-time-formatting) to verify that your dates are formatted according to your needs.
:::

## Avatar changes

The `Avatar` styles are applied through CSS from the version 12 upwards. Therefore, the following changes were applied:
Expand Down
2 changes: 1 addition & 1 deletion src/components/DateSeparator/DateSeparator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export type DateSeparatorProps = TimestampFormatterOptions & {

const UnMemoizedDateSeparator = (props: DateSeparatorProps) => {
const {
calendar = true,
calendar,
date: messageCreatedAt,
formatDate,
position = 'right',
Expand Down
192 changes: 149 additions & 43 deletions src/components/DateSeparator/__tests__/DateSeparator.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,76 +2,182 @@ import React from 'react';
import renderer from 'react-test-renderer';
import Dayjs from 'dayjs';
import calendar from 'dayjs/plugin/calendar';
import { cleanup, render, screen } from '@testing-library/react';
import { act, cleanup, render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';

import { Chat } from '../../Chat';
import { DateSeparator } from '../DateSeparator';
import { TranslationContext } from '../../../context';
import { getTestClient } from '../../../mock-builders';
import { Streami18n } from '../../../i18n';

Dayjs.extend(calendar);

afterEach(cleanup); // eslint-disable-line

const now = new Date('2020-03-30T22:57:47.173Z');

const withContext = (props) => {
const t = jest.fn((key) => key);
const tDateTimeParser = jest.fn((input) => Dayjs(input));
const Component = (
<TranslationContext.Provider value={{ t, tDateTimeParser }}>
<DateSeparator {...props} />
</TranslationContext.Provider>
);

return { Component, t, tDateTimeParser };
const DATE_SEPARATOR_TEST_ID = 'date-separator';
const dateMock = 'the date';
const date = new Date('2020-03-30T22:57:47.173Z');
const formatDate = () => dateMock;

const renderComponent = async ({ chatProps, props }) => {
let result;
await act(() => {
result = render(
<Chat client={getTestClient()} {...chatProps}>
<DateSeparator {...props} />
</Chat>,
);
});
return result;
};

describe('DateSeparator', () => {
it('should use formatDate if it is provided', () => {
const { queryByText } = render(<DateSeparator date={now} formatDate={() => 'the date'} />);
it('should use the default formatting with calendar', async () => {
await renderComponent({ props: { date } });
expect(screen.queryByText(Dayjs(date.toISOString()).calendar())).toBeInTheDocument();
});

it('should apply custom formatting options from i18n service', async () => {
await renderComponent({
chatProps: {
i18nInstance: new Streami18n({
translationsForLanguage: {
'timestamp/DateSeparator':
'{{ timestamp | timestampFormatter(calendar: false, format: "YYYY") }}',
},
}),
},
props: { date },
});
expect(screen.queryByTestId(DATE_SEPARATOR_TEST_ID)).toHaveTextContent(
date.getFullYear().toString(),
);
});

it('should combine default formatting options from 18n service with those passed through props', async () => {
await renderComponent({
props: {
calendarFormats: {
lastDay: 'A YYYY',
lastWeek: 'B YYYY',
nextDay: 'C YYYY',
nextWeek: 'D YYYY',
sameDay: 'E YYYY',
sameElse: 'F YYYY',
},
date,
},
});
expect(screen.queryByTestId(DATE_SEPARATOR_TEST_ID)).toHaveTextContent(
`F ${date.getFullYear().toString()}`,
);
});

it('ignores calendarFormats if calendar is not enabled', async () => {
await renderComponent({
chatProps: {
i18nInstance: new Streami18n({
translationsForLanguage: {
'timestamp/DateSeparator':
'{{ timestamp | timestampFormatter(calendar: false, format: "YYYY") }}',
},
}),
},
props: {
calendarFormats: {
lastDay: 'A YYYY',
lastWeek: 'B YYYY',
nextDay: 'C YYYY',
nextWeek: 'D YYYY',
sameDay: 'E YYYY',
sameElse: 'F YYYY',
},
date,
},
});

expect(screen.queryByTestId(DATE_SEPARATOR_TEST_ID)).toHaveTextContent(
date.getFullYear().toString(),
);
});

it('should combine custom formatting options from i18n service with those passed through props', async () => {
await renderComponent({
chatProps: {
i18nInstance: new Streami18n({
translationsForLanguage: {
'timestamp/DateSeparator': '{{ timestamp | timestampFormatter(calendar: false) }}',
},
}),
},
props: { date, format: 'YYYY' },
});
expect(screen.queryByTestId(DATE_SEPARATOR_TEST_ID)).toHaveTextContent(
date.getFullYear().toString(),
);
});

it('should format date with formatDate instead of defaults provided with i18n service', async () => {
const { queryByText } = await renderComponent({
props: { date, formatDate },
});
expect(queryByText('the date')).toBeInTheDocument();
});

it('should render New text if unread prop is true', () => {
const { Component, t } = withContext({ date: now, unread: true });
render(Component);
it('should format date with formatDate instead of customs provided with i18n service', async () => {
await renderComponent({
chatProps: {
i18nInstance: new Streami18n({
translationsForLanguage: {
'timestamp/DateSeparator':
'{{ timestamp | timestampFormatter(calendar: false, format: "YYYY") }}',
},
}),
},
props: { date, formatDate },
});
expect(screen.queryByTestId(DATE_SEPARATOR_TEST_ID)).toHaveTextContent(dateMock);
});

expect(screen.getByText('New - 03/30/2020')).toBeInTheDocument();
expect(t).toHaveBeenCalledWith('New');
it('should format date with formatDate instead of customs provided via props', async () => {
await renderComponent({
chatProps: {
i18nInstance: new Streami18n({
translationsForLanguage: {
'timestamp/DateSeparator': '{{ timestamp | timestampFormatter(calendar: false) }}',
},
}),
},
props: { date, format: 'YYYY', formatDate },
});
expect(screen.queryByTestId(DATE_SEPARATOR_TEST_ID)).toHaveTextContent(dateMock);
});

it('should render properly for unread', () => {
const { Component } = withContext({ date: now, unread: true });
const tree = renderer.create(Component).toJSON();
expect(tree).toMatchInlineSnapshot(`
<div
className="str-chat__date-separator"
data-testid="date-separator"
>
<hr
className="str-chat__date-separator-line"
/>
it('should render New text if unread prop is true', async () => {
const { container } = await renderComponent({ props: { date, unread: true } });
expect(container).toMatchInlineSnapshot(`
<div>
<div
className="str-chat__date-separator-date"
class="str-chat__date-separator"
data-testid="date-separator"
>
New - 03/30/2020
<hr
class="str-chat__date-separator-line"
/>
<div
class="str-chat__date-separator-date"
>
New - 03/30/2020
</div>
</div>
</div>
`);
});

it("should use tDateTimeParser's calendar method by default", () => {
const { Component, tDateTimeParser } = withContext({ date: now });
const { queryByText } = render(Component);

expect(tDateTimeParser).toHaveBeenCalledWith(now);
expect(queryByText(Dayjs(now.toISOString()).calendar())).toBeInTheDocument();
expect(screen.getByText('New - 03/30/2020')).toBeInTheDocument();
});

describe('Position prop', () => {
const renderWithPosition = (position) => (
<DateSeparator date={now} formatDate={() => 'the date'} position={position} />
<DateSeparator date={date} formatDate={formatDate} position={position} />
);

const defaultPosition = renderer.create(renderWithPosition()).toJSON();
Expand Down
2 changes: 1 addition & 1 deletion src/components/EventComponent/EventComponent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ const UnMemoizedEventComponent = <
>(
props: EventComponentProps<StreamChatGenerics>,
) => {
const { calendar, calendarFormats, format = 'dddd L', Avatar = DefaultAvatar, message } = props;
const { calendar, calendarFormats, format, Avatar = DefaultAvatar, message } = props;

const { t, tDateTimeParser } = useTranslationContext('EventComponent');
const { created_at = '', event, text, type } = message;
Expand Down
Loading
Loading