diff --git a/backend/app/routes/incidents.py b/backend/app/routes/incidents.py index ab88ecc..6d7989a 100644 --- a/backend/app/routes/incidents.py +++ b/backend/app/routes/incidents.py @@ -9,6 +9,7 @@ from app.repos import FormRepo, IncidentRepo, TimestampRepo, UserRepo from app.schemas.actions import ( CreateIncidentSchema, + CreateIncidentUpdateSchema, IncidentSearchSchema, PaginationParamsSchema, PatchIncidentFieldValuesSchema, @@ -210,3 +211,22 @@ async def incident_patch_field_values( db.commit() return Response(None, status_code=status.HTTP_202_ACCEPTED) + + +@router.post("/{id}/updates", response_model=IncidentUpdateSchema | None) +async def incident_create_update( + id: str, create_in: CreateIncidentUpdateSchema, db: DatabaseSession, user: CurrentUser, events: EventsService +): + """Create a new incident update""" + incident_repo = IncidentRepo(session=db) + incident = incident_repo.get_incident_by_id_or_raise(id) + + if not user.belongs_to(organisation=incident.organisation): + raise NotPermittedError() + + incident_service = create_incident_service(session=db, organisation=incident.organisation, events=events) + update = incident_service.create_update_from_schema(incident=incident, creator=user, create_in=create_in) + + db.commit() + + return update diff --git a/backend/app/services/incident.py b/backend/app/services/incident.py index 04a5ac9..bffceb0 100644 --- a/backend/app/services/incident.py +++ b/backend/app/services/incident.py @@ -12,6 +12,7 @@ IncidentStatus, IncidentStatusCategoryEnum, IncidentType, + IncidentUpdate, Organisation, User, ) @@ -224,7 +225,9 @@ def create_incident( return incident - def create_update_from_schema(self, incident: Incident, creator: User, create_in: CreateIncidentUpdateSchema): + def create_update_from_schema( + self, incident: Incident, creator: User, create_in: CreateIncidentUpdateSchema + ) -> IncidentUpdate | None: incident_severity: IncidentSeverity | None = None summary: str | None = None incident_status: IncidentStatus | None = None @@ -247,7 +250,7 @@ def create_update_from_schema(self, incident: Incident, creator: User, create_in SetIncidentFieldValueSchema(field=ModelIdSchema(id=form_field.field.id), value=value) ) - self.create_update( + incident_update = self.create_update( incident=incident, creator=creator, new_status=incident_status, @@ -259,6 +262,8 @@ def create_update_from_schema(self, incident: Incident, creator: User, create_in patch_incident_field_values_in = PatchIncidentFieldValuesSchema(root=custom_fields_patches) self.incident_repo.patch_incident_custom_fields(incident=incident, patch_in=patch_incident_field_values_in) + return incident_update + def create_update( self, incident: Incident, @@ -266,7 +271,7 @@ def create_update( new_status: IncidentStatus | None = None, new_severity: IncidentSeverity | None = None, summary: str | None = None, - ): + ) -> IncidentUpdate | None: """Create an incident update""" can_update = False @@ -283,7 +288,7 @@ def create_update( ) if not can_update: - return + return None incident_update = self.incident_repo.create_incident_update( incident=incident, @@ -300,6 +305,8 @@ def create_update( ) self.events.queue_job(SyncBookmarksTaskParameters(incident_id=incident.id)) + return incident_update + def patch_incident(self, user: User, incident: Incident, patch_in: PatchIncidentSchema): """Change details of an incident""" new_status = None diff --git a/frontend/src/components/Incident/ShareUpdateForm.tsx b/frontend/src/components/Incident/ShareUpdateForm.tsx new file mode 100644 index 0000000..5e9e71c --- /dev/null +++ b/frontend/src/components/Incident/ShareUpdateForm.tsx @@ -0,0 +1,162 @@ +import { useQuery } from '@tanstack/react-query' +import { Form, Formik, FormikHelpers } from 'formik' +import { useMemo } from 'react' +import * as Yup from 'yup' + +import spinner from '@/assets/icons/spinner.svg' +import GeneralError from '@/components/Form/GeneralError' +import Icon from '@/components/Icon/Icon' +import { StyledButton } from '@/components/Theme/Styles' +import useApiService from '@/hooks/useApi' +import useGlobal from '@/hooks/useGlobal' +import { FieldInterfaceKind, FieldKind, RequirementType } from '@/types/enums' +import { IField, IForm, IFormField, IIncident, IIncidentType } from '@/types/models' +import { ICombinedFieldAndValue } from '@/types/special' + +import Loading from '../Loading/Loading' +import { createCustomDefaultFieldValue } from './Field/utils' +import FormField from './FormField' + +export type FormValues = Record + +interface Props { + incident: IIncident + form: IForm + fieldValues: ICombinedFieldAndValue[] + onSubmit: (values: FormValues, helpers: FormikHelpers) => void | Promise +} + +const createValidationSchema = (formFields: IFormField[]) => { + const fieldsShape: Record = {} + + for (const field of formFields) { + if (field.requirementType === RequirementType.REQUIRED) { + if (field.field.interfaceKind === FieldInterfaceKind.MULTI_SELECT) { + fieldsShape[field.id] = Yup.array(Yup.string()).required('This field is required') + } else { + fieldsShape[field.id] = Yup.string().required('This field is required') + } + } + } + + return Yup.object().shape(fieldsShape) +} + +const getFieldDefaultValue = (field: IField, incident: IIncident, fieldValues: ICombinedFieldAndValue[]) => { + switch (field.kind) { + case FieldKind.INCIDENT_SEVERITY: + return incident.incidentSeverity.id + case FieldKind.INCIDENT_STATUS: + return incident.incidentStatus.id + case FieldKind.USER_DEFINED: { + const customField = fieldValues.find((it) => it.field.id === field.id) + if (customField && customField.value) { + const defaultValueKv = createCustomDefaultFieldValue(customField.field, customField.value) + return defaultValueKv[customField.field.id] + } + } + } +} + +const createDefaultValues = ( + formFields: IFormField[], + incidentTypes: IIncidentType[], + incident: IIncident, + fieldValues: ICombinedFieldAndValue[] +) => { + const defaultValues: Record = {} + + for (const formField of formFields) { + const value = getFieldDefaultValue(formField.field, incident, fieldValues) + if (value) { + defaultValues[formField.id] = value + } else { + defaultValues[formField.id] = formField.defaultValue ? formField.defaultValue : '' + } + } + + return defaultValues +} + +const ShareUpdateForm: React.FC = ({ onSubmit, form, incident, fieldValues }) => { + const { organisation } = useGlobal() + const { apiService } = useApiService() + + const incidentTypesQuery = useQuery({ + queryKey: ['incident-types', organisation!.id], + queryFn: () => apiService.getIncidentTypes() + }) + + const formFieldsQuery = useQuery({ + queryKey: ['form-fields', form.id], + queryFn: () => apiService.getFormFields(form) + }) + + const incidentStatusQuery = useQuery({ + queryKey: ['incident-statuses', organisation!.id], + queryFn: () => apiService.getIncidentStatuses() + }) + + const severitiesQuery = useQuery({ + queryKey: ['severities', organisation!.id], + queryFn: () => apiService.getIncidentSeverities() + }) + + const fields = useMemo(() => { + if ( + !incidentTypesQuery.isSuccess || + !formFieldsQuery.isSuccess || + !incidentStatusQuery.isSuccess || + !severitiesQuery.isSuccess + ) { + return [] + } + + return formFieldsQuery.data.items + .sort((a, b) => (a.rank < b.rank ? -1 : 1)) + .map((it) => ( + + )) + }, [incidentTypesQuery, formFieldsQuery, incidentStatusQuery, severitiesQuery]) + + const handleSubmit = (values: FormValues, helpers: FormikHelpers) => { + onSubmit(values, helpers) + } + + if (!incidentTypesQuery.isSuccess || !formFieldsQuery.isSuccess) { + return + } + + return ( + + {({ isSubmitting }) => ( +
+ + {fields} +
+ + {isSubmitting && } Share update + +
+ + )} +
+ ) +} + +export default ShareUpdateForm diff --git a/frontend/src/pages/Incidents/Show.tsx b/frontend/src/pages/Incidents/Show.tsx index a903566..1a0a0ab 100644 --- a/frontend/src/pages/Incidents/Show.tsx +++ b/frontend/src/pages/Incidents/Show.tsx @@ -7,7 +7,9 @@ import styled from 'styled-components' import slack from '@/assets/icons/slack.svg' import wrench from '@/assets/icons/wrench.svg' +import Button from '@/components/Button/Button' import Icon from '@/components/Icon/Icon' +import ShareUpdateForm, { FormValues as ShareUpdateFormValues } from '@/components/Incident/ShareUpdateForm' import Loading from '@/components/Loading/Loading' import { useModal } from '@/components/Modal/useModal' import { Box, Content, ContentMain, Header, Title } from '@/components/Theme/Styles' @@ -15,7 +17,7 @@ import MiniAvatar from '@/components/User/MiniAvatar' import useApiService from '@/hooks/useApi' import useGlobal from '@/hooks/useGlobal' import { APIError } from '@/services/transport' -import { IncidentRoleKind } from '@/types/enums' +import { FormType, IncidentRoleKind } from '@/types/enums' import { IField, IIncidentFieldValue, IIncidentRole } from '@/types/models' import { rankSorter } from '@/utils/sort' import { getLocalTimeZone } from '@/utils/time' @@ -102,6 +104,15 @@ const ContentSidebar = styled.div` padding: 1rem; height: 100vh; ` +const ContentHeader = styled.div` + display: flex; + justify-content: space-between; +` +const ContentSection = styled.div` + padding-bottom: 3rem; + border-bottom: 1px solid var(--color-slate-200); + margin-bottom: 1rem; +` type UrlParams = { id: string @@ -111,7 +122,7 @@ const ShowIncident = () => { const { apiService } = useApiService() const { id } = useParams() as UrlParams const { setModal, closeModal } = useModal() - const { organisation } = useGlobal() + const { organisation, forms } = useGlobal() // Incident severities const severitiesQuery = useQuery({ @@ -362,6 +373,52 @@ const ShowIncident = () => { [setModal, incidentQuery.data, handleSetFieldValue] ) + // Show share update modal + const handleShowShareUpdateForm = (evt: React.MouseEvent) => { + evt.preventDefault() + + if (!incidentQuery.data || !fieldValuesQuery.data) { + return + } + + const updateIncidentForm = forms.find((it) => it.type == FormType.UPDATE_INCIDENT) + if (!updateIncidentForm) { + console.error('Could not find update incident form') + return + } + + setModal( + +

Share update

+ +
+ ) + } + + const handleShareUpdate = useCallback( + async (values: ShareUpdateFormValues) => { + if (!incidentQuery.data) { + return + } + try { + await apiService.createIncidentUpdate(incidentQuery.data.id, values) + incidentUpdatesQuery.refetch() + closeModal() + } catch (e) { + if (e instanceof APIError) { + toast(e.detail, { type: 'error' }) + } + console.error(e) + } + }, + [incidentQuery, apiService, incidentUpdatesQuery, closeModal] + ) + const slackUrl = useMemo( () => `slack://channel?team=${organisation?.slackTeamId}&id=${incidentQuery.data?.slackChannelId}`, [organisation, incidentQuery.data?.slackChannelId] @@ -380,17 +437,26 @@ const ShowIncident = () => { -

Summary

- - - - -

Updates

- {incidentUpdatesQuery.isSuccess ? ( - - ) : ( -

There was an issue

- )} + +

Summary

+
+ + + + + + + +

Updates

+ +
+ + {incidentUpdatesQuery.isSuccess ? ( + + ) : ( +

There was an issue

+ )} +
Properties diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 0cb65ba..77d52a2 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -463,4 +463,10 @@ export class ApiService { callApi('DELETE', `/forms/fields/${id}`, { user: this.user }) + + createIncidentUpdate = (incidentId: ModelID, values: Record) => + callApi('POST', `/incidents/${incidentId}/updates`, { + user: this.user, + json: values + }) }