mirror of
https://github.com/rowyio/rowy.git
synced 2025-12-29 00:16:39 +01:00
Connect Table: support single selection without arrays (#504)
This commit is contained in:
@@ -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 doesn’t 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 doesn’t 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
|
||||
// value’s 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 value’s 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` won’t 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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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} > {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" }}
|
||||
/>
|
||||
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",
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user