= ({
const [isCopied, setIsCopied] = useState(false)
- const copyCode = () => {
+ const copyCode = () =>
copyToClipboard(stringifyCode(code), () => setIsCopied(true))
- }
useEffect(() => {
if (isCopied) setTimeout(() => setIsCopied(false), 1250)
diff --git a/src/components/Code/utils.ts b/src/components/Code/utils.ts
index 45dba7d0..118c6ca1 100644
--- a/src/components/Code/utils.ts
+++ b/src/components/Code/utils.ts
@@ -23,7 +23,7 @@ interface CopyToClipboard {
export const copyToClipboard: CopyToClipboard = (str, callback) =>
navigator.clipboard.writeText(str).then(callback)
-const codePalette = {
+export const codePalette = {
[dark]: {
background: blacks['darken-20']
},
@@ -70,14 +70,14 @@ const lightCommonColor = themedStyles[light].base.color
const { shade } = ColorManipulationTypes
-const prismColors = {
+export const tokenColors = {
[dark]: {
boolean: reds.base,
char: darkCommonColor,
className: darkCommonColor,
comment: darkCommonColor,
function: darkCommonColor,
- keyword: darkCommonColor,
+ keyword: reds.base,
lineHighlight: darkCommonColor,
method: darkCommonColor,
number: reds.base,
@@ -95,7 +95,7 @@ const prismColors = {
className: lightCommonColor,
comment: lightCommonColor,
function: lightCommonColor,
- keyword: lightCommonColor,
+ keyword: oranges.base,
lineHighlight: lightCommonColor,
method: lightCommonColor,
number: oranges.base,
@@ -119,7 +119,7 @@ const generateThemedPreCodeStyles = (themeType: ThemeType) => {
} = themedStyles[themeType]
return {
- ...Object.entries(prismColors[themeType]).reduce(
+ ...Object.entries(tokenColors[themeType]).reduce(
(acc, [key, val]) => ({
...acc,
[`& .token.${key}`]: {
@@ -169,6 +169,7 @@ export const useStyles = createUseStyles({
...font.label,
fontFamily: 'Fira Code, monospace',
fontWeight: fontWeight.light,
+ tabSize: 3,
textShadow: 'none'
},
...generateThemedCodeStyles(light),
diff --git a/src/components/JSONPathPicker/JSONPathPicker.stories.tsx b/src/components/JSONPathPicker/JSONPathPicker.stories.tsx
new file mode 100644
index 00000000..627928e1
--- /dev/null
+++ b/src/components/JSONPathPicker/JSONPathPicker.stories.tsx
@@ -0,0 +1,77 @@
+import { Input } from 'components/Input'
+import { JSONPathPicker, JSONPathPickerProps } from '.'
+import { Meta, Story } from '@storybook/react/types-6-0'
+import React, { useState } from 'react'
+
+export default {
+ component: JSONPathPicker,
+ title: 'JSONPathPicker'
+} as Meta
+
+const Template: Story
= args => {
+ const [path, setPath] = useState('$.dassana')
+
+ return (
+
+
setPath(e.target.value)}
+ value={path}
+ />
+
+
{
+ setPath(path)
+ console.log(path)
+ }}
+ path={path}
+ />
+
+ )
+}
+
+/* eslint-disable sort-keys */
+const sampleJSON = {
+ dassana: {
+ context: {
+ attachedResources: {
+ 'count-of-attached-eni': 3,
+ 'does-any-eni-have-public-ip': true,
+ 'are-there-flows-from-internet-in-vpcflows': false
+ }
+ }
+ },
+ resourceConfig: {
+ configuration: {
+ description: 'ssh-from-world',
+ groupName: 'prod-cache',
+ ipPermissions: [
+ {
+ fromPort: 22,
+ ipProtocol: 'tcp',
+ ipv6Ranges: [
+ {
+ cidrIpv6: '::/0'
+ }
+ ],
+ prefixListIds: [],
+ toPort: 22,
+ userIdGroupPairs: [],
+ ipv4Ranges: [
+ {
+ cidrIp: '0.0.0.0/0'
+ }
+ ]
+ }
+ ]
+ },
+ 'a-really-really-really-really-really-really-really-really-really-really-really-really-really-really-really-reallyreally-really-really-long-key': null
+ }
+}
+/* eslint-enable sort-keys */
+
+export const Default = Template.bind({})
+Default.args = {
+ json: sampleJSON
+}
diff --git a/src/components/JSONPathPicker/index.tsx b/src/components/JSONPathPicker/index.tsx
new file mode 100644
index 00000000..b11fef59
--- /dev/null
+++ b/src/components/JSONPathPicker/index.tsx
@@ -0,0 +1,63 @@
+import cn from 'classnames'
+import { CodeControls } from 'components/Code/CodeControls'
+import { recursiveRender } from './utils'
+import { useStyles } from './styles'
+import { copyToClipboard, stringifyCode } from 'components/Code/utils'
+import React, { FC, useEffect, useState } from 'react'
+
+export type JSONValue =
+ | string
+ | number
+ | boolean
+ | null
+ | JSONValue[]
+ | { [key: string]: JSONValue }
+
+export interface JSONPathPickerProps {
+ classes?: string[]
+ displayControls?: boolean
+ json: Record
+ path: string
+ onChange: (path: string) => void
+}
+
+export const JSONPathPicker: FC = ({
+ classes = [],
+ displayControls = true,
+ json,
+ path,
+ onChange
+}: JSONPathPickerProps) => {
+ const compClasses = useStyles()
+
+ const [isCopied, setIsCopied] = useState(false)
+
+ const copyJSON = () =>
+ copyToClipboard(stringifyCode(json), () => setIsCopied(true))
+
+ useEffect(() => {
+ if (isCopied) setTimeout(() => setIsCopied(false), 1250)
+ }, [isCopied])
+
+ return (
+
+ {displayControls && (
+
+ )}
+
+ {recursiveRender({
+ classes: compClasses,
+ currPath: '$',
+ isLastItem: true,
+ onChange,
+ pickedPath: path,
+ remainingJSON: json
+ })}
+
+
+ )
+}
diff --git a/src/components/JSONPathPicker/styles.ts b/src/components/JSONPathPicker/styles.ts
new file mode 100644
index 00000000..05d24c05
--- /dev/null
+++ b/src/components/JSONPathPicker/styles.ts
@@ -0,0 +1,122 @@
+import { createUseStyles } from 'react-jss'
+import { codePalette, tokenColors } from '../Code/utils'
+import { styleguide, themedStyles, ThemeType } from '../assets/styles'
+
+const {
+ colors: { blacks, grays },
+ font,
+ fontWeight,
+ spacing
+} = styleguide
+
+const { light, dark } = ThemeType
+
+const { dark: darkTokenClrs, light: lightTokenClrs } = tokenColors
+
+const tokenStyles = {
+ [dark]: {
+ '& $boolean': { color: darkTokenClrs.boolean },
+ '& $null': { color: darkTokenClrs.keyword },
+ '& $number': { color: darkTokenClrs.number },
+ '& $operator': { color: darkTokenClrs.operator },
+ '& $property': {
+ '&:hover': {
+ borderBottom: `1px solid ${darkTokenClrs.property}`
+ },
+ color: darkTokenClrs.property
+ },
+ '& $punctuation': { color: darkTokenClrs.punctuation },
+ '& $string': { color: darkTokenClrs.string }
+ },
+ [light]: {
+ boolean: { color: lightTokenClrs.boolean },
+ null: { color: lightTokenClrs.keyword },
+ number: { color: lightTokenClrs.number },
+ operator: {
+ color: lightTokenClrs.operator,
+ padding: { left: spacing.xs / 2, right: spacing.xs }
+ },
+ property: {
+ '&:hover': {
+ borderBottom: `1px solid ${lightTokenClrs.property}`
+ },
+ color: lightTokenClrs.property,
+ cursor: 'pointer'
+ },
+ punctuation: {
+ color: lightTokenClrs.punctuation,
+ padding: { left: spacing.xs / 2 }
+ },
+ string: { color: lightTokenClrs.string }
+ }
+}
+
+const generateThemedContainerStyles = (themeType: ThemeType) => {
+ const { background } = codePalette[themeType]
+ const {
+ base: { color }
+ } = themedStyles[themeType]
+
+ const {
+ base: { borderColor }
+ } = themedStyles[themeType]
+
+ return {
+ background,
+ border: `1px solid ${borderColor}`,
+ color
+ }
+}
+
+const styles = {
+ ...tokenStyles[light],
+ container: {
+ '& ul, & ol': {
+ '& li': {
+ listStyle: 'none',
+ margin: 0,
+ padding: 0,
+ whiteSpace: 'nowrap'
+ },
+ height: 'min-content',
+ margin: 0,
+ padding: 0,
+ paddingLeft: spacing['m+'],
+ whiteSpace: 'nowrap',
+ width: 'min-content'
+ },
+ '&:hover': {
+ '& $controls': { opacity: 1 }
+ },
+ ...font.label,
+ ...generateThemedContainerStyles(light),
+ fontFamily: 'Fira Code, monospace',
+ fontWeight: fontWeight.light,
+ height: '100%',
+ padding: spacing.s,
+ position: 'relative',
+ width: '100%'
+ },
+ controls: { opacity: 0 },
+ pickedItem: { backgroundColor: grays.base },
+ wrapper: {
+ height: '100%',
+ overflow: 'auto',
+ whiteSpace: 'nowrap',
+ width: '100%'
+ },
+ // eslint-disable-next-line sort-keys
+ '@global': {
+ [`.${dark}`]: {
+ ...tokenStyles[dark],
+ '& $container': generateThemedContainerStyles(dark),
+ '& $pickedItem': {
+ backgroundColor: blacks['lighten-10']
+ }
+ }
+ }
+}
+
+export const useStyles = createUseStyles(styles)
+
+export type Classes = Record
diff --git a/src/components/JSONPathPicker/utils.tsx b/src/components/JSONPathPicker/utils.tsx
new file mode 100644
index 00000000..f1031120
--- /dev/null
+++ b/src/components/JSONPathPicker/utils.tsx
@@ -0,0 +1,289 @@
+import { Classes } from './styles'
+import cn from 'classnames'
+import { getJSONPathArr } from 'components/utils'
+import isEmpty from 'lodash/isEmpty'
+import isNull from 'lodash/isNull'
+import { JSONValue } from '.'
+import React, { ReactNode } from 'react'
+
+enum Relationship {
+ other,
+ self,
+ ancestor
+}
+
+const { other, self, ancestor } = Relationship
+/**
+ * Gets the relationship between current path and the picked path
+ * @returns { Relationship } - Can either be "other", "self" or "ancestor"
+ */
+const getPathRelationship = (
+ currPath: string,
+ pickedPath: string
+): Relationship => {
+ if (!pickedPath) return other
+
+ const pickedAttrs = getJSONPathArr(pickedPath)
+ const pickedLen = pickedAttrs.length
+
+ const currAttrs = getJSONPathArr(currPath)
+ const currLen = currAttrs.length
+
+ if (currLen > pickedLen) return other
+
+ for (let i = 0; i < currLen; i++) {
+ const isInPath =
+ currAttrs[i] === pickedAttrs[i] || pickedAttrs[i] === '*'
+
+ if (!isInPath) return other
+ }
+
+ return currLen === pickedLen ? self : ancestor
+}
+
+/* -x-x-x-x- Helper functions to render JSON -x-x-x-x- */
+
+// ----------------------- Types -----------------------
+
+type RemainingJSON = JSONValue | Record
+
+interface RenderParams {
+ classes: Classes
+ pickedPath: string
+ isLastItem?: boolean
+ currPath: string
+ onChange: (pickedPath: string) => void
+ remainingJSON: RemainingJSON
+}
+
+enum Types {
+ array = 'array',
+ boolean = 'boolean',
+ null = 'null',
+ number = 'number',
+ object = 'object',
+ string = 'string'
+}
+
+const { array, boolean, null: nullType, number, object, string } = Types
+
+// -----------------------------------------------------
+
+const renderComma = ({
+ classes,
+ isLastItem
+}: Pick): ReactNode =>
+ isLastItem ? <>> : renderPunctuation(',', classes)
+
+const renderPunctuation = (
+ punctuation: string,
+ classes: RenderParams['classes']
+): ReactNode => {punctuation}
+
+const renderArray = ({
+ classes,
+ isLastItem,
+ onChange,
+ pickedPath,
+ currPath,
+ remainingJSON
+}: RenderParams): ReactNode => {
+ const arr = remainingJSON as Record[]
+ const relation = getPathRelationship(currPath, pickedPath)
+
+ const pickedItemClasses = cn({
+ [classes.pickedItem]: relation === Relationship.self
+ })
+
+ return (
+ <>
+ {arr.length === 0 ? (
+
+ {renderPunctuation('[', classes)}
+ {renderPunctuation(']', classes)}
+ {renderComma({ classes, isLastItem })}
+
+ ) : (
+ <>
+ {renderPunctuation('[', classes)}
+
+ {arr.map((item, i) => (
+ -
+ {recursiveRender({
+ classes,
+ currPath: `${currPath}[${i}]`,
+ isLastItem: arr.length - 1 === i,
+ onChange,
+ pickedPath,
+ remainingJSON: item
+ })}
+
+ ))}
+
+
+ {renderPunctuation(']', classes)}
+ {renderComma({ classes, isLastItem })}
+
+ >
+ )}
+ >
+ )
+}
+
+const renderObject = ({
+ classes,
+ isLastItem,
+ onChange,
+ pickedPath,
+ remainingJSON,
+ currPath
+}: RenderParams): ReactNode => {
+ const json = remainingJSON as Record
+ const remainingKeys = Object.keys(json)
+
+ const relation = getPathRelationship(currPath, pickedPath)
+
+ const pickedItemClasses = cn({
+ [classes.pickedItem]: relation === Relationship.self
+ })
+
+ return isEmpty(json) ? (
+
+ {renderPunctuation('{', classes)}
+ {renderPunctuation('}', classes)}
+ {renderComma({ classes, isLastItem })}
+
+ ) : (
+ <>
+ {renderPunctuation('{', classes)}
+
+ {remainingKeys.map((key, i) => (
+ -
+ onChange(`${currPath}.${key}`)}
+ >{`"${key}"`}
+ :
+ {recursiveRender({
+ classes,
+ currPath: `${currPath}.${key}`,
+ isLastItem: remainingKeys.length - 1 === i,
+ onChange,
+ pickedPath,
+ remainingJSON: json[key]
+ })}
+
+ ))}
+
+
+ {renderPunctuation('}', classes)}
+ {renderComma({ classes, isLastItem })}
+
+ >
+ )
+}
+
+interface RenderPrimitiveParams extends RenderParams {
+ type: Types.boolean | Types.number | Types.null
+}
+const renderPrimitive = ({
+ classes,
+ pickedPath,
+ remainingJSON,
+ isLastItem,
+ currPath,
+ type
+}: RenderPrimitiveParams): ReactNode => {
+ const relation = getPathRelationship(currPath, pickedPath)
+
+ const pickedItemClasses = cn({
+ [classes[type]]: true,
+ [classes.pickedItem]: relation === Relationship.self
+ })
+
+ return (
+ <>
+
+ {JSON.stringify(remainingJSON)}
+
+ {renderComma({ classes, isLastItem })}
+ >
+ )
+}
+
+const renderString = ({
+ classes,
+ pickedPath,
+ remainingJSON,
+ isLastItem,
+ currPath
+}: RenderParams): ReactNode => {
+ const relation = getPathRelationship(currPath, pickedPath)
+
+ const pickedItemClasses = cn({
+ [classes.string]: true,
+ [classes.pickedItem]: relation === Relationship.self
+ })
+
+ return (
+ <>
+ {`"${remainingJSON}"`}
+ {renderComma({ classes, isLastItem })}
+ >
+ )
+}
+
+// -------------------------------------------------
+
+const mappedTypesToRenderFns = {
+ [array]: renderArray,
+ [boolean]: (params: RenderParams) =>
+ renderPrimitive({ ...params, type: boolean }),
+ [nullType]: (params: RenderParams) =>
+ renderPrimitive({ ...params, type: nullType }),
+ [number]: (params: RenderParams) =>
+ renderPrimitive({ ...params, type: number }),
+ [object]: renderObject,
+ [string]: renderString
+}
+
+const getRemainingJSONType = (remainingJSON: RemainingJSON) => {
+ if (isNull(remainingJSON)) return nullType
+ else if (Array.isArray(remainingJSON)) return array
+ else {
+ const type = typeof remainingJSON
+
+ switch (type) {
+ case 'number':
+ case 'bigint':
+ return number
+ case 'object':
+ return object
+ case 'string':
+ return string
+ case 'boolean':
+ return boolean
+ default:
+ return nullType
+ }
+ }
+}
+
+export const recursiveRender = ({
+ classes,
+ pickedPath,
+ isLastItem,
+ currPath,
+ onChange,
+ remainingJSON
+}: RenderParams): ReactNode =>
+ mappedTypesToRenderFns[getRemainingJSONType(remainingJSON)]({
+ classes,
+ currPath,
+ isLastItem,
+ onChange,
+ pickedPath,
+ remainingJSON
+ })
+
+/* -x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x- */
diff --git a/src/components/Table/utils.tsx b/src/components/Table/utils.tsx
index 95953724..16b808ae 100644
--- a/src/components/Table/utils.tsx
+++ b/src/components/Table/utils.tsx
@@ -3,9 +3,7 @@ import bytes from 'bytes'
import { CellWithTooltip } from './CellWithTooltip'
import { ColoredDot } from 'components/ColoredDot'
import { EditableCell } from './EditableCell'
-import { getJSONPathValue } from 'components/utils'
import isUndefined from 'lodash/isUndefined'
-import { JSONPath } from 'jsonpath-plus'
import moment from 'moment'
import {
ColumnFormats,
@@ -20,6 +18,7 @@ import {
RenderPropsIcon
} from './types'
import { defaultIconHeight, MultipleIcons } from './MultipleIcons'
+import { getJSONPathArr, getJSONPathValue } from 'components/utils'
import { Icon, IconName, IconProps } from '../Icon'
import { Link, LinkProps } from '../Link'
import React, { Key, MouseEvent } from 'react'
@@ -106,10 +105,11 @@ export function processData(
partialData[dataIndex as keyof TableData] = value
}
- //@ts-ignore
- const pathArr: string[] = JSONPath.toPathArray(`$.${dataIndex}`)
+ const pathArr: string[] = getJSONPathArr(`$.${dataIndex}`)
+ // Fix for this issue https://github.com/JSONPath-Plus/JSONPath/issues/102
+ if (pathArr[0] === '$') pathArr.shift()
- if (pathArr && pathArr.length) {
+ if (pathArr.length) {
partialData[pathArr[0] as keyof TableData] = item[pathArr[0]]
}
})
diff --git a/src/components/index.ts b/src/components/index.ts
index 9ff98b54..dcb2e654 100644
--- a/src/components/index.ts
+++ b/src/components/index.ts
@@ -14,6 +14,7 @@ export * from './Form'
export * from './Input'
export * from './Icon'
export * from './IconButton'
+export * from './JSONPathPicker'
export * from './Link'
export * from './Modal'
export * from './Menu'
diff --git a/src/components/utils.ts b/src/components/utils.ts
index cc2f9c0e..ddbb0492 100644
--- a/src/components/utils.ts
+++ b/src/components/utils.ts
@@ -134,6 +134,12 @@ export const getJSONPathValue = (path: string, obj: Record) => {
if (value && Array.isArray(value)) return value[0]
}
+/**
+ * Takes a JSON path as string and converts to an array
+ */
+export const getJSONPathArr = (path: string): string[] =>
+ JSONPath.toPathArray(path)
+
export const getPopupContainerProps = (
popupContainerSelector = ''
): PopupContainerProps => {