Skip to content

Commit

Permalink
feat: adds support for jsonata based references in static.json (#47)
Browse files Browse the repository at this point in the history
### Problem

After creating a few example search portals, it's become obvious that
some of the data is not in ideal structures for programmatic output
and/or it is relying on application-specific code to render reliably.

When obtaining property values based on fields in a `static.json` file,
this generator (and most others) use JSONPath. This works for
well-structured data that has been made with the generator's usage in
mind, but starts to encounter issues in existing (complex) data
structures.

As an example, [the Convergance Graph in this
result](https://from-static-labs.github.io/aps-rpl/results?subject=globus%3A%2F%2Fbb8d048a-2cad-4029-a9c7-671ec5d1f84d%2Fportal%2Freports%2Fhome%2Frpl%2Fexperiments%2FColorPicker_26_155_177_2023-04-20%2Fresults)
does not render based on [application-specific logic that replaces the
hostname on
files](https://github.com/globus-gladier/rpl-portal/blob/136f846fd53ac21e85a7089f6aae3620c9f3ca4f/rpl_portal/fields.py#L28-L35)
([current portal for
comparison](https://acdc.alcf.anl.gov/rpl/detail/globus%253A%252F%252Fbb8d048a-2cad-4029-a9c7-671ec5d1f84d%252Fportal%252Freports%252Fhome%252Frpl%252Fexperiments%252FColorPicker_26_155_177_2023-04-20%252Fresults/)).

In addition to the incorrect host, I was using a very naive lookup for
the Convergence Graph based on what the data seemed to imply:

```json
{
  "label": "Convergence Graph",
  "property": "entries[0].content.files[2].url",
  "type": "image"
}
```

The third result appeared to always be the correct reference, but in
reality, there are no guarantees.

### Solution - JSONata

> JSON query and transformation language
https://jsonata.org/

By allowing `static.json` authors to express attribute references as
JSONata it allows for more advanced property selection and
transformation. The above example can be expressed as:

```json
{
  "label": "Convergence Graph",
  "property": "$replace(entries[0].content.files[filename='convergence_graph.png'].url, 'https://bb8d048a-2cad-4029-a9c7-671ec5d1f84d.e.globus.org/', 'https://g-cd34a.fd635.8443.data.globus.org/')",
  "type": "image"
}
```

This is improved on the initial implementation by:
- Replacing the host with the actual host needed to render the asset.
- Querying for the file that has the `convergence_graph.png` filename.


Another example is value is the representation of array objects.

<img width="1680" alt="Screenshot 2024-04-22 at 10 18 20 AM"
src="https://github.com/globus/static-search-portal/assets/694253/e8384ed0-90ac-45f0-a7d7-df284a962036">

Here, `gmeta[0].entries[0].content.datacite.creators` is an array of
objects:

```
"creators": [
    {
        "family_name": "Example",
        "given_name": "Vandana",
        "creator_name": "Vandana Example"
    }
],
```

Our default rendering scheme here is just to dump the object (this will
likely be improved, but can only go so far). Using JSONata a user can
alter the `static.json` file to something like:

```json
{
  "label": "Creator",
  "property": "$join(entries[0].content.datacite.creators.creator_name, ', ')"
}
```

<img width="1334" alt="Screenshot 2024-04-22 at 10 25 57 AM"
src="https://github.com/globus/static-search-portal/assets/694253/66ed96ba-1f62-45ee-bb01-df2c166e0131">
  • Loading branch information
jbottigliero authored Apr 22, 2024
1 parent 31b22ed commit 6232a8b
Show file tree
Hide file tree
Showing 7 changed files with 125 additions and 40 deletions.
10 changes: 10 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
"eslint-plugin-promise": "^6.1.1",
"eslint-plugin-react": "^7.34.1",
"eslint-plugin-react-hooks": "^4.6.0",
"jsonata": "^2.0.4",
"prettier": "3.2.5",
"typedoc": "^0.25.13",
"typedoc-plugin-markdown": "^3.17.1",
Expand Down
31 changes: 25 additions & 6 deletions src/components/Field.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import React from "react";
import React, { useEffect } from "react";
import { get } from "lodash";
import { Heading, Box, HStack } from "@chakra-ui/react";
import jsonnata from "jsonata";

import RgbaField from "./Fields/RgbaField";
import ImageField from "./Fields/ImageField";
import FallbackField from "./Fields/FallbackField";

import type { GMetaResult } from "../globus/search";
import { isFeatureEnabled } from "../../static";

export type FieldDefinition =
| string
Expand All @@ -21,16 +23,16 @@ export type FieldDefinition =
type?: string;
};

type ProcessedField = {
export type ProcessedField = {
label: string | undefined;
value: unknown;
type: string | undefined;
};

export function getProcessedField(
export async function getProcessedField(
field: FieldDefinition,
data: GMetaResult,
): ProcessedField {
): Promise<ProcessedField> {
/**
* Ensure we're working with a FieldDefinition object.
*/
Expand All @@ -39,7 +41,12 @@ export function getProcessedField(
if ("value" in def) {
value = def.value;
} else {
value = get(data, def.property);
if (isFeatureEnabled("jsonata")) {
const expression = jsonnata(def.property);
value = await expression.evaluate(data);
} else {
value = get(data, def.property);
}
}
return {
label: undefined,
Expand Down Expand Up @@ -74,7 +81,19 @@ export const Field = ({
gmeta: GMetaResult;
condensed?: boolean;
}) => {
const processedField = getProcessedField(field, gmeta);
const [processedField, setProcessedField] = React.useState<ProcessedField>();

useEffect(() => {
getProcessedField(field, gmeta).then((result) => {
console.log(result);
setProcessedField(result);
});
}, [field, gmeta]);

if (!processedField) {
return null;
}

if (condensed) {
return (
<HStack>
Expand Down
17 changes: 11 additions & 6 deletions src/components/Result.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,20 +55,25 @@ export type ResultComponentOptions = {
};

export default function Result({ result }: { result?: GMetaResult | GError }) {
const [heading, setHeading] = React.useState<string>();
const [summary, setSummary] = React.useState<string>();
if (!result) {
return null;
}
if (isGError(result)) {
return <Error error={result} />;
}
const heading = getAttributeFrom<string>(
result,
"components.ResultListing.heading",

getAttributeFrom<string>(result, "components.ResultListing.heading").then(
(result) => {
setHeading(result);
},
);

const summary = getAttributeFrom<string>(
result,
"components.ResultListing.summary",
getAttributeFrom<string>(result, "components.ResultListing.summary").then(
(result) => {
setSummary(result);
},
);

const fields = getAttribute("components.Result.fields", []);
Expand Down
83 changes: 60 additions & 23 deletions src/components/ResultListing.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React from "react";
import React, { useEffect } from "react";
import NextLink from "next/link";
import {
LinkBox,
Expand All @@ -17,7 +17,12 @@ import {
import { getAttributeFrom, getAttribute } from "../../static";

import type { GMetaResult } from "@/globus/search";
import { FieldDefinition, FieldValue, getProcessedField } from "./Field";
import {
FieldDefinition,
FieldValue,
ProcessedField,
getProcessedField,
} from "./Field";
import ImageField from "./Fields/ImageField";

export type ResultListingComponentOptions = {
Expand Down Expand Up @@ -59,6 +64,35 @@ export type ResultListingComponentOptions = {
fields?: FieldDefinition[];
};

function ResultListingFieldTableRow({
field,
gmeta,
}: {
field: FieldDefinition;
gmeta: GMetaResult;
}) {
const [processedField, setProcessedField] = React.useState<ProcessedField>();

useEffect(() => {
getProcessedField(field, gmeta).then((result) => {
setProcessedField(result);
});
}, [field, gmeta]);

if (!processedField) {
return null;
}

return (
<Tr>
<Td>{processedField.label}</Td>
<Td>
<FieldValue value={processedField.value} type={processedField.type} />
</Td>
</Tr>
);
}

function ResultListingFields({
fields,
gmeta,
Expand All @@ -74,17 +108,8 @@ function ResultListingFields({
<Table size="sm">
<Tbody>
{fields.map((field: FieldDefinition, i: number) => {
const processedField = getProcessedField(field, gmeta);
return (
<Tr key={i}>
<Td>{processedField.label}</Td>
<Td>
<FieldValue
value={processedField.value}
type={processedField.type}
/>
</Td>
</Tr>
<ResultListingFieldTableRow key={i} field={field} gmeta={gmeta} />
);
})}
</Tbody>
Expand All @@ -94,26 +119,38 @@ function ResultListingFields({
}

export default function ResultListing({ gmeta }: { gmeta: GMetaResult }) {
const heading = getAttributeFrom<string>(
gmeta,
"components.ResultListing.heading",
const [heading, setHeading] = React.useState<string>();
const [summary, setSummary] = React.useState<string>();
const [image, setImage] = React.useState<{
src: string;
alt?: string;
}>();
getAttributeFrom<string>(gmeta, "components.ResultListing.heading").then(
(result) => {
console.log(result);
setHeading(result);
},
);

const summary = getAttributeFrom<string>(
gmeta,
"components.ResultListing.summary",
getAttributeFrom<string>(gmeta, "components.ResultListing.summary").then(
(result) => {
setSummary(result);
},
);

let image = getAttributeFrom<
getAttributeFrom<
| string
| {
src: string;
alt?: string;
}
>(gmeta, "components.ResultListing.image");
if (typeof image === "string") {
image = { src: image };
}
>(gmeta, "components.ResultListing.image").then((result) => {
let image = result;
if (typeof image === "string") {
image = { src: image };
}
setImage(image);
});

const fields = getAttribute("components.ResultListing.fields");

Expand Down
1 change: 0 additions & 1 deletion src/components/Search.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,6 @@ export function Search() {
Authorization: `Bearer ${auth.authorization.tokens.search.access_token}`,
}
: undefined;

const response = await gsearch.query.post(SEARCH_INDEX, {
payload: getSearchPayload(query, search),
headers,
Expand Down
22 changes: 18 additions & 4 deletions static.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import _STATIC from "./static.json";
import { defaultsDeep, get } from "lodash";

import type { ResultComponentOptions } from "@/components/Result";
import { ResultListingComponentOptions } from "@/components/ResultListing";

Expand Down Expand Up @@ -64,6 +63,11 @@ export type Data = {
};

features?: {
/**
* Enable JSONata support for processing the `static.json` file.
* @see https://jsonata.org/
*/
jsonnata?: boolean;
authentication?: boolean;
};

Expand Down Expand Up @@ -195,15 +199,25 @@ export function getAttribute(key: string, defaultValue?: any) {
return get(STATIC, `data.attributes.${key}`, defaultValue);
}

let jsonata: typeof import("jsonata") | null = null;
/**
* @private
*/
export function getAttributeFrom<T>(
export async function getAttributeFrom<T>(
obj: Record<string, any>,
key: string,
defaultValue?: T,
): T | undefined {
return get(obj, getAttribute(key), defaultValue);
): Promise<T | undefined> {
const useJSONata = isFeatureEnabled("jsonata");
if (useJSONata && !jsonata) {
jsonata = (await import("jsonata")).default;
}
const lookup = getAttribute(key);
if (useJSONata && jsonata && lookup) {
const expression = jsonata(lookup);
return (await expression.evaluate(obj)) || defaultValue;
}
return get(obj, lookup, defaultValue);
}

/**
Expand Down

0 comments on commit 6232a8b

Please sign in to comment.