Connect Table: support single selection without arrays (#504)

This commit is contained in:
Sidney Alcantara
2021-10-14 17:42:07 +11:00
parent 83f1451684
commit d5f514e70a
5 changed files with 287 additions and 91 deletions

View File

@@ -4,12 +4,18 @@ import useAlgolia from "use-algolia";
import _find from "lodash/find";
import _get from "lodash/get";
import _pick from "lodash/pick";
import createPersistedState from "use-persisted-state";
import { useSnackbar } from "notistack";
import { Button } from "@mui/material";
import MultiSelect, { MultiSelectProps } from "@rowy/multiselect";
import Loading from "components/Loading";
import createPersistedState from "use-persisted-state";
import InlineOpenInNewIcon from "components/InlineOpenInNewIcon";
import { useProjectContext } from "@src/contexts/ProjectContext";
import { runRoutes } from "@src/constants/runRoutes";
import { WIKI_LINKS } from "constants/externalLinks";
const useAlgoliaSearchKeys = createPersistedState("_ROWY_algolia-search-keys");
const useAlgoliaAppId = createPersistedState("_ROWY_algolia-app-id");
@@ -25,8 +31,8 @@ const replacer = (data: any) => (m: string, key: string) => {
};
export interface IConnectTableSelectProps {
value: ConnectTableValue[];
onChange: (value: ConnectTableValue[]) => void;
value: ConnectTableValue[] | ConnectTableValue | null;
onChange: (value: ConnectTableValue[] | ConnectTableValue | null) => void;
column: any;
config: {
filters: string;
@@ -61,35 +67,44 @@ export default function ConnectTableSelect({
onClose,
loadBeforeOpen,
}: IConnectTableSelectProps) {
// Store a local copy of the value so the dropdown doesnt automatically close
// when the user selects a new item and we allow for multiple selections
const { enqueueSnackbar } = useSnackbar();
const { rowyRun } = useProjectContext();
const [algoliaAppId, setAlgoliaAppId] = useAlgoliaAppId<string | undefined>(
undefined
);
useEffect(() => {
if (!algoliaAppId && rowyRun) {
rowyRun({ route: runRoutes.algoliaAppId }).then(
({ success, appId, message }) => {
if (success) {
setAlgoliaAppId(appId);
} else {
enqueueSnackbar(message, { variant: "error" });
}
if (success) setAlgoliaAppId(appId);
else
enqueueSnackbar(
message.replace("not setup", "not set up") +
": Failed to get app ID",
{
variant: "error",
action: (
<Button
href={WIKI_LINKS.fieldTypesConnectTable}
target="_blank"
rel="noopener noreferrer"
>
Docs
<InlineOpenInNewIcon />
</Button>
),
}
);
}
);
}
}, []);
const [localValue, setLocalValue] = useState(
Array.isArray(value) ? value : []
);
const filters = config.filters
? config.filters.replace(/\{\{(.*?)\}\}/g, replacer(row))
: "";
const algoliaIndex = config.index;
const algoliaIndex = config.index;
const [algoliaSearchKeys, setAlgoliaSearchKeys] = useAlgoliaSearchKeys<any>(
{}
);
@@ -150,54 +165,97 @@ export default function ConnectTableSelect({
value: hit.objectID,
}));
// Pass a list of objectIDs to MultiSelect
const sanitisedValue = localValue.map(
(item) => item.docPath.split("/")[item.docPath.split("/").length - 1]
);
const handleChange = (_newValue) => {
// Ensure we return an array
const newValue = Array.isArray(_newValue)
? _newValue
: _newValue !== null
? [_newValue]
// Store a local copy of the value so the dropdown doesnt automatically close
// when the user selects a new item and we allow for multiple selections
let initialLocalValue: any;
if (config.multiple !== false) {
initialLocalValue = Array.isArray(value)
? value
: value?.docPath
? [value]
: [];
} else {
initialLocalValue = Array.isArray(value)
? value[0]
: value?.docPath
? value
: null;
}
const [localValue, setLocalValue] = useState(initialLocalValue);
// Calculate new value
const newLocalValue = newValue.map((objectID) => {
// If this objectID is already in the previous value, use that previous
// values snapshot (in case it points to an object not in the current
// Algolia query)
const existingMatch = _find(localValue, {
docPath: `${algoliaIndex}/${objectID}`,
});
if (existingMatch) return existingMatch;
// Pass objectID[] | objectID | null to MultiSelect
const sanitisedValue =
config.multiple !== false
? localValue.map((item) => item.docPath.split("/").pop())
: localValue?.docPath?.split("/").pop() ?? null;
// If this is a completely new selection, grab the snapshot from the
// current Algolia query
const match = _find(algoliaState.hits, { objectID });
const { _highlightResult, ...snapshot } = match;
const handleChange = (_newValue: string[] | string | null) => {
let newLocalValue: any;
if (config.multiple !== false && Array.isArray(_newValue)) {
newLocalValue = (_newValue as string[])
.map((objectID) => {
const docPath = `${algoliaIndex}/${objectID}`;
// Use snapshotFields to limit snapshots
let partialSnapshot = snapshot;
if (
Array.isArray(config.snapshotFields) &&
config.snapshotFields.length > 0
)
partialSnapshot = _pick(snapshot, config.snapshotFields);
// Try to find the snapshot from the current Algolia query
const match = _find(algoliaState.hits, { objectID });
return {
snapshot: partialSnapshot,
docPath: `${algoliaIndex}/${snapshot.objectID}`,
};
});
// If not found and this objectID is already in the previous value,
// use that previous values snapshot
// Else return null
if (!match) {
const existingMatch = _find(localValue, { docPath });
if (existingMatch) return existingMatch;
else return null;
}
const { _highlightResult, ...snapshot } = match;
// Use snapshotFields to limit snapshots
let partialSnapshot = snapshot;
if (
Array.isArray(config.snapshotFields) &&
config.snapshotFields.length > 0
)
partialSnapshot = _pick(snapshot, config.snapshotFields);
return { snapshot: partialSnapshot, docPath };
})
.filter((x) => x !== null);
} else if (config.multiple === false && typeof _newValue === "string") {
const docPath = `${algoliaIndex}/${_newValue}`;
// Try to find the snapshot from the current Algolia query
const match = _find(algoliaState.hits, { objectID: _newValue });
// If not found and this objectID is the previous value, use that or null
if (!match) {
if (localValue?.docPath === docPath) newLocalValue = localValue;
else newLocalValue = null;
} else {
const { _highlightResult, ...snapshot } = match;
// Use snapshotFields to limit snapshots
let partialSnapshot = snapshot;
if (
Array.isArray(config.snapshotFields) &&
config.snapshotFields.length > 0
)
partialSnapshot = _pick(snapshot, config.snapshotFields);
newLocalValue = { snapshot: partialSnapshot, docPath };
}
} else if (config.multiple === false && _newValue === null) {
newLocalValue = null;
}
// Store in `localValue` until user closes dropdown and triggers `handleSave`
setLocalValue(newLocalValue);
// If !multiple, we MUST change the value (bypassing localValue),
// otherwise `setLocalValue` wont be called in time for the new
// `localValue` to be read by `handleSave`
// `localValue` to be read by `handleSave` because this component is
// unmounted before `handleSave` is called
if (config.multiple === false) onChange(newLocalValue);
// Otherwise, `setLocalValue` until user closes dropdown
else setLocalValue(newLocalValue);
};
// Save when user closes dropdown
@@ -214,12 +272,10 @@ export default function ConnectTableSelect({
return (
<MultiSelect
value={config.multiple === false ? sanitisedValue[0] : sanitisedValue}
value={sanitisedValue}
onChange={handleChange}
onOpen={() => {
setAlgoliaConfig({
indexName: algoliaIndex,
});
setAlgoliaConfig({ indexName: algoliaIndex });
requestDispatch({ filters });
}}
onClose={handleSave}
@@ -227,11 +283,27 @@ export default function ConnectTableSelect({
TextFieldProps={{
className,
hiddenLabel: true,
SelectProps: {
renderValue: () => {
if (Array.isArray(localValue)) {
if (localValue.length !== 1)
return `${localValue.length} selected`;
return config.primaryKeys
?.map((key: string) => localValue[0]?.snapshot?.[key])
.join(" ");
} else {
if (!localValue?.snapshot) return "0 selected";
return config.primaryKeys
?.map((key: string) => localValue?.snapshot?.[key])
.join(" ");
}
},
},
...TextFieldProps,
}}
label={column?.name}
labelPlural={config.searchLabel}
multiple={(config.multiple ?? true) as any}
multiple={config.multiple !== false}
{...({
AutocompleteProps: {
loading: algoliaState.loading,
@@ -243,9 +315,11 @@ export default function ConnectTableSelect({
filterOptions: () => options,
},
} as any)}
countText={`${localValue.length} of ${
algoliaState.response?.nbHits ?? "?"
}`}
countText={
Array.isArray(localValue)
? `${localValue.length} of ${algoliaState.response?.nbHits ?? "?"}`
: undefined
}
disabled={disabled}
/>
);

View File

@@ -28,16 +28,25 @@ export const ConnectTable = forwardRef(function ConnectTable(
}}
>
<ChipList>
{Array.isArray(value) &&
value.map((doc: any) => (
<Grid item key={doc.docPath}>
{Array.isArray(value) ? (
value.map((item: any) => (
<Grid item key={item.docPath}>
<Chip
label={config.primaryKeys
.map((key: string) => doc.snapshot[key])
.map((key: string) => item.snapshot[key])
.join(" ")}
/>
</Grid>
))}
))
) : value ? (
<Grid item>
<Chip
label={config.primaryKeys
.map((key: string) => value.snapshot[key])
.join(" ")}
/>
</Grid>
) : null}
</ChipList>
{!disabled && (

View File

@@ -2,22 +2,34 @@ import { useEffect, useState } from "react";
import { ISettingsProps } from "../types";
import _sortBy from "lodash/sortBy";
import { TextField } from "@mui/material";
import {
Typography,
Link,
TextField,
FormControlLabel,
Checkbox,
FormHelperText,
} from "@mui/material";
import MultiSelect from "@rowy/multiselect";
import InlineOpenInNewIcon from "components/InlineOpenInNewIcon";
import WarningIcon from "@mui/icons-material/WarningAmberOutlined";
import { FieldType } from "constants/fields";
import { db } from "../../../firebase";
import { db } from "@src/firebase";
import { useProjectContext } from "contexts/ProjectContext";
import { TABLE_SCHEMAS } from "config/dbPaths";
import { WIKI_LINKS } from "constants/externalLinks";
export default function Settings({ handleChange, config }: ISettingsProps) {
const { tables } = useProjectContext();
const tableOptions = _sortBy(
tables?.map((t) => ({
label: `${t.section} ${t.name} (${t.collection})`,
value: t.id,
tables?.map((table) => ({
label: table.name,
value: table.id,
section: table.section,
collection: table.collection,
})) ?? [],
"label"
["section", "label"]
);
const [columns, setColumns] = useState<
@@ -43,6 +55,18 @@ export default function Settings({ handleChange, config }: ISettingsProps) {
return (
<>
<Typography>
Connect Table requires additional setup.{" "}
<Link
href={WIKI_LINKS.fieldTypesConnectTable}
target="_blank"
rel="noopener noreferrer"
>
Instructions
<InlineOpenInNewIcon />
</Link>
</Typography>
<MultiSelect
options={tableOptions}
freeText={false}
@@ -51,16 +75,86 @@ export default function Settings({ handleChange, config }: ISettingsProps) {
multiple={false}
label="Table"
labelPlural="tables"
itemRenderer={(option: Record<string, string>) => (
<>
{option.section} &gt; {option.label}{" "}
<code style={{ marginLeft: "auto" }}>{option.collection}</code>
</>
)}
TextFieldProps={{
helperText:
"Make sure this table is being synced to an Algolia index",
}}
/>
<FormControlLabel
control={
<Checkbox
checked={config.multiple !== false}
onChange={(e) => handleChange("multiple")(e.target.checked)}
/>
}
label={
<>
Multiple selection
<FormHelperText>
{config.multiple === false ? (
<>
Field values will be either{" "}
<code>
{"{ docPath: string; snapshot: Record<string, any>; }"}
</code>{" "}
or <code>null</code>.<br />
Easier to filter.
</>
) : (
<>
Field values will be an array of{" "}
<code>
{"{ docPath: string; snapshot: Record<string, any>; }"}
</code>{" "}
or an empty array.
<br />
Harder to filter.
</>
)}
</FormHelperText>
<FormHelperText>
<WarningIcon
color="warning"
aria-label="Warning: "
style={{ verticalAlign: "text-bottom", fontSize: "1rem" }}
/>
&nbsp; Existing values in this table will not be updated
</FormHelperText>
</>
}
style={{ marginLeft: -10 }}
/>
<TextField
label="Filter template"
name="filters"
fullWidth
value={config.filters}
onChange={(e) => {
handleChange("filters")(e.target.value);
}}
onChange={(e) => handleChange("filters")(e.target.value)}
placeholder="attribute:value AND | OR | NOT attribute:value"
id="connectTable-filters"
helperText={
<>
Use the Algolia syntax for filters:{" "}
<Link
href="https://www.algolia.com/doc/api-reference/api-parameters/filters/"
target="_blank"
rel="noopener noreferrer"
>
Algolia documentation
<InlineOpenInNewIcon />
</Link>
</>
}
/>
<MultiSelect
label="Primary keys"
value={config.primaryKeys ?? []}
@@ -68,18 +162,26 @@ export default function Settings({ handleChange, config }: ISettingsProps) {
[FieldType.shortText, FieldType.singleSelect].includes(c.type)
)}
onChange={handleChange("primaryKeys")}
TextFieldProps={{ helperText: "Field values displayed" }}
/>
<MultiSelect
label="Snapshot fields"
value={config.snapshotFields ?? []}
options={columns.filter((c) => ![FieldType.subTable].includes(c.type))}
onChange={handleChange("snapshotFields")}
TextFieldProps={{ helperText: "Fields stored in the snapshots" }}
/>
<MultiSelect
label="Tracked fields"
value={config.trackedFields ?? []}
options={columns.filter((c) => ![FieldType.subTable].includes(c.type))}
onChange={handleChange("trackedFields")}
TextFieldProps={{
helperText:
"Fields to be tracked for changes and synced to the snapshot",
}}
/>
</>
);

View File

@@ -18,10 +18,9 @@ export default function ConnectTable({
control={control}
name={column.key}
render={({ field: { onChange, onBlur, value } }) => {
const handleDelete = (hit: any) => () => {
// if (multiple)
onChange(value.filter((v) => v.snapshot.objectID !== hit.objectID));
// else form.setFieldValue(field.name, []);
const handleDelete = (docPath: string) => () => {
if (column.config?.multiple === false) onChange(null);
else onChange(value.filter((v) => v.docPath !== docPath));
};
return (
@@ -38,26 +37,37 @@ export default function ConnectTable({
hiddenLabel: true,
fullWidth: true,
onBlur,
SelectProps: {
renderValue: () => `${value?.length ?? 0} selected`,
},
}}
/>
)}
{Array.isArray(value) && (
{value && (
<Grid container spacing={0.5} style={{ marginTop: 2 }}>
{value.map(({ snapshot }) => (
<Grid item key={snapshot.objectID}>
{Array.isArray(value) ? (
value.map(({ snapshot, docPath }) => (
<Grid item key={docPath}>
<Chip
component="li"
label={column.config?.primaryKeys
?.map((key: string) => snapshot[key])
.join(" ")}
onDelete={disabled ? undefined : handleDelete(docPath)}
/>
</Grid>
))
) : value ? (
<Grid item>
<Chip
component="li"
label={column.config?.primaryKeys
?.map((key: string) => snapshot[key])
?.map((key: string) => value.snapshot[key])
.join(" ")}
onDelete={disabled ? undefined : handleDelete(snapshot)}
onDelete={
disabled ? undefined : handleDelete(value.docPath)
}
/>
</Grid>
))}
) : null}
</Grid>
)}
</>

View File

@@ -25,11 +25,12 @@ export const config: IFieldConfig = {
type: FieldType.connectTable,
name: "Connect Table (Alpha)",
group: "Connection",
dataType: "{ docPath: string; snapshot: Record<string, any>; }[]",
dataType:
"{ docPath: string; snapshot: Record<string, any>; }[] | { docPath: string; snapshot: Record<string, any>; } | null",
initialValue: [],
icon: <ConnectTableIcon />,
description:
"Connects to an existing table to fetch a snapshot of values from a row. Requires Algolia integration.",
"Connects to an existing table to fetch a snapshot of values from a row. Requires Algolia setup.",
TableCell: withPopoverCell(BasicCell, InlineCell, PopoverCell, {
anchorOrigin: { horizontal: "left", vertical: "bottom" },
transparent: true,