diff --git a/apps/client/locales/en/values.json b/apps/client/locales/en/values.json index eddca38d0..3b8ea4461 100644 --- a/apps/client/locales/en/values.json +++ b/apps/client/locales/en/values.json @@ -76,7 +76,8 @@ "warningNotApplicable": "Warning Not Applicable", "isPrimary": "Is Primary", "isDispositionDescription": "A disposition code can be used when a call is closed. This will be used to determine the outcome of the call.", - "isDisposition": "Is Disposition" + "isDisposition": "Is Disposition", + "mustBeValidJson": "Must be valid JSON" }, "PENAL_CODE_GROUP": { "MANAGE": "Manage Penal Code Groups", diff --git a/apps/client/src/components/admin/values/ManageValueModal.tsx b/apps/client/src/components/admin/values/ManageValueModal.tsx index 706cdc7cc..2736bc491 100644 --- a/apps/client/src/components/admin/values/ManageValueModal.tsx +++ b/apps/client/src/components/admin/values/ManageValueModal.tsx @@ -152,7 +152,7 @@ function createInitialValues(options: CreateInitialValuesOptions) { extraFields: value && (isDivisionValue(value) || isDepartmentValue(value)) - ? JSON.stringify(value.extraFields) + ? safelyStringifyJSON(value.extraFields) : "null", departmentLinks: value && isDepartmentValue(value) ? value.links ?? [] : [], @@ -194,6 +194,11 @@ export function ManageValueModal({ onCreate, onUpdate, type, value }: Props) { values: typeof INITIAL_VALUES, helpers: FormikHelpers, ) { + if (safelyParseJSON(values.extraFields) === false) { + helpers.setFieldError("extraFields", tValues("mustBeValidJson")); + return; + } + const data = { ...values, whatPages: values.whatPages, @@ -201,7 +206,7 @@ export function ManageValueModal({ onCreate, onUpdate, type, value }: Props) { divisions: values.divisions, officerRankDepartments: values.officerRankDepartments, trimLevels: values.trimLevels, - extraFields: JSON.parse(values.extraFields), + extraFields: safelyParseJSON(values.extraFields), }; if (value) { @@ -450,3 +455,23 @@ export function ManageValueModal({ onCreate, onUpdate, type, value }: Props) { ); } + +function safelyParseJSON(json: string) { + if (!json) return null; + + try { + return JSON.parse(json); + } catch { + return false; + } +} + +function safelyStringifyJSON(json: string | null) { + if (!json) return "null"; + + try { + return JSON.stringify(json, null, 4); + } catch { + return "null"; + } +} diff --git a/apps/client/src/components/admin/values/manage-modal/department-fields.tsx b/apps/client/src/components/admin/values/manage-modal/department-fields.tsx index 48ca7ec43..22f3713f2 100644 --- a/apps/client/src/components/admin/values/manage-modal/department-fields.tsx +++ b/apps/client/src/components/admin/values/manage-modal/department-fields.tsx @@ -1,4 +1,4 @@ -import { SelectField, SwitchField, TextField } from "@snailycad/ui"; +import { JsonEditor, SelectField, SwitchField, TextField } from "@snailycad/ui"; import { useFormikContext } from "formik"; import { DepartmentType, ValueType } from "@snailycad/types"; import { useValues } from "context/ValuesContext"; @@ -7,6 +7,7 @@ import { ValueSelectField } from "components/form/inputs/value-select-field"; import { CALLSIGN_TEMPLATE_VARIABLES } from "components/admin/manage/cad-settings/misc-features/template-tab"; import { DepartmentLinksSection } from "./department-links-section"; import type { ManageValueFormValues } from "../ManageValueModal"; +import { FormField } from "components/form/FormField"; export const DEPARTMENT_LABELS = { [DepartmentType.LEO]: "LEO", @@ -96,17 +97,12 @@ export function DepartmentFields() { ) : null} - setFieldValue("extraFields", value)} - value={values.extraFields} - placeholder="JSON" - /> + + setFieldValue("extraFields", value)} + /> + diff --git a/apps/client/src/components/admin/values/manage-modal/division-fields.tsx b/apps/client/src/components/admin/values/manage-modal/division-fields.tsx index da188f514..75ef1fee7 100644 --- a/apps/client/src/components/admin/values/manage-modal/division-fields.tsx +++ b/apps/client/src/components/admin/values/manage-modal/division-fields.tsx @@ -1,10 +1,11 @@ -import { TextField } from "@snailycad/ui"; +import { JsonEditor, TextField } from "@snailycad/ui"; import { useValues } from "context/ValuesContext"; import { useFormikContext } from "formik"; import { useTranslations } from "use-intl"; import { ValueSelectField } from "components/form/inputs/value-select-field"; import { ValueType } from "@snailycad/types"; import type { ManageValueFormValues } from "../ManageValueModal"; +import { FormField } from "components/form/FormField"; export function DivisionFields() { const { values, errors, setFieldValue } = useFormikContext(); @@ -46,17 +47,12 @@ export function DivisionFields() { value={values.pairedUnitTemplate} /> - setFieldValue("extraFields", value)} - value={values.extraFields} - placeholder="JSON" - /> + + setFieldValue("extraFields", value)} + /> + ); } diff --git a/apps/client/src/components/admin/values/manage-modal/emergency-vehicle-fields.tsx b/apps/client/src/components/admin/values/manage-modal/emergency-vehicle-fields.tsx index 6aed7fe2c..3c2b1d133 100644 --- a/apps/client/src/components/admin/values/manage-modal/emergency-vehicle-fields.tsx +++ b/apps/client/src/components/admin/values/manage-modal/emergency-vehicle-fields.tsx @@ -1,11 +1,12 @@ import type { AnyValue } from "@snailycad/types"; -import { SelectField } from "@snailycad/ui"; +import { JsonEditor, SelectField } from "@snailycad/ui"; import { isEmergencyVehicleValue } from "@snailycad/utils"; import { useValues } from "context/ValuesContext"; import { useFormikContext } from "formik"; import { useFeatureEnabled } from "hooks/useFeatureEnabled"; import { useTranslations } from "use-intl"; import type { ManageValueFormValues } from "../ManageValueModal"; +import { FormField } from "components/form/FormField"; export function useDefaultDivisions() { const { division } = useValues(); @@ -22,7 +23,7 @@ export function useDefaultDivisions() { } export function EmergencyVehicleFields() { - const { values, setFieldValue } = useFormikContext(); + const { values, errors, setFieldValue } = useFormikContext(); const { division, department } = useValues(); const { DIVISIONS } = useFeatureEnabled(); const t = useTranslations("Values"); @@ -54,6 +55,13 @@ export function EmergencyVehicleFields() { selectedKeys={values.divisions} /> ) : null} + + + setFieldValue("extraFields", value)} + /> + ); } diff --git a/packages/ui/package.json b/packages/ui/package.json index b007b41dd..356ccae9c 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -31,6 +31,7 @@ "@storybook/test-runner": "^0.13.0", "@storybook/testing-library": "^0.2.2", "autoprefixer": "^10.4.16", + "monaco-editor": "^0.44.0", "postcss": "^8.4.31", "prop-types": "^15.8.1", "storybook": "^7.5.2", @@ -60,6 +61,7 @@ "dependencies": { "@casperiv/useful": "^3.0.0", "@internationalized/date": "^3.5.0", + "@monaco-editor/react": "^4.6.0", "@radix-ui/react-accordion": "^1.1.2", "@radix-ui/react-context-menu": "^2.1.5", "@radix-ui/react-dropdown-menu": "^2.0.6", diff --git a/packages/ui/src/components/editors/json-editor.tsx b/packages/ui/src/components/editors/json-editor.tsx new file mode 100644 index 000000000..0565ab378 --- /dev/null +++ b/packages/ui/src/components/editors/json-editor.tsx @@ -0,0 +1,38 @@ +import Editor from "@monaco-editor/react"; + +export interface JsonEditorProps { + value: string; + onChange(value: string | undefined): void; +} + +export function JsonEditor(props: JsonEditorProps) { + return ( +
+ } + theme="vs-dark" + height="200px" + language="json" + value={props.value} + onChange={props.onChange} + /> +
+ ); +} + +function SkeletonEditorLoading() { + return ( +
+ + + + + + + +
+ ); +} diff --git a/packages/ui/src/components/index.tsx b/packages/ui/src/components/index.tsx index 7d7a00d9f..1cb38da38 100644 --- a/packages/ui/src/components/index.tsx +++ b/packages/ui/src/components/index.tsx @@ -30,3 +30,4 @@ export * from "./form-row"; export * from "./full-date"; export * from "./infofield"; export * from "./status"; +export * from "./editors/json-editor"; diff --git a/packages/ui/src/components/stories/editors/json-editor.stories.tsx b/packages/ui/src/components/stories/editors/json-editor.stories.tsx new file mode 100644 index 000000000..c97a9cb9d --- /dev/null +++ b/packages/ui/src/components/stories/editors/json-editor.stories.tsx @@ -0,0 +1,22 @@ +import * as React from "react"; +import type { Meta, StoryObj } from "@storybook/react"; +import { JsonEditor } from "../../editors/json-editor"; + +const meta = { + title: "Editors/JSON Editor", + component: JsonEditor, + tags: ["autodocs"], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +function DefaultRenderer() { + const [value, setValue] = React.useState(""); + + return ; +} + +export const Default: Story = { + render: () => , +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f5dc2ff59..adc0becfd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -656,6 +656,9 @@ importers: "@internationalized/date": specifier: ^3.5.0 version: 3.5.0 + "@monaco-editor/react": + specifier: ^4.6.0 + version: 4.6.0(monaco-editor@0.44.0)(react-dom@18.2.0)(react@18.2.0) "@radix-ui/react-accordion": specifier: ^1.1.2 version: 1.1.2(@types/react-dom@18.2.14)(@types/react@18.2.34)(react-dom@18.2.0)(react@18.2.0) @@ -876,6 +879,9 @@ importers: autoprefixer: specifier: ^10.4.16 version: 10.4.16(postcss@8.4.31) + monaco-editor: + specifier: ^0.44.0 + version: 0.44.0 postcss: specifier: ^8.4.31 version: 8.4.31 @@ -4434,6 +4440,34 @@ packages: react: 18.2.0 dev: true + /@monaco-editor/loader@1.4.0(monaco-editor@0.44.0): + resolution: + { + integrity: sha512-00ioBig0x642hytVspPl7DbQyaSWRaolYie/UFNjoTdvoKPzo6xrXLhTk9ixgIKcLH5b5vDOjVNiGyY+uDCUlg==, + } + peerDependencies: + monaco-editor: ">= 0.21.0 < 1" + dependencies: + monaco-editor: 0.44.0 + state-local: 1.0.7 + dev: false + + /@monaco-editor/react@4.6.0(monaco-editor@0.44.0)(react-dom@18.2.0)(react@18.2.0): + resolution: + { + integrity: sha512-RFkU9/i7cN2bsq/iTkurMWOEErmYcY6JiQI3Jn+WeR/FGISH8JbHERjpS9oRuSOPvDMJI0Z8nJeKkbOs9sBYQw==, + } + peerDependencies: + monaco-editor: ">= 0.25.0 < 1" + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 + dependencies: + "@monaco-editor/loader": 1.4.0(monaco-editor@0.44.0) + monaco-editor: 0.44.0 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + /@ndelangen/get-tarball@3.0.9: resolution: { @@ -20872,6 +20906,12 @@ packages: engines: { node: ">=0.10.0" } dev: true + /monaco-editor@0.44.0: + resolution: + { + integrity: sha512-5SmjNStN6bSuSE5WPT2ZV+iYn1/yI9sd4Igtk23ChvqB7kDk9lZbB9F5frsuvpB+2njdIeGGFf2G4gbE6rCC9Q==, + } + /mri@1.2.0: resolution: { @@ -21139,7 +21179,7 @@ packages: react: 18.2.0 react-dom: 18.2.0(react@18.2.0) sass: 1.69.5 - styled-jsx: 5.1.1(@babel/core@7.22.11)(react@18.2.0) + styled-jsx: 5.1.1(react@18.2.0) watchpack: 2.4.0 optionalDependencies: "@next/swc-darwin-arm64": 14.0.1 @@ -25243,6 +25283,13 @@ packages: type-fest: 0.7.1 dev: false + /state-local@1.0.7: + resolution: + { + integrity: sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==, + } + dev: false + /statuses@2.0.1: resolution: { @@ -25599,6 +25646,26 @@ packages: "@babel/core": 7.22.11 client-only: 0.0.1 react: 18.2.0 + dev: false + + /styled-jsx@5.1.1(react@18.2.0): + resolution: + { + integrity: sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==, + } + engines: { node: ">= 12.0.0" } + peerDependencies: + "@babel/core": "*" + babel-plugin-macros: "*" + react: ">= 16.8.0 || 17.x.x || ^18.0.0-0" + peerDependenciesMeta: + "@babel/core": + optional: true + babel-plugin-macros: + optional: true + dependencies: + client-only: 0.0.1 + react: 18.2.0 /stylis@4.2.0: resolution: