Skip to content

Commit

Permalink
feat: share updates from web ui
Browse files Browse the repository at this point in the history
  • Loading branch information
sanjeevan committed Oct 8, 2024
1 parent 3f25e21 commit 1aaf826
Show file tree
Hide file tree
Showing 5 changed files with 278 additions and 17 deletions.
20 changes: 20 additions & 0 deletions backend/app/routes/incidents.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from app.repos import FormRepo, IncidentRepo, TimestampRepo, UserRepo
from app.schemas.actions import (
CreateIncidentSchema,
CreateIncidentUpdateSchema,
IncidentSearchSchema,
PaginationParamsSchema,
PatchIncidentFieldValuesSchema,
Expand Down Expand Up @@ -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
15 changes: 11 additions & 4 deletions backend/app/services/incident.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
IncidentStatus,
IncidentStatusCategoryEnum,
IncidentType,
IncidentUpdate,
Organisation,
User,
)
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -259,14 +262,16 @@ 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,
creator: User,
new_status: IncidentStatus | None = None,
new_severity: IncidentSeverity | None = None,
summary: str | None = None,
):
) -> IncidentUpdate | None:
"""Create an incident update"""
can_update = False

Expand All @@ -283,7 +288,7 @@ def create_update(
)

if not can_update:
return
return None

incident_update = self.incident_repo.create_incident_update(
incident=incident,
Expand All @@ -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
Expand Down
162 changes: 162 additions & 0 deletions frontend/src/components/Incident/ShareUpdateForm.tsx
Original file line number Diff line number Diff line change
@@ -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<string, string | string[]>

interface Props {
incident: IIncident
form: IForm
fieldValues: ICombinedFieldAndValue[]
onSubmit: (values: FormValues, helpers: FormikHelpers<FormValues>) => void | Promise<void>
}

const createValidationSchema = (formFields: IFormField[]) => {
const fieldsShape: Record<string, Yup.Schema> = {}

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<string, string | string[]> = {}

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<Props> = ({ 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) => (
<FormField
key={it.id}
formField={it}
statusList={incidentStatusQuery.data.items}
severityList={severitiesQuery.data.items}
incidentTypes={incidentTypesQuery.data.items}
/>
))
}, [incidentTypesQuery, formFieldsQuery, incidentStatusQuery, severitiesQuery])

const handleSubmit = (values: FormValues, helpers: FormikHelpers<FormValues>) => {
onSubmit(values, helpers)
}

if (!incidentTypesQuery.isSuccess || !formFieldsQuery.isSuccess) {
return <Loading />
}

return (
<Formik
validationSchema={createValidationSchema(formFieldsQuery.data.items)}
onSubmit={handleSubmit}
initialValues={createDefaultValues(
formFieldsQuery.data.items,
incidentTypesQuery.data.items,
incident,
fieldValues
)}
>
{({ isSubmitting }) => (
<Form className="space-y-2">
<GeneralError />
{fields}
<div>
<StyledButton $primary={true} type="submit" disabled={isSubmitting}>
{isSubmitting && <Icon icon={spinner} spin={true} />} Share update
</StyledButton>
</div>
</Form>
)}
</Formik>
)
}

export default ShareUpdateForm
92 changes: 79 additions & 13 deletions frontend/src/pages/Incidents/Show.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,17 @@ 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'
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'
Expand Down Expand Up @@ -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
Expand All @@ -111,7 +122,7 @@ const ShowIncident = () => {
const { apiService } = useApiService()
const { id } = useParams<UrlParams>() as UrlParams
const { setModal, closeModal } = useModal()
const { organisation } = useGlobal()
const { organisation, forms } = useGlobal()

// Incident severities
const severitiesQuery = useQuery({
Expand Down Expand Up @@ -362,6 +373,52 @@ const ShowIncident = () => {
[setModal, incidentQuery.data, handleSetFieldValue]
)

// Show share update modal
const handleShowShareUpdateForm = (evt: React.MouseEvent<HTMLButtonElement>) => {
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(
<ModalContainer>
<h2>Share update</h2>
<ShareUpdateForm
form={updateIncidentForm}
onSubmit={handleShareUpdate}
incident={incidentQuery.data}
fieldValues={fieldValuesQuery.data.items}
/>
</ModalContainer>
)
}

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]
Expand All @@ -380,17 +437,26 @@ const ShowIncident = () => {
</Header>
<Content>
<ContentMain>
<h3>Summary</h3>
<Description>
<EditDescriptionForm incident={incidentQuery.data} onSubmit={handleChangeDescription} />
</Description>

<h3>Updates</h3>
{incidentUpdatesQuery.isSuccess ? (
<Timeline updates={incidentUpdatesQuery.data.items} />
) : (
<p>There was an issue </p>
)}
<ContentHeader>
<h3>Summary</h3>
</ContentHeader>
<ContentSection>
<Description>
<EditDescriptionForm incident={incidentQuery.data} onSubmit={handleChangeDescription} />
</Description>
</ContentSection>

<ContentHeader>
<h3>Updates</h3>
<Button onClick={handleShowShareUpdateForm}>Share update</Button>
</ContentHeader>
<ContentSection>
{incidentUpdatesQuery.isSuccess ? (
<Timeline updates={incidentUpdatesQuery.data.items} />
) : (
<p>There was an issue </p>
)}
</ContentSection>
</ContentMain>
<ContentSidebar>
<FieldsHeader>Properties</FieldsHeader>
Expand Down
Loading

0 comments on commit 1aaf826

Please sign in to comment.