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
36 changes: 35 additions & 1 deletion app/components/friendly_date/friendly_date.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@
import React from 'react';

import {renderWithIntl} from '@test/intl-test-helper';
import {getIntlShape} from '@utils/general';

import FriendlyDate from './index';
import FriendlyDate, {getFriendlyDate} from './index';

describe('Friendly Date', () => {
it('should render correctly', () => {
Expand Down Expand Up @@ -162,4 +163,37 @@ describe('Friendly Date', () => {

jest.useRealTimers();
});

describe('getFriendlyDate with narrow style', () => {
const intl = getIntlShape();

beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2020-05-15T12:00:00.000Z'));
});

afterEach(() => {
jest.useRealTimers();
});

it('should render abbreviated relative time', () => {
const minutesAgo = new Date();
minutesAgo.setMinutes(minutesAgo.getMinutes() - 38);
expect(getFriendlyDate(intl, minutesAgo.getTime(), 'narrow')).toBe('38m ago');

const hoursAgo = new Date();
hoursAgo.setHours(hoursAgo.getHours() - 4);
expect(getFriendlyDate(intl, hoursAgo.getTime(), 'narrow')).toBe('4h ago');

const daysAgo = new Date();
daysAgo.setDate(daysAgo.getDate() - 3);
expect(getFriendlyDate(intl, daysAgo.getTime(), 'narrow')).toBe('3d ago');
});

it('should still render "Now" for the sub-minute branch', () => {
const justNow = new Date();
justNow.setSeconds(justNow.getSeconds() - 10);
expect(getFriendlyDate(intl, justNow.getTime(), 'narrow')).toBe('Now');
});
});
});
16 changes: 10 additions & 6 deletions app/components/friendly_date/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,17 @@ function getDiff(inputDate: number, unit: moment.unitOfTime.Diff) {
return diff;
}

export function getFriendlyDate(intl: IntlShape, inputDate: number): string {
export function getFriendlyDate(intl: IntlShape, inputDate: number, style: 'long' | 'narrow' = 'long'): string {
const today = new Date();
const date = new Date(inputDate);
const difference = (date.getTime() - today.getTime()) / 1000;
const absoluteDifference = Math.abs(difference);
const sign = Math.sign(difference);

// The narrow style yields abbreviated, always-numeric output (e.g. "4h ago", "38m ago", "3d ago").
const narrow = style === 'narrow';
const formatOptions: Intl.RelativeTimeFormatOptions = narrow ? {numeric: 'always', style: 'narrow'} : {numeric: 'auto'};

// Message: Now
if (absoluteDifference < SECONDS.MINUTE) {
return intl.formatMessage({
Expand All @@ -52,30 +56,30 @@ export function getFriendlyDate(intl: IntlShape, inputDate: number): string {

// Message: Minutes
if (absoluteDifference < SECONDS.HOUR) {
return intl.formatRelativeTime(getDiff(inputDate, 'minute'), 'minute', {numeric: 'auto', style: 'short'});
return intl.formatRelativeTime(getDiff(inputDate, 'minute'), 'minute', narrow ? formatOptions : {numeric: 'auto', style: 'short'});
}

// Message: Hours
if (absoluteDifference < SECONDS.DAY) {
return intl.formatRelativeTime(getDiff(inputDate, 'hour'), 'hour', {numeric: 'auto'});
return intl.formatRelativeTime(getDiff(inputDate, 'hour'), 'hour', formatOptions);
}

// Message: Days
if (absoluteDifference < SECONDS.DAYS_31) {
const passedDate = sign === 1 ? today.getDate() <= date.getDate() : today.getDate() >= date.getDate();
const completedAMonth = today.getMonth() !== date.getMonth() && passedDate;
if (!completedAMonth) {
return intl.formatRelativeTime(getDiff(inputDate, 'day'), 'day', {numeric: 'auto'});
return intl.formatRelativeTime(getDiff(inputDate, 'day'), 'day', formatOptions);
}
}

// Message: Months
if (absoluteDifference < SECONDS.DAYS_366) {
return intl.formatRelativeTime(getDiff(inputDate, 'month'), 'month', {numeric: 'auto'});
return intl.formatRelativeTime(getDiff(inputDate, 'month'), 'month', formatOptions);
}

// Message: Years
return intl.formatRelativeTime(getDiff(inputDate, 'year'), 'year', {numeric: 'auto'});
return intl.formatRelativeTime(getDiff(inputDate, 'year'), 'year', formatOptions);
}

export default FriendlyDate;
21 changes: 21 additions & 0 deletions app/database/migration/server/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.

import {PLAYBOOK_TABLES} from '@playbooks/constants/database';

import serverMigrations from './index';

describe('server migrations', () => {
it('adds timeline events to existing playbook runs in schema version 21', () => {
const migration = serverMigrations.sortedMigrations.find(({toVersion}) => toVersion === 21);

expect(migration).toEqual({
toVersion: 21,
steps: [{
type: 'add_columns',
table: PLAYBOOK_TABLES.PLAYBOOK_RUN,
columns: [{name: 'timeline_events', type: 'string', isOptional: true}],
}],
});
});
});
11 changes: 11 additions & 0 deletions app/database/migration/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,17 @@ const {AI_BOT, AI_THREAD} = AGENTS_TABLES;
const {BOARD_VIEW} = BOARDS_TABLES;

export default schemaMigrations({migrations: [
{
toVersion: 21,
steps: [
addColumns({
table: PLAYBOOK_RUN,
columns: [
{name: 'timeline_events', type: 'string', isOptional: true}, // JSON string
],
}),
],
},
{
toVersion: 20,
steps: [
Expand Down
2 changes: 1 addition & 1 deletion app/database/schema/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ import {
} from './table_schemas';

export const serverSchema: AppSchema = appSchema({
version: 20,
version: 21,
tables: [
AiBotSchema,
AiThreadSchema,
Expand Down
4 changes: 3 additions & 1 deletion app/database/schema/server/test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ const {PLAYBOOK_RUN, PLAYBOOK_CHECKLIST, PLAYBOOK_CHECKLIST_ITEM, PLAYBOOK_RUN_A
describe('*** Test schema for SERVER database ***', () => {
it('=> The SERVER SCHEMA should strictly match', () => {
expect(serverSchema).toEqual({
version: 20,
version: 21,
unsafeSql: undefined,
tables: {
[AI_BOT]: {
Expand Down Expand Up @@ -510,6 +510,7 @@ describe('*** Test schema for SERVER database ***', () => {
active_stage: {name: 'active_stage', type: 'number'},
active_stage_title: {name: 'active_stage_title', type: 'string'},
participant_ids: {name: 'participant_ids', type: 'string'}, // JSON string
timeline_events: {name: 'timeline_events', type: 'string', isOptional: true}, // JSON string
summary: {name: 'summary', type: 'string'},
current_status: {name: 'current_status', type: 'string', isIndexed: true},
last_status_update_at: {name: 'last_status_update_at', type: 'number'},
Expand Down Expand Up @@ -537,6 +538,7 @@ describe('*** Test schema for SERVER database ***', () => {
{name: 'active_stage', type: 'number'},
{name: 'active_stage_title', type: 'string'},
{name: 'participant_ids', type: 'string'}, // JSON string
{name: 'timeline_events', type: 'string', isOptional: true}, // JSON string
{name: 'summary', type: 'string'},
{name: 'current_status', type: 'string', isIndexed: true},
{name: 'last_status_update_at', type: 'number'},
Expand Down
51 changes: 51 additions & 0 deletions app/products/playbooks/database/models/playbook_run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,57 @@ describe('PlaybookRunModel', () => {
await DatabaseManager.destroyServerDatabase(SERVER_URL);
});

describe('timelineEvents', () => {
it.each([
['malformed JSON', '{invalid'],
['empty JSON', ''],
])('returns an empty array for %s', async (_name, storedValue) => {
const run = TestHelper.createPlaybookRuns(1, 0, 0, true)[0];
await operator.handlePlaybookRun({runs: [run], prepareRecordsOnly: false, processChildren: true});
const runModel = await operator.database.get<PlaybookRunModel>(PLAYBOOK_RUN).find(run.id);

await operator.database.write(async () => {
await runModel.update((record) => {
(record._raw as unknown as Record<string, string>).timeline_events = storedValue;
});
});

expect(runModel.timelineEvents).toEqual([]);
});

it('keeps well-formed events and drops malformed entries', async () => {
const run = TestHelper.createPlaybookRuns(1, 0, 0, true)[0];
await operator.handlePlaybookRun({runs: [run], prepareRecordsOnly: false, processChildren: true});
const runModel = await operator.database.get<PlaybookRunModel>(PLAYBOOK_RUN).find(run.id);

const validEvent = {
id: 'event-1',
playbook_run_id: run.id,
create_at: 1,
event_at: 1000,
event_type: 'task_state_modified',
summary: '',
details: '{"action":"check"}',
post_id: '',
subject_user_id: 'user-1',
creator_user_id: 'user-1',
};

await operator.database.write(async () => {
await runModel.update((record) => {
(record._raw as unknown as Record<string, string>).timeline_events = JSON.stringify([
validEvent,
{event_type: 'task_state_modified'}, // missing event_at/details/subject_user_id
'not-an-object',
null,
]);
});
});

expect(runModel.timelineEvents).toEqual([validEvent]);
});
});

describe('prepareDestroyWithRelations', () => {
it('should prepare run and all its checklists and items for destruction when run has checklists with items', async () => {
// Create playbook runs with checklists and items
Expand Down
27 changes: 27 additions & 0 deletions app/products/playbooks/database/models/playbook_run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,30 @@ const {

const {PLAYBOOK_RUN, PLAYBOOK_CHECKLIST} = PLAYBOOK_TABLES;

// Timeline events are JSON objects. The shared string-array sanitizer is used by
// the run's ID arrays, but would intentionally discard these object values. Validate
// the fields the task-activity resolver relies on so malformed entries are dropped
// rather than surfacing as a false TimelineEvent to consumers.
const isTimelineEvent = (value: unknown): value is TimelineEvent => {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return false;
}

const event = value as Partial<TimelineEvent>;
return typeof event.event_type === 'string' &&
typeof event.event_at === 'number' &&
typeof event.details === 'string' &&
typeof event.subject_user_id === 'string';
Comment thread
vish9812 marked this conversation as resolved.
};

const safeParseTimelineEvents = (value: unknown): TimelineEvent[] => {
if (!Array.isArray(value)) {
return [];
}

return value.filter(isTimelineEvent);
};

/**
* The PlaybookRun model represents a playbook run in the Mattermost app.
*/
Expand Down Expand Up @@ -94,6 +118,9 @@ export default class PlaybookRunModel extends Model implements PlaybookRunModelI
/** participant_ids : An array of user IDs that participate in the run */
@json('participant_ids', safeParseJSONStringArray) participantIds!: string[];

/** timeline_events : The latest run timeline events */
@json('timeline_events', safeParseTimelineEvents) timelineEvents!: TimelineEvent[];

/** summary : Summary of the playbook run */
@field('summary') summary!: string;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,22 @@ import type PlaybookRunPropertyValueModel from '@playbooks/types/database/models

const {PLAYBOOK_RUN, PLAYBOOK_RUN_ATTRIBUTE, PLAYBOOK_RUN_ATTRIBUTE_VALUE} = PLAYBOOK_TABLES;

const timelineEvent: TimelineEvent = {
id: 'event_1',
playbook_run_id: 'playbook_run_1',
create_at: 1620000000000,
event_at: 1620000000000,
event_type: 'task_state_modified',
summary: '',
details: JSON.stringify({action: 'check', task: 'Task 1'}),
post_id: '',
subject_user_id: 'user_1',
creator_user_id: 'user_1',
};

describe('*** PLAYBOOK_RUN Prepare Records Test ***', () => {
it('=> transformPlaybookRunRecord: should return a record of type PlaybookRun for CREATE action', async () => {
expect.assertions(3);
expect.assertions(4);

const database = await createTestConnection({databaseName: 'playbook_run_prepare_records', setActive: true});
expect(database).toBeTruthy();
Expand Down Expand Up @@ -63,7 +76,7 @@ describe('*** PLAYBOOK_RUN Prepare Records Test ***', () => {
remove_channel_member_on_removed_participant: false,
invited_user_ids: [],
invited_group_ids: [],
timeline_events: [],
timeline_events: [timelineEvent],
broadcast_channel_ids: [],
webhook_on_creation_urls: [],
webhook_on_status_update_urls: [],
Expand All @@ -78,6 +91,7 @@ describe('*** PLAYBOOK_RUN Prepare Records Test ***', () => {

expect(preparedRecord).toBeTruthy();
expect(preparedRecord!.collection.table).toBe(PLAYBOOK_RUN);
expect(preparedRecord!.timelineEvents).toEqual([timelineEvent]);
});

it('=> transformPlaybookRunRecord: should return a record of type PlaybookRun for UPDATE action', async () => {
Expand All @@ -104,6 +118,7 @@ describe('*** PLAYBOOK_RUN Prepare Records Test ***', () => {
record.activeStage = 1;
record.activeStageTitle = 'Stage 1';
record.participantIds = ['user_2', 'user_3'];
record.timelineEvents = [timelineEvent];
record.summary = 'Existing summary';
record.currentStatus = 'InProgress';
record.lastStatusUpdateAt = 1620000001000;
Expand Down Expand Up @@ -256,6 +271,7 @@ describe('*** PLAYBOOK_RUN Prepare Records Test ***', () => {
record.activeStage = 1;
record.activeStageTitle = 'Stage 1';
record.participantIds = ['user_2', 'user_3'];
record.timelineEvents = [timelineEvent];
record.summary = 'Existing summary';
record.currentStatus = 'InProgress';
record.lastStatusUpdateAt = 1620000001000;
Expand Down Expand Up @@ -300,6 +316,7 @@ describe('*** PLAYBOOK_RUN Prepare Records Test ***', () => {
expect(preparedRecord.activeStage).toBe(1);
expect(preparedRecord.activeStageTitle).toBe('Stage 1');
expect(preparedRecord.participantIds).toEqual(['user_2', 'user_3']);
expect(preparedRecord.timelineEvents).toEqual([timelineEvent]);
expect(preparedRecord.summary).toBe('Existing summary');
expect(preparedRecord.currentStatus).toBe('InProgress');
expect(preparedRecord.lastStatusUpdateAt).toBe(1620000001000);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export const transformPlaybookRunRecord = ({action, database, value}: Transforme
run.activeStage = raw.active_stage ?? record?.activeStage ?? 0;
run.activeStageTitle = raw.active_stage_title ?? record?.activeStageTitle ?? '';
run.participantIds = raw.participant_ids ?? record?.participantIds ?? [];
run.timelineEvents = raw.timeline_events ?? record?.timelineEvents ?? [];
run.summary = raw.summary ?? record?.summary ?? '';
run.currentStatus = raw.current_status ?? record?.currentStatus ?? 'InProgress';
run.lastStatusUpdateAt = raw.last_status_update_at ?? record?.lastStatusUpdateAt ?? 0;
Expand Down
1 change: 1 addition & 0 deletions app/products/playbooks/database/schema/playbook_run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export default tableSchema({
{name: 'active_stage', type: 'number'},
{name: 'active_stage_title', type: 'string'},
{name: 'participant_ids', type: 'string'}, // JSON string
{name: 'timeline_events', type: 'string', isOptional: true}, // JSON string
{name: 'summary', type: 'string'},
{name: 'current_status', type: 'string', isIndexed: true},
{name: 'last_status_update_at', type: 'number'},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ describe('Checklist', () => {
checklist: mockChecklist,
checklistNumber: 0,
items: mockItems,
timelineEvents: [],
channelId: 'channel-id-1',
playbookRunId: 'run-id-1',
playbookRunName: 'Test Run',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ type Props = {
checklist: PlaybookChecklistModel | PlaybookChecklist;
checklistNumber: number;
items: Array<PlaybookChecklistItemModel | PlaybookChecklistItem>;
timelineEvents: TimelineEvent[];
channelId: string;
playbookRunId: string;
playbookRunName: string;
Expand All @@ -120,6 +121,7 @@ const Checklist = ({
checklist,
checklistNumber,
items,
timelineEvents,
channelId,
playbookRunId,
playbookRunName,
Expand Down Expand Up @@ -238,6 +240,7 @@ const Checklist = ({
<ChecklistItem
key={item.id}
item={item}
timelineEvents={timelineEvents}
channelId={channelId}
checklistNumber={checklistNumber}
itemNumber={index}
Expand Down Expand Up @@ -266,6 +269,7 @@ const Checklist = ({
<ChecklistItem
key={`calc-${item.id}`}
item={item}
timelineEvents={timelineEvents}
channelId={channelId}
checklistNumber={checklistNumber}
itemNumber={index}
Expand Down
Loading
Loading