Merge pull request #1445 from rowyio/develop

Develop
This commit is contained in:
Shams
2023-10-25 00:43:15 -07:00
committed by GitHub
29 changed files with 1032 additions and 237 deletions

View File

@@ -41,6 +41,12 @@ const SetupPage = lazy(() => import("@src/pages/SetupPage" /* webpackChunkName:
const Navigation = lazy(() => import("@src/layouts/Navigation" /* webpackChunkName: "Navigation" */));
// prettier-ignore
const TableSettingsDialog = lazy(() => import("@src/components/TableSettingsDialog" /* webpackChunkName: "TableSettingsDialog" */));
const ProjectSettingsDialog = lazy(
() =>
import(
"@src/components/ProjectSettingsDialog" /* webpackChunkName: "ProjectSettingsDialog" */
)
);
// prettier-ignore
const TablesPage = lazy(() => import("@src/pages/TablesPage" /* webpackChunkName: "TablesPage" */));
@@ -99,6 +105,7 @@ export default function App() {
<RequireAuth>
<Navigation>
<TableSettingsDialog />
<ProjectSettingsDialog />
</Navigation>
</RequireAuth>
}

View File

@@ -131,6 +131,26 @@ export const tableSettingsDialogAtom = atom(
}
);
export type ProjectSettingsDialogTab =
| "general"
| "rowy-run"
| "services"
| "secrets";
export type ProjectSettingsDialogState = {
open: boolean;
tab: ProjectSettingsDialogTab;
};
export const projectSettingsDialogAtom = atom(
{ open: false, tab: "secrets" } as ProjectSettingsDialogState,
(_, set, update?: Partial<ProjectSettingsDialogState>) => {
set(projectSettingsDialogAtom, {
open: true,
tab: "secrets",
...update,
});
}
);
/**
* Store the current ID of the table being edited in tableSettingsDialog
* to derive tableSettingsDialogSchemaAtom

View File

@@ -30,6 +30,12 @@ export const themeOverriddenAtom = atomWithStorage(
false
);
/** User's default table settings (affecting saving and popup behavior) */
export const defaultTableSettingsAtom = atom((get) => {
const userSettings = get(userSettingsAtom);
return userSettings.defaultTableSettings;
});
/** Customized base theme based on project and user settings */
export const customizedThemesAtom = atom((get) => {
const publicSettings = get(publicSettingsAtom);

View File

@@ -128,4 +128,15 @@ type PushNotificationRequest = {
type PushNotificationBody = (
context: ExtensionContext
) => Message | Message[] | Promise<Message | Message[]>;
type TaskBody = (context: ExtensionContext) => Promise<any>;
type BuildshipAuthenticatedTriggerBody = (
context: ExtensionContext
) => Promise<{
buildshipConfig: {
projectId: string;
workflowId: string;
};
body: string;
}>;

View File

@@ -0,0 +1,282 @@
import React from "react";
import { useAtom } from "jotai";
import {
projectScope,
projectSettingsDialogAtom,
ProjectSettingsDialogTab,
rowyRunAtom,
secretNamesAtom,
updateSecretNamesAtom,
} from "@src/atoms/projectScope";
import Modal from "@src/components/Modal";
import { Box, Button, Paper, Tab, Tooltip, Typography } from "@mui/material";
import { TabContext, TabPanel, TabList } from "@mui/lab";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
import EditIcon from "@mui/icons-material/Edit";
import SecretDetailsModal from "./SecretDetailsModal";
import { runRoutes } from "@src/constants/runRoutes";
export default function ProjectSettingsDialog() {
const [{ open, tab }, setProjectSettingsDialog] = useAtom(
projectSettingsDialogAtom,
projectScope
);
const [secretNames] = useAtom(secretNamesAtom, projectScope);
const [secretDetailsModal, setSecretDetailsModal] = React.useState<{
open: boolean;
loading?: boolean;
mode?: "add" | "edit" | "delete";
secretName?: string;
error?: string;
}>({
open: false,
});
const [rowyRun] = useAtom(rowyRunAtom, projectScope);
const [updateSecretNames] = useAtom(updateSecretNamesAtom, projectScope);
if (!open) return null;
const handleClose = () => {
setProjectSettingsDialog({ open: false });
};
const handleTabChange = (
event: React.SyntheticEvent,
newTab: ProjectSettingsDialogTab
) => {
setProjectSettingsDialog({ tab: newTab });
};
console.log(secretDetailsModal);
return (
<>
<Modal
onClose={handleClose}
open={open}
maxWidth="sm"
fullWidth
title={"Project settings"}
sx={{
".MuiDialogContent-root": {
display: "flex",
flexDirection: "column",
height: "100%",
},
}}
children={
<>
<TabContext value={tab}>
<Box
sx={{
borderBottom: 1,
borderColor: "divider",
}}
>
<TabList value={tab} onChange={handleTabChange}>
<Tab label="Secret keys" value={"secrets"} />
</TabList>
</Box>
<TabPanel
value={tab}
sx={{
overflowY: "scroll",
}}
>
<Paper elevation={1} variant={"outlined"}>
<Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: 3,
}}
>
<Typography variant="h6" sx={{ fontWeight: "bold" }}>
Secrets
</Typography>
<Button
variant="contained"
color="primary"
onClick={() => {
setSecretDetailsModal({
open: true,
mode: "add",
});
}}
>
Add secret key
</Button>
</Box>
{secretNames.secretNames?.map((secretName) => (
<Box
key={secretName}
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: 3,
borderTop: 1,
borderColor: "divider",
}}
>
<Typography variant="body2" color="text.secondary">
{secretName}
</Typography>
<Box>
<Tooltip title={"Edit"}>
<Button
variant="outlined"
color="primary"
style={{
minWidth: "40px",
paddingLeft: 0,
paddingRight: 0,
marginRight: "8px",
}}
onClick={() => {
setSecretDetailsModal({
open: true,
mode: "edit",
secretName,
});
}}
>
<EditIcon color={"secondary"} />
</Button>
</Tooltip>
<Tooltip title={"Delete"}>
<Button
variant="outlined"
color="primary"
style={{
minWidth: "40px",
paddingLeft: 0,
paddingRight: 0,
}}
onClick={() => {
console.log("setting", {
open: true,
mode: "delete",
secretName,
});
setSecretDetailsModal({
open: true,
mode: "delete",
secretName,
});
}}
>
<DeleteOutlineIcon color={"secondary"} />
</Button>
</Tooltip>
</Box>
</Box>
))}
</Paper>
</TabPanel>
</TabContext>
</>
}
/>
<SecretDetailsModal
open={secretDetailsModal.open}
mode={secretDetailsModal.mode}
error={secretDetailsModal.error}
loading={secretDetailsModal.loading}
secretName={secretDetailsModal.secretName}
handleClose={() => {
setSecretDetailsModal({ ...secretDetailsModal, open: false });
}}
handleAdd={async (newSecretName, secretValue) => {
setSecretDetailsModal({
...secretDetailsModal,
loading: true,
});
try {
await rowyRun({
route: runRoutes.addSecret,
body: {
name: newSecretName,
value: secretValue,
},
});
setSecretDetailsModal({
...secretDetailsModal,
open: false,
loading: false,
});
// update secret name causes an unknown modal-related bug, to be fixed
// updateSecretNames?.();
} catch (error: any) {
console.error(error);
setSecretDetailsModal({
...secretDetailsModal,
error: error.message,
});
}
}}
handleEdit={async (secretName, secretValue) => {
setSecretDetailsModal({
...secretDetailsModal,
loading: true,
});
try {
await rowyRun({
route: runRoutes.editSecret,
body: {
name: secretName,
value: secretValue,
},
});
setSecretDetailsModal({
...secretDetailsModal,
open: false,
loading: false,
});
// update secret name causes an unknown modal-related bug, to be fixed
// updateSecretNames?.();
} catch (error: any) {
console.error(error);
setSecretDetailsModal({
...secretDetailsModal,
error: error.message,
});
}
}}
handleDelete={async (secretName) => {
setSecretDetailsModal({
...secretDetailsModal,
loading: true,
});
try {
await rowyRun({
route: runRoutes.deleteSecret,
body: {
name: secretName,
},
});
console.log("Setting", {
...secretDetailsModal,
open: false,
loading: false,
});
setSecretDetailsModal({
...secretDetailsModal,
open: false,
loading: false,
});
// update secret name causes an unknown modal-related bug, to be fixed
// updateSecretNames?.();
} catch (error: any) {
console.error(error);
setSecretDetailsModal({
...secretDetailsModal,
error: error.message,
});
}
}}
/>
</>
);
}

View File

@@ -0,0 +1,157 @@
import React, { useState } from "react";
import Modal from "@src/components/Modal";
import { Box, Button, TextField, Typography } from "@mui/material";
import { capitalize } from "lodash-es";
import LoadingButton from "@mui/lab/LoadingButton";
export interface ISecretDetailsModalProps {
open: boolean;
loading?: boolean;
mode?: "add" | "edit" | "delete";
error?: string;
secretName?: string;
handleClose: () => void;
handleAdd: (secretName: string, secretValue: string) => void;
handleEdit: (secretName: string, secretValue: string) => void;
handleDelete: (secretName: string) => void;
}
export default function SecretDetailsModal({
open,
loading,
mode,
error,
secretName,
handleClose,
handleAdd,
handleEdit,
handleDelete,
}: ISecretDetailsModalProps) {
const [newSecretName, setNewSecretName] = useState("");
const [secretValue, setSecretValue] = useState("");
return (
<Modal
onClose={handleClose}
open={open}
maxWidth="xs"
fullWidth
title={`${capitalize(mode)} secret key`}
sx={{
".MuiDialogContent-root": {
display: "flex",
flexDirection: "column",
height: "100%",
},
}}
children={
<Box
sx={{
marginTop: 1,
}}
>
{mode === "add" && (
<Box
sx={{
display: "flex",
flexDirection: "column",
justifyContent: "flex-start",
gap: 1,
}}
>
<Typography variant="subtitle2">Secret Name</Typography>
<TextField
fullWidth
variant="outlined"
value={newSecretName}
onChange={(e) => setNewSecretName(e.target.value)}
/>
<Typography
variant={"body2"}
color={"text.secondary"}
fontSize={"12px"}
>
This will create a secret key on Google Cloud.
</Typography>
</Box>
)}
{mode === "delete" ? (
<Typography>
Are you sure you want to delete this secret key {secretName}?
</Typography>
) : (
<Box
sx={{
display: "flex",
flexDirection: "column",
justifyContent: "flex-start",
gap: 1,
marginTop: 2,
}}
>
<Typography variant="subtitle2">Secret Value</Typography>
<TextField
fullWidth
variant="outlined"
value={secretValue}
onChange={(e) => setSecretValue(e.target.value)}
/>
<Typography
variant={"body2"}
color={"text.secondary"}
fontSize={"12px"}
>
Paste your secret key here.
</Typography>
</Box>
)}
{error?.length && (
<Typography color={"error"} marginTop={2}>
{error}
</Typography>
)}
<Box
sx={{
display: "flex",
justifyContent: "flex-start",
gap: 1,
marginTop: 4,
}}
>
<Button
variant="outlined"
onClick={handleClose}
sx={{ textTransform: "none" }}
>
Cancel
</Button>
<LoadingButton
variant="contained"
color={"primary"}
loading={loading}
disabled={
(mode === "add" && (!newSecretName || !secretValue)) ||
(mode === "edit" && !secretValue)
}
onClick={() => {
switch (mode) {
case "add":
handleAdd(newSecretName, secretValue);
break;
case "edit":
handleEdit(secretName ?? "", secretValue);
break;
case "delete":
handleDelete(secretName ?? "");
break;
}
}}
>
{mode === "delete" ? "Delete" : "Save"}
</LoadingButton>
</Box>
</Box>
}
/>
);
}

View File

@@ -0,0 +1,2 @@
export * from "./ProjectSettingsDialog";
export { default } from "./ProjectSettingsDialog";

View File

@@ -0,0 +1,103 @@
import { merge } from "lodash-es";
import { IUserSettingsChildProps } from "@src/pages/Settings/UserSettingsPage";
import {
FormControl,
FormControlLabel,
Divider,
Checkbox,
Collapse,
} from "@mui/material";
export default function TableSettings({
settings,
updateSettings,
}: IUserSettingsChildProps) {
return (
<>
<FormControl sx={{ my: -10 / 10, display: "flex" }}>
<FormControlLabel
control={
<Checkbox
checked={Boolean(
settings.defaultTableSettings?.saveSortsPopupDisabled
)}
onChange={(e) => {
updateSettings({
defaultTableSettings: merge(settings.defaultTableSettings, {
saveSortsPopupDisabled: e.target.checked,
}),
});
}}
/>
}
label="Disable popup - to save sorting changes to the team"
style={{ marginLeft: -11, marginBottom: 13 }}
/>
<Collapse in={settings.defaultTableSettings?.saveSortsPopupDisabled}>
<FormControlLabel
control={
<Checkbox
checked={Boolean(
settings.defaultTableSettings?.automaticallyApplySorts
)}
onChange={(e) => {
updateSettings({
defaultTableSettings: merge(settings.defaultTableSettings, {
automaticallyApplySorts: e.target.checked,
}),
});
}}
/>
}
label="Automatically apply sorting changes to all users"
style={{ marginLeft: 20, marginBottom: 10, marginTop: -13 }}
/>
</Collapse>
<Divider />
<FormControlLabel
control={
<Checkbox
checked={Boolean(
settings.defaultTableSettings?.saveColumnSizingPopupDisabled
)}
onChange={(e) => {
updateSettings({
defaultTableSettings: merge(settings.defaultTableSettings, {
saveColumnSizingPopupDisabled: e.target.checked,
}),
});
}}
/>
}
label="Disable popup - to save column width changes to the team"
style={{ marginLeft: -11, marginTop: 13 }}
/>
<Collapse
in={settings.defaultTableSettings?.saveColumnSizingPopupDisabled}
>
<FormControlLabel
control={
<Checkbox
checked={Boolean(
settings.defaultTableSettings?.automaticallyApplyColumnSizing
)}
onChange={(e) => {
updateSettings({
defaultTableSettings: merge(settings.defaultTableSettings, {
automaticallyApplyColumnSizing: e.target.checked,
}),
});
}}
/>
}
label="Automatically apply column width changes to all users"
style={{ marginLeft: 20 }}
/>
</Collapse>
</FormControl>
</>
);
}

View File

@@ -1,5 +1,5 @@
import { useCallback, useState } from "react";
import { useAtom } from "jotai";
import { useAtom, useAtomValue } from "jotai";
import { SnackbarKey, useSnackbar } from "notistack";
import LoadingButton from "@mui/lab/LoadingButton";
@@ -11,17 +11,25 @@ import {
tableScope,
updateTableSchemaAtom,
} from "@src/atoms/tableScope";
import { projectScope, updateUserSettingsAtom } from "@src/atoms/projectScope";
import {
defaultTableSettingsAtom,
projectScope,
updateUserSettingsAtom,
} from "@src/atoms/projectScope";
import { TableSort } from "@src/types/table";
function useSaveTableSorts(canEditColumns: boolean) {
const [updateTableSchema] = useAtom(updateTableSchemaAtom, tableScope);
const [updateUserSettings] = useAtom(updateUserSettingsAtom, projectScope);
const [tableId] = useAtom(tableIdAtom, tableScope);
const defaultTableSettings = useAtomValue(
defaultTableSettingsAtom,
projectScope
);
const { enqueueSnackbar, closeSnackbar } = useSnackbar();
const [snackbarId, setSnackbarId] = useState<SnackbarKey | null>(null);
// Offer to save when table sorts changes
// Offer to save when table sorts changes, depending on user settings
const trigger = useCallback(
(sorts: TableSort[]) => {
if (!updateTableSchema) throw new Error("Cannot update table schema");
@@ -33,6 +41,15 @@ function useSaveTableSorts(canEditColumns: boolean) {
});
}
if (!canEditColumns) return;
// If the user has disabled the popup, return early
if (defaultTableSettings?.saveSortsPopupDisabled) {
// If the user has `automaticallyApplySorts` set to true, apply the sorting before returning
if (defaultTableSettings?.automaticallyApplySorts) {
const updateTable = async () => await updateTableSchema({ sorts });
updateTable();
}
return;
}
if (snackbarId) {
closeSnackbar(snackbarId);
}
@@ -43,7 +60,7 @@ function useSaveTableSorts(canEditColumns: boolean) {
updateTable={async () => await updateTableSchema({ sorts })}
/>
),
anchorOrigin: { horizontal: "center", vertical: "top" },
anchorOrigin: { horizontal: "left", vertical: "bottom" },
})
);
@@ -57,6 +74,7 @@ function useSaveTableSorts(canEditColumns: boolean) {
tableId,
closeSnackbar,
updateTableSchema,
defaultTableSettings,
]
);

View File

@@ -285,7 +285,7 @@ export default function Table({
const { handler: hotKeysHandler } = useHotKeys([
["mod+C", handleCopy],
["mod+X", handleCut],
["mod+V", handlePaste],
["mod+V", (e) => handlePaste], // So the event isn't passed to the handler
]);
// Handle prompt to save local column sizes if user `canEditColumns`
@@ -324,6 +324,14 @@ export default function Table({
fetchMoreOnBottomReached(containerRef.current);
}, [fetchMoreOnBottomReached, tableNextPage.loading, containerRef]);
useEffect(() => {
document.addEventListener("paste", handlePaste);
return () => {
document.removeEventListener("paste", handlePaste);
};
}, [handlePaste]);
// apply user default sort on first render
const [applySort, setApplySort] = useState(true);
useEffect(() => {

View File

@@ -12,7 +12,6 @@ export default function useHotKeys(actions: HotKeysAction[]) {
const event_ = "nativeEvent" in event ? event.nativeEvent : event;
actions.forEach(([hotkey, handler_]) => {
if (getHotkeyMatcher(hotkey)(event_)) {
event.preventDefault();
handler_(event_);
}
});

View File

@@ -161,71 +161,96 @@ export function useMenuAction(
handleClose,
]);
const handlePaste = useCallback(async () => {
try {
if (!selectedCell || !selectedCol) return;
let text;
const handlePaste = useCallback(
async (e?: ClipboardEvent) => {
try {
text = await navigator.clipboard.readText();
} catch (e) {
enqueueSnackbar(`Read clipboard permission denied.`, {
variant: "error",
});
return;
}
const cellDataType = getFieldProp("dataType", getFieldType(selectedCol));
let parsed;
switch (cellDataType) {
case "number":
parsed = Number(text);
if (isNaN(parsed)) throw new Error(`${text} is not a number`);
break;
case "string":
parsed = text;
break;
case "reference":
try {
parsed = doc(firebaseDb, text);
} catch (e: any) {
enqueueSnackbar(`Invalid reference.`, { variant: "error" });
if (!selectedCell || !selectedCol) return;
let text: string;
// Firefox doesn't allow for reading clipboard data, hence the workaround
if (navigator.userAgent.includes("Firefox")) {
if (!e || !e.clipboardData) {
enqueueSnackbar(
`If you're on Firefox, please use the hotkey instead (Ctrl + V / Cmd + V).`,
{
variant: "info",
autoHideDuration: 7000,
}
);
enqueueSnackbar(`Cannot read clipboard data.`, {
variant: "error",
});
return;
}
break;
default:
parsed = JSON.parse(text);
break;
}
text = e.clipboardData.getData("text/plain") || "";
} else {
try {
text = await navigator.clipboard.readText();
} catch (e) {
enqueueSnackbar(`Read clipboard permission denied.`, {
variant: "error",
});
return;
}
}
const cellDataType = getFieldProp(
"dataType",
getFieldType(selectedCol)
);
let parsed;
switch (cellDataType) {
case "number":
parsed = Number(text);
if (isNaN(parsed)) throw new Error(`${text} is not a number`);
break;
case "string":
parsed = text;
break;
case "reference":
try {
parsed = doc(firebaseDb, text);
} catch (e: any) {
enqueueSnackbar(`Invalid reference.`, { variant: "error" });
}
break;
default:
parsed = JSON.parse(text);
break;
}
if (selectedCol.type === FieldType.slider) {
if (parsed < selectedCol.config?.min) parsed = selectedCol.config?.min;
else if (parsed > selectedCol.config?.max)
parsed = selectedCol.config?.max;
}
if (selectedCol.type === FieldType.slider) {
if (parsed < selectedCol.config?.min)
parsed = selectedCol.config?.min;
else if (parsed > selectedCol.config?.max)
parsed = selectedCol.config?.max;
}
if (selectedCol.type === FieldType.rating) {
if (parsed < 0) parsed = 0;
if (parsed > (selectedCol.config?.max || 5))
parsed = selectedCol.config?.max || 5;
}
if (selectedCol.type === FieldType.rating) {
if (parsed < 0) parsed = 0;
if (parsed > (selectedCol.config?.max || 5))
parsed = selectedCol.config?.max || 5;
}
if (selectedCol.type === FieldType.percentage) {
parsed = parsed / 100;
if (selectedCol.type === FieldType.percentage) {
parsed = parsed / 100;
}
updateField({
path: selectedCell.path,
fieldName: selectedCol.fieldName,
value: parsed,
arrayTableData: {
index: selectedCell.arrayIndex,
},
});
} catch (error) {
enqueueSnackbar(
`${selectedCol?.type} field does not support the data type being pasted`,
{ variant: "error" }
);
}
updateField({
path: selectedCell.path,
fieldName: selectedCol.fieldName,
value: parsed,
arrayTableData: {
index: selectedCell.arrayIndex,
},
});
} catch (error) {
enqueueSnackbar(
`${selectedCol?.type} field does not support the data type being pasted`,
{ variant: "error" }
);
}
if (handleClose) handleClose();
}, [selectedCell, selectedCol, updateField, enqueueSnackbar, handleClose]);
if (handleClose) handleClose();
},
[selectedCell, selectedCol, updateField, enqueueSnackbar, handleClose]
);
useEffect(() => {
if (!selectedCell) return setCellValue("");
@@ -276,9 +301,9 @@ export function useMenuAction(
};
}
const fieldType = getFieldType(selectedCol);
return function () {
return function (e?: ClipboardEvent) {
if (SUPPORTED_TYPES_PASTE.has(fieldType)) {
return func();
return func(e);
} else {
enqueueSnackbar(
`${fieldType} field does not support paste functionality`,

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from "react";
import { useSetAtom } from "jotai";
import { useAtomValue, useSetAtom } from "jotai";
import { useSnackbar } from "notistack";
import { useDebounce } from "use-debounce";
import { isEqual, isEmpty } from "lodash-es";
@@ -13,6 +13,10 @@ import {
updateColumnAtom,
IUpdateColumnOptions,
} from "@src/atoms/tableScope";
import {
defaultTableSettingsAtom,
projectScope,
} from "@src/atoms/projectScope";
import { DEBOUNCE_DELAY } from "./Table";
import { ColumnSizingState } from "@tanstack/react-table";
@@ -26,14 +30,31 @@ export function useSaveColumnSizing(
) {
const { enqueueSnackbar, closeSnackbar } = useSnackbar();
const updateColumn = useSetAtom(updateColumnAtom, tableScope);
const defaultTableSettings = useAtomValue(
defaultTableSettingsAtom,
projectScope
);
// Debounce for saving to schema
const [debouncedColumnSizing] = useDebounce(columnSizing, DEBOUNCE_DELAY, {
equalityFn: isEqual,
});
// Offer to save when column sizing changes
// Offer to save when column sizing changes, depending on user settings
useEffect(() => {
if (!canEditColumns || isEmpty(debouncedColumnSizing)) return;
// If the user has disabled the popup, return early
if (defaultTableSettings?.saveColumnSizingPopupDisabled) {
// If the user has `automaticallyApplyColumnSizing` set to true, apply the column width before returning
if (defaultTableSettings?.automaticallyApplyColumnSizing) {
const updateTable = async () => {
for (const [key, value] of Object.entries(debouncedColumnSizing)) {
await updateColumn({ key, config: { width: value } });
}
};
updateTable();
}
return;
}
const snackbarId = enqueueSnackbar("Save column sizes for all users?", {
action: (
@@ -42,7 +63,7 @@ export function useSaveColumnSizing(
updateColumn={updateColumn}
/>
),
anchorOrigin: { horizontal: "center", vertical: "top" },
anchorOrigin: { horizontal: "left", vertical: "bottom" },
});
return () => closeSnackbar(snackbarId);
@@ -52,6 +73,7 @@ export function useSaveColumnSizing(
enqueueSnackbar,
closeSnackbar,
updateColumn,
defaultTableSettings,
]);
return null;

View File

@@ -8,6 +8,7 @@ import {
IconButton,
Stack,
TextField,
Tooltip,
Typography,
useTheme,
} from "@mui/material";
@@ -98,15 +99,17 @@ export default function Details() {
Description
</Typography>
{isAdmin && (
<IconButton
aria-label="Edit description"
onClick={() => {
setEditDescription(!editDescription);
}}
sx={{ top: 4 }}
>
{editDescription ? <EditOffIcon /> : <EditIcon />}
</IconButton>
<Tooltip title="Edit">
<IconButton
aria-label="Edit description"
onClick={() => {
setEditDescription(!editDescription);
}}
sx={{ top: 4 }}
>
{editDescription ? <EditOffIcon /> : <EditIcon />}
</IconButton>
</Tooltip>
)}
</Stack>
{editDescription ? (
@@ -145,15 +148,17 @@ export default function Details() {
Details
</Typography>
{isAdmin && (
<IconButton
aria-label="Edit details"
onClick={() => {
setEditDetails(!editDetails);
}}
sx={{ top: 4 }}
>
{editDetails ? <EditOffIcon /> : <EditIcon />}
</IconButton>
<Tooltip title="Edit">
<IconButton
aria-label="Edit details"
onClick={() => {
setEditDetails(!editDetails);
}}
sx={{ top: 4 }}
>
{editDetails ? <EditOffIcon /> : <EditIcon />}
</IconButton>
</Tooltip>
)}
</Stack>
<Box

View File

@@ -10,6 +10,7 @@ import {
IconButton,
Stack,
styled,
Tooltip,
Typography,
} from "@mui/material";
import CloseIcon from "@mui/icons-material/Close";
@@ -111,12 +112,14 @@ export default function SideDrawer() {
Information
</Typography>
</Stack>
<IconButton
onClick={() => setSideDrawer(RESET)}
aria-label="Close"
>
<CloseIcon />
</IconButton>
<Tooltip title="Close">
<IconButton
onClick={() => setSideDrawer(RESET)}
aria-label="Close"
>
<CloseIcon />
</IconButton>
</Tooltip>
</Stack>
<Box
sx={{

View File

@@ -1,4 +1,5 @@
export const extensionTypes = [
"buildshipAuthenticatedTrigger",
"task",
"docSync",
"historySnapshot",
@@ -15,6 +16,7 @@ export const extensionTypes = [
export type ExtensionType = typeof extensionTypes[number];
export const extensionNames: Record<ExtensionType, string> = {
buildshipAuthenticatedTrigger: "BuildShip Authenticated Trigger",
task: "Task",
docSync: "Doc Sync",
historySnapshot: "History Snapshot",
@@ -61,6 +63,30 @@ export interface IRuntimeOptions {
export const triggerTypes: ExtensionTrigger[] = ["create", "update", "delete"];
const extensionBodyTemplate = {
buildshipAuthenticatedTrigger: `const extensionBody: BuildshipAuthenticatedTriggerBody = async({row, db, change, ref, logging}) => {
logging.log("extensionBody started")
// Put your endpoint URL and request body below.
// It will trigger your endpoint with the request body.
return ({
buildshipConfig: {
projectId: "",
workflowId: ""
},
body: JSON.stringify({
row,
ref: {
id: ref.id,
path: ref.path
},
change: {
before: change.before.get(),
after: change.after.get(),
},
// Add your own payload here
})
})
}`,
task: `const extensionBody: TaskBody = async({row, db, change, ref, logging}) => {
logging.log("extensionBody started")

View File

@@ -323,7 +323,9 @@ export default function ImportCsvWizard({ onClose }: ITableModalProps) {
disableNext:
config.pairs.length === 0 ||
!validRows ||
(config.documentId === "column" && !config.documentIdCsvKey),
(config.documentId === "column" && !config.documentIdCsvKey) ||
config.pairs.some((pair) => !pair.columnKey) ||
config.newColumns.some((col) => !col.key),
},
config.newColumns.length > 0 && {
title: "Set column types",

View File

@@ -257,8 +257,16 @@ export default function Step1Columns({
const isNewColumn = !!find(config.newColumns, { key: columnKey });
return (
<Grid container key={field} component="li" wrap="nowrap">
<Grid item xs>
<Grid
container
key={field}
component="li"
wrap="nowrap"
sx={{
marginTop: "36px !important",
}}
>
<Grid container item xs alignItems={"center"}>
<FormControlLabel
key={field}
control={
@@ -291,88 +299,145 @@ export default function Step1Columns({
<ArrowIcon color="disabled" sx={{ color: "secondary.main" }} />
</Grid>
<Grid item xs>
<Grid item container spacing={4} xs alignItems={"center"}>
{selected && (
<ColumnSelect
multiple={false}
value={columnKey}
onChange={handleChange(field) as any}
TextFieldProps={{
hiddenLabel: true,
SelectProps: {
renderValue: () => {
if (!columnKey) return "Select or add column";
else
return (
<Stack
direction="row"
gap={1}
alignItems="center"
>
<Box sx={{ width: 24, height: 24 }}>
{!isNewColumn ? (
getFieldProp("icon", matchingColumn?.type)
) : (
<TableColumnIcon color="disabled" />
)}
</Box>
{matchingColumn?.name}
{isNewColumn && (
<Chip
label="New"
color="primary"
size="small"
variant="outlined"
style={{
marginLeft: "auto",
pointerEvents: "none",
height: 24,
fontWeight: "normal",
}}
/>
)}
</Stack>
);
},
sx: [
{
backgroundColor: "background.default",
border: (theme) =>
`1px solid ${theme.palette.divider}`,
borderRadius: 0,
boxShadow: "none",
"& .MuiSelect-select": {
boxSizing: "border-box",
height: COLUMN_HEADER_HEIGHT - 2,
typography: "caption",
fontWeight: "medium",
lineHeight: "28px",
<>
<Grid item xs>
<ColumnSelect
multiple={false}
value={columnKey}
onChange={handleChange(field) as any}
TextFieldProps={{
hiddenLabel: true,
SelectProps: {
renderValue: () => {
if (!columnKey) return "Select or add column";
else
return (
<Stack
direction="row"
gap={1}
alignItems="center"
>
<Box sx={{ width: 24, height: 24 }}>
{!isNewColumn ? (
getFieldProp(
"icon",
matchingColumn?.type
)
) : (
<TableColumnIcon color="disabled" />
)}
</Box>
{matchingColumn?.name}
{isNewColumn && (
<Chip
label="New"
color="primary"
size="small"
variant="outlined"
style={{
marginLeft: "auto",
pointerEvents: "none",
height: 24,
fontWeight: "normal",
}}
/>
)}
</Stack>
);
},
sx: [
{
backgroundColor: "background.default",
border: (theme) =>
`1px solid ${theme.palette.divider}`,
borderRadius: 0,
boxShadow: "none",
"& .MuiSelect-select": {
boxSizing: "border-box",
height: COLUMN_HEADER_HEIGHT - 2,
typography: "caption",
fontWeight: "medium",
lineHeight: "28px",
},
color: "text.secondary",
"&:hover": {
backgroundColor: "background.default",
color: "text.primary",
boxShadow: "none",
},
color: "text.secondary",
"&:hover": {
backgroundColor: "background.default",
color: "text.primary",
boxShadow: "none",
},
"&::before": { content: "none" },
"&::after": { pointerEvents: "none" },
"&::before": { content: "none" },
"&::after": { pointerEvents: "none" },
},
!columnKey && { color: "text.disabled" },
],
},
!columnKey && { color: "text.disabled" },
],
},
sx: { "& .MuiInputLabel-root": { display: "none" } },
}}
clearable={false}
displayEmpty
freeText
AddButtonProps={{ children: "Create column…" }}
AddDialogProps={{
title: "Create column",
textFieldLabel: "Column name",
}}
/>
sx: { "& .MuiInputLabel-root": { display: "none" } },
}}
clearable={false}
displayEmpty
freeText
AddButtonProps={{ children: "Create column…" }}
AddDialogProps={{
title: "Create column",
textFieldLabel: "Column name",
}}
/>
</Grid>
<Grid item>
<TextField
label="Field key"
value={
config.pairs.find(
(pair) => pair.columnKey === columnKey
)?.columnKey ??
config.newColumns.find(
(pair) => pair.key === columnKey
)?.key
}
onChange={(e) => {
const newKey = e.target.value;
const newPairs = config.pairs.map((pair) => {
if (pair.columnKey === columnKey) {
return { ...pair, columnKey: newKey };
} else {
return pair;
}
});
const newColumns = config.newColumns.map((column) => {
if (column.key === columnKey) {
return {
...column,
key: newKey,
fieldName: newKey,
};
} else {
return column;
}
});
setConfig((config) => ({
...config,
pairs: newPairs,
newColumns,
}));
}}
sx={{
"& .MuiInputLabel-root": {
position: "absolute",
transform: "translateY(-100%)",
},
"& .MuiInputBase-root": {
height: 40,
},
}}
/>
</Grid>
</>
)}
</Grid>
</Grid>

View File

@@ -7,6 +7,7 @@ import {
projectScope,
secretNamesAtom,
updateSecretNamesAtom,
projectSettingsDialogAtom,
} from "@src/atoms/projectScope";
import InputLabel from "@mui/material/InputLabel";
import MenuItem from "@mui/material/MenuItem";
@@ -56,6 +57,10 @@ export const webhookStripe = {
Auth: (webhookObject: IWebhook, setWebhookObject: (w: IWebhook) => void) => {
const [secretNames] = useAtom(secretNamesAtom, projectScope);
const [updateSecretNames] = useAtom(updateSecretNamesAtom, projectScope);
const [{ open, tab }, setProjectSettingsDialog] = useAtom(
projectSettingsDialogAtom,
projectScope
);
return (
<>
@@ -118,8 +123,9 @@ export const webhookStripe = {
})}
<MenuItem
onClick={() => {
const secretManagerLink = `https://console.cloud.google.com/security/secret-manager/create`;
window?.open?.(secretManagerLink, "_blank")?.focus();
setProjectSettingsDialog({
open: true,
});
}}
>
Add a key in Secret Manager

View File

@@ -3,7 +3,7 @@ import { Control } from "react-hook-form";
import { useSetAtom } from "jotai";
import type { UseFormReturn, FieldValues } from "react-hook-form";
import { IconButton, Menu, MenuItem } from "@mui/material";
import { IconButton, Menu, MenuItem, Tooltip } from "@mui/material";
import { Export as ExportIcon, Import as ImportIcon } from "@src/assets/icons";
import ImportSettings from "./ImportSettings";
@@ -50,16 +50,18 @@ export default function ActionsMenu({
return (
<>
<IconButton
aria-label="Actions…"
id="table-settings-actions-button"
aria-controls="table-settings-actions-menu"
aria-haspopup="true"
aria-expanded={open ? "true" : undefined}
onClick={handleOpen}
>
{mode === "create" ? <ImportIcon /> : <ExportIcon />}
</IconButton>
<Tooltip title="Actions menu">
<IconButton
aria-label="Actions…"
id="table-settings-actions-button"
aria-controls="table-settings-actions-menu"
aria-haspopup="true"
aria-expanded={open ? "true" : undefined}
onClick={handleOpen}
>
{mode === "create" ? <ImportIcon /> : <ExportIcon />}
</IconButton>
</Tooltip>
<Menu
id="table-settings-actions-menu"

View File

@@ -3,7 +3,13 @@ import { useAtom, useSetAtom } from "jotai";
import { useNavigate } from "react-router-dom";
import { useSnackbar } from "notistack";
import { IconButton, Menu, MenuItem, DialogContentText } from "@mui/material";
import {
IconButton,
Menu,
MenuItem,
DialogContentText,
Tooltip,
} from "@mui/material";
import DeleteIcon from "@mui/icons-material/DeleteOutlined";
import {
@@ -55,16 +61,18 @@ export default function DeleteMenu({ clearDialog, data }: IDeleteMenuProps) {
return (
<>
<IconButton
aria-label="Delete table…"
id="table-settings-delete-button"
aria-controls="table-settings-delete-menu"
aria-haspopup="true"
aria-expanded={open ? "true" : undefined}
onClick={(e) => setAnchorEl(e.currentTarget)}
>
<DeleteIcon />
</IconButton>
<Tooltip title="Delete menu">
<IconButton
aria-label="Delete table…"
id="table-settings-delete-button"
aria-controls="table-settings-delete-menu"
aria-haspopup="true"
aria-expanded={open ? "true" : undefined}
onClick={(e) => setAnchorEl(e.currentTarget)}
>
<DeleteIcon />
</IconButton>
</Tooltip>
<Menu
id="table-settings-delete-menu"

View File

@@ -27,7 +27,7 @@ export default function TableName({ watchedField, ...props }: ITableNameProps) {
onChange(startCase(watchedValue));
} else if (typeof value === "string") {
// otherwise if table name is valid, set watched value to table name
onChange(value.trim());
onChange(startCase(value.trim()));
}
}
}, [watchedValue, disabled, onChange, value]);

View File

@@ -411,7 +411,7 @@ export default function TableSettingsDialog() {
},
/*
* TODO: Figure out where to store this settings
{
id: "function",
title: "Cloud Function",

View File

@@ -213,7 +213,7 @@ export const tableSettings = (
name: "name",
label: "Table name",
required: true,
watchedField: "collection",
watchedField: "name",
assistiveText: "User-facing name for this table",
autoFocus: true,
gridCols: { xs: 12, sm: 6 },

View File

@@ -37,6 +37,9 @@ export const runRoutes = {
setFirestoreRules: { path: "/setFirestoreRules", method: "POST" } as RunRoute,
listCollections: { path: "/listCollections", method: "GET" } as RunRoute,
listSecrets: { path: "/listSecrets", method: "GET" } as RunRoute,
addSecret: { path: "/addSecret", method: "POST" } as RunRoute,
editSecret: { path: "/editSecret", method: "POST" } as RunRoute,
deleteSecret: { path: "/deleteSecret", method: "POST" } as RunRoute,
serviceAccountAccess: {
path: "/serviceAccountAccess",
method: "GET",

View File

@@ -9,6 +9,7 @@ import SettingsSkeleton from "@src/components/Settings/SettingsSkeleton";
import SettingsSection from "@src/components/Settings/SettingsSection";
import Account from "@src/components/Settings/UserSettings/Account";
import Theme from "@src/components/Settings/UserSettings/Theme";
import TableSettings from "@src/components/Settings/UserSettings/TableSettings";
import Personalization from "@src/components/Settings/UserSettings/Personalization";
import {
@@ -57,6 +58,7 @@ export default function UserSettingsPage() {
const sections = [
{ title: "Account", Component: Account, props: childProps },
{ title: "Theme", Component: Theme, props: childProps },
{ title: "Table Settings", Component: TableSettings, props: childProps },
{ title: "Personalization", Component: Personalization, props: childProps },
];

View File

@@ -137,39 +137,45 @@ export default function TablesPage() {
const getActions = (table: TableSettings) => (
<>
{userRoles.includes("ADMIN") && (
<IconButton
aria-label="Edit table"
onClick={() =>
openTableSettingsDialog({ mode: "update", data: table })
}
size={view === "list" ? "large" : undefined}
>
<EditIcon />
</IconButton>
<Tooltip title="Edit Table">
<IconButton
aria-label="Edit table"
onClick={() =>
openTableSettingsDialog({ mode: "update", data: table })
}
size={view === "list" ? "large" : undefined}
>
<EditIcon />
</IconButton>
</Tooltip>
)}
<Checkbox
onChange={handleFavorite(table.id)}
checked={favorites.includes(table.id)}
icon={<FavoriteBorderIcon />}
checkedIcon={
<Zoom in>
<FavoriteIcon />
</Zoom>
}
name={`favorite-${table.id}`}
inputProps={{ "aria-label": "Favorite" }}
sx={view === "list" ? { p: 1.5 } : undefined}
color="secondary"
/>
<IconButton
aria-label="Table information"
size={view === "list" ? "large" : undefined}
component={Link}
to={`${getLink(table)}#sideDrawer="table-information"`}
style={{ marginLeft: 0 }}
>
<InfoIcon />
</IconButton>
<Tooltip title="Favorite">
<Checkbox
onChange={handleFavorite(table.id)}
checked={favorites.includes(table.id)}
icon={<FavoriteBorderIcon />}
checkedIcon={
<Zoom in>
<FavoriteIcon />
</Zoom>
}
name={`favorite-${table.id}`}
inputProps={{ "aria-label": "Favorite" }}
sx={view === "list" ? { p: 1.5 } : undefined}
color="secondary"
/>
</Tooltip>
<Tooltip title="Table information">
<IconButton
aria-label="Table information"
size={view === "list" ? "large" : undefined}
component={Link}
to={`${getLink(table)}#sideDrawer="table-information"`}
style={{ marginLeft: 0 }}
>
<InfoIcon />
</IconButton>
</Tooltip>
</>
);

View File

@@ -50,7 +50,14 @@ export type UserSettings = Partial<{
theme: Record<"base" | "light" | "dark", ThemeOptions>;
favoriteTables: string[];
/** Stores user overrides */
/** Stores default user settings for all tables */
defaultTableSettings: Partial<{
saveSortsPopupDisabled: boolean;
automaticallyApplySorts: boolean;
saveColumnSizingPopupDisabled: boolean;
automaticallyApplyColumnSizing: boolean;
}>;
/** Stores table-specific user overrides */
tables: Record<
string,
Partial<{

View File

@@ -8739,9 +8739,9 @@ tinybench@^2.5.0:
integrity sha512-kRwSG8Zx4tjF9ZiyH4bhaebu+EDz1BOx9hOigYHlUW4xxI/wKIUQUqo018UlU4ar6ATPBsaMrdbKZ+tmPdohFA==
tinymce@^5, tinymce@^5.5.1:
version "5.10.7"
resolved "https://registry.yarnpkg.com/tinymce/-/tinymce-5.10.7.tgz#d89d446f1962f2a1df6b2b70018ce475ec7ffb80"
integrity sha512-9UUjaO0R7FxcFo0oxnd1lMs7H+D0Eh+dDVo5hKbVe1a+VB0nit97vOqlinj+YwgoBDt6/DSCUoWqAYlLI8BLYA==
version "5.10.8"
resolved "https://registry.yarnpkg.com/tinymce/-/tinymce-5.10.8.tgz#c85758fa3cca2cbb4b14dd037a0b315b6462c50e"
integrity sha512-iyoo3VGMAJhLMDdblAefKvYgBRk9kQi58GTwAmoieqsyggGsKZWlQl/YY6nTILFHUCA1FhYu0HdmM5YYjs17UQ==
tinypool@^0.5.0:
version "0.5.0"