mirror of
https://github.com/rowyio/rowy.git
synced 2026-02-24 04:01:17 +01:00
2
.gitignore
vendored
2
.gitignore
vendored
@@ -44,3 +44,5 @@ package-lock.json
|
||||
node_modules/
|
||||
|
||||
cloud_functions/functions/src/functionConfig.json
|
||||
*.iml
|
||||
.idea
|
||||
@@ -3,7 +3,19 @@ import * as _ from "lodash";
|
||||
export const serverTimestamp = admin.firestore.FieldValue.serverTimestamp;
|
||||
import { sendEmail } from "./email";
|
||||
import { hasAnyRole } from "./auth";
|
||||
export default { sendEmail, serverTimestamp, hasAnyRole };
|
||||
|
||||
var characters =
|
||||
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
||||
export function generateId(length) {
|
||||
var result = "";
|
||||
var charactersLength = characters.length;
|
||||
for (var i = 0; i < length; i++) {
|
||||
result += characters.charAt(Math.floor(Math.random() * charactersLength));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export default { generateId, sendEmail, serverTimestamp, hasAnyRole };
|
||||
export const replacer = (data: any) => (m: string, key: string) => {
|
||||
const objKey = key.split(":")[0];
|
||||
const defaultValue = key.split(":")[1] || "";
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@antlerengineering/multiselect": "^0.5.0",
|
||||
"@antlerengineering/multiselect": "^0.6.0",
|
||||
"@date-io/date-fns": "1.x",
|
||||
"@material-ui/core": "^4.11.0",
|
||||
"@material-ui/icons": "^4.9.1",
|
||||
@@ -45,7 +45,7 @@
|
||||
"serve": "^11.3.2",
|
||||
"tinymce": "^5.2.0",
|
||||
"typescript": "^3.7.2",
|
||||
"use-algolia": "^1.3.0",
|
||||
"use-algolia": "^1.4.1",
|
||||
"use-debounce": "^3.3.0",
|
||||
"use-persisted-state": "^0.3.0",
|
||||
"yarn": "^1.19.0",
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import React from "react";
|
||||
import _merge from "lodash/merge";
|
||||
|
||||
import { createMuiTheme, ThemeOptions, fade } from "@material-ui/core/styles";
|
||||
import {
|
||||
createMuiTheme,
|
||||
Theme as ThemeType,
|
||||
ThemeOptions,
|
||||
fade,
|
||||
} from "@material-ui/core/styles";
|
||||
import ClearIcon from "@material-ui/icons/Clear";
|
||||
|
||||
export const HEADING_FONT = "Europa, sans-serif";
|
||||
export const BODY_FONT = '"Open Sans", sans-serif';
|
||||
|
||||
export const ANTLER_RED = "#ed4747";
|
||||
export const ANTLER_RED_ACCESSIBLE = "#e22729";
|
||||
export const SECONDARY_GREY = "#282829";
|
||||
export const SECONDARY_TEXT = "rgba(0, 0, 0, 0.6)";
|
||||
export const ERROR = "#b00020";
|
||||
@@ -98,7 +102,7 @@ export const themeBase = createMuiTheme({
|
||||
},
|
||||
});
|
||||
|
||||
export const defaultOverrides: ThemeOptions = {
|
||||
export const defaultOverrides = (theme: ThemeType): ThemeOptions => ({
|
||||
transitions: {
|
||||
easing: { custom: "cubic-bezier(0.25, 0.1, 0.25, 1)" },
|
||||
},
|
||||
@@ -106,18 +110,16 @@ export const defaultOverrides: ThemeOptions = {
|
||||
MuiContainer: {
|
||||
root: {
|
||||
"@supports (padding: max(0px))": {
|
||||
paddingLeft: `max(${themeBase.spacing(
|
||||
2
|
||||
)}px, env(safe-area-inset-left))`,
|
||||
paddingRight: `max(${themeBase.spacing(
|
||||
paddingLeft: `max(${theme.spacing(2)}px, env(safe-area-inset-left))`,
|
||||
paddingRight: `max(${theme.spacing(
|
||||
2
|
||||
)}px, env(safe-area-inset-right))`,
|
||||
|
||||
"@media (min-width: 640px)": {
|
||||
paddingLeft: `max(${themeBase.spacing(
|
||||
paddingLeft: `max(${theme.spacing(
|
||||
3
|
||||
)}px, env(safe-area-inset-left))`,
|
||||
paddingRight: `max(${themeBase.spacing(
|
||||
paddingRight: `max(${theme.spacing(
|
||||
3
|
||||
)}px, env(safe-area-inset-right))`,
|
||||
},
|
||||
@@ -125,7 +127,7 @@ export const defaultOverrides: ThemeOptions = {
|
||||
},
|
||||
},
|
||||
MuiTooltip: {
|
||||
tooltip: themeBase.typography.caption,
|
||||
tooltip: theme.typography.caption,
|
||||
},
|
||||
MuiButton: {
|
||||
root: { minHeight: 36 },
|
||||
@@ -137,7 +139,7 @@ export const defaultOverrides: ThemeOptions = {
|
||||
boxShadow: "none",
|
||||
},
|
||||
containedSizeLarge: {
|
||||
padding: themeBase.spacing(1, 4),
|
||||
padding: theme.spacing(1, 4),
|
||||
},
|
||||
|
||||
outlinedPrimary: {
|
||||
@@ -145,10 +147,10 @@ export const defaultOverrides: ThemeOptions = {
|
||||
borderColor: "rgba(0, 0, 0, 0.23)",
|
||||
},
|
||||
outlinedSizeLarge: {
|
||||
padding: themeBase.spacing(1, 4),
|
||||
padding: theme.spacing(1, 4),
|
||||
borderRadius: 500,
|
||||
|
||||
"&$outlinedPrimary": { borderColor: ANTLER_RED },
|
||||
"&$outlinedPrimary": { borderColor: theme.palette.primary.main },
|
||||
},
|
||||
},
|
||||
MuiSvgIcon: {
|
||||
@@ -157,14 +159,14 @@ export const defaultOverrides: ThemeOptions = {
|
||||
// Override text field label
|
||||
MuiFormLabel: {
|
||||
root: {
|
||||
...themeBase.typography.subtitle2,
|
||||
...theme.typography.subtitle2,
|
||||
lineHeight: 1,
|
||||
},
|
||||
},
|
||||
// Override radio & checkbox labels
|
||||
MuiFormControlLabel: {
|
||||
root: { display: "flex" },
|
||||
label: themeBase.typography.body1,
|
||||
label: theme.typography.body1,
|
||||
},
|
||||
MuiChip: {
|
||||
root: {
|
||||
@@ -174,32 +176,32 @@ export const defaultOverrides: ThemeOptions = {
|
||||
height: "auto",
|
||||
minHeight: 32,
|
||||
|
||||
color: themeBase.palette.text.secondary,
|
||||
color: theme.palette.text.secondary,
|
||||
},
|
||||
label: {
|
||||
...themeBase.typography.caption,
|
||||
...theme.typography.caption,
|
||||
color: "inherit",
|
||||
padding: themeBase.spacing(1, 1.5),
|
||||
padding: theme.spacing(1, 1.5),
|
||||
// whiteSpace: "normal",
|
||||
|
||||
"$outlined &": {
|
||||
paddingTop: themeBase.spacing(0.875),
|
||||
paddingBottom: themeBase.spacing(0.875),
|
||||
paddingTop: theme.spacing(0.875),
|
||||
paddingBottom: theme.spacing(0.875),
|
||||
},
|
||||
},
|
||||
sizeSmall: { minHeight: 24 },
|
||||
labelSmall: {
|
||||
padding: themeBase.spacing(0.5, 1.5),
|
||||
padding: theme.spacing(0.5, 1.5),
|
||||
},
|
||||
|
||||
outlined: {
|
||||
backgroundColor: themeBase.palette.action.selected,
|
||||
borderColor: themeBase.palette.action.selected,
|
||||
backgroundColor: theme.palette.action.selected,
|
||||
borderColor: theme.palette.action.selected,
|
||||
},
|
||||
outlinedPrimary: {
|
||||
backgroundColor: fade(
|
||||
ANTLER_RED,
|
||||
themeBase.palette.action.selectedOpacity
|
||||
theme.palette.primary.main,
|
||||
theme.palette.action.selectedOpacity
|
||||
),
|
||||
},
|
||||
|
||||
@@ -207,7 +209,7 @@ export const defaultOverrides: ThemeOptions = {
|
||||
},
|
||||
MuiBadge: {
|
||||
badge: {
|
||||
...themeBase.typography.caption,
|
||||
...theme.typography.caption,
|
||||
fontFeatureSettings: '"tnum"',
|
||||
},
|
||||
},
|
||||
@@ -252,8 +254,8 @@ export const defaultOverrides: ThemeOptions = {
|
||||
|
||||
valueLabel: {
|
||||
top: -22,
|
||||
...themeBase.typography.caption,
|
||||
color: themeBase.palette.primary.main,
|
||||
...theme.typography.caption,
|
||||
color: theme.palette.primary.main,
|
||||
|
||||
"& > *": {
|
||||
width: "auto",
|
||||
@@ -263,12 +265,12 @@ export const defaultOverrides: ThemeOptions = {
|
||||
whiteSpace: "nowrap",
|
||||
borderRadius: 500,
|
||||
|
||||
padding: themeBase.spacing(0, 1),
|
||||
paddingRight: themeBase.spacing(0.875),
|
||||
padding: theme.spacing(0, 1),
|
||||
paddingRight: theme.spacing(0.875),
|
||||
},
|
||||
"& *": { transform: "none" },
|
||||
},
|
||||
markLabel: themeBase.typography.caption,
|
||||
markLabel: theme.typography.caption,
|
||||
},
|
||||
MuiLinearProgress: {
|
||||
colorPrimary: { backgroundColor: "#e7e7e7" },
|
||||
@@ -308,8 +310,12 @@ export const defaultOverrides: ThemeOptions = {
|
||||
},
|
||||
MuiTextField: { variant: "filled" },
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export const Theme = (customization: ThemeOptions) =>
|
||||
createMuiTheme(_merge(_merge(themeBase, defaultOverrides), customization));
|
||||
createMuiTheme(
|
||||
_merge(themeBase, defaultOverrides(_merge(themeBase, customization))),
|
||||
customization
|
||||
);
|
||||
|
||||
export default Theme;
|
||||
|
||||
@@ -1,73 +1,79 @@
|
||||
import React, { useState, useMemo, useEffect } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import clsx from "clsx";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
import _get from "lodash/get";
|
||||
|
||||
import {
|
||||
Grid,
|
||||
TextField,
|
||||
List,
|
||||
MenuItem,
|
||||
ListItemIcon,
|
||||
Checkbox,
|
||||
ListItemText,
|
||||
Divider,
|
||||
Button,
|
||||
Typography,
|
||||
Checkbox,
|
||||
Divider,
|
||||
Grid,
|
||||
InputAdornment,
|
||||
List,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
MenuItem,
|
||||
TextField,
|
||||
Typography,
|
||||
Radio,
|
||||
} from "@material-ui/core";
|
||||
import SearchIcon from "@material-ui/icons/Search";
|
||||
|
||||
import { IConnectTableSelectProps } from ".";
|
||||
import { IConnectServiceSelectProps } from ".";
|
||||
import useStyles from "./styles";
|
||||
import Loading from "components/Loading";
|
||||
|
||||
import algoliasearch from "algoliasearch/lite";
|
||||
|
||||
import { useFiretableContext } from "../../contexts/firetableContext";
|
||||
const searchClient = algoliasearch(
|
||||
process.env.REACT_APP_ALGOLIA_APP_ID ?? "",
|
||||
process.env.REACT_APP_ALGOLIA_SEARCH_API_KEY ?? ""
|
||||
);
|
||||
|
||||
export interface IPopupContentsProps
|
||||
extends Omit<IConnectTableSelectProps, "className" | "TextFieldProps"> {}
|
||||
extends Omit<IConnectServiceSelectProps, "className" | "TextFieldProps"> {}
|
||||
|
||||
// TODO: Implement infinite scroll here
|
||||
export default function PopupContents({
|
||||
value = [],
|
||||
onChange,
|
||||
collectionPath,
|
||||
config,
|
||||
multiple = true,
|
||||
row,
|
||||
docRef,
|
||||
}: IPopupContentsProps) {
|
||||
const index = collectionPath ?? config.index; //temporary for pre column restructure migration
|
||||
const url = config.url;
|
||||
const titleKey = config.titleKey ?? config.primaryKey;
|
||||
const subtitleKey = config.subtitleKey;
|
||||
const resultsKey = config.resultsKey;
|
||||
const primaryKey = config.primaryKey;
|
||||
const multiple = Boolean(config.multiple);
|
||||
|
||||
const classes = useStyles();
|
||||
|
||||
const { userClaims } = useFiretableContext();
|
||||
|
||||
const algoliaIndex = useMemo(() => {
|
||||
return searchClient.initIndex(index);
|
||||
}, [index]);
|
||||
|
||||
// Algolia search query
|
||||
// Webservice search query
|
||||
const [query, setQuery] = useState("");
|
||||
// Algolia query response
|
||||
// Webservice response
|
||||
const [response, setResponse] = useState<any | null>(null);
|
||||
const hits: any["hits"] = response?.hits ?? [];
|
||||
|
||||
const [docData, setDocData] = useState<any | null>(null);
|
||||
useEffect(() => {
|
||||
docRef.get().then((d) => setDocData(d.data()));
|
||||
}, []);
|
||||
|
||||
const hits: any["hits"] = _get(response, resultsKey) ?? [];
|
||||
const [search] = useDebouncedCallback(
|
||||
async (query: string) => {
|
||||
if (!algoliaIndex) return;
|
||||
const data = { ...userClaims, ...row };
|
||||
const filters = config?.filters
|
||||
? config?.filters.replace(/\{\{(.*?)\}\}/g, (m, k) => data[k])
|
||||
: "";
|
||||
if (!docData) return;
|
||||
if (!url) return;
|
||||
const uri = new URL(url),
|
||||
params = { q: query };
|
||||
Object.keys(params).forEach((key) =>
|
||||
uri.searchParams.append(key, params[key])
|
||||
);
|
||||
|
||||
const resp = await algoliaIndex.search(query, {
|
||||
filters,
|
||||
const resp = await fetch(uri.toString(), {
|
||||
method: "POST",
|
||||
body: JSON.stringify(docData),
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
setResponse(resp);
|
||||
|
||||
const jsonBody = await resp.json();
|
||||
setResponse(jsonBody);
|
||||
},
|
||||
1000,
|
||||
{ leading: true }
|
||||
@@ -75,27 +81,21 @@ export default function PopupContents({
|
||||
|
||||
useEffect(() => {
|
||||
search(query);
|
||||
}, [query]);
|
||||
}, [query, docData]);
|
||||
|
||||
if (!response) return <Loading />;
|
||||
|
||||
const select = (hit: any) => () => {
|
||||
const { _highlightResult, ...snapshot } = hit;
|
||||
const output = {
|
||||
snapshot,
|
||||
docPath: `${index}/${snapshot.objectID}`,
|
||||
};
|
||||
|
||||
if (multiple) onChange([...value, output]);
|
||||
else onChange([output]);
|
||||
if (multiple) onChange([...value, hit]);
|
||||
else onChange([hit]);
|
||||
};
|
||||
const deselect = (hit: any) => () => {
|
||||
if (multiple)
|
||||
onChange(value.filter((v) => v.snapshot.objectID !== hit.objectID));
|
||||
onChange(value.filter((v) => v[primaryKey] !== hit[primaryKey]));
|
||||
else onChange([]);
|
||||
};
|
||||
|
||||
const selectedValues = value?.map((item) => item.snapshot.objectID);
|
||||
const selectedValues = value?.map((item) => _get(item, primaryKey));
|
||||
|
||||
const clearSelection = () => onChange([]);
|
||||
|
||||
@@ -125,35 +125,46 @@ export default function PopupContents({
|
||||
<Grid item xs className={classes.listRow}>
|
||||
<List className={classes.list}>
|
||||
{hits.map((hit) => {
|
||||
const isSelected = selectedValues.indexOf(hit.objectID) !== -1;
|
||||
|
||||
const isSelected =
|
||||
selectedValues.indexOf(_get(hit, primaryKey)) !== -1;
|
||||
console.log(`Selected Values: ${selectedValues}`);
|
||||
return (
|
||||
<React.Fragment key={hit.objectID}>
|
||||
<React.Fragment key={_get(hit, primaryKey)}>
|
||||
<MenuItem
|
||||
dense
|
||||
onClick={isSelected ? deselect(hit) : select(hit)}
|
||||
>
|
||||
<ListItemIcon className={classes.checkboxContainer}>
|
||||
<Checkbox
|
||||
edge="start"
|
||||
checked={isSelected}
|
||||
tabIndex={-1}
|
||||
color="secondary"
|
||||
className={classes.checkbox}
|
||||
disableRipple
|
||||
inputProps={{
|
||||
"aria-labelledby": `label-${hit.objectID}`,
|
||||
}}
|
||||
/>
|
||||
{multiple ? (
|
||||
<Checkbox
|
||||
edge="start"
|
||||
checked={isSelected}
|
||||
tabIndex={-1}
|
||||
color="secondary"
|
||||
className={classes.checkbox}
|
||||
disableRipple
|
||||
inputProps={{
|
||||
"aria-labelledby": `label-${_get(hit, primaryKey)}`,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Radio
|
||||
edge="start"
|
||||
checked={isSelected}
|
||||
tabIndex={-1}
|
||||
color="secondary"
|
||||
className={classes.checkbox}
|
||||
disableRipple
|
||||
inputProps={{
|
||||
"aria-labelledby": `label-${_get(hit, primaryKey)}`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
id={`label-${hit.objectID}`}
|
||||
primary={config?.primaryKeys
|
||||
?.map((key: string) => hit[key])
|
||||
.join(" ")}
|
||||
secondary={config?.secondaryKeys
|
||||
?.map((key: string) => hit[key])
|
||||
.join(" ")}
|
||||
id={`label-${_get(hit, primaryKey)}`}
|
||||
primary={_get(hit, titleKey)}
|
||||
secondary={!subtitleKey ? "" : _get(hit, subtitleKey)}
|
||||
/>
|
||||
</MenuItem>
|
||||
<Divider className={classes.divider} />
|
||||
@@ -176,7 +187,7 @@ export default function PopupContents({
|
||||
color="textSecondary"
|
||||
className={classes.selectedNum}
|
||||
>
|
||||
{value?.length} of {response?.nbHits}
|
||||
{value?.length} of {hits?.length}
|
||||
</Typography>
|
||||
|
||||
<Button
|
||||
75
www/src/components/ConnectServiceSelect/index.tsx
Normal file
75
www/src/components/ConnectServiceSelect/index.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import React, { lazy, Suspense } from "react";
|
||||
import clsx from "clsx";
|
||||
|
||||
import { TextField, TextFieldProps } from "@material-ui/core";
|
||||
|
||||
import useStyles from "./styles";
|
||||
import Loading from "components/Loading";
|
||||
import ErrorBoundary from "components/ErrorBoundary";
|
||||
|
||||
const PopupContents = lazy(
|
||||
() => import("./PopupContents" /* webpackChunkName: "PopupContents" */)
|
||||
);
|
||||
|
||||
export type ServiceValue = { value: string; [prop: string]: any };
|
||||
|
||||
export interface IConnectServiceSelectProps {
|
||||
value: ServiceValue[];
|
||||
onChange: (value: ServiceValue[]) => void;
|
||||
row: any;
|
||||
config: {
|
||||
displayKey: string;
|
||||
[key: string]: any;
|
||||
};
|
||||
editable?: boolean;
|
||||
/** Optional style overrides for root MUI `TextField` component */
|
||||
className?: string;
|
||||
/** Override any props of the root MUI `TextField` component */
|
||||
TextFieldProps?: Partial<TextFieldProps>;
|
||||
docRef: firebase.firestore.DocumentReference;
|
||||
}
|
||||
|
||||
export default function ConnectServiceSelect({
|
||||
value = [],
|
||||
className,
|
||||
TextFieldProps = {},
|
||||
...props
|
||||
}: IConnectServiceSelectProps) {
|
||||
const classes = useStyles();
|
||||
|
||||
const sanitisedValue = Array.isArray(value) ? value : [];
|
||||
|
||||
return (
|
||||
<TextField
|
||||
label=""
|
||||
hiddenLabel
|
||||
variant={"filled" as any}
|
||||
select
|
||||
value={sanitisedValue}
|
||||
className={clsx(classes.root, className)}
|
||||
{...TextFieldProps}
|
||||
SelectProps={{
|
||||
renderValue: (value) => `${(value as any[]).length} selected`,
|
||||
displayEmpty: true,
|
||||
classes: { root: classes.selectRoot },
|
||||
...TextFieldProps.SelectProps,
|
||||
// Must have this set to prevent MUI transforming `value`
|
||||
// prop for this component to a comma-separated string
|
||||
MenuProps: {
|
||||
classes: { paper: classes.paper, list: classes.menuChild },
|
||||
MenuListProps: { disablePadding: true },
|
||||
getContentAnchorEl: null,
|
||||
anchorOrigin: { vertical: "bottom", horizontal: "center" },
|
||||
transformOrigin: { vertical: "top", horizontal: "center" },
|
||||
...TextFieldProps.SelectProps?.MenuProps,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ErrorBoundary>
|
||||
<Suspense fallback={<Loading />}>
|
||||
<PopupContents value={sanitisedValue} {...props} />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</TextField>
|
||||
);
|
||||
}
|
||||
@@ -1,85 +1,146 @@
|
||||
import React, { lazy, Suspense } from "react";
|
||||
import clsx from "clsx";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import useAlgolia from "use-algolia";
|
||||
import _find from "lodash/find";
|
||||
import { useDebounce } from "use-debounce";
|
||||
|
||||
import { TextField, TextFieldProps } from "@material-ui/core";
|
||||
|
||||
import useStyles from "./styles";
|
||||
import MultiSelect, { MultiSelectProps } from "@antlerengineering/multiselect";
|
||||
import Loading from "components/Loading";
|
||||
import ErrorBoundary from "components/ErrorBoundary";
|
||||
|
||||
const PopupContents = lazy(
|
||||
() => import("./PopupContents" /* webpackChunkName: "PopupContents" */)
|
||||
);
|
||||
|
||||
export type ConnectTableValue = { snapshot: any; docPath: string };
|
||||
export type ConnectTableValue = {
|
||||
snapshot: any;
|
||||
docPath: string;
|
||||
};
|
||||
|
||||
export interface IConnectTableSelectProps {
|
||||
value: ConnectTableValue[];
|
||||
onChange: (value: ConnectTableValue[]) => void;
|
||||
row: any;
|
||||
column: any;
|
||||
collectionPath: string;
|
||||
config: {
|
||||
filters: string;
|
||||
primaryKeys: string[];
|
||||
secondaryKeys: string[];
|
||||
multiple?: boolean;
|
||||
[key: string]: any;
|
||||
};
|
||||
editable?: boolean;
|
||||
/** Optionally set this prop to `false` to only allow one option */
|
||||
multiple?: boolean;
|
||||
/** Optional style overrides for root MUI `TextField` component */
|
||||
className?: string;
|
||||
/** Override any props of the root MUI `TextField` component */
|
||||
TextFieldProps?: Partial<TextFieldProps>;
|
||||
TextFieldProps?: MultiSelectProps<ConnectTableValue[]>["TextFieldProps"];
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO: Update this to use @antlerengineering/multiselect
|
||||
* This is a copy-paste of the old MultiSelect
|
||||
*/
|
||||
export default function ConnectTableSelect({
|
||||
value = [],
|
||||
onChange,
|
||||
row,
|
||||
column,
|
||||
|
||||
collectionPath,
|
||||
config,
|
||||
editable,
|
||||
className,
|
||||
|
||||
TextFieldProps = {},
|
||||
...props
|
||||
}: IConnectTableSelectProps) {
|
||||
const classes = useStyles();
|
||||
// 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 [localValue, setLocalValue] = useState(
|
||||
Array.isArray(value) ? value : []
|
||||
);
|
||||
|
||||
const sanitisedValue = Array.isArray(value) ? value : [];
|
||||
const [algoliaState, requestDispatch, , setAlgoliaConfig] = useAlgolia(
|
||||
process.env.REACT_APP_ALGOLIA_APP_ID!,
|
||||
process.env.REACT_APP_ALGOLIA_SEARCH_API_KEY!,
|
||||
"" // Don’t choose the index until the user opens the dropdown
|
||||
);
|
||||
const algoliaIndex = collectionPath ?? config.index;
|
||||
const options = algoliaState.hits.map((hit) => ({
|
||||
label: config?.primaryKeys?.map((key: string) => hit[key]).join(" "),
|
||||
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]
|
||||
: [];
|
||||
|
||||
// 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;
|
||||
|
||||
// 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;
|
||||
return {
|
||||
snapshot,
|
||||
docPath: `${algoliaIndex}/${snapshot.objectID}`,
|
||||
};
|
||||
});
|
||||
|
||||
// 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`
|
||||
if (config.multiple === false) onChange(newLocalValue);
|
||||
// Otherwise, `setLocalValue` until user closes dropdown
|
||||
else setLocalValue(newLocalValue);
|
||||
};
|
||||
|
||||
// Save when user closes dropdown
|
||||
const handleSave = () => {
|
||||
if (config.multiple !== false) onChange(localValue);
|
||||
};
|
||||
|
||||
// Change MultiSelect input field to search Algolia directly
|
||||
const [search, setSearch] = useState("");
|
||||
const [debouncedSearch] = useDebounce(search, 1000);
|
||||
useEffect(() => {
|
||||
requestDispatch({ query: debouncedSearch });
|
||||
}, [debouncedSearch]);
|
||||
|
||||
return (
|
||||
<TextField
|
||||
label=""
|
||||
hiddenLabel
|
||||
variant={"filled" as any}
|
||||
select
|
||||
value={sanitisedValue}
|
||||
className={clsx(classes.root, className)}
|
||||
{...TextFieldProps}
|
||||
SelectProps={{
|
||||
renderValue: (value) => `${(value as any[]).length} selected`,
|
||||
displayEmpty: true,
|
||||
classes: { root: classes.selectRoot },
|
||||
...TextFieldProps.SelectProps,
|
||||
// Must have this set to prevent MUI transforming `value`
|
||||
// prop for this component to a comma-separated string
|
||||
multiple: true,
|
||||
MenuProps: {
|
||||
classes: { paper: classes.paper, list: classes.menuChild },
|
||||
MenuListProps: { disablePadding: true },
|
||||
getContentAnchorEl: null,
|
||||
anchorOrigin: { vertical: "bottom", horizontal: "center" },
|
||||
transformOrigin: { vertical: "top", horizontal: "center" },
|
||||
...TextFieldProps.SelectProps?.MenuProps,
|
||||
},
|
||||
<MultiSelect
|
||||
value={config.multiple === false ? sanitisedValue[0] : sanitisedValue}
|
||||
onChange={handleChange}
|
||||
onOpen={() => setAlgoliaConfig({ indexName: algoliaIndex })}
|
||||
onClose={handleSave}
|
||||
options={options}
|
||||
TextFieldProps={{
|
||||
className,
|
||||
hiddenLabel: true,
|
||||
...TextFieldProps,
|
||||
}}
|
||||
>
|
||||
<ErrorBoundary>
|
||||
<Suspense fallback={<Loading />}>
|
||||
<PopupContents value={sanitisedValue} {...props} />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</TextField>
|
||||
label={column?.name}
|
||||
multiple={(config?.multiple ?? true) as any}
|
||||
AutocompleteProps={{
|
||||
loading: algoliaState.loading,
|
||||
loadingText: <Loading />,
|
||||
inputValue: search,
|
||||
onInputChange: (_, value, reason) => {
|
||||
if (reason === "input") setSearch(value);
|
||||
},
|
||||
filterOptions: () => options,
|
||||
}}
|
||||
countText={`${localValue.length} of ${
|
||||
algoliaState.response?.nbHits ?? "?"
|
||||
}`}
|
||||
disabled={editable === false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
84
www/src/components/SideDrawer/Form/Fields/ConnectService.tsx
Normal file
84
www/src/components/SideDrawer/Form/Fields/ConnectService.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import React from "react";
|
||||
import { Controller, useWatch } from "react-hook-form";
|
||||
import { IFieldProps } from "../utils";
|
||||
|
||||
import { Chip, Grid, useTheme } from "@material-ui/core";
|
||||
import { get } from "lodash";
|
||||
import ConnectServiceSelect, {
|
||||
IConnectServiceSelectProps,
|
||||
} from "../../../ConnectServiceSelect";
|
||||
|
||||
export interface IConnectServiceProps
|
||||
extends IFieldProps,
|
||||
Partial<Omit<IConnectServiceSelectProps, "docRef">> {}
|
||||
|
||||
export default function ConnectService({
|
||||
control,
|
||||
name,
|
||||
editable,
|
||||
...props
|
||||
}: IConnectServiceProps) {
|
||||
const theme = useTheme();
|
||||
|
||||
const disabled = editable === false;
|
||||
const { config, docRef } = props;
|
||||
if (!config) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
const displayKey = config.titleKey ?? config.primaryKey;
|
||||
return (
|
||||
<Controller
|
||||
control={control}
|
||||
name={name}
|
||||
render={({ onChange, onBlur, value }) => {
|
||||
const handleDelete = (hit: any) => () => {
|
||||
// if (multiple)
|
||||
onChange(
|
||||
value.filter(
|
||||
(v) => get(v, config.primaryKey) !== get(hit, config.primaryKey)
|
||||
)
|
||||
);
|
||||
// else form.setFieldValue(field.name, []);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{!disabled && (
|
||||
<ConnectServiceSelect
|
||||
{...(props as any)}
|
||||
value={value}
|
||||
multiple={false}
|
||||
onChange={onChange}
|
||||
docRef={docRef}
|
||||
TextFieldProps={{
|
||||
fullWidth: true,
|
||||
onBlur,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{Array.isArray(value) && (
|
||||
<Grid
|
||||
container
|
||||
spacing={1}
|
||||
style={{ marginTop: theme.spacing(1) }}
|
||||
>
|
||||
{value.map((snapshot) => (
|
||||
<Grid item key={get(snapshot, config.primaryKey)}>
|
||||
<Chip
|
||||
component="li"
|
||||
size="medium"
|
||||
label={get(snapshot, displayKey)}
|
||||
onDelete={disabled ? undefined : handleDelete(snapshot)}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -44,6 +44,9 @@ export default function ConnectTable({
|
||||
TextFieldProps={{
|
||||
fullWidth: true,
|
||||
onBlur,
|
||||
SelectProps: {
|
||||
renderValue: () => `${value?.length ?? 0} selected`,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -9,7 +9,8 @@ import {
|
||||
} from "@material-ui/pickers";
|
||||
import { DATE_TIME_FORMAT } from "constants/dates";
|
||||
|
||||
import AccessTimeIcon from "@material-ui/icons/AccessTime";
|
||||
import DateRangeIcon from "@material-ui/icons/DateRange";
|
||||
import TimeIcon from "@material-ui/icons/Schedule";
|
||||
|
||||
import { MuiPickersUtilsProvider } from "@material-ui/pickers";
|
||||
import DateFnsUtils from "@date-io/date-fns";
|
||||
@@ -52,7 +53,7 @@ export default function DateTimePicker({
|
||||
InputAdornmentProps={{
|
||||
style: { marginRight: theme.spacing(-1) },
|
||||
}}
|
||||
keyboardIcon={<AccessTimeIcon />}
|
||||
keyboardIcon={<TimeIcon />}
|
||||
{...props}
|
||||
value={transformedValue}
|
||||
onChange={handleChange}
|
||||
@@ -60,6 +61,16 @@ export default function DateTimePicker({
|
||||
label=""
|
||||
hiddenLabel
|
||||
id={`sidedrawer-field-${name}`}
|
||||
dateRangeIcon={
|
||||
<DateRangeIcon
|
||||
style={{ color: theme.palette.primary.contrastText }}
|
||||
/>
|
||||
}
|
||||
timeIcon={
|
||||
<TimeIcon
|
||||
style={{ color: theme.palette.primary.contrastText }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
|
||||
@@ -12,7 +12,7 @@ export type IMultiSelectProps = IFieldProps &
|
||||
MultiSelectProps<string>,
|
||||
"name" | "multiple" | "value" | "onChange" | "options"
|
||||
> & {
|
||||
config?: { options: string[] };
|
||||
config?: { options: string[]; freeText: boolean };
|
||||
};
|
||||
|
||||
export default function MultiSelect({
|
||||
@@ -51,7 +51,7 @@ export default function MultiSelect({
|
||||
onBlur,
|
||||
}}
|
||||
searchable
|
||||
freeText={false}
|
||||
freeText={config?.freeText}
|
||||
/>
|
||||
|
||||
{value && Array.isArray(value) && (
|
||||
|
||||
@@ -12,7 +12,10 @@ export type ISingleSelectProps = IFieldProps &
|
||||
MultiSelectProps<string>,
|
||||
"name" | "multiple" | "value" | "onChange" | "options"
|
||||
> & {
|
||||
config?: { options: string[] };
|
||||
config?: {
|
||||
options: string[];
|
||||
freeText?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -39,7 +42,7 @@ export default function SingleSelect({
|
||||
{...props}
|
||||
options={config?.options ?? []}
|
||||
multiple={false}
|
||||
value={value}
|
||||
value={Array.isArray(value) ? value[0] : value}
|
||||
onChange={onChange}
|
||||
disabled={editable === false}
|
||||
TextFieldProps={{
|
||||
@@ -48,7 +51,7 @@ export default function SingleSelect({
|
||||
onBlur,
|
||||
}}
|
||||
searchable
|
||||
freeText={false}
|
||||
freeText={config?.freeText}
|
||||
/>
|
||||
|
||||
{value?.length > 0 && (
|
||||
|
||||
@@ -8,7 +8,8 @@ import { Fields, Values, getInitialValues, Field } from "./utils";
|
||||
import { FieldType } from "constants/fields";
|
||||
import Autosave from "./Autosave";
|
||||
import FieldWrapper from "./FieldWrapper";
|
||||
|
||||
import { useAppContext } from "contexts/appContext";
|
||||
import { useFiretableContext } from "contexts/firetableContext";
|
||||
import Text from "./Fields/Text";
|
||||
const Url = lazy(
|
||||
() => import("./Fields/Url" /* webpackChunkName: "SideDrawer-Url" */)
|
||||
@@ -84,6 +85,13 @@ const ConnectTable = lazy(
|
||||
"./Fields/ConnectTable" /* webpackChunkName: "SideDrawer-ConnectTable" */
|
||||
)
|
||||
);
|
||||
|
||||
const ConnectService = lazy(
|
||||
() =>
|
||||
import(
|
||||
"./Fields/ConnectService" /* webpackChunkName: "SideDrawer-ConnectTable" */
|
||||
)
|
||||
);
|
||||
const Code = lazy(
|
||||
() => import("./Fields/Code" /* webpackChunkName: "SideDrawer-Code" */)
|
||||
);
|
||||
@@ -105,6 +113,11 @@ export default function Form({ fields, values }: IFormProps) {
|
||||
const { ref: docRef, ...rowValues } = values;
|
||||
const defaultValues = { ...initialValues, ...rowValues };
|
||||
|
||||
const { tableState } = useFiretableContext();
|
||||
const { userDoc } = useAppContext();
|
||||
const userDocHiddenFields =
|
||||
userDoc.state.doc?.tables?.[`${tableState?.tablePath}`]?.hiddenFields ?? [];
|
||||
|
||||
const { register, control } = useForm({
|
||||
mode: "onBlur",
|
||||
defaultValues,
|
||||
@@ -138,130 +151,136 @@ export default function Form({ fields, values }: IFormProps) {
|
||||
/>
|
||||
|
||||
<Grid container spacing={4} direction="column" wrap="nowrap">
|
||||
{fields.map((_field, i) => {
|
||||
// Call the field function with values if necessary
|
||||
// Otherwise, just use the field object
|
||||
const field: Field = _isFunction(_field) ? _field(values) : _field;
|
||||
const { type, ...fieldProps } = field;
|
||||
let _type = type;
|
||||
{fields
|
||||
.filter((f) => !userDocHiddenFields.includes(f.name))
|
||||
.map((_field, i) => {
|
||||
// Call the field function with values if necessary
|
||||
// Otherwise, just use the field object
|
||||
const field: Field = _isFunction(_field) ? _field(values) : _field;
|
||||
const { type, ...fieldProps } = field;
|
||||
let _type = type;
|
||||
|
||||
// Derivative/aggregate field support
|
||||
if (field.config && field.config.renderFieldType) {
|
||||
_type = field.config.renderFieldType;
|
||||
}
|
||||
// Derivative/aggregate field support
|
||||
if (field.config && field.config.renderFieldType) {
|
||||
_type = field.config.renderFieldType;
|
||||
}
|
||||
|
||||
let fieldComponent: React.ComponentType<any> | null = null;
|
||||
let fieldComponent: React.ComponentType<any> | null = null;
|
||||
|
||||
switch (_type) {
|
||||
case FieldType.shortText:
|
||||
case FieldType.longText:
|
||||
case FieldType.email:
|
||||
case FieldType.phone:
|
||||
case FieldType.number:
|
||||
fieldComponent = Text;
|
||||
break;
|
||||
switch (_type) {
|
||||
case FieldType.shortText:
|
||||
case FieldType.longText:
|
||||
case FieldType.email:
|
||||
case FieldType.phone:
|
||||
case FieldType.number:
|
||||
fieldComponent = Text;
|
||||
break;
|
||||
|
||||
case FieldType.url:
|
||||
fieldComponent = Url;
|
||||
break;
|
||||
case FieldType.url:
|
||||
fieldComponent = Url;
|
||||
break;
|
||||
|
||||
case FieldType.singleSelect:
|
||||
fieldComponent = SingleSelect;
|
||||
break;
|
||||
case FieldType.singleSelect:
|
||||
fieldComponent = SingleSelect;
|
||||
break;
|
||||
|
||||
case FieldType.multiSelect:
|
||||
fieldComponent = MultiSelect;
|
||||
break;
|
||||
case FieldType.multiSelect:
|
||||
fieldComponent = MultiSelect;
|
||||
break;
|
||||
|
||||
case FieldType.date:
|
||||
fieldComponent = DatePicker;
|
||||
break;
|
||||
case FieldType.date:
|
||||
fieldComponent = DatePicker;
|
||||
break;
|
||||
|
||||
case FieldType.dateTime:
|
||||
fieldComponent = DateTimePicker;
|
||||
break;
|
||||
case FieldType.dateTime:
|
||||
fieldComponent = DateTimePicker;
|
||||
break;
|
||||
|
||||
case FieldType.checkbox:
|
||||
fieldComponent = Checkbox;
|
||||
break;
|
||||
case FieldType.checkbox:
|
||||
fieldComponent = Checkbox;
|
||||
break;
|
||||
|
||||
case FieldType.color:
|
||||
fieldComponent = Color;
|
||||
break;
|
||||
case FieldType.color:
|
||||
fieldComponent = Color;
|
||||
break;
|
||||
|
||||
case FieldType.slider:
|
||||
fieldComponent = Slider;
|
||||
break;
|
||||
case FieldType.slider:
|
||||
fieldComponent = Slider;
|
||||
break;
|
||||
|
||||
case FieldType.richText:
|
||||
fieldComponent = RichText;
|
||||
break;
|
||||
case FieldType.richText:
|
||||
fieldComponent = RichText;
|
||||
break;
|
||||
|
||||
case FieldType.image:
|
||||
fieldComponent = ImageUploader;
|
||||
break;
|
||||
case FieldType.image:
|
||||
fieldComponent = ImageUploader;
|
||||
break;
|
||||
|
||||
case FieldType.file:
|
||||
fieldComponent = FileUploader;
|
||||
break;
|
||||
case FieldType.file:
|
||||
fieldComponent = FileUploader;
|
||||
break;
|
||||
|
||||
case FieldType.rating:
|
||||
fieldComponent = Rating;
|
||||
break;
|
||||
case FieldType.rating:
|
||||
fieldComponent = Rating;
|
||||
break;
|
||||
|
||||
case FieldType.percentage:
|
||||
fieldComponent = Percentage;
|
||||
break;
|
||||
case FieldType.percentage:
|
||||
fieldComponent = Percentage;
|
||||
break;
|
||||
|
||||
case FieldType.connectTable:
|
||||
fieldComponent = ConnectTable;
|
||||
break;
|
||||
case FieldType.connectTable:
|
||||
fieldComponent = ConnectTable;
|
||||
break;
|
||||
|
||||
case FieldType.subTable:
|
||||
fieldComponent = SubTable;
|
||||
break;
|
||||
case FieldType.connectService:
|
||||
fieldComponent = ConnectService;
|
||||
break;
|
||||
|
||||
case FieldType.action:
|
||||
fieldComponent = Action;
|
||||
break;
|
||||
case FieldType.subTable:
|
||||
fieldComponent = SubTable;
|
||||
break;
|
||||
|
||||
case FieldType.json:
|
||||
fieldComponent = JsonEditor;
|
||||
break;
|
||||
case FieldType.action:
|
||||
fieldComponent = Action;
|
||||
break;
|
||||
|
||||
case FieldType.code:
|
||||
fieldComponent = Code;
|
||||
break;
|
||||
case FieldType.json:
|
||||
fieldComponent = JsonEditor;
|
||||
break;
|
||||
|
||||
case undefined:
|
||||
// default:
|
||||
case FieldType.code:
|
||||
fieldComponent = Code;
|
||||
break;
|
||||
|
||||
case undefined:
|
||||
// default:
|
||||
return null;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
// Should not reach this state
|
||||
if (fieldComponent === null) {
|
||||
console.error("`fieldComponent` is null");
|
||||
return null;
|
||||
}
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
// Should not reach this state
|
||||
if (fieldComponent === null) {
|
||||
console.error("`fieldComponent` is null");
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<FieldWrapper
|
||||
key={fieldProps.name ?? i}
|
||||
type={_type}
|
||||
name={field.name}
|
||||
label={field.label}
|
||||
>
|
||||
{React.createElement(fieldComponent, {
|
||||
...fieldProps,
|
||||
control,
|
||||
docRef,
|
||||
})}
|
||||
</FieldWrapper>
|
||||
);
|
||||
})}
|
||||
return (
|
||||
<FieldWrapper
|
||||
key={fieldProps.name ?? i}
|
||||
type={_type}
|
||||
name={field.name}
|
||||
label={field.label}
|
||||
>
|
||||
{React.createElement(fieldComponent, {
|
||||
...fieldProps,
|
||||
control,
|
||||
docRef,
|
||||
})}
|
||||
</FieldWrapper>
|
||||
);
|
||||
})}
|
||||
|
||||
<FieldWrapper
|
||||
type="debug"
|
||||
|
||||
@@ -21,11 +21,12 @@ export type Fields = (Field | ((values: Values) => Field))[];
|
||||
|
||||
export const initializeValue = (type) => {
|
||||
switch (type) {
|
||||
case FieldType.singleSelect:
|
||||
case FieldType.multiSelect:
|
||||
case FieldType.image:
|
||||
case FieldType.file:
|
||||
return [];
|
||||
|
||||
case FieldType.singleSelect:
|
||||
case FieldType.date:
|
||||
case FieldType.dateTime:
|
||||
return null;
|
||||
|
||||
@@ -68,13 +68,13 @@ export default function SideDrawer() {
|
||||
|
||||
useEffect(() => {
|
||||
if (cell) {
|
||||
window.history.pushState(
|
||||
"",
|
||||
`${tableState?.tablePath}`,
|
||||
`${window.location.pathname}?rowRef=${encodeURIComponent(
|
||||
tableState?.rows[cell.row].ref.path
|
||||
)}`
|
||||
);
|
||||
// window.history.pushState(
|
||||
// "",
|
||||
// `${tableState?.tablePath}`,
|
||||
// `${window.location.pathname}?rowRef=${encodeURIComponent(
|
||||
// tableState?.rows[cell.row].ref.path
|
||||
// )}`
|
||||
// );
|
||||
console.log(tableState?.tablePath, tableState?.rows[cell.row].id);
|
||||
if (urlDocState.doc) {
|
||||
urlDocState.unsubscribe();
|
||||
|
||||
@@ -66,6 +66,70 @@ const ConfigFields = ({ fieldType, config, handleChange, tables, columns }) => {
|
||||
</Grid>
|
||||
</>
|
||||
);
|
||||
case FieldType.connectService:
|
||||
return (
|
||||
<>
|
||||
<TextField
|
||||
label="Webservice Url"
|
||||
name="url"
|
||||
value={config.url}
|
||||
fullWidth
|
||||
onChange={(e) => {
|
||||
handleChange("url")(e.target.value);
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
label="Results key Path"
|
||||
name="resultsKey"
|
||||
helperText="Can be specified as a key path"
|
||||
placeholder="data.results"
|
||||
value={config.resultsKey}
|
||||
fullWidth
|
||||
onChange={(e) => {
|
||||
handleChange("resultsKey")(e.target.value);
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
label="Primary Key"
|
||||
name="primaryKey"
|
||||
value={config.primaryKey}
|
||||
fullWidth
|
||||
onChange={(e) => {
|
||||
handleChange("primaryKey")(e.target.value);
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
label="Title Key (optional)"
|
||||
name="titleKey"
|
||||
value={config.titleKey}
|
||||
fullWidth
|
||||
onChange={(e) => {
|
||||
handleChange("titleKey")(e.target.value);
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
label="SubTitle Key (optional)"
|
||||
name="subtitleKey"
|
||||
value={config.subtitleKey}
|
||||
fullWidth
|
||||
onChange={(e) => {
|
||||
handleChange("subtitleKey")(e.target.value);
|
||||
}}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={config.multiple}
|
||||
onChange={() =>
|
||||
handleChange("multiple")(!Boolean(config.multiple))
|
||||
}
|
||||
name="select-multiple"
|
||||
/>
|
||||
}
|
||||
label="Enable multiple item selection"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
case FieldType.connectTable:
|
||||
const tableOptions = _sortBy(
|
||||
tables?.map((t) => ({
|
||||
|
||||
@@ -106,7 +106,13 @@ export default function Action({
|
||||
className={classes.fab}
|
||||
onClick={handleRun}
|
||||
disabled={
|
||||
isRunning || !!(hasRan && !value.redo && !value.undo) || disabled
|
||||
isRunning ||
|
||||
!!(
|
||||
hasRan &&
|
||||
(config["redo.enabled"] ? false : !value.redo) &&
|
||||
(config["undo.enabled"] ? false : !value.undo)
|
||||
) ||
|
||||
disabled
|
||||
}
|
||||
>
|
||||
{isRunning ? (
|
||||
|
||||
121
www/src/components/Table/formatters/ConnectService.tsx
Normal file
121
www/src/components/Table/formatters/ConnectService.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import React from "react";
|
||||
import clsx from "clsx";
|
||||
import { CustomCellProps } from "./withCustomCell";
|
||||
import _get from "lodash/get";
|
||||
import { createStyles, makeStyles, Grid, Chip } from "@material-ui/core";
|
||||
|
||||
import ConnectServiceSelect from "components/ConnectServiceSelect";
|
||||
import { useFiretableContext } from "contexts/firetableContext";
|
||||
|
||||
const useStyles = makeStyles((theme) =>
|
||||
createStyles({
|
||||
root: {
|
||||
minWidth: 0,
|
||||
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
},
|
||||
disabled: {},
|
||||
|
||||
fullHeight: {
|
||||
height: "100%",
|
||||
font: "inherit",
|
||||
color: "inherit",
|
||||
letterSpacing: "inherit",
|
||||
},
|
||||
|
||||
select: {
|
||||
padding: theme.spacing(0, 3, 0, 1.5),
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
|
||||
"&&": { paddingRight: theme.spacing(4) },
|
||||
|
||||
"$disabled &": { paddingRight: theme.spacing(0) },
|
||||
},
|
||||
icon: {
|
||||
marginRight: theme.spacing(1),
|
||||
"$disabled &": { display: "none" },
|
||||
},
|
||||
|
||||
chipList: {
|
||||
overflowX: "hidden",
|
||||
width: "100%",
|
||||
},
|
||||
chip: { cursor: "inherit" },
|
||||
})
|
||||
);
|
||||
|
||||
export default function ConnectService({
|
||||
rowIdx,
|
||||
column,
|
||||
value,
|
||||
onSubmit,
|
||||
row,
|
||||
docRef,
|
||||
}: CustomCellProps) {
|
||||
const classes = useStyles();
|
||||
const { config } = column as any;
|
||||
const { dataGridRef } = useFiretableContext();
|
||||
if (!config) return <></>;
|
||||
const disabled = !column.editable || config?.isLocked;
|
||||
const titleKey = config.titleKey ?? config.primaryKey;
|
||||
// Render chips
|
||||
const renderValue = (value) => (
|
||||
<Grid container spacing={1} wrap="nowrap" className={classes.chipList}>
|
||||
{value?.map((doc: any) => (
|
||||
<Grid item key={_get(doc, config.primaryKey)}>
|
||||
<Chip label={_get(doc, titleKey)} className={classes.chip} />
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
);
|
||||
|
||||
const onClick = (e) => e.stopPropagation();
|
||||
const onClose = () => {
|
||||
if (dataGridRef?.current?.selectCell)
|
||||
dataGridRef.current.selectCell({ rowIdx, idx: column.idx });
|
||||
};
|
||||
|
||||
return (
|
||||
<ConnectServiceSelect
|
||||
row={row}
|
||||
value={value}
|
||||
onChange={onSubmit}
|
||||
config={config}
|
||||
docRef={docRef}
|
||||
TextFieldProps={{
|
||||
fullWidth: true,
|
||||
label: "",
|
||||
hiddenLabel: true,
|
||||
variant: "standard" as "filled",
|
||||
InputProps: {
|
||||
disableUnderline: true,
|
||||
classes: { root: classes.fullHeight },
|
||||
},
|
||||
SelectProps: {
|
||||
onClose,
|
||||
classes: {
|
||||
root: clsx(classes.fullHeight, classes.select),
|
||||
icon: clsx(classes.icon),
|
||||
},
|
||||
renderValue,
|
||||
MenuProps: {
|
||||
anchorOrigin: { vertical: "bottom", horizontal: "left" },
|
||||
transformOrigin: { vertical: "top", horizontal: "left" },
|
||||
},
|
||||
},
|
||||
onClick,
|
||||
disabled,
|
||||
}}
|
||||
className={clsx(
|
||||
classes.fullHeight,
|
||||
classes.root,
|
||||
disabled && classes.disabled
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -61,21 +61,22 @@ export default function ConnectTable({
|
||||
const { collectionPath, config } = column as any;
|
||||
const { dataGridRef } = useFiretableContext();
|
||||
if (!config || !config.primaryKeys) return <></>;
|
||||
const disabled = !column.editable || config?.isLocked;
|
||||
const disabled = column.editable === false || config?.isLocked;
|
||||
|
||||
// Render chips
|
||||
const renderValue = (value) => (
|
||||
const renderValue = () => (
|
||||
<Grid container spacing={1} wrap="nowrap" className={classes.chipList}>
|
||||
{value?.map((doc: any) => (
|
||||
<Grid item key={doc.docPath}>
|
||||
<Chip
|
||||
label={config.primaryKeys
|
||||
.map((key: string) => doc.snapshot[key])
|
||||
.join(" ")}
|
||||
className={classes.chip}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
{Array.isArray(value) &&
|
||||
value.map((doc: any) => (
|
||||
<Grid item key={doc.docPath}>
|
||||
<Chip
|
||||
label={config.primaryKeys
|
||||
.map((key: string) => doc.snapshot[key])
|
||||
.join(" ")}
|
||||
className={classes.chip}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
);
|
||||
|
||||
@@ -88,10 +89,12 @@ export default function ConnectTable({
|
||||
return (
|
||||
<ConnectTableSelect
|
||||
row={row}
|
||||
column={column}
|
||||
value={value}
|
||||
onChange={onSubmit}
|
||||
collectionPath={collectionPath}
|
||||
config={config}
|
||||
editable={column.editable as boolean}
|
||||
TextFieldProps={{
|
||||
fullWidth: true,
|
||||
label: "",
|
||||
@@ -114,7 +117,6 @@ export default function ConnectTable({
|
||||
},
|
||||
},
|
||||
onClick,
|
||||
disabled,
|
||||
}}
|
||||
className={clsx(
|
||||
classes.fullHeight,
|
||||
|
||||
@@ -4,6 +4,9 @@ import { CustomCellProps } from "./withCustomCell";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
|
||||
import { makeStyles, createStyles } from "@material-ui/core";
|
||||
import DateRangeIcon from "@material-ui/icons/DateRange";
|
||||
import TimeIcon from "@material-ui/icons/Schedule";
|
||||
|
||||
import { FieldType, DateIcon, DateTimeIcon } from "constants/fields";
|
||||
import { DATE_FORMAT, DATE_TIME_FORMAT } from "constants/dates";
|
||||
|
||||
@@ -37,6 +40,10 @@ const useStyles = makeStyles((theme) =>
|
||||
height: "100%",
|
||||
padding: theme.spacing(1.5, 0),
|
||||
},
|
||||
|
||||
dateTabIcon: {
|
||||
color: theme.palette.primary.contrastText,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
@@ -92,6 +99,8 @@ export default function Date({
|
||||
classes: { root: "row-hover-iconButton" },
|
||||
}}
|
||||
DialogProps={{ onClick: (e) => e.stopPropagation() }}
|
||||
dateRangeIcon={<DateRangeIcon className={classes.dateTabIcon} />}
|
||||
timeIcon={<TimeIcon className={classes.dateTabIcon} />}
|
||||
/>
|
||||
</MuiPickersUtilsProvider>
|
||||
);
|
||||
|
||||
@@ -63,6 +63,16 @@ export default function MultiSelect({
|
||||
// Support SingleSelect field
|
||||
const isSingle = (column as any).type === FieldType.singleSelect;
|
||||
|
||||
let sanitisedValue: any;
|
||||
if (isSingle) {
|
||||
if (value === undefined || value === null) sanitisedValue = null;
|
||||
else if (Array.isArray(value)) sanitisedValue = value[0];
|
||||
else sanitisedValue = value;
|
||||
} else {
|
||||
if (value === undefined || value === null) sanitisedValue = [];
|
||||
else sanitisedValue = value;
|
||||
}
|
||||
|
||||
// Render chips or basic string
|
||||
const renderValue = isSingle
|
||||
? () =>
|
||||
@@ -110,15 +120,9 @@ export default function MultiSelect({
|
||||
>
|
||||
<div>
|
||||
<MultiSelect_
|
||||
value={
|
||||
value === undefined || value === null
|
||||
? isSingle
|
||||
? null
|
||||
: []
|
||||
: value
|
||||
}
|
||||
value={sanitisedValue}
|
||||
onChange={onSubmit}
|
||||
freeText={!isSingle && config.freeText}
|
||||
freeText={config.freeText}
|
||||
multiple={!isSingle as any}
|
||||
label={column.name}
|
||||
labelPlural={column.name}
|
||||
|
||||
@@ -19,7 +19,7 @@ export default function Rating({
|
||||
onSubmit,
|
||||
}: CustomCellProps) {
|
||||
const classes = useStyles();
|
||||
const { max, precision } = (column as any).config as {
|
||||
const { max, precision } = ((column as any).config ?? {}) as {
|
||||
max: number;
|
||||
precision: number;
|
||||
};
|
||||
|
||||
@@ -31,6 +31,7 @@ const Action = lazy(() => import("./Action" /* webpackChunkName: "Action" */));
|
||||
const ConnectTable = lazy(
|
||||
() => import("./ConnectTable" /* webpackChunkName: "ConnectTable" */)
|
||||
);
|
||||
const ConnectService = lazy(() => import("./ConnectService"));
|
||||
const SubTable = lazy(
|
||||
() => import("./SubTable" /* webpackChunkName: "SubTable" */)
|
||||
);
|
||||
@@ -106,6 +107,9 @@ export const getFormatter = (column: any, readOnly: boolean = false) => {
|
||||
case FieldType.connectTable:
|
||||
return withCustomCell(ConnectTable, readOnly);
|
||||
|
||||
case FieldType.connectService:
|
||||
return withCustomCell(ConnectService, readOnly);
|
||||
|
||||
case FieldType.subTable:
|
||||
return withCustomCell(SubTable, readOnly);
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ const useStyles = makeStyles((theme) =>
|
||||
export type CustomCellProps = FormatterProps<any> & {
|
||||
value: any;
|
||||
onSubmit: (value: any) => void;
|
||||
docRef: firebase.firestore.DocumentReference;
|
||||
};
|
||||
|
||||
const getCellValue = (row, key) => {
|
||||
@@ -49,6 +50,7 @@ const withCustomCell = (
|
||||
<Suspense fallback={<div />}>
|
||||
<Component
|
||||
{...props}
|
||||
docRef={props.row.ref}
|
||||
value={getCellValue(props.row, props.column.key as string)}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
|
||||
@@ -27,8 +27,8 @@ const useStyles = makeStyles((theme) =>
|
||||
margin: 0,
|
||||
padding: theme.spacing(1.5, 0, 3),
|
||||
|
||||
height: 200,
|
||||
overflowY: "auto",
|
||||
height: 400,
|
||||
overflowY: "overlay" as any,
|
||||
|
||||
"& li": { margin: theme.spacing(0.5, 0) },
|
||||
},
|
||||
|
||||
@@ -50,7 +50,7 @@ const useStyles = makeStyles((theme) =>
|
||||
flexShrink: 0,
|
||||
},
|
||||
|
||||
header: { overflowX: "scroll" },
|
||||
header: { overflowX: "hidden" },
|
||||
data: {
|
||||
overflow: "scroll",
|
||||
height: 300,
|
||||
|
||||
@@ -34,11 +34,21 @@ export default function ImportWizard() {
|
||||
|
||||
const { tableState, tableActions } = useFiretableContext();
|
||||
useEffect(() => {
|
||||
if (!tableState) return;
|
||||
|
||||
if (
|
||||
tableState?.config.tableConfig.doc &&
|
||||
!tableState?.config.tableConfig.doc?.columns
|
||||
)
|
||||
tableState.config.tableConfig.doc &&
|
||||
!tableState.config.tableConfig.doc?.columns
|
||||
) {
|
||||
setOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(tableState.filters) && tableState.filters?.length > 0)
|
||||
tableActions!.table.filter([]);
|
||||
|
||||
if (Array.isArray(tableState.orderBy) && tableState.orderBy?.length > 0)
|
||||
tableActions!.table.orderBy([]);
|
||||
}, [tableState]);
|
||||
|
||||
if (tableState?.rows.length === 0) return null;
|
||||
|
||||
@@ -23,6 +23,8 @@ import ImageIcon from "@material-ui/icons/PhotoSizeSelectActual";
|
||||
import FileIcon from "@material-ui/icons/AttachFile";
|
||||
|
||||
import SingleSelectIcon from "@material-ui/icons/FormatListBulleted";
|
||||
import WebServiceIcon from "@material-ui/icons/Http";
|
||||
|
||||
import MultiSelectIcon from "assets/icons/MultiSelect";
|
||||
|
||||
import ConnectTableIcon from "assets/icons/ConnectTable";
|
||||
@@ -67,6 +69,7 @@ export {
|
||||
ColorIcon,
|
||||
SliderIcon,
|
||||
UserIcon,
|
||||
WebServiceIcon,
|
||||
};
|
||||
|
||||
export enum FieldType {
|
||||
@@ -92,6 +95,7 @@ export enum FieldType {
|
||||
singleSelect = "SINGLE_SELECT",
|
||||
multiSelect = "MULTI_SELECT",
|
||||
|
||||
connectService = "SERVICE_SELECT",
|
||||
connectTable = "DOCUMENT_SELECT",
|
||||
subTable = "SUB_TABLE",
|
||||
|
||||
@@ -128,7 +132,6 @@ export const FIELDS = [
|
||||
|
||||
{ icon: <ImageIcon />, name: "Image", type: FieldType.image },
|
||||
{ icon: <FileIcon />, name: "File", type: FieldType.file },
|
||||
|
||||
{
|
||||
icon: <SingleSelectIcon />,
|
||||
name: "Single Select",
|
||||
@@ -139,16 +142,20 @@ export const FIELDS = [
|
||||
name: "Multi Select",
|
||||
type: FieldType.multiSelect,
|
||||
},
|
||||
|
||||
{
|
||||
icon: <SubTableIcon />,
|
||||
name: "Sub-table",
|
||||
type: FieldType.subTable,
|
||||
},
|
||||
{
|
||||
icon: <ConnectTableIcon />,
|
||||
name: "Connect Table",
|
||||
type: FieldType.connectTable,
|
||||
},
|
||||
{
|
||||
icon: <SubTableIcon />,
|
||||
name: "Sub-table",
|
||||
type: FieldType.subTable,
|
||||
icon: <WebServiceIcon />,
|
||||
name: "Webservice Select",
|
||||
type: FieldType.connectService,
|
||||
},
|
||||
|
||||
{ icon: <JsonIcon />, name: "JSON", type: FieldType.json },
|
||||
@@ -198,6 +205,7 @@ export const FIELD_TYPE_DESCRIPTIONS = {
|
||||
|
||||
[FieldType.connectTable]:
|
||||
"Connects to an existing table to fetch a snapshot of values from a row. Requires Algolia integration.",
|
||||
[FieldType.connectService]: "Select a value from a list of websevice results",
|
||||
[FieldType.subTable]:
|
||||
"Creates a sub-table. Also displays number of rows inside the sub-table. Max sub-table levels: 100.",
|
||||
|
||||
|
||||
@@ -106,10 +106,10 @@
|
||||
"@algolia/logger-common" "4.1.0"
|
||||
"@algolia/requester-common" "4.1.0"
|
||||
|
||||
"@antlerengineering/multiselect@^0.5.0":
|
||||
version "0.5.0"
|
||||
resolved "https://registry.yarnpkg.com/@antlerengineering/multiselect/-/multiselect-0.5.0.tgz#26e9790a9050f45986d4530df01cbedf54d310e9"
|
||||
integrity sha512-rPLgC6Qy/WF2DDlhIjgEaDEmULRRnxC3DKjt11oT3iQiDkRJj7Mx8cCL4bViRLEkg9W4/AW5EngneHpa8aZybA==
|
||||
"@antlerengineering/multiselect@^0.6.0":
|
||||
version "0.6.0"
|
||||
resolved "https://registry.yarnpkg.com/@antlerengineering/multiselect/-/multiselect-0.6.0.tgz#aed7aef5f2c4a9316d00328d85eb41d149694b47"
|
||||
integrity sha512-fvW26wQsaghW9KjDnUWP3WLaktx/DgzT1M2quPIMrQ5e+gPT5psNEmWGEqG4jN0hYfsnl+5m5cHMwCD/wR/Mmg==
|
||||
|
||||
"@babel/code-frame@7.8.3", "@babel/code-frame@^7.0.0", "@babel/code-frame@^7.8.3":
|
||||
version "7.8.3"
|
||||
@@ -9964,11 +9964,15 @@ node-fetch@^2.3.0, node-fetch@^2.6.0:
|
||||
resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.0.tgz#e633456386d4aa55863f676a7ab0daa8fdecb0fd"
|
||||
integrity sha512-8dG4H5ujfvFiqDmVu9fQ5bOHUC15JMjMY/Zumv26oOvvVJjM67KF8koCWIabKQ1GJIa9r2mMZscBq/TbdOcmNA==
|
||||
|
||||
node-forge@0.9.0:
|
||||
version "0.9.0"
|
||||
resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.9.0.tgz#d624050edbb44874adca12bb9a52ec63cb782579"
|
||||
integrity sha512-7ASaDa3pD+lJ3WvXFsxekJQelBKRpne+GOVbLbtHYdd7pFspyeuJHnWfLplGf3SwKGbfs/aYl5V/JCIaHVUKKQ==
|
||||
|
||||
node-forge@^0.10.0:
|
||||
version "0.10.0"
|
||||
resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.1.0.tgz#775368e6846558ab6676858a4d8c6e8d16c677b5"
|
||||
integrity sha512-G6RlQt5Sb4GMBzXvhfkeFmbqR6MzhtnT7VTHuLadjkii3rdYHNdw0m8zA4BTxVIh68FicCQ2NSUANpsqkr9jvQ==
|
||||
node-forge@^0.9.0:
|
||||
version "0.9.2"
|
||||
resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.9.2.tgz#b35a44c28889b2ea55cabf8c79e3563f9676190a"
|
||||
integrity sha512-naKSScof4Wn+aoHU6HBsifh92Zeicm1GDQKd1vp3Y/kOi8ub0DozCa9KpvYNCXslFHYRmLNiqRopGdTGwNLpNw==
|
||||
|
||||
node-int64@^0.4.0:
|
||||
version "0.4.0"
|
||||
@@ -14370,10 +14374,10 @@ url@^0.11.0:
|
||||
punycode "1.3.2"
|
||||
querystring "0.2.0"
|
||||
|
||||
use-algolia@^1.3.0:
|
||||
version "1.3.0"
|
||||
resolved "https://registry.yarnpkg.com/use-algolia/-/use-algolia-1.3.0.tgz#a53623b9c248bad14ca1cba551658c625613cb72"
|
||||
integrity sha512-t6rY8apz0CmeNUbVchsi0+7UIeutJ8FPNfGTbx/f2vCw1LFYAo9ZUrG81vIsKMAhSLP2FKEXeztDWYr976MEpw==
|
||||
use-algolia@^1.4.1:
|
||||
version "1.4.1"
|
||||
resolved "https://registry.yarnpkg.com/use-algolia/-/use-algolia-1.4.1.tgz#a39747a3c39c69a99b19815ffebf2426526e8287"
|
||||
integrity sha512-V1XlPYDQK45Tws2Zk//SKFjHiBmlwiGFESygFWWtNrVwlZRJrbF/pPLMquv1Hkq9jW2npX8y7GA/pdWBAfAeuQ==
|
||||
|
||||
use-debounce@^3.3.0:
|
||||
version "3.4.0"
|
||||
|
||||
Reference in New Issue
Block a user