Skip to content

Commit

Permalink
refactor: remove calendarFormats option from timestamp formatting
Browse files Browse the repository at this point in the history
  • Loading branch information
MartinCupela committed Jun 11, 2024
1 parent 9511550 commit 6b33ced
Show file tree
Hide file tree
Showing 18 changed files with 77 additions and 167 deletions.
28 changes: 16 additions & 12 deletions docusaurus/docs/React/guides/date-time-formatting.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,12 @@ All the mentioned components accept timestamp formatter props:
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;
/* Object specifying date display formats for dates formatted with calendar extension. Active only if calendar prop enabled. */
calendarFormats?: Record<string, string> | null;
/* Overrides the default timestamp format if calendar is disabled. */
format?: string | null;
};
```

If calendar formatting is enabled, the dates are formatted with time-relative words ("yesterday at ...", "last ..."). The calendar strings can be further customized with `calendarFormats` object. It also means that the `format` prop would be ignored. On the other hand, if calendar is disabled, then `calendarFormats` is ignored and `format` string is applied.
If calendar formatting is enabled, the dates are formatted with time-relative words ("yesterday at ...", "last ..."). When `calendar` is enabled, the `format` prop is ignored and vice versa. If `calendar` is disabled, `format` string will be applied.

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

Expand All @@ -61,7 +59,7 @@ const SystemMessage = (props: EventComponentProps) => (
);

const CustomMessageTimestamp = (props: MessageTimestampProps) => (
<MessageTimestamp {...props} calendar={false} format={'YYYY-MM-DDTHH:mm:ss'} /> // calendar is enabled by default
<MessageTimestamp {...props} format={'YYYY-MM-DDTHH:mm:ss'} /> // calendar is disabled by default
);

const App = () => (
Expand All @@ -88,7 +86,7 @@ By default, the function is consumed by the `MessageTimestamp` component. This m

### Date & time formatting with i18n service

Until now, the datetime values could be customized within `Channel` at best. Formatting via i18n service allows for SDK wide configuration. The configuration is stored with other translations in JSON files. Formatting with i18n service has the following advantages:
Until now, the datetime values could be customized within the `Channel` component at best. Formatting via i18n service allows for SDK wide configuration. The configuration is stored with other translations in JSON files. Formatting with i18n service has the following advantages:

- it is centralized
- it takes into consideration the locale out of the box
Expand All @@ -102,7 +100,7 @@ The default datetime formatting configuration is stored in the JSON translation

##### 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. An example follows:
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:

```jsx
import {
Expand All @@ -115,19 +113,25 @@ import {
} from 'stream-chat-react';

const CustomDateSeparator = (props: DateSeparatorProps) => (
<DateSeparator {...props} calendar={null} calendarFormats={null} format={null} />
<DateSeparator {...props} calendar={null} format={null} />
);

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

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

Besides overriding the formatting parameters above, we can customize the translation key via `timestampTranslationKey` prop all the above components (`DateSeparator`, `EventComponent`, `Timestamp`).
Now we can apply custom configuration in all the translation JSON files. It could look similar to the following example key-value pair.

```
"timestamp/SystemMessage": "{{ timestamp, timestampFormatter(format: YYYY) }}",
```

Besides overriding the formatting parameters above, we can customize the translation key via `timestampTranslationKey` prop. All the above components (`DateSeparator`, `EventComponent`, `Timestamp`) accept this prop.

```tsx
import { MessageTimestampProps, MessageTimestamp } from 'stream-chat-react';
Expand All @@ -142,15 +146,15 @@ const CustomMessageTimestamp = (props: MessageTimestampProps) => (
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:

```
"timestamp/SystemMessage": "{{ timestamp, timestampFormatter(calendar: true; calendarFormats: {\"sameElse\": \"dddd L\"}) }}",
"timestamp/SystemMessage": "{{ timestamp, timestampFormatter(calendar: true) }}",
```

Let's dissect the example:

- The curly brackets (`{{`, `}}`) indicate the place where a value will be interpolated (inserted) into the string.
- variable `timestamp` is the name of variable which value will be inserted into the string
- `timestampFormatter` is the name of the formatting function that is used to convert the `timestamp` value into desired format
- the `timestampFormatter` is 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` is can be passed the same parameters as the React components (`calendar`, `format`) as if the function was called with these values.

:::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
4 changes: 2 additions & 2 deletions src/components/DateSeparator/DateSeparator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,18 +20,18 @@ const UnMemoizedDateSeparator = (props: DateSeparatorProps) => {
const {
calendar = true,
date: messageCreatedAt,
format,
formatDate,
position = 'right',
timestampTranslationKey = 'timestamp/DateSeparator',
unread,
...restTimestampFormatterOptions
} = props;

const { t, tDateTimeParser } = useTranslationContext('DateSeparator');

const formattedDate = getDateString({
calendar,
...restTimestampFormatterOptions,
format,
formatDate,
messageCreatedAt,
t,
Expand Down
6 changes: 2 additions & 4 deletions src/components/EventComponent/EventComponent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,8 @@ const UnMemoizedEventComponent = <
props: EventComponentProps<StreamChatGenerics>,
) => {
const {
calendar = true,
calendarFormats = { sameElse: 'dddd L' },
format,
calendar = false,
format = 'dddd L',
Avatar = DefaultAvatar,
message,
timestampTranslationKey = 'timestamp/SystemMessage',
Expand All @@ -54,7 +53,6 @@ const UnMemoizedEventComponent = <
{getDateString({
...getDateOptions,
calendar,
calendarFormats,
format,
t,
timestampTranslationKey,
Expand Down
3 changes: 0 additions & 3 deletions src/components/Message/Timestamp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ export const defaultTimestampFormat = 'h:mmA';
export function Timestamp(props: TimestampProps) {
const {
calendar,
calendarFormats,
customClass,
format = defaultTimestampFormat,
timestamp,
Expand All @@ -35,7 +34,6 @@ export function Timestamp(props: TimestampProps) {
() =>
getDateString({
calendar,
calendarFormats,
format,
formatDate,
messageCreatedAt: normalizedTimestamp,
Expand All @@ -45,7 +43,6 @@ export function Timestamp(props: TimestampProps) {
}),
[
calendar,
calendarFormats,
format,
formatDate,
normalizedTimestamp,
Expand Down
147 changes: 43 additions & 104 deletions src/i18n/__tests__/utils.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -98,54 +98,40 @@ describe('getDateString', () => {
).toBe(expectedValue);
});

it.each([
['defined', { x: 'y' }],
['undefined', undefined],
])(
'invokes calendar method on dayOrMoment object with calendar formats %s',
(_, calendarFormats) => {
const dayOrMoment = {
calendar: jest.fn(),
format: jest.fn(),
isSame: true,
};
getDateString({
calendar: true,
calendarFormats,
format: 'hh:mm A',
formatDate: undefined,
messageCreatedAt,
tDateTimeParser: () => dayOrMoment,
});
expect(dayOrMoment.calendar).toHaveBeenCalledWith(undefined, calendarFormats);
expect(dayOrMoment.format).not.toHaveBeenCalled();
},
);
it('invokes calendar method on dayOrMoment object', () => {
const dayOrMoment = {
calendar: jest.fn(),
format: jest.fn(),
isSame: true,
};
getDateString({
calendar: true,
format: 'hh:mm A',
formatDate: undefined,
messageCreatedAt,
tDateTimeParser: () => dayOrMoment,
});
expect(dayOrMoment.calendar).toHaveBeenCalledWith();
expect(dayOrMoment.format).not.toHaveBeenCalled();
});

it.each([
['defined', { x: 'y' }],
['undefined', undefined],
])(
'invokes format method on dayOrMoment object with calendar formats %s',
(_, calendarFormats) => {
const dayOrMoment = {
calendar: jest.fn(),
format: jest.fn(),
isSame: true,
};
const format = 'XY';
getDateString({
calendar: false,
calendarFormats,
format,
formatDate: undefined,
messageCreatedAt,
tDateTimeParser: () => dayOrMoment,
});
expect(dayOrMoment.format).toHaveBeenCalledWith(format);
expect(dayOrMoment.calendar).not.toHaveBeenCalled();
},
);
it('invokes format method on dayOrMoment object', () => {
const dayOrMoment = {
calendar: jest.fn(),
format: jest.fn(),
isSame: true,
};
const format = 'XY';
getDateString({
calendar: false,
format,
formatDate: undefined,
messageCreatedAt,
tDateTimeParser: () => dayOrMoment,
});
expect(dayOrMoment.format).toHaveBeenCalledWith(format);
expect(dayOrMoment.calendar).not.toHaveBeenCalled();
});

it.each([null, undefined, {}, [], new Set(), true, new RegExp('')])(
'returns null if datetime formatter does not return either string, number or Date instance',
Expand All @@ -161,6 +147,7 @@ describe('getDateString', () => {
).toBeNull();
},
);

it('gives preference to custom formatDate function before translation', () => {
const expectedValue = 0;
const formatDate = jest.fn();
Expand All @@ -176,6 +163,7 @@ describe('getDateString', () => {
expect(t).not.toHaveBeenCalled();
expect(formatDate).toHaveBeenCalledWith(new Date(messageCreatedAt));
});

it('does not apply translation if timestampTranslationKey key is missing', () => {
const expectedValue = new Date().toISOString();
const result = getDateString({
Expand All @@ -188,6 +176,7 @@ describe('getDateString', () => {
expect(t).not.toHaveBeenCalled();
expect(result).toBe(expectedValue);
});

it('does not apply translation if translator function is missing', () => {
const expectedValue = new Date().toISOString();
const result = getDateString({
Expand All @@ -200,34 +189,14 @@ describe('getDateString', () => {
expect(t).not.toHaveBeenCalled();
expect(result).toBe(expectedValue);
});

it.each([
['all enabled', { calendar: true, calendarFormats: { x: 'y' }, format: 'hh:mm A' }],
['calendar disabled', { calendar: false, calendarFormats: { x: 'y' }, format: 'hh:mm A' }],
['calendar formats omitted', { calendar: true, calendarFormats: undefined, format: 'hh:mm A' }],
['only format provided', { calendar: false, calendarFormats: undefined, format: 'hh:mm A' }],
['format undefined', { calendar: true, calendarFormats: { x: 'y' }, format: undefined }],
[
'calendar disabled and format undefined',
{ calendar: false, calendarFormats: { x: 'y' }, format: undefined },
],
[
'calendar formats and format undefined',
{ calendar: true, calendarFormats: undefined, format: undefined },
],
[
'calendar disabled and rest undefined',
{ calendar: false, calendarFormats: undefined, format: undefined },
],
['calendar undefined', { calendar: undefined, calendarFormats: { x: 'y' }, format: 'hh:mm A' }],
[
'calendar and calendar formats undefined',
{ calendar: undefined, calendarFormats: undefined, format: 'hh:mm A' },
],
[
'calendar and format undefined',
{ calendar: undefined, calendarFormats: { x: 'y' }, format: undefined },
],
['all undefined', { calendar: undefined, calendarFormats: undefined, format: undefined }],
['all enabled', { calendar: true, format: 'hh:mm A' }],
['calendar disabled', { calendar: false, format: 'hh:mm A' }],
['format undefined', { calendar: true, format: undefined }],
['calendar disabled and format undefined', { calendar: false, format: undefined }],
['calendar undefined', { calendar: undefined, format: 'hh:mm A' }],
['all undefined', { calendar: undefined, format: undefined }],
])(
'applies formatting via translation service with translation formatting params %s',
(_, params) => {
Expand Down Expand Up @@ -267,40 +236,13 @@ describe('predefinedFormatters', () => {
expect(
timestampFormatter(yesterday, 'en', {
calendar: true,
calendarFormats: { sameElse: 'dddd L' },
}).startsWith('Yesterday'),
).toBeTruthy();
});
it('should ignore calendarFormats if calendar is disabled', () => {
expect(
timestampFormatter(yesterday, 'en', {
calendar: false,
calendarFormats: { sameElse: 'dddd L' },
}).startsWith('Yesterday'),
).toBeFalsy();
});
it('should log error parsing invalid calendarFormats', () => {
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementationOnce(() => null);
timestampFormatter(yesterday, 'en', { calendar: true, calendarFormats: '}' });
expect(consoleErrorSpy.mock.calls[0][0]).toBe('[TIMESTAMP FORMATTER]');
expect(
consoleErrorSpy.mock.calls[0][1].message.startsWith('Unexpected token'),
).toBeTruthy();
consoleErrorSpy.mockRestore();
});
it('should parse calendarFormats', () => {
expect(
timestampFormatter(yesterday, 'en', {
calendar: true,
calendarFormats: '{ "sameElse": "dddd L" }',
}).startsWith('Yesterday'),
).toBeTruthy();
});
it('should ignore format parameter if calendar is enabled', () => {
expect(
timestampFormatter(yesterday, 'en', {
calendar: true,
calendarFormats: { sameElse: 'dddd L' },
format: 'YYYY',
}).startsWith('Yesterday'),
).toBeTruthy();
Expand All @@ -309,7 +251,6 @@ describe('predefinedFormatters', () => {
expect(
timestampFormatter(yesterday, 'en', {
calendar: false,
calendarFormats: { sameElse: 'dddd L' },
format: 'YYYY',
}),
).toBe(new Date().getFullYear().toString());
Expand All @@ -320,7 +261,6 @@ describe('predefinedFormatters', () => {
expect(
timestampFormatter(null, 'en', {
calendar: false,
calendarFormats: { sameElse: 'dddd L' },
format: 'YYYY',
}),
).toBe('null');
Expand All @@ -329,7 +269,6 @@ describe('predefinedFormatters', () => {
expect(
timestampFormatter(undefined, 'en', {
calendar: false,
calendarFormats: { sameElse: 'dddd L' },
format: 'YYYY',
}),
).toBeUndefined();
Expand Down
2 changes: 1 addition & 1 deletion src/i18n/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@
"searchResultsCount_other": "{{ count }} Ergebnisse",
"this content could not be displayed": "Dieser Inhalt konnte nicht angezeigt werden",
"timestamp/DateSeparator": "{{ timestamp, timestampFormatter(calendar: true) }}",
"timestamp/SystemMessage": "{{ timestamp, timestampFormatter(calendar: true; calendarFormats: {\"sameElse\": \"dddd L\"}) }}",
"timestamp/SystemMessage": "{{ timestamp, timestampFormatter(calendar: false; format: dddd L) }}",
"timestamp/MessageTimestamp": "{{ timestamp, timestampFormatter(calendar: true; format: h:mmA) }}",
"unban-command-args": "[@Benutzername]",
"unban-command-description": "Einen Benutzer entbannen",
Expand Down
2 changes: 1 addition & 1 deletion src/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@
"searchResultsCount_other": "{{ count }} results",
"this content could not be displayed": "this content could not be displayed",
"timestamp/DateSeparator": "{{ timestamp, timestampFormatter(calendar: true) }}",
"timestamp/SystemMessage": "{{ timestamp, timestampFormatter(calendar: true; calendarFormats: {\"sameElse\": \"dddd L\"}) }}",
"timestamp/SystemMessage": "{{ timestamp, timestampFormatter(calendar: true; format: dddd L) }}",
"timestamp/MessageTimestamp": "{{ timestamp, timestampFormatter(calendar: true; format: h:mmA) }}",
"unreadMessagesSeparatorText_one": "1 unread message",
"unreadMessagesSeparatorText_other": "{{count}} unread messages",
Expand Down
Loading

0 comments on commit 6b33ced

Please sign in to comment.