Skip to content

Commit

Permalink
feat: remove default timestamp formatting props from DateSeparator, E…
Browse files Browse the repository at this point in the history
…ventComponent, MessageTimestamp
  • Loading branch information
MartinCupela committed Jul 1, 2024
1 parent aeb87ff commit f6061a1
Show file tree
Hide file tree
Showing 24 changed files with 547 additions and 223 deletions.
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

0 comments on commit f6061a1

Please sign in to comment.