@oneuptime/common 8.0.5129 → 8.0.5142

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/Models/DatabaseModels/IncidentTemplate.ts +71 -0
  2. package/Server/Infrastructure/Postgres/SchemaMigrations/1757416939595-MigrationName.ts +41 -0
  3. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -0
  4. package/Server/Services/IncidentService.ts +93 -18
  5. package/Server/Services/ScheduledMaintenanceService.ts +6 -5
  6. package/Server/Services/StatusPageService.ts +1 -1
  7. package/Tests/UI/Components/MarkdownEditor.test.tsx +87 -2
  8. package/Types/Date.ts +77 -3
  9. package/UI/Components/Date/RangeStartAndEndDateView.tsx +2 -2
  10. package/UI/Components/Detail/Detail.tsx +2 -2
  11. package/UI/Components/EventHistoryList/EventHistoryDayList.tsx +4 -1
  12. package/UI/Components/EventItem/EventItem.tsx +2 -2
  13. package/UI/Components/Feed/FeedItem.tsx +1 -1
  14. package/UI/Components/Filters/FilterViewer.tsx +5 -5
  15. package/UI/Components/Forms/Fields/FormField.tsx +2 -2
  16. package/UI/Components/Forms/Types/Field.ts +1 -0
  17. package/UI/Components/Graphs/DayUptimeGraph.tsx +2 -2
  18. package/UI/Components/LogsViewer/LogItem.tsx +2 -1
  19. package/UI/Components/Markdown.tsx/MarkdownEditor.tsx +652 -13
  20. package/UI/Components/Table/TableRow.tsx +4 -4
  21. package/UI/Utils/Markdown.tsx +1 -15
  22. package/build/dist/Models/DatabaseModels/IncidentTemplate.js +70 -0
  23. package/build/dist/Models/DatabaseModels/IncidentTemplate.js.map +1 -1
  24. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1757416939595-MigrationName.js +20 -0
  25. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1757416939595-MigrationName.js.map +1 -0
  26. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -0
  27. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  28. package/build/dist/Server/Services/IncidentService.js +77 -15
  29. package/build/dist/Server/Services/IncidentService.js.map +1 -1
  30. package/build/dist/Server/Services/ScheduledMaintenanceService.js +6 -5
  31. package/build/dist/Server/Services/ScheduledMaintenanceService.js.map +1 -1
  32. package/build/dist/Server/Services/StatusPageService.js +1 -1
  33. package/build/dist/Server/Services/StatusPageService.js.map +1 -1
  34. package/build/dist/Tests/UI/Components/MarkdownEditor.test.js +48 -2
  35. package/build/dist/Tests/UI/Components/MarkdownEditor.test.js.map +1 -1
  36. package/build/dist/Types/Date.js +44 -4
  37. package/build/dist/Types/Date.js.map +1 -1
  38. package/build/dist/UI/Components/Date/RangeStartAndEndDateView.js +2 -2
  39. package/build/dist/UI/Components/Date/RangeStartAndEndDateView.js.map +1 -1
  40. package/build/dist/UI/Components/Detail/Detail.js +2 -2
  41. package/build/dist/UI/Components/Detail/Detail.js.map +1 -1
  42. package/build/dist/UI/Components/EventHistoryList/EventHistoryDayList.js +1 -1
  43. package/build/dist/UI/Components/EventHistoryList/EventHistoryDayList.js.map +1 -1
  44. package/build/dist/UI/Components/EventItem/EventItem.js +2 -2
  45. package/build/dist/UI/Components/EventItem/EventItem.js.map +1 -1
  46. package/build/dist/UI/Components/Feed/FeedItem.js +1 -1
  47. package/build/dist/UI/Components/Feed/FeedItem.js.map +1 -1
  48. package/build/dist/UI/Components/Filters/FilterViewer.js +5 -5
  49. package/build/dist/UI/Components/Filters/FilterViewer.js.map +1 -1
  50. package/build/dist/UI/Components/Forms/Fields/FormField.js +2 -1
  51. package/build/dist/UI/Components/Forms/Fields/FormField.js.map +1 -1
  52. package/build/dist/UI/Components/Graphs/DayUptimeGraph.js +2 -2
  53. package/build/dist/UI/Components/Graphs/DayUptimeGraph.js.map +1 -1
  54. package/build/dist/UI/Components/LogsViewer/LogItem.js +3 -2
  55. package/build/dist/UI/Components/LogsViewer/LogItem.js.map +1 -1
  56. package/build/dist/UI/Components/Markdown.tsx/MarkdownEditor.js +337 -3
  57. package/build/dist/UI/Components/Markdown.tsx/MarkdownEditor.js.map +1 -1
  58. package/build/dist/UI/Components/Table/TableRow.js +2 -2
  59. package/build/dist/UI/Components/Table/TableRow.js.map +1 -1
  60. package/build/dist/UI/Utils/Markdown.js +1 -8
  61. package/build/dist/UI/Utils/Markdown.js.map +1 -1
  62. package/package.json +1 -1
@@ -716,6 +716,77 @@ export default class IncidentTemplate extends BaseModel {
716
716
  })
717
717
  public changeMonitorStatusToId?: ObjectID = undefined;
718
718
 
719
+ @ColumnAccessControl({
720
+ create: [
721
+ Permission.ProjectOwner,
722
+ Permission.ProjectAdmin,
723
+ Permission.ProjectMember,
724
+ Permission.CreateIncidentTemplate,
725
+ ],
726
+ read: [
727
+ Permission.ProjectOwner,
728
+ Permission.ProjectAdmin,
729
+ Permission.ProjectMember,
730
+ Permission.ReadIncidentTemplate,
731
+ ],
732
+ update: [],
733
+ })
734
+ @TableColumn({
735
+ manyToOneRelationColumn: "initialIncidentStateId",
736
+ type: TableColumnType.Entity,
737
+ modelType: IncidentState,
738
+ title: "Initial Incident State",
739
+ description:
740
+ "Relation to Incident State Object. Incidents created from this template will start in this state.",
741
+ })
742
+ @ManyToOne(
743
+ () => {
744
+ return IncidentState;
745
+ },
746
+ {
747
+ eager: false,
748
+ nullable: true,
749
+ orphanedRowAction: "nullify",
750
+ },
751
+ )
752
+ @JoinColumn({ name: "initialIncidentStateId" })
753
+ public initialIncidentState?: IncidentState = undefined;
754
+
755
+ @ColumnAccessControl({
756
+ create: [
757
+ Permission.ProjectOwner,
758
+ Permission.ProjectAdmin,
759
+ Permission.ProjectMember,
760
+ Permission.CreateIncidentTemplate,
761
+ ],
762
+ read: [
763
+ Permission.ProjectOwner,
764
+ Permission.ProjectAdmin,
765
+ Permission.ProjectMember,
766
+ Permission.ReadIncidentTemplate,
767
+ ],
768
+ update: [
769
+ Permission.ProjectOwner,
770
+ Permission.ProjectAdmin,
771
+ Permission.ProjectMember,
772
+ Permission.EditIncidentTemplate,
773
+ ],
774
+ })
775
+ @Index()
776
+ @TableColumn({
777
+ type: TableColumnType.ObjectID,
778
+ required: false,
779
+ title: "Initial Incident State ID",
780
+ description:
781
+ "Relation to Incident State Object ID. Incidents created from this template will start in this state.",
782
+ })
783
+ @Column({
784
+ type: ColumnType.ObjectID,
785
+ nullable: true,
786
+ transformer: ObjectID.getDatabaseTransformer(),
787
+ })
788
+ public initialIncidentStateId?: ObjectID = undefined;
789
+
719
790
  @ColumnAccessControl({
720
791
  create: [
721
792
  Permission.ProjectOwner,
@@ -0,0 +1,41 @@
1
+ import { MigrationInterface, QueryRunner } from "typeorm";
2
+
3
+ export class MigrationName1757416939595 implements MigrationInterface {
4
+ public name = "MigrationName1757416939595";
5
+
6
+ public async up(queryRunner: QueryRunner): Promise<void> {
7
+ await queryRunner.query(
8
+ `ALTER TABLE "IncidentTemplate" ADD "initialIncidentStateId" uuid`,
9
+ );
10
+ await queryRunner.query(
11
+ `ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "rotation" SET DEFAULT '{"_type":"Recurring","value":{"intervalType":"Day","intervalCount":{"_type":"PositiveNumber","value":1}}}'`,
12
+ );
13
+ await queryRunner.query(
14
+ `ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "restrictionTimes" SET DEFAULT '{"_type":"RestrictionTimes","value":{"restictionType":"None","dayRestrictionTimes":null,"weeklyRestrictionTimes":[]}}'`,
15
+ );
16
+ await queryRunner.query(
17
+ `CREATE INDEX "IDX_36317c99429a40d3344d838223" ON "IncidentTemplate" ("initialIncidentStateId") `,
18
+ );
19
+ await queryRunner.query(
20
+ `ALTER TABLE "IncidentTemplate" ADD CONSTRAINT "FK_36317c99429a40d3344d838223f" FOREIGN KEY ("initialIncidentStateId") REFERENCES "IncidentState"("_id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
21
+ );
22
+ }
23
+
24
+ public async down(queryRunner: QueryRunner): Promise<void> {
25
+ await queryRunner.query(
26
+ `ALTER TABLE "IncidentTemplate" DROP CONSTRAINT "FK_36317c99429a40d3344d838223f"`,
27
+ );
28
+ await queryRunner.query(
29
+ `DROP INDEX "public"."IDX_36317c99429a40d3344d838223"`,
30
+ );
31
+ await queryRunner.query(
32
+ `ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "restrictionTimes" SET DEFAULT '{"_type": "RestrictionTimes", "value": {"restictionType": "None", "dayRestrictionTimes": null, "weeklyRestrictionTimes": []}}'`,
33
+ );
34
+ await queryRunner.query(
35
+ `ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "rotation" SET DEFAULT '{"_type": "Recurring", "value": {"intervalType": "Day", "intervalCount": {"_type": "PositiveNumber", "value": 1}}}'`,
36
+ );
37
+ await queryRunner.query(
38
+ `ALTER TABLE "IncidentTemplate" DROP COLUMN "initialIncidentStateId"`,
39
+ );
40
+ }
41
+ }
@@ -165,6 +165,7 @@ import { MigrationName1756293325324 } from "./1756293325324-MigrationName";
165
165
  import { MigrationName1756296282627 } from "./1756296282627-MigrationName";
166
166
  import { MigrationName1756300358095 } from "./1756300358095-MigrationName";
167
167
  import { MigrationName1756821449686 } from "./1756821449686-MigrationName";
168
+ import { MigrationName1757416939595 } from "./1757416939595-MigrationName";
168
169
 
169
170
  export default [
170
171
  InitialMigration,
@@ -334,4 +335,5 @@ export default [
334
335
  MigrationName1756296282627,
335
336
  MigrationName1756300358095,
336
337
  MigrationName1756821449686,
338
+ MigrationName1757416939595,
337
339
  ];
@@ -64,6 +64,8 @@ import MetricType from "../../Models/DatabaseModels/MetricType";
64
64
  import UpdateBy from "../Types/Database/UpdateBy";
65
65
  import OnCallDutyPolicy from "../../Models/DatabaseModels/OnCallDutyPolicy";
66
66
  import Dictionary from "../../Types/Dictionary";
67
+ import IncidentTemplateService from "./IncidentTemplateService";
68
+ import IncidentTemplate from "../../Models/DatabaseModels/IncidentTemplate";
67
69
 
68
70
  // key is incidentId for this dictionary.
69
71
  type UpdateCarryForward = Dictionary<{
@@ -466,24 +468,97 @@ export class Service extends DatabaseService<Model> {
466
468
  const projectId: ObjectID =
467
469
  createBy.props.tenantId || createBy.data.projectId!;
468
470
 
469
- const incidentState: IncidentState | null =
470
- await IncidentStateService.findOneBy({
471
- query: {
472
- projectId: projectId,
473
- isCreatedState: true,
474
- },
475
- select: {
476
- _id: true,
477
- },
478
- props: {
479
- isRoot: true,
480
- },
481
- });
471
+ // Determine the initial incident state
472
+ let initialIncidentStateId: ObjectID | undefined = undefined;
482
473
 
483
- if (!incidentState || !incidentState.id) {
484
- throw new BadDataException(
485
- "Created incident state not found for this project. Please add created incident state from settings.",
486
- );
474
+ // If currentIncidentStateId is already provided (manual selection), use it
475
+ if (createBy.data.currentIncidentStateId) {
476
+ initialIncidentStateId = createBy.data.currentIncidentStateId;
477
+
478
+ // Validate that the provided state exists and belongs to the project
479
+ const providedState: IncidentState | null =
480
+ await IncidentStateService.findOneBy({
481
+ query: {
482
+ _id: initialIncidentStateId.toString(),
483
+ projectId: projectId,
484
+ },
485
+ select: {
486
+ _id: true,
487
+ },
488
+ props: {
489
+ isRoot: true,
490
+ },
491
+ });
492
+
493
+ if (!providedState) {
494
+ throw new BadDataException(
495
+ "Invalid incident state provided. The state does not exist or does not belong to this project.",
496
+ );
497
+ }
498
+ } else if (createBy.data.createdIncidentTemplateId) {
499
+ // If created from a template, check if template has a custom initial state
500
+ const incidentTemplate: IncidentTemplate | null =
501
+ await IncidentTemplateService.findOneBy({
502
+ query: {
503
+ _id: createBy.data.createdIncidentTemplateId.toString(),
504
+ projectId: projectId,
505
+ },
506
+ select: {
507
+ initialIncidentStateId: true,
508
+ },
509
+ props: {
510
+ isRoot: true,
511
+ },
512
+ });
513
+
514
+ if (incidentTemplate?.initialIncidentStateId) {
515
+ initialIncidentStateId = incidentTemplate.initialIncidentStateId;
516
+
517
+ // Validate that the template's state exists and belongs to the project
518
+ const templateState: IncidentState | null =
519
+ await IncidentStateService.findOneBy({
520
+ query: {
521
+ _id: initialIncidentStateId.toString(),
522
+ projectId: projectId,
523
+ },
524
+ select: {
525
+ _id: true,
526
+ },
527
+ props: {
528
+ isRoot: true,
529
+ },
530
+ });
531
+
532
+ if (!templateState) {
533
+ // Fall back to default if template state is invalid
534
+ initialIncidentStateId = undefined;
535
+ }
536
+ }
537
+ }
538
+
539
+ // If no custom state is provided or found, fall back to default created state
540
+ if (!initialIncidentStateId) {
541
+ const incidentState: IncidentState | null =
542
+ await IncidentStateService.findOneBy({
543
+ query: {
544
+ projectId: projectId,
545
+ isCreatedState: true,
546
+ },
547
+ select: {
548
+ _id: true,
549
+ },
550
+ props: {
551
+ isRoot: true,
552
+ },
553
+ });
554
+
555
+ if (!incidentState || !incidentState.id) {
556
+ throw new BadDataException(
557
+ "Created incident state not found for this project. Please add created incident state from settings.",
558
+ );
559
+ }
560
+
561
+ initialIncidentStateId = incidentState.id;
487
562
  }
488
563
 
489
564
  let mutex: SemaphoreMutex | null = null;
@@ -517,7 +592,7 @@ export class Service extends DatabaseService<Model> {
517
592
  projectId: projectId,
518
593
  })) + 1;
519
594
 
520
- createBy.data.currentIncidentStateId = incidentState.id;
595
+ createBy.data.currentIncidentStateId = initialIncidentStateId;
521
596
  createBy.data.incidentNumber = incidentNumberForThisIncident;
522
597
 
523
598
  if (
@@ -261,7 +261,7 @@ export class Service extends DatabaseService<Model> {
261
261
  if (subscriber.slackIncomingWebhookUrl) {
262
262
  const slackMessage: string = `## 🔧 Scheduled Maintenance - ${event.title || ""}
263
263
 
264
- **Scheduled Date:** ${OneUptimeDate.getDateAsFormattedString(event.startsAt!)}
264
+ **Scheduled Date:** ${OneUptimeDate.getDateAsUserFriendlyFormattedString(event.startsAt!)}
265
265
 
266
266
  ${resourcesAffected ? `**Resources Affected:** ${resourcesAffected}` : ""}
267
267
 
@@ -305,6 +305,7 @@ ${resourcesAffected ? `**Resources Affected:** ${resourcesAffected}` : ""}
305
305
  OneUptimeDate.getDateAsFormattedHTMLInMultipleTimezones({
306
306
  date: event.startsAt!,
307
307
  timezones: statuspage.subscriberTimezones || [],
308
+ use12HourFormat: true,
308
309
  }),
309
310
  eventTitle: event.title || "",
310
311
  eventDescription: await Markdown.convertToHTML(
@@ -769,11 +770,11 @@ ${scheduledMaintenance.description || "No description provided."}
769
770
 
770
771
  // add starts at and ends at.
771
772
  if (scheduledMaintenance.startsAt) {
772
- feedInfoInMarkdown += `**Starts At**: ${OneUptimeDate.getDateAsLocalFormattedString(scheduledMaintenance.startsAt)} \n\n`;
773
+ feedInfoInMarkdown += `**Starts At**: ${OneUptimeDate.getDateAsUserFriendlyLocalFormattedString(scheduledMaintenance.startsAt)} \n\n`;
773
774
  }
774
775
 
775
776
  if (scheduledMaintenance.endsAt) {
776
- feedInfoInMarkdown += `**Ends At**: ${OneUptimeDate.getDateAsLocalFormattedString(scheduledMaintenance.endsAt)} \n\n`;
777
+ feedInfoInMarkdown += `**Ends At**: ${OneUptimeDate.getDateAsUserFriendlyLocalFormattedString(scheduledMaintenance.endsAt)} \n\n`;
777
778
  }
778
779
 
779
780
  if (scheduledMaintenance.currentScheduledMaintenanceState?.name) {
@@ -1064,7 +1065,7 @@ ${onUpdate.updateBy.data.title || "No title provided."}
1064
1065
  // add scheduledMaintenance feed.
1065
1066
 
1066
1067
  feedInfoInMarkdown += `\n\n**Starts At**:
1067
- ${OneUptimeDate.getDateAsLocalFormattedString(onUpdate.updateBy.data.startsAt as Date) || "No title provided."}
1068
+ ${OneUptimeDate.getDateAsUserFriendlyLocalFormattedString(onUpdate.updateBy.data.startsAt as Date) || "No title provided."}
1068
1069
  `;
1069
1070
  shouldAddScheduledMaintenanceFeed = true;
1070
1071
  }
@@ -1073,7 +1074,7 @@ ${OneUptimeDate.getDateAsLocalFormattedString(onUpdate.updateBy.data.startsAt as
1073
1074
  // add scheduledMaintenance feed.
1074
1075
 
1075
1076
  feedInfoInMarkdown += `\n\n**Ends At**:
1076
- ${OneUptimeDate.getDateAsLocalFormattedString(onUpdate.updateBy.data.endsAt as Date) || "No title provided."}
1077
+ ${OneUptimeDate.getDateAsUserFriendlyLocalFormattedString(onUpdate.updateBy.data.endsAt as Date) || "No title provided."}
1077
1078
  `;
1078
1079
  shouldAddScheduledMaintenanceFeed = true;
1079
1080
  }
@@ -877,7 +877,7 @@ export class Service extends DatabaseService<StatusPage> {
877
877
 
878
878
  const endDate: Date = OneUptimeDate.getCurrentDate();
879
879
  const startDate: Date = OneUptimeDate.getSomeDaysAgo(numberOfDays);
880
- const startAndEndDate: string = `${numberOfDays} days (${OneUptimeDate.getDateAsLocalFormattedString(startDate, true)} - ${OneUptimeDate.getDateAsLocalFormattedString(endDate, true)})`;
880
+ const startAndEndDate: string = `${numberOfDays} days (${OneUptimeDate.getDateAsUserFriendlyLocalFormattedString(startDate, true)} - ${OneUptimeDate.getDateAsUserFriendlyLocalFormattedString(endDate, true)})`;
881
881
 
882
882
  if (statusPageResources.length === 0) {
883
883
  return {
@@ -1,9 +1,55 @@
1
1
  import React from "react";
2
2
  import MarkdownEditor from "../../../UI/Components/Markdown.tsx/MarkdownEditor";
3
- import { render, screen } from "@testing-library/react";
3
+ import { render, screen, fireEvent } from "@testing-library/react";
4
4
  import { describe, expect, test } from "@jest/globals";
5
5
 
6
- describe("MarkdownEditor with SpellCheck", () => {
6
+ describe("MarkdownEditor", () => {
7
+ test("should render with toolbar buttons", () => {
8
+ render(
9
+ <MarkdownEditor
10
+ initialValue="This is a test"
11
+ placeholder="Enter markdown here..."
12
+ />,
13
+ );
14
+
15
+ // Check for toolbar buttons
16
+ expect(screen.getByTitle("Bold (Ctrl+B)")).toBeInTheDocument();
17
+ expect(screen.getByTitle("Italic (Ctrl+I)")).toBeInTheDocument();
18
+ expect(screen.getByTitle("Underline")).toBeInTheDocument();
19
+ expect(screen.getByTitle("Strikethrough")).toBeInTheDocument();
20
+ expect(screen.getByTitle("Heading 1")).toBeInTheDocument();
21
+ expect(screen.getByTitle("Heading 2")).toBeInTheDocument();
22
+ expect(screen.getByTitle("Heading 3")).toBeInTheDocument();
23
+ expect(screen.getByTitle("Bullet List")).toBeInTheDocument();
24
+ expect(screen.getByTitle("Numbered List")).toBeInTheDocument();
25
+ expect(screen.getByTitle("Task List")).toBeInTheDocument();
26
+ expect(screen.getByTitle("Link")).toBeInTheDocument();
27
+ expect(screen.getByTitle("Image")).toBeInTheDocument();
28
+ expect(screen.getByTitle("Table")).toBeInTheDocument();
29
+ expect(screen.getByTitle("Code")).toBeInTheDocument();
30
+ expect(screen.getByTitle("Quote")).toBeInTheDocument();
31
+ expect(screen.getByTitle("Horizontal Rule")).toBeInTheDocument();
32
+ });
33
+
34
+ test("should toggle preview mode", () => {
35
+ render(
36
+ <MarkdownEditor
37
+ initialValue="**bold text**"
38
+ placeholder="Enter markdown here..."
39
+ />,
40
+ );
41
+
42
+ const previewButton: HTMLElement = screen.getByText("Preview");
43
+ fireEvent.click(previewButton);
44
+
45
+ // Should show preview
46
+ expect(screen.getByText("Write")).toBeInTheDocument();
47
+
48
+ // Click to go back to write mode
49
+ fireEvent.click(screen.getByText("Write"));
50
+ expect(screen.getByText("Preview")).toBeInTheDocument();
51
+ });
52
+
7
53
  test("should enable spell check by default", () => {
8
54
  render(
9
55
  <MarkdownEditor
@@ -18,6 +64,21 @@ describe("MarkdownEditor with SpellCheck", () => {
18
64
  expect(textarea.spellcheck).toBe(true);
19
65
  });
20
66
 
67
+ test("should enable spell check when disableSpellCheck is undefined", () => {
68
+ render(
69
+ <MarkdownEditor
70
+ initialValue="This is a test with spelling errors"
71
+ placeholder="Enter markdown here..."
72
+ disableSpellCheck={undefined}
73
+ />,
74
+ );
75
+
76
+ const textarea: HTMLTextAreaElement = screen.getByRole(
77
+ "textbox",
78
+ ) as HTMLTextAreaElement;
79
+ expect(textarea.spellcheck).toBe(true);
80
+ });
81
+
21
82
  test("should disable spell check when disableSpellCheck is true", () => {
22
83
  render(
23
84
  <MarkdownEditor
@@ -58,4 +119,28 @@ describe("MarkdownEditor with SpellCheck", () => {
58
119
  textarea = screen.getByRole("textbox") as HTMLTextAreaElement;
59
120
  expect(textarea.spellcheck).toBe(false);
60
121
  });
122
+
123
+ test("should show help text", () => {
124
+ render(
125
+ <MarkdownEditor initialValue="" placeholder="Enter markdown here..." />,
126
+ );
127
+
128
+ expect(screen.getByText("Markdown help")).toBeInTheDocument();
129
+ });
130
+
131
+ test("should handle onChange callback", () => {
132
+ const mockOnChange: jest.Mock = jest.fn();
133
+ render(
134
+ <MarkdownEditor
135
+ initialValue=""
136
+ placeholder="Enter markdown here..."
137
+ onChange={mockOnChange}
138
+ />,
139
+ );
140
+
141
+ const textarea: HTMLElement = screen.getByRole("textbox");
142
+ fireEvent.change(textarea, { target: { value: "new text" } });
143
+
144
+ expect(mockOnChange).toHaveBeenCalledWith("new text");
145
+ });
61
146
  });
package/Types/Date.ts CHANGED
@@ -201,12 +201,19 @@ export default class OneUptimeDate {
201
201
  return this.secondsToFormattedFriendlyTimeString(seconds);
202
202
  }
203
203
 
204
- public static toTimeString(date: Date | string): string {
204
+ public static toTimeString(
205
+ date: Date | string,
206
+ use12HourFormat?: boolean,
207
+ ): string {
205
208
  if (typeof date === "string") {
206
209
  date = this.fromString(date);
207
210
  }
208
211
 
209
- return moment(date).format("HH:mm");
212
+ const format: "hh:mm A" | "HH:mm" =
213
+ use12HourFormat || this.getUserPrefers12HourFormat()
214
+ ? "hh:mm A"
215
+ : "HH:mm";
216
+ return moment(date).format(format);
210
217
  }
211
218
 
212
219
  public static isSame(date1: Date, date2: Date): boolean {
@@ -879,23 +886,72 @@ export default class OneUptimeDate {
879
886
  public static getCurrentDateAsFormattedString(options?: {
880
887
  onlyShowDate?: boolean;
881
888
  showSeconds?: boolean;
889
+ use12HourFormat?: boolean;
882
890
  }): string {
883
891
  return this.getDateAsFormattedString(new Date(), options);
884
892
  }
885
893
 
894
+ public static getUserPrefers12HourFormat(): boolean {
895
+ if (typeof window === "undefined") {
896
+ // Server-side: default to 12-hour format for user-friendly display
897
+ return true;
898
+ }
899
+
900
+ // Client-side: detect user's preferred time format from browser locale
901
+ const testDate: Date = new Date();
902
+ const timeString: string = testDate.toLocaleTimeString();
903
+ return (
904
+ timeString.toLowerCase().includes("am") ||
905
+ timeString.toLowerCase().includes("pm")
906
+ );
907
+ }
908
+
909
+ public static getDateAsUserFriendlyFormattedString(
910
+ date: string | Date,
911
+ options?: {
912
+ onlyShowDate?: boolean;
913
+ showSeconds?: boolean;
914
+ },
915
+ ): string {
916
+ return this.getDateAsFormattedString(date, {
917
+ ...options,
918
+ use12HourFormat: this.getUserPrefers12HourFormat(),
919
+ });
920
+ }
921
+
922
+ public static getDateAsUserFriendlyLocalFormattedString(
923
+ date: string | Date,
924
+ onlyShowDate?: boolean,
925
+ ): string {
926
+ return this.getDateAsLocalFormattedString(
927
+ date,
928
+ onlyShowDate,
929
+ this.getUserPrefers12HourFormat(),
930
+ );
931
+ }
932
+
886
933
  public static getDateAsFormattedString(
887
934
  date: string | Date,
888
935
  options?: {
889
936
  onlyShowDate?: boolean;
890
937
  showSeconds?: boolean;
938
+ use12HourFormat?: boolean;
891
939
  },
892
940
  ): string {
893
941
  date = this.fromString(date);
894
942
 
895
943
  let formatstring: string = "MMM DD YYYY, HH:mm";
896
944
 
945
+ if (options?.use12HourFormat) {
946
+ formatstring = "MMM DD YYYY, hh:mm A";
947
+ }
948
+
897
949
  if (options?.showSeconds) {
898
- formatstring = "MMM DD YYYY, HH:mm:ss";
950
+ if (options?.use12HourFormat) {
951
+ formatstring = "MMM DD YYYY, hh:mm:ss A";
952
+ } else {
953
+ formatstring = "MMM DD YYYY, HH:mm:ss";
954
+ }
899
955
  }
900
956
 
901
957
  if (options?.onlyShowDate) {
@@ -1061,15 +1117,22 @@ export default class OneUptimeDate {
1061
1117
  date: string | Date;
1062
1118
  onlyShowDate?: boolean | undefined;
1063
1119
  timezones?: Array<Timezone> | undefined;
1120
+ use12HourFormat?: boolean | undefined;
1064
1121
  }): Array<string> {
1065
1122
  let date: string | Date = data.date;
1066
1123
  const onlyShowDate: boolean | undefined = data.onlyShowDate;
1067
1124
  let timezones: Array<Timezone> | undefined = data.timezones;
1125
+ const use12HourFormat: boolean =
1126
+ data.use12HourFormat ?? this.getUserPrefers12HourFormat();
1068
1127
 
1069
1128
  date = this.fromString(date);
1070
1129
 
1071
1130
  let formatstring: string = "MMM DD YYYY, HH:mm";
1072
1131
 
1132
+ if (use12HourFormat) {
1133
+ formatstring = "MMM DD YYYY, hh:mm A";
1134
+ }
1135
+
1073
1136
  if (onlyShowDate) {
1074
1137
  formatstring = "MMM DD, YYYY";
1075
1138
  }
@@ -1106,15 +1169,18 @@ export default class OneUptimeDate {
1106
1169
  date: string | Date;
1107
1170
  onlyShowDate?: boolean;
1108
1171
  timezones?: Array<Timezone> | undefined; // if this is skipped, then it will show the default timezones in the order of UTC, EST, PST, IST, ACT
1172
+ use12HourFormat?: boolean | undefined;
1109
1173
  }): string {
1110
1174
  const date: string | Date = data.date;
1111
1175
  const onlyShowDate: boolean | undefined = data.onlyShowDate;
1112
1176
  const timezones: Array<Timezone> | undefined = data.timezones;
1177
+ const use12HourFormat: boolean | undefined = data.use12HourFormat;
1113
1178
 
1114
1179
  return this.getDateAsFormattedArrayInMultipleTimezones({
1115
1180
  date,
1116
1181
  onlyShowDate,
1117
1182
  timezones,
1183
+ use12HourFormat,
1118
1184
  }).join("<br/>");
1119
1185
  }
1120
1186
 
@@ -1122,26 +1188,34 @@ export default class OneUptimeDate {
1122
1188
  date: string | Date;
1123
1189
  onlyShowDate?: boolean | undefined;
1124
1190
  timezones?: Array<Timezone> | undefined; // if this is skipped, then it will show the default timezones in the order of UTC, EST, PST, IST, ACT
1191
+ use12HourFormat?: boolean | undefined;
1125
1192
  }): string {
1126
1193
  const date: string | Date = data.date;
1127
1194
  const onlyShowDate: boolean | undefined = data.onlyShowDate;
1128
1195
  const timezones: Array<Timezone> | undefined = data.timezones;
1196
+ const use12HourFormat: boolean | undefined = data.use12HourFormat;
1129
1197
 
1130
1198
  return this.getDateAsFormattedArrayInMultipleTimezones({
1131
1199
  date,
1132
1200
  onlyShowDate,
1133
1201
  timezones,
1202
+ use12HourFormat,
1134
1203
  }).join("\n");
1135
1204
  }
1136
1205
 
1137
1206
  public static getDateAsLocalFormattedString(
1138
1207
  date: string | Date,
1139
1208
  onlyShowDate?: boolean,
1209
+ use12HourFormat?: boolean,
1140
1210
  ): string {
1141
1211
  date = this.fromString(date);
1142
1212
 
1143
1213
  let formatstring: string = "MMM DD YYYY, HH:mm";
1144
1214
 
1215
+ if (use12HourFormat) {
1216
+ formatstring = "MMM DD YYYY, hh:mm A";
1217
+ }
1218
+
1145
1219
  if (onlyShowDate) {
1146
1220
  formatstring = "MMM DD, YYYY";
1147
1221
  }
@@ -29,10 +29,10 @@ const DashboardStartAndEndDateView: FunctionComponent<ComponentProps> = (
29
29
 
30
30
  const getContent: GetReactElementFunction = (): ReactElement => {
31
31
  const title: string = isCustomRange
32
- ? `${OneUptimeDate.getDateAsLocalFormattedString(
32
+ ? `${OneUptimeDate.getDateAsUserFriendlyLocalFormattedString(
33
33
  props.dashboardStartAndEndDate.startAndEndDate?.startValue ||
34
34
  OneUptimeDate.getCurrentDate(),
35
- )} - ${OneUptimeDate.getDateAsLocalFormattedString(
35
+ )} - ${OneUptimeDate.getDateAsUserFriendlyLocalFormattedString(
36
36
  props.dashboardStartAndEndDate.startAndEndDate?.endValue ||
37
37
  OneUptimeDate.getCurrentDate(),
38
38
  )}`
@@ -178,7 +178,7 @@ const Detail: DetailFunction = <T extends GenericObject>(
178
178
 
179
179
  if (field.fieldType === FieldType.Date) {
180
180
  if (data) {
181
- data = OneUptimeDate.getDateAsLocalFormattedString(
181
+ data = OneUptimeDate.getDateAsUserFriendlyLocalFormattedString(
182
182
  data as string,
183
183
  true,
184
184
  );
@@ -197,7 +197,7 @@ const Detail: DetailFunction = <T extends GenericObject>(
197
197
 
198
198
  if (field.fieldType === FieldType.DateTime) {
199
199
  if (data) {
200
- data = OneUptimeDate.getDateAsLocalFormattedString(
200
+ data = OneUptimeDate.getDateAsUserFriendlyLocalFormattedString(
201
201
  data as string,
202
202
  false,
203
203
  );
@@ -55,7 +55,10 @@ const EventHistoryDayList: FunctionComponent<ComponentProps> = (
55
55
  width: isMobile ? "100%" : "15%",
56
56
  }}
57
57
  >
58
- {OneUptimeDate.getDateAsLocalFormattedString(props.date, true)}
58
+ {OneUptimeDate.getDateAsUserFriendlyLocalFormattedString(
59
+ props.date,
60
+ true,
61
+ )}
59
62
  </div>
60
63
  <div
61
64
  style={{
@@ -224,7 +224,7 @@ const EventItem: FunctionComponent<ComponentProps> = (
224
224
  </div>
225
225
  <div>
226
226
  <span className="text-sm leading-8 text-gray-500 whitespace-nowrap">
227
- {OneUptimeDate.getDateAsLocalFormattedString(
227
+ {OneUptimeDate.getDateAsUserFriendlyLocalFormattedString(
228
228
  item.date,
229
229
  )}
230
230
  </span>
@@ -269,7 +269,7 @@ const EventItem: FunctionComponent<ComponentProps> = (
269
269
  </div>
270
270
  <p className="mt-0.5 text-sm text-gray-500">
271
271
  posted on{" "}
272
- {OneUptimeDate.getDateAsLocalFormattedString(
272
+ {OneUptimeDate.getDateAsUserFriendlyLocalFormattedString(
273
273
  item.date,
274
274
  )}
275
275
  </p>
@@ -117,7 +117,7 @@ const FeedItem: FunctionComponent<ComponentProps> = (
117
117
  )}
118
118
  <div className="mt-0.5 text-sm text-gray-500 w-fit">
119
119
  <Tooltip
120
- text={OneUptimeDate.getDateAsLocalFormattedString(
120
+ text={OneUptimeDate.getDateAsUserFriendlyLocalFormattedString(
121
121
  props.itemDateTime,
122
122
  )}
123
123
  >