Compare commits

...

4 Commits

Author SHA1 Message Date
Nawaz Dhandala
3ff0861d68 Merge branch 'master' into files-notes 2025-11-12 13:04:19 +00:00
Simon Larsen
96f0b111c1 chore(forms/ui/models): normalize imports/formatting, minor type & mapping fixes
- Reformat typeorm imports in note DB models for consistency
- Normalize JSX/indentation in FilePicker and FileList
- Add missing Link import to EventItem
- Strengthen filter type annotation in FileList
- Clarify MultipleFiles -> FieldType.File mapping in FormFieldSchemaTypeUtil
- Minor formatting cleanups in FormField and dashboard note view files
2025-11-10 14:01:37 +00:00
Simon Larsen
044ec492da feat(forms): add MultipleFiles field type and multi-file handling
- Add MultipleFiles to FormFieldSchemaType enum.
- Map MultipleFiles to FieldType.File in FormFieldSchemaTypeUtil.
- Enhance FilePicker to append new uploads for multi-picker, deduplicate by id, and return updated file list.
- Update FormField to pass isMultiFilePicker, normalize/strip FileModel fields, and set array or single value based on field type.
- Replace file + isMultiFilePicker usage in various note/public/internal pages with MultipleFiles.
2025-11-10 13:48:32 +00:00
Simon Larsen
6d4462c969 feat(notes): add file attachments support to notes (models, services, API, UI, status page)
- Add attachments ManyToMany relation (File) + JoinTable to Alert/Incident/ScheduledMaintenance
  internal/public note models and table metadata.
- Update note services to accept attachments: map File | ObjectID to File instances on create.
- Include attachments fields in StatusPageAPI selections for notes.
- Add new FileList UI component to render attachment links.
- Integrate file picker/display in Dashboard note pages (alerts, incidents, scheduled maintenance):
  add form field, selectMoreFields, and render attachments alongside markdown.
- Include attachments on Status Page timeline items so public notes display files.
2025-11-10 13:37:15 +00:00
25 changed files with 737 additions and 116 deletions

View File

@@ -17,7 +17,16 @@ import TenantColumn from "../../Types/Database/TenantColumn";
import IconProp from "../../Types/Icon/IconProp";
import ObjectID from "../../Types/ObjectID";
import Permission from "../../Types/Permission";
import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
import File from "./File";
import {
Column,
Entity,
Index,
JoinColumn,
JoinTable,
ManyToMany,
ManyToOne,
} from "typeorm";
@EnableDocumentation()
@CanAccessIfCanReadOn("alert")
@@ -340,6 +349,54 @@ export default class AlertInternalNote extends BaseModel {
})
public note?: string = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateAlertInternalNote,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadAlertInternalNote,
],
update: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.EditAlertInternalNote,
],
})
@TableColumn({
required: false,
type: TableColumnType.EntityArray,
modelType: File,
title: "Attachments",
description: "Files attached to this note.",
})
@ManyToMany(
() => {
return File;
},
{
eager: false,
},
)
@JoinTable({
name: "AlertInternalNoteFile",
joinColumn: {
name: "alertInternalNoteId",
referencedColumnName: "_id",
},
inverseJoinColumn: {
name: "fileId",
referencedColumnName: "_id",
},
})
public attachments?: Array<File> = undefined;
@ColumnAccessControl({
create: [],
read: [

View File

@@ -17,7 +17,16 @@ import TenantColumn from "../../Types/Database/TenantColumn";
import IconProp from "../../Types/Icon/IconProp";
import ObjectID from "../../Types/ObjectID";
import Permission from "../../Types/Permission";
import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
import File from "./File";
import {
Column,
Entity,
Index,
JoinColumn,
JoinTable,
ManyToMany,
ManyToOne,
} from "typeorm";
@EnableDocumentation()
@CanAccessIfCanReadOn("incident")
@@ -340,6 +349,54 @@ export default class IncidentInternalNote extends BaseModel {
})
public note?: string = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateIncidentInternalNote,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadIncidentInternalNote,
],
update: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.EditIncidentInternalNote,
],
})
@TableColumn({
required: false,
type: TableColumnType.EntityArray,
modelType: File,
title: "Attachments",
description: "Files attached to this note.",
})
@ManyToMany(
() => {
return File;
},
{
eager: false,
},
)
@JoinTable({
name: "IncidentInternalNoteFile",
joinColumn: {
name: "incidentInternalNoteId",
referencedColumnName: "_id",
},
inverseJoinColumn: {
name: "fileId",
referencedColumnName: "_id",
},
})
public attachments?: Array<File> = undefined;
@ColumnAccessControl({
create: [],
read: [

View File

@@ -18,7 +18,16 @@ import IconProp from "../../Types/Icon/IconProp";
import ObjectID from "../../Types/ObjectID";
import Permission from "../../Types/Permission";
import StatusPageSubscriberNotificationStatus from "../../Types/StatusPage/StatusPageSubscriberNotificationStatus";
import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
import File from "./File";
import {
Column,
Entity,
Index,
JoinColumn,
JoinTable,
ManyToMany,
ManyToOne,
} from "typeorm";
@EnableDocumentation()
@CanAccessIfCanReadOn("incident")
@@ -341,6 +350,54 @@ export default class IncidentPublicNote extends BaseModel {
})
public note?: string = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateIncidentPublicNote,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadIncidentPublicNote,
],
update: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.EditIncidentPublicNote,
],
})
@TableColumn({
required: false,
type: TableColumnType.EntityArray,
modelType: File,
title: "Attachments",
description: "Files attached to this note.",
})
@ManyToMany(
() => {
return File;
},
{
eager: false,
},
)
@JoinTable({
name: "IncidentPublicNoteFile",
joinColumn: {
name: "incidentPublicNoteId",
referencedColumnName: "_id",
},
inverseJoinColumn: {
name: "fileId",
referencedColumnName: "_id",
},
})
public attachments?: Array<File> = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,

View File

@@ -16,7 +16,16 @@ import TenantColumn from "../../Types/Database/TenantColumn";
import IconProp from "../../Types/Icon/IconProp";
import ObjectID from "../../Types/ObjectID";
import Permission from "../../Types/Permission";
import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
import File from "./File";
import {
Column,
Entity,
Index,
JoinColumn,
JoinTable,
ManyToMany,
ManyToOne,
} from "typeorm";
@CanAccessIfCanReadOn("scheduledMaintenance")
@TenantColumn("projectId")
@@ -340,6 +349,54 @@ export default class ScheduledMaintenanceInternalNote extends BaseModel {
})
public note?: string = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateScheduledMaintenanceInternalNote,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadScheduledMaintenanceInternalNote,
],
update: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.EditScheduledMaintenanceInternalNote,
],
})
@TableColumn({
required: false,
type: TableColumnType.EntityArray,
modelType: File,
title: "Attachments",
description: "Files attached to this note.",
})
@ManyToMany(
() => {
return File;
},
{
eager: false,
},
)
@JoinTable({
name: "ScheduledMaintenanceInternalNoteFile",
joinColumn: {
name: "scheduledMaintenanceInternalNoteId",
referencedColumnName: "_id",
},
inverseJoinColumn: {
name: "fileId",
referencedColumnName: "_id",
},
})
public attachments?: Array<File> = undefined;
@ColumnAccessControl({
create: [],
read: [

View File

@@ -18,7 +18,16 @@ import IconProp from "../../Types/Icon/IconProp";
import ObjectID from "../../Types/ObjectID";
import Permission from "../../Types/Permission";
import StatusPageSubscriberNotificationStatus from "../../Types/StatusPage/StatusPageSubscriberNotificationStatus";
import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
import File from "./File";
import {
Column,
Entity,
Index,
JoinColumn,
JoinTable,
ManyToMany,
ManyToOne,
} from "typeorm";
@EnableDocumentation()
@CanAccessIfCanReadOn("scheduledMaintenance")
@@ -342,6 +351,54 @@ export default class ScheduledMaintenancePublicNote extends BaseModel {
})
public note?: string = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateScheduledMaintenancePublicNote,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadScheduledMaintenancePublicNote,
],
update: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.EditScheduledMaintenancePublicNote,
],
})
@TableColumn({
required: false,
type: TableColumnType.EntityArray,
modelType: File,
title: "Attachments",
description: "Files attached to this note.",
})
@ManyToMany(
() => {
return File;
},
{
eager: false,
},
)
@JoinTable({
name: "ScheduledMaintenancePublicNoteFile",
joinColumn: {
name: "scheduledMaintenancePublicNoteId",
referencedColumnName: "_id",
},
inverseJoinColumn: {
name: "fileId",
referencedColumnName: "_id",
},
})
public attachments?: Array<File> = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,

View File

@@ -1269,6 +1269,10 @@ export default class StatusPageAPI extends BaseAPI<
note: true,
incidentId: true,
postedAt: true,
attachments: {
_id: true,
name: true,
},
},
sort: {
postedAt: SortOrder.Descending, // new note first
@@ -1454,6 +1458,10 @@ export default class StatusPageAPI extends BaseAPI<
postedAt: true,
note: true,
scheduledMaintenanceId: true,
attachments: {
_id: true,
name: true,
},
},
sort: {
postedAt: SortOrder.Ascending,
@@ -1993,6 +2001,10 @@ export default class StatusPageAPI extends BaseAPI<
postedAt: true,
note: true,
scheduledMaintenanceId: true,
attachments: {
_id: true,
name: true,
},
},
sort: {
postedAt: SortOrder.Ascending,
@@ -3155,6 +3167,10 @@ export default class StatusPageAPI extends BaseAPI<
postedAt: true,
note: true,
incidentId: true,
attachments: {
_id: true,
name: true,
},
},
sort: {
postedAt: SortOrder.Descending, // new note first

View File

@@ -9,6 +9,7 @@ import { LIMIT_PER_PROJECT } from "../../Types/Database/LimitMax";
import Alert from "../../Models/DatabaseModels/Alert";
import AlertService from "./AlertService";
import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
import File from "../../Models/DatabaseModels/File";
export class Service extends DatabaseService<Model> {
public constructor() {
@@ -21,6 +22,7 @@ export class Service extends DatabaseService<Model> {
alertId: ObjectID;
projectId: ObjectID;
note: string;
attachments?: Array<File | ObjectID>;
}): Promise<Model> {
const internalNote: Model = new Model();
internalNote.createdByUserId = data.userId;
@@ -28,6 +30,20 @@ export class Service extends DatabaseService<Model> {
internalNote.projectId = data.projectId;
internalNote.note = data.note;
if (data.attachments && data.attachments.length > 0) {
internalNote.attachments = data.attachments.map(
(attachment: File | ObjectID) => {
if (attachment instanceof File) {
return attachment;
}
const file: File = new File();
file.id = attachment;
return file;
},
);
}
return this.create({
data: internalNote,
props: {

View File

@@ -9,6 +9,7 @@ import { LIMIT_PER_PROJECT } from "../../Types/Database/LimitMax";
import IncidentService from "./IncidentService";
import Incident from "../../Models/DatabaseModels/Incident";
import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
import File from "../../Models/DatabaseModels/File";
export class Service extends DatabaseService<Model> {
public constructor() {
@@ -21,6 +22,7 @@ export class Service extends DatabaseService<Model> {
incidentId: ObjectID;
projectId: ObjectID;
note: string;
attachments?: Array<File | ObjectID>;
}): Promise<Model> {
const internalNote: Model = new Model();
internalNote.createdByUserId = data.userId;
@@ -28,6 +30,20 @@ export class Service extends DatabaseService<Model> {
internalNote.projectId = data.projectId;
internalNote.note = data.note;
if (data.attachments && data.attachments.length > 0) {
internalNote.attachments = data.attachments.map(
(attachment: File | ObjectID) => {
if (attachment instanceof File) {
return attachment;
}
const file: File = new File();
file.id = attachment;
return file;
},
);
}
return this.create({
data: internalNote,
props: {

View File

@@ -12,6 +12,7 @@ import IncidentService from "./IncidentService";
import Incident from "../../Models/DatabaseModels/Incident";
import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
import StatusPageSubscriberNotificationStatus from "../../Types/StatusPage/StatusPageSubscriberNotificationStatus";
import File from "../../Models/DatabaseModels/File";
export class Service extends DatabaseService<Model> {
public constructor() {
@@ -24,6 +25,7 @@ export class Service extends DatabaseService<Model> {
incidentId: ObjectID;
projectId: ObjectID;
note: string;
attachments?: Array<File | ObjectID>;
}): Promise<Model> {
const publicNote: Model = new Model();
publicNote.createdByUserId = data.userId;
@@ -32,6 +34,20 @@ export class Service extends DatabaseService<Model> {
publicNote.note = data.note;
publicNote.postedAt = OneUptimeDate.getCurrentDate();
if (data.attachments && data.attachments.length > 0) {
publicNote.attachments = data.attachments.map(
(attachment: File | ObjectID) => {
if (attachment instanceof File) {
return attachment;
}
const file: File = new File();
file.id = attachment;
return file;
},
);
}
return this.create({
data: publicNote,
props: {

View File

@@ -9,6 +9,7 @@ import { LIMIT_PER_PROJECT } from "../../Types/Database/LimitMax";
import ScheduledMaintenance from "../../Models/DatabaseModels/ScheduledMaintenance";
import ScheduledMaintenanceService from "./ScheduledMaintenanceService";
import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
import File from "../../Models/DatabaseModels/File";
export class Service extends DatabaseService<Model> {
public constructor() {
@@ -21,6 +22,7 @@ export class Service extends DatabaseService<Model> {
scheduledMaintenanceId: ObjectID;
projectId: ObjectID;
note: string;
attachments?: Array<File | ObjectID>;
}): Promise<Model> {
const internalNote: Model = new Model();
internalNote.createdByUserId = data.userId;
@@ -28,6 +30,20 @@ export class Service extends DatabaseService<Model> {
internalNote.projectId = data.projectId;
internalNote.note = data.note;
if (data.attachments && data.attachments.length > 0) {
internalNote.attachments = data.attachments.map(
(attachment: File | ObjectID) => {
if (attachment instanceof File) {
return attachment;
}
const file: File = new File();
file.id = attachment;
return file;
},
);
}
return this.create({
data: internalNote,
props: {

View File

@@ -12,6 +12,7 @@ import ScheduledMaintenanceService from "./ScheduledMaintenanceService";
import ScheduledMaintenance from "../../Models/DatabaseModels/ScheduledMaintenance";
import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
import StatusPageSubscriberNotificationStatus from "../../Types/StatusPage/StatusPageSubscriberNotificationStatus";
import File from "../../Models/DatabaseModels/File";
export class Service extends DatabaseService<Model> {
public constructor() {
@@ -149,6 +150,7 @@ ${updatedItem.note}
scheduledMaintenanceId: ObjectID;
projectId: ObjectID;
note: string;
attachments?: Array<File | ObjectID>;
}): Promise<Model> {
const publicNote: Model = new Model();
publicNote.createdByUserId = data.userId;
@@ -157,6 +159,20 @@ ${updatedItem.note}
publicNote.note = data.note;
publicNote.postedAt = OneUptimeDate.getCurrentDate();
if (data.attachments && data.attachments.length > 0) {
publicNote.attachments = data.attachments.map(
(attachment: File | ObjectID) => {
if (attachment instanceof File) {
return attachment;
}
const file: File = new File();
file.id = attachment;
return file;
},
);
}
return this.create({
data: publicNote,
props: {

View File

@@ -10,6 +10,8 @@ import Color from "../../../Types/Color";
import OneUptimeDate from "../../../Types/Date";
import IconProp from "../../../Types/Icon/IconProp";
import React, { FunctionComponent, ReactElement } from "react";
import FileModel from "../../../Models/DatabaseModels/File";
import FileList from "../FileList/FileList";
export enum TimelineItemType {
StateChange = "StateChange",
@@ -23,6 +25,7 @@ export interface TimelineItem {
state?: BaseModel;
icon: IconProp;
iconColor: Color;
attachments?: Array<FileModel> | undefined;
}
export interface EventItemLabel {
@@ -278,6 +281,11 @@ const EventItem: FunctionComponent<ComponentProps> = (
<p>
<MarkdownViewer text={item.note || ""} />
</p>
<FileList
files={item.attachments}
containerClassName="mt-3 space-y-2"
linkClassName="text-indigo-600 hover:text-indigo-500"
/>
</div>
</div>
</div>

View File

@@ -0,0 +1,64 @@
import Link from "../Link/Link";
import FileModel from "../../../Models/DatabaseModels/File";
import URL from "../../../Types/API/URL";
import React, { FunctionComponent, ReactElement } from "react";
import { FILE_URL } from "../../Config";
export interface FileListProps {
files?: Array<FileModel | undefined | null> | null | undefined;
containerClassName?: string;
linkClassName?: string;
openInNewTab?: boolean;
getFileName?: (file: FileModel, index: number) => string;
}
const DEFAULT_LINK_CLASSNAME: string = "text-primary-500 hover:underline";
const DEFAULT_CONTAINER_CLASSNAME: string = "flex flex-col space-y-2";
const FileList: FunctionComponent<FileListProps> = (
props: FileListProps,
): ReactElement | null => {
const files: Array<FileModel> = (props.files || []).filter(
(file: FileModel | undefined | null): file is FileModel => {
return Boolean(file);
},
);
if (!files.length) {
return null;
}
return (
<div className={props.containerClassName || DEFAULT_CONTAINER_CLASSNAME}>
{files.map((file: FileModel, index: number) => {
const fileId: string | null =
file.id?.toString?.() || (file as any)._id?.toString?.() || null;
if (!fileId) {
return null;
}
const fileUrl: URL = URL.fromString(FILE_URL.toString()).addRoute(
`/image/${fileId}`,
);
const label: string = props.getFileName
? props.getFileName(file, index)
: file.name || `Attachment ${index + 1}`;
return (
<Link
key={`${fileId}-${index}`}
to={fileUrl}
openInNewTab={props.openInNewTab !== false}
className={props.linkClassName || DEFAULT_LINK_CLASSNAME}
>
{label}
</Link>
);
})}
</div>
);
};
export default FileList;

View File

@@ -58,23 +58,22 @@ const FilePicker: FunctionComponent<ComponentProps> = (
}, [props.initialValue]);
const setInitialValue: VoidFunction = () => {
if (
Array.isArray(props.initialValue) &&
props.initialValue &&
props.initialValue.length > 0
) {
if (Array.isArray(props.initialValue)) {
setFilesModel(props.initialValue);
} else if (props.initialValue instanceof FileModel) {
setFilesModel([props.initialValue as FileModel]);
} else {
setFilesModel([]);
}
};
useEffect(() => {
if (props.value && props.value.length > 0) {
setFilesModel(props.value && props.value.length > 0 ? props.value : []);
} else {
setInitialValue();
if (props.value) {
setFilesModel(props.value);
return;
}
setInitialValue();
}, [props.value]);
const { getRootProps, getInputProps } = useDropzone({
@@ -112,10 +111,22 @@ const FilePicker: FunctionComponent<ComponentProps> = (
filesResult.push(result.data as FileModel);
}
setFilesModel(filesResult);
const updatedFiles: Array<FileModel> = props.isMultiFilePicker
? [...filesModel, ...filesResult]
: filesResult;
const uniqueFiles: Array<FileModel> = Array.from(
new Map(
updatedFiles.map((file: FileModel) => {
return [file._id?.toString(), file];
}),
).values(),
);
setFilesModel(uniqueFiles);
props.onBlur?.();
props.onChange?.(filesResult);
props.onChange?.(uniqueFiles);
} catch (err) {
setError(API.getFriendlyMessage(err));
}
@@ -183,84 +194,79 @@ const FilePicker: FunctionComponent<ComponentProps> = (
data-testid={props.dataTestId}
className="flex max-w-lg justify-center rounded-md border-2 border-dashed border-gray-300 px-6 pt-5 pb-6"
>
{props.isMultiFilePicker ||
(filesModel.length === 0 && (
<div
{...getRootProps({
className: "space-y-1 text-center",
})}
{(props.isMultiFilePicker || filesModel.length === 0) && (
<div
{...getRootProps({
className: "space-y-1 text-center",
})}
>
<svg
className="mx-auto h-12 w-12 text-gray-400"
stroke="currentColor"
fill="none"
viewBox="0 0 48 48"
aria-hidden="true"
>
<svg
className="mx-auto h-12 w-12 text-gray-400"
stroke="currentColor"
fill="none"
viewBox="0 0 48 48"
aria-hidden="true"
>
<path
d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
></path>
</svg>
<div className="flex text-sm text-gray-600">
<label className="relative cursor-pointer rounded-md bg-white font-medium text-indigo-600 focus-within:outline-none focus-within:ring-2 focus-within:ring-indigo-500 focus-within:ring-offset-2 hover:text-indigo-500">
{!props.placeholder && !error && (
<span>{"Upload a file"}</span>
)}
<path
d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
></path>
</svg>
<div className="flex text-sm text-gray-600">
<label className="relative cursor-pointer rounded-md bg-white font-medium text-indigo-600 focus-within:outline-none focus-within:ring-2 focus-within:ring-indigo-500 focus-within:ring-offset-2 hover:text-indigo-500">
{!props.placeholder && !error && <span>{"Upload a file"}</span>}
{error && (
<span>
<span>{error}</span>
</span>
)}
{props.placeholder && !error && (
<span>{props.placeholder}</span>
)}
<input
tabIndex={props.tabIndex}
{...(getInputProps() as any)}
id="file-upload"
name="file-upload"
type="file"
className="sr-only"
/>
</label>
<p className="pl-1">or drag and drop</p>
</div>
<p className="text-xs text-gray-500">
{props.mimeTypes && props.mimeTypes?.length > 0 && (
<span>File types: </span>
{error && (
<span>
<span>{error}</span>
</span>
)}
{props.mimeTypes &&
props.mimeTypes
.map((type: MimeType) => {
const enumKey: string | undefined =
Object.keys(MimeType)[
Object.values(MimeType).indexOf(type)
];
return enumKey?.toUpperCase() || "";
})
.filter(
(
item: string | undefined,
pos: number,
array: Array<string | undefined>,
) => {
return array.indexOf(item) === pos;
},
)
.join(", ")}
{props.mimeTypes && props.mimeTypes?.length > 0 && (
<span>.</span>
{props.placeholder && !error && (
<span>{props.placeholder}</span>
)}
&nbsp;10 MB or less.
</p>
<input
tabIndex={props.tabIndex}
{...(getInputProps() as any)}
id="file-upload"
name="file-upload"
type="file"
className="sr-only"
/>
</label>
<p className="pl-1">or drag and drop</p>
</div>
))}
<p className="text-xs text-gray-500">
{props.mimeTypes && props.mimeTypes?.length > 0 && (
<span>File types: </span>
)}
{props.mimeTypes &&
props.mimeTypes
.map((type: MimeType) => {
const enumKey: string | undefined =
Object.keys(MimeType)[
Object.values(MimeType).indexOf(type)
];
return enumKey?.toUpperCase() || "";
})
.filter(
(
item: string | undefined,
pos: number,
array: Array<string | undefined>,
) => {
return array.indexOf(item) === pos;
},
)
.join(", ")}
{props.mimeTypes && props.mimeTypes?.length > 0 && <span>.</span>}
&nbsp;10 MB or less.
</p>
</div>
)}
<aside>{getThumbs()}</aside>
</div>
{props.error && (

View File

@@ -570,33 +570,51 @@ const FormField: <T extends GenericObject>(
)}
{(props.field.fieldType === FormFieldSchemaType.File ||
props.field.fieldType === FormFieldSchemaType.ImageFile) && (
props.field.fieldType === FormFieldSchemaType.ImageFile ||
props.field.fieldType === FormFieldSchemaType.MultipleFiles) && (
<FilePicker
error={props.touched && props.error ? props.error : undefined}
tabIndex={index}
isMultiFilePicker={
props.field.fieldType === FormFieldSchemaType.MultipleFiles
}
onChange={async (files: Array<FileModel>) => {
let fileResult: FileModel | Array<FileModel> | null = files.map(
const strippedFiles: Array<FileModel> = files.map(
(i: FileModel) => {
const strippedModel: FileModel = new FileModel();
strippedModel._id = i._id!;
const fileId: string | undefined =
(i as any)._id?.toString?.() ||
(i as any).id?.toString?.();
if (fileId) {
strippedModel._id = fileId;
}
if (i.name) {
strippedModel.name = i.name;
}
if (i.fileType) {
strippedModel.fileType = i.fileType;
}
return strippedModel;
},
);
if (
(props.field.fieldType === FormFieldSchemaType.File ||
props.field.fieldType === FormFieldSchemaType.ImageFile) &&
Array.isArray(fileResult)
props.field.fieldType === FormFieldSchemaType.MultipleFiles
) {
if (fileResult.length > 0) {
fileResult = fileResult[0] as FileModel;
} else {
fileResult = null;
}
onChange(strippedFiles);
props.setFieldValue(props.fieldName, strippedFiles);
return;
}
onChange(fileResult);
props.setFieldValue(props.fieldName, fileResult);
const singleFile: FileModel | null = strippedFiles.length
? strippedFiles[0]!
: null;
onChange(singleFile);
props.setFieldValue(props.fieldName, singleFile);
}}
onBlur={async () => {
props.setFieldTouched(props.fieldName, true);
@@ -611,7 +629,9 @@ const FormField: <T extends GenericObject>(
props.currentValues &&
(props.currentValues as any)[props.fieldName]
? (props.currentValues as any)[props.fieldName]
: []
: props.field.fieldType === FormFieldSchemaType.MultipleFiles
? []
: undefined
}
placeholder={props.field.placeholder || ""}
/>

View File

@@ -104,7 +104,6 @@ export default interface Field<TEntity> {
) => ReactElement | undefined; // custom element to render instead of the elements in the form.
categoryCheckboxProps?: CategoryCheckboxProps | undefined; // props for the category checkbox component. If fieldType is CategoryCheckbox, this prop is required.
dataTestId?: string | undefined;
// set this to true if you want to show this field in the form even when the form is in edit mode.
doNotShowWhenEditing?: boolean | undefined;
doNotShowWhenCreating?: boolean | undefined;

View File

@@ -19,6 +19,7 @@ enum FormFieldSchemaType {
Color = "Color",
Dropdown = "Dropdown",
File = "File",
MultipleFiles = "MultipleFiles",
MultiSelectDropdown = "MultiSelectDropdown",
OptionChooserButton = "OptionChooserButton",
Toggle = "Boolean",

View File

@@ -47,7 +47,8 @@ export default class FormFieldSchemaTypeUtil {
case FormFieldSchemaType.RadioButton:
return FieldType.Text;
case FormFieldSchemaType.File:
return FieldType.File;
case FormFieldSchemaType.MultipleFiles:
return FieldType.File; // Treat MultipleFiles as standard file input
case FormFieldSchemaType.MultiSelectDropdown:
return FieldType.MultiSelectDropdown;
case FormFieldSchemaType.Toggle:

View File

@@ -1,3 +1,5 @@
import FileList from "Common/UI/Components/FileList/FileList";
import MarkdownViewer from "Common/UI/Components/Markdown.tsx/LazyMarkdownViewer";
import MarkdownUtil from "Common/UI/Utils/Markdown";
import UserElement from "../../../Components/User/User";
import ProjectUser from "../../../Utils/ProjectUser";
@@ -165,7 +167,25 @@ const AlertDelete: FunctionComponent<PageComponentProps> = (
"Add a private note to this alert here. This is private to your team and is not visible on Status Page",
),
},
{
field: {
attachments: {
_id: true,
name: true,
},
},
title: "Attachments",
description: "Upload files to attach to this note.",
fieldType: FormFieldSchemaType.MultipleFiles,
required: false,
},
]}
selectMoreFields={{
attachments: {
_id: true,
name: true,
},
}}
showAs={ShowAs.List}
showRefreshButton={true}
viewPageRoute={Navigation.getCurrentRoute()}
@@ -243,9 +263,17 @@ const AlertDelete: FunctionComponent<PageComponentProps> = (
},
title: "",
type: FieldType.Markdown,
contentClassName: "-mt-3 space-y-6 text-sm text-gray-800",
type: FieldType.Element,
contentClassName: "-mt-3",
colSpan: 2,
getElement: (item: AlertInternalNote): ReactElement => {
return (
<div className="space-y-3 text-sm text-gray-800">
<MarkdownViewer text={item.note || ""} />
<FileList files={item.attachments} />
</div>
);
},
},
]}
/>

View File

@@ -1,3 +1,5 @@
import FileList from "Common/UI/Components/FileList/FileList";
import MarkdownViewer from "Common/UI/Components/Markdown.tsx/LazyMarkdownViewer";
import MarkdownUtil from "Common/UI/Utils/Markdown";
import UserElement from "../../../Components/User/User";
import ProjectUser from "../../../Utils/ProjectUser";
@@ -165,6 +167,18 @@ const IncidentDelete: FunctionComponent<PageComponentProps> = (
"Add a private note to this incident here. This is private to your team and is not visible on Status Page",
),
},
{
field: {
attachments: {
_id: true,
name: true,
},
},
title: "Attachments",
description: "Upload files to attach to this note.",
fieldType: FormFieldSchemaType.MultipleFiles,
required: false,
},
]}
showAs={ShowAs.List}
showRefreshButton={true}
@@ -202,6 +216,12 @@ const IncidentDelete: FunctionComponent<PageComponentProps> = (
title: "Created At",
},
]}
selectMoreFields={{
attachments: {
_id: true,
name: true,
},
}}
columns={[
{
field: {
@@ -243,9 +263,17 @@ const IncidentDelete: FunctionComponent<PageComponentProps> = (
},
title: "",
type: FieldType.Markdown,
contentClassName: "-mt-3 space-y-6 text-sm text-gray-800",
type: FieldType.Element,
contentClassName: "-mt-3",
colSpan: 2,
getElement: (item: IncidentInternalNote): ReactElement => {
return (
<div className="space-y-3 text-sm text-gray-800">
<MarkdownViewer text={item.note || ""} />
<FileList files={item.attachments} />
</div>
);
},
},
]}
/>

View File

@@ -1,3 +1,5 @@
import FileList from "Common/UI/Components/FileList/FileList";
import MarkdownViewer from "Common/UI/Components/Markdown.tsx/LazyMarkdownViewer";
import MarkdownUtil from "Common/UI/Utils/Markdown";
import UserElement from "../../../Components/User/User";
import ProjectUser from "../../../Utils/ProjectUser";
@@ -191,6 +193,18 @@ const PublicNote: FunctionComponent<PageComponentProps> = (
"This note is visible on your Status Page",
),
},
{
field: {
attachments: {
_id: true,
name: true,
},
},
title: "Attachments",
description: "Upload files to attach to this note.",
fieldType: FormFieldSchemaType.MultipleFiles,
required: false,
},
{
field: {
shouldStatusPageSubscribersBeNotifiedOnNoteCreated: true,
@@ -224,6 +238,10 @@ const PublicNote: FunctionComponent<PageComponentProps> = (
viewPageRoute={Navigation.getCurrentRoute()}
selectMoreFields={{
subscriberNotificationStatusMessage: true,
attachments: {
_id: true,
name: true,
},
}}
filters={[
{
@@ -300,9 +318,17 @@ const PublicNote: FunctionComponent<PageComponentProps> = (
},
title: "",
type: FieldType.Markdown,
contentClassName: "-mt-3 space-y-1 text-sm text-gray-800",
type: FieldType.Element,
contentClassName: "-mt-3",
colSpan: 2,
getElement: (item: IncidentPublicNote): ReactElement => {
return (
<div className="space-y-3 text-sm text-gray-800">
<MarkdownViewer text={item.note || ""} />
<FileList files={item.attachments} />
</div>
);
},
},
{
field: {

View File

@@ -1,3 +1,5 @@
import FileList from "Common/UI/Components/FileList/FileList";
import MarkdownViewer from "Common/UI/Components/Markdown.tsx/LazyMarkdownViewer";
import MarkdownUtil from "Common/UI/Utils/Markdown";
import UserElement from "../../../Components/User/User";
import ProjectUser from "../../../Utils/ProjectUser";
@@ -177,9 +179,27 @@ const ScheduledMaintenanceDelete: FunctionComponent<PageComponentProps> = (
"Add a private note to this scheduled maintenance here",
),
},
{
field: {
attachments: {
_id: true,
name: true,
},
},
title: "Attachments",
description: "Upload files to attach to this note.",
fieldType: FormFieldSchemaType.MultipleFiles,
required: false,
},
]}
showRefreshButton={true}
showAs={ShowAs.List}
selectMoreFields={{
attachments: {
_id: true,
name: true,
},
}}
viewPageRoute={Navigation.getCurrentRoute()}
filters={[
{
@@ -257,9 +277,19 @@ const ScheduledMaintenanceDelete: FunctionComponent<PageComponentProps> = (
},
title: "",
type: FieldType.Markdown,
contentClassName: "-mt-3 space-y-6 text-sm text-gray-800",
type: FieldType.Element,
contentClassName: "-mt-3",
colSpan: 2,
getElement: (
item: ScheduledMaintenanceInternalNote,
): ReactElement => {
return (
<div className="space-y-3 text-sm text-gray-800">
<MarkdownViewer text={item.note || ""} />
<FileList files={item.attachments} />
</div>
);
},
},
]}
/>

View File

@@ -1,3 +1,5 @@
import FileList from "Common/UI/Components/FileList/FileList";
import MarkdownViewer from "Common/UI/Components/Markdown.tsx/LazyMarkdownViewer";
import MarkdownUtil from "Common/UI/Utils/Markdown";
import UserElement from "../../../Components/User/User";
import ProjectUser from "../../../Utils/ProjectUser";
@@ -204,6 +206,18 @@ const PublicNote: FunctionComponent<PageComponentProps> = (
"This note is visible on your Status Page",
),
},
{
field: {
attachments: {
_id: true,
name: true,
},
},
title: "Attachments",
description: "Upload files to attach to this note.",
fieldType: FormFieldSchemaType.MultipleFiles,
required: false,
},
{
field: {
shouldStatusPageSubscribersBeNotifiedOnNoteCreated: true,
@@ -237,6 +251,10 @@ const PublicNote: FunctionComponent<PageComponentProps> = (
viewPageRoute={Navigation.getCurrentRoute()}
selectMoreFields={{
subscriberNotificationStatusMessage: true,
attachments: {
_id: true,
name: true,
},
}}
filters={[
{
@@ -312,11 +330,20 @@ const PublicNote: FunctionComponent<PageComponentProps> = (
field: {
note: true,
},
title: "",
type: FieldType.Markdown,
contentClassName: "-mt-3 space-y-6 text-sm text-gray-800",
type: FieldType.Element,
contentClassName: "-mt-3",
colSpan: 2,
getElement: (
item: ScheduledMaintenancePublicNote,
): ReactElement => {
return (
<div className="space-y-3 text-sm text-gray-800">
<MarkdownViewer text={item.note || ""} />
<FileList files={item.attachments} />
</div>
);
},
},
{
field: {

View File

@@ -101,6 +101,7 @@ export const getIncidentEventItem: GetIncidentEventItemFunction = (
type: TimelineItemType.Note,
icon: IconProp.Chat,
iconColor: Gray500,
attachments: incidentPublicNote.attachments,
});
// If this incident is a sumamry then don't include all the notes .

View File

@@ -113,6 +113,7 @@ export const getScheduledEventEventItem: GetScheduledEventEventItemFunction = (
type: TimelineItemType.Note,
icon: IconProp.Chat,
iconColor: Gray500,
attachments: scheduledMaintenancePublicNote.attachments,
});
if (isSummary) {