Generalize WIP

This commit is contained in:
Darren Clarke 2024-04-26 14:31:33 +02:00
parent a3e8b89128
commit cb7a3a08dc
31 changed files with 657 additions and 106 deletions

View file

@ -0,0 +1,56 @@
"use client";
import { FC } from "react";
import { useFormState } from "react-dom";
import { Grid } from "@mui/material";
import { TextField } from "ui";
import { Create as InternalCreate } from "@/app/_components/Create";
import { generateCreateAction } from "@/app/_lib/actions";
import { serviceConfig } from "@/app/_lib/config";
type CreateProps = {
service: string;
};
export const Create: FC<CreateProps> = ({ service }) => {
const {
[service]: { entity, table, displayName, createFields: fields },
} = serviceConfig;
const createFieldNames = fields.map((val) => val.name);
const createAction = generateCreateAction({ entity, table, fields });
const initialState = {
message: null,
errors: {},
values: createFieldNames.reduce((acc, key) => {
// @ts-expect-error
acc[key] = fields[key].defaultValue;
return acc;
}, {}),
};
console.log("initialState", initialState);
const [formState, formAction] = useFormState(createAction, initialState);
return (
<InternalCreate
title={`Create ${displayName}`}
entity={entity}
formAction={formAction}
formState={formState}
>
<Grid container direction="row" rowSpacing={3} columnSpacing={2}>
{fields.map((field) => (
<Grid item xs={field.size ?? 6}>
<TextField
key={field.name}
name={field.name}
label={field.label}
required={field.required ?? false}
formState={formState}
helperText={field.helperText}
/>
</Grid>
))}
</Grid>
</InternalCreate>
);
};

View file

@ -0,0 +1,9 @@
import { Create } from "./_components/Create";
type PageProps = {
params: { service: string };
};
export default function Page({ params: { service } }: PageProps) {
return <Create service={service} />;
}

View file

@ -0,0 +1,45 @@
"use client";
import { FC } from "react";
import { Grid } from "@mui/material";
import { DisplayTextField } from "ui";
import { Selectable } from "kysely";
import { Database } from "@/app/_lib/database";
import { Detail as InternalDetail } from "@/app/_components/Detail";
import { generateDeleteAction } from "@/app/_lib/actions";
import { serviceConfig } from "@/app/_lib/config";
type DetailProps = {
service: string;
row: Selectable<keyof Database>;
};
export const Detail: FC<DetailProps> = ({ service, row }) => {
const {
[service]: { entity, table, displayName, displayFields: fields },
} = serviceConfig;
const deleteAction = generateDeleteAction({ entity, table });
return (
<InternalDetail
title={`${displayName}: ${row.name}`}
entity={entity}
id={row.id as string}
deleteAction={deleteAction}
>
<Grid container direction="row" rowSpacing={3} columnSpacing={2}>
{fields.map((field) => (
<Grid item xs={6} key={field.name}>
<DisplayTextField
name={field.name}
label={field.label}
lines={field.lines ?? 1}
value={row[field.name] as string}
copyable={field.copyable ?? false}
/>
</Grid>
))}
</Grid>
</InternalDetail>
);
};

View file

@ -0,0 +1,27 @@
import { db } from "@/app/_lib/database";
import { serviceConfig } from "@/app/_lib/config";
import { Detail } from "./_components/Detail";
type Props = {
params: { service: string; segment: string[] };
};
export default async function Page({ params: { service, segment } }: Props) {
const id = segment?.[0];
if (!id) return null;
const {
[service]: { table },
} = serviceConfig;
const row = await db
.selectFrom(table)
.selectAll()
.where("id", "=", id)
.executeTakeFirst();
if (!row) return null;
return <Detail service={service} row={row} />;
}

View file

@ -0,0 +1,57 @@
"use client";
import { FC } from "react";
import { useFormState } from "react-dom";
import { Grid } from "@mui/material";
import { TextField } from "ui";
import { Selectable } from "kysely";
import { Database } from "@/app/_lib/database";
import { Edit as InternalEdit } from "@/app/_components/Edit";
import { generateUpdateAction } from "@/app/_lib/actions";
import { serviceConfig } from "@/app/_lib/config";
type EditProps = {
service: string;
row: Selectable<keyof Database>;
};
export const Edit: FC<EditProps> = ({ service, row }) => {
const {
[service]: { entity, table, displayName, updateFields: fields },
} = serviceConfig;
const updateFieldNames = fields.map((val) => val.name);
const updateAction = generateUpdateAction({ entity, table, fields });
const initialState = {
message: null,
errors: {},
values: Object.fromEntries(
Object.entries(row).filter(([key]) => updateFieldNames.includes(key)),
),
};
console.log("initialState", initialState);
const [formState, formAction] = useFormState(updateAction, initialState);
return (
<InternalEdit
title={`Edit ${displayName}: ${row.name}`}
entity={entity}
formAction={formAction}
formState={formState}
>
<Grid container direction="row" rowSpacing={3} columnSpacing={2}>
{fields.map((field) => (
<Grid item xs={field.size ?? 6}>
<TextField
key={field.name}
name={field.name}
label={field.label}
required={field.required ?? false}
formState={formState}
helperText={field.helperText}
/>
</Grid>
))}
</Grid>
</InternalEdit>
);
};

View file

@ -0,0 +1,29 @@
import { db } from "@/app/_lib/database";
import { serviceConfig } from "@/app/_lib/config";
import { Edit } from "./_components/Edit";
type PageProps = {
params: { service: string; segment: string[] };
};
export default async function Page({
params: { service, segment },
}: PageProps) {
const id = segment?.[0];
if (!id) return null;
const {
[service]: { table },
} = serviceConfig;
const row = await db
.selectFrom(table)
.selectAll()
.where("id", "=", id)
.executeTakeFirst();
if (!row) return null;
return <Edit service={service} row={row} />;
}

View file

@ -0,0 +1,66 @@
"use server";
import { addAction, updateAction, deleteAction } from "@/app/_lib/actions";
// write a function that returns the addRecordAction
export function generateAddRecordAction(
entity: string,
table: string,
fields: string[],
) {
// The returned function matches the signature of addRecordAction, but uses the parameters from the outer function.
return async (currentState: any, formData: FormData) => {
return addAction({
entity,
table,
fields,
currentState,
formData,
});
};
}
export const addRecordAction = async (
currentState: any,
formData: FormData,
) => {
return addAction({
entity,
table,
fields: [
"name",
"description",
"appId",
"appSecret",
"pageId",
"pageAccessToken",
],
currentState,
formData,
});
};
export const updateFacebookBotAction = async (
currentState: any,
formData: FormData,
) => {
return updateAction({
entity,
table,
fields: [
"name",
"description",
"appId",
"appSecret",
"pageId",
"pageAccessToken",
],
currentState,
formData,
});
};
export const deleteFacebookBotAction = async (id: string) => {
return deleteAction({ entity, table, id });
};

View file

@ -0,0 +1,25 @@
"use client";
import { FC } from "react";
import { List as InternalList } from "@/app/_components/List";
import type { Selectable } from "kysely";
import { Database } from "@/app/_lib/database";
import { serviceConfig } from "@/app/_lib/config";
type ListProps = {
service: string;
rows: Selectable<keyof Database>[];
};
export const List: FC<ListProps> = ({ service, rows }) => {
const { displayName, entity, listColumns } = serviceConfig[service];
return (
<InternalList
title={displayName}
entity={entity}
rows={rows}
columns={listColumns}
/>
);
};

View file

@ -0,0 +1,32 @@
type ServiceLayoutProps = {
children: any;
detail: any;
edit: any;
create: any;
params: {
segment: string[];
};
};
export const ServiceLayout = ({
children,
detail,
edit,
create,
params: { segment },
}: ServiceLayoutProps) => {
const length = segment?.length ?? 0;
const isCreate = length === 2 && segment[1] === "create";
const isEdit = length === 3 && segment[2] === "edit";
const id = length > 1 && !isCreate ? segment[1] : null;
const isDetail = length === 2 && !!id && !isCreate && !isEdit;
return (
<>
{children}
{isDetail && detail}
{isEdit && edit}
{isCreate && create}
</>
);
};

View file

@ -0,0 +1,19 @@
// import { List } from "./_components/List";
// import { db } from "@/app/_lib/database";
// import { serviceConfig } from "@/app/_lib/config";
type PageProps = {
params: {
service: string;
};
};
export default async function Page({ params: { service } }: PageProps) {
console.log({ service });
return <h1> Nice</h1>;
// const config = serviceConfig[service];
// const rows = await db.selectFrom(config.table).selectAll().execute();
// return <List service={service} rows={rows} />;
}

View file

@ -11,21 +11,19 @@ type SignalBotsListProps = {
export const SignalBotsList: FC<SignalBotsListProps> = ({ rows }) => { export const SignalBotsList: FC<SignalBotsListProps> = ({ rows }) => {
const columns: GridColDef[] = [ const columns: GridColDef[] = [
{ {
field: "id", field: "name",
headerName: "ID", headerName: "Name",
flex: 1, flex: 1,
}, },
{ {
field: "phoneNumber", field: "phoneNumber",
headerName: "Phone Number", headerName: "Phone Number",
flex: 2, flex: 1,
}, },
{ {
field: "createdAt", field: "description",
headerName: "Created At", headerName: "Description",
valueGetter: (params: any) => flex: 2,
new Date(params.row?.createdAt).toLocaleString(),
flex: 1,
}, },
{ {
field: "updatedAt", field: "updatedAt",

View file

@ -2,30 +2,29 @@
import { FC } from "react"; import { FC } from "react";
import { GridColDef } from "@mui/x-data-grid-pro"; import { GridColDef } from "@mui/x-data-grid-pro";
import { WhatsappBot } from "@/app/_lib/database";
import { List } from "@/app/_components/List"; import { List } from "@/app/_components/List";
type WhatsappListProps = { type WhatsappListProps = {
rows: any[]; rows: WhatsappBot[];
}; };
export const WhatsappList: FC<WhatsappListProps> = ({ rows }) => { export const WhatsappList: FC<WhatsappListProps> = ({ rows }) => {
const columns: GridColDef[] = [ const columns: GridColDef[] = [
{ {
field: "id", field: "name",
headerName: "ID", headerName: "Name",
flex: 1, flex: 1,
}, },
{ {
field: "phoneNumber", field: "phoneNumber",
headerName: "Phone Number", headerName: "Phone Number",
flex: 2, flex: 1,
}, },
{ {
field: "createdAt", field: "description",
headerName: "Created At", headerName: "Description",
valueGetter: (params: any) => flex: 2,
new Date(params.row?.createdAt).toLocaleString(),
flex: 1,
}, },
{ {
field: "updatedAt", field: "updatedAt",

View file

@ -0,0 +1,94 @@
"use server";
import { revalidatePath } from "next/cache";
import { db, Database } from "@/app/_lib/database";
import { FieldDescription, Entity } from "@/app/_lib/service";
type CreateActionArgs = {
entity: Entity;
table: keyof Database;
fields: FieldDescription[];
currentState: any;
formData: FormData;
};
export const createAction = async ({
entity,
table,
fields,
currentState,
formData,
}: CreateActionArgs) => {
const newRecord = fields.reduce(
(acc: Record<string, string>, field: FieldDescription) => {
// @ts-expect-error
acc[field.name] = formData.get(field.name)?.toString() ?? null;
return acc;
},
{},
);
await db.insertInto(table).values(newRecord).execute();
revalidatePath(`/${entity}`);
return {
...currentState,
values: newRecord,
success: true,
};
};
type UpdateActionArgs = {
entity: Entity;
table: keyof Database;
fields: FieldDescription[];
currentState: any;
formData: FormData;
};
export const updateAction = async ({
entity,
table,
fields,
currentState,
formData,
}: UpdateActionArgs) => {
const id = currentState.values.id;
const updatedRecord = fields.reduce(
(acc: Record<string, string>, field: FieldDescription) => {
// @ts-expect-error
acc[field.name] = formData.get(field.name)?.toString() ?? null;
return acc;
},
{},
);
await db
.updateTable(table)
.set(updatedRecord)
.where("id", "=", id)
.executeTakeFirst();
revalidatePath(`/${entity}/${id}`);
return {
...currentState,
values: updatedRecord,
success: true,
};
};
type DeleteActionArgs = {
entity: Entity;
table: keyof Database;
id: string;
};
export const deleteAction = async ({ entity, table, id }: DeleteActionArgs) => {
await db.deleteFrom(table).where("id", "=", id).execute();
revalidatePath(`/${entity}`);
return true;
};

View file

@ -4,11 +4,13 @@ import { FC } from "react";
import { GridColDef } from "@mui/x-data-grid-pro"; import { GridColDef } from "@mui/x-data-grid-pro";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { List as InternalList, Button } from "ui"; import { List as InternalList, Button } from "ui";
import type { Selectable } from "kysely";
import { Database } from "@/app/_lib/database";
interface ListProps { interface ListProps {
title: string; title: string;
entity: string; entity: string;
rows: any; rows: Selectable<keyof Database>[];
columns: GridColDef<any>[]; columns: GridColDef<any>[];
} }

View file

@ -375,6 +375,7 @@ export const Sidebar: FC<SidebarProps> = ({ open, setOpen }) => {
</Box> </Box>
</Grid> </Grid>
)} )}
{open && (
<Grid item> <Grid item>
<Box <Box
sx={{ sx={{
@ -382,9 +383,10 @@ export const Sidebar: FC<SidebarProps> = ({ open, setOpen }) => {
color: "white", color: "white",
}} }}
> >
{user?.name} {user?.email}
</Box> </Box>
</Grid> </Grid>
)}
</Grid> </Grid>
</Grid> </Grid>
</Drawer> </Drawer>

View file

@ -1,93 +1,65 @@
"use server"; import { Database } from "./database";
import { FieldDescription, Entity } from "./service";
import {
createAction,
updateAction,
deleteAction,
} from "@/app/_actions/service";
import { revalidatePath } from "next/cache"; type GenerateCreateActionArgs = {
import { db, Database } from "./database"; entity: Entity;
type AddActionArgs = {
entity: string;
table: keyof Database; table: keyof Database;
fields: string[]; fields: FieldDescription[];
currentState: any;
formData: FormData;
}; };
export const addAction = async ({ export function generateCreateAction({
entity,
table,
fields,
}: GenerateCreateActionArgs) {
return async (currentState: any, formData: FormData) => {
return createAction({
entity, entity,
table, table,
fields, fields,
currentState, currentState,
formData, formData,
}: AddActionArgs) => { });
const newRecord = fields.reduce(
(acc: Record<string, string>, field: string) => {
// @ts-expect-error
acc[field] = formData.get(field)?.toString() ?? null;
return acc;
},
{},
);
await db.insertInto(table).values(newRecord).execute();
revalidatePath(`/${entity}`);
return {
...currentState,
values: newRecord,
success: true,
};
}; };
}
type UpdateActionArgs = { type GenerateUpdateActionArgs = {
entity: string; entity: Entity;
table: keyof Database; table: keyof Database;
fields: string[]; fields: FieldDescription[];
currentState: any;
formData: FormData;
}; };
export const updateAction = async ({ export function generateUpdateAction({
entity,
table,
fields,
}: GenerateUpdateActionArgs) {
return async (currentState: any, formData: FormData) => {
return updateAction({
entity, entity,
table, table,
fields, fields,
currentState, currentState,
formData, formData,
}: UpdateActionArgs) => { });
const id = currentState.values.id;
const updatedRecord = fields.reduce(
(acc: Record<string, string>, field: string) => {
// @ts-expect-error
acc[field] = formData.get(field)?.toString() ?? null;
return acc;
},
{},
);
await db
.updateTable(table)
.set(updatedRecord)
.where("id", "=", id)
.executeTakeFirst();
revalidatePath(`/${entity}/${id}`);
return {
...currentState,
values: updatedRecord,
success: true,
};
}; };
}
type DeleteActionArgs = { type GenerateDeleteActionArgs = {
entity: string; entity: Entity;
table: keyof Database; table: keyof Database;
id: string;
}; };
export const deleteAction = async ({ entity, table, id }: DeleteActionArgs) => { export function generateDeleteAction({
await db.deleteFrom(table).where("id", "=", id).execute(); entity,
table,
revalidatePath(`/${entity}`); }: GenerateDeleteActionArgs) {
return async (id: string) => {
return true; return deleteAction({ entity, table, id });
}; };
}

View file

@ -0,0 +1,6 @@
import type { ServiceConfig } from "./service";
import { facebookConfig as facebook } from "./facebook";
export const serviceConfig: Record<string, ServiceConfig> = {
facebook,
};

View file

@ -1,5 +1,83 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { Service } from "./service"; import { Service, ServiceConfig } from "./service";
export const facebookConfig: ServiceConfig = {
entity: "facebook",
table: "FacebookBot",
displayName: "Facebook Connections",
createFields: [
{ name: "name", type: "text", label: "Name", required: true },
{
name: "description",
type: "text",
label: "Description",
required: true,
},
{ name: "appId", type: "text", label: "App ID", required: true },
{ name: "appSecret", type: "text", label: "App Secret", required: true },
{ name: "pageId", type: "text", label: "Page ID", required: true },
{
name: "pageAccessToken",
type: "text",
label: "Page Access Token",
required: true,
},
],
updateFields: [
{ name: "name", type: "text", label: "Name", required: true },
{
name: "description",
type: "text",
label: "Description",
required: true,
},
{ name: "appId", type: "text", label: "App ID", required: true },
{ name: "appSecret", type: "text", label: "App Secret", required: true },
{ name: "pageId", type: "text", label: "Page ID", required: true },
{
name: "pageAccessToken",
type: "text",
label: "Page Access Token",
required: true,
},
],
displayFields: [
{ name: "name", type: "text", label: "Name", required: true },
{
name: "description",
type: "text",
label: "Description",
required: true,
},
{ name: "appId", type: "text", label: "App ID", required: true },
{ name: "appSecret", type: "text", label: "App Secret", required: true },
{ name: "pageId", type: "text", label: "Page ID", required: true },
{
name: "pageAccessToken",
type: "text",
label: "Page Access Token",
required: true,
},
],
listColumns: [
{
field: "name",
headerName: "Name",
flex: 1,
},
{
field: "description",
headerName: "Description",
flex: 2,
},
{
field: "updatedAt",
headerName: "Updated At",
valueGetter: (value: any) => new Date(value).toLocaleString(),
flex: 1,
},
],
};
const getAllBots = async (req: NextRequest) => { const getAllBots = async (req: NextRequest) => {
console.log({ req }); console.log({ req });

View file

@ -1,6 +1,42 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { GridColDef } from "@mui/x-data-grid-pro";
import { Database } from "./database";
export interface Service { const entities = [
"facebook",
"whatsapp",
"signal",
"voice",
"webhook",
"user",
] as const;
export type Entity = (typeof entities)[number];
export type FieldDescription = {
name: string;
type: string;
label: string;
lines?: number;
copyable?: boolean;
defaultValue?: string;
required?: boolean;
disabled?: boolean;
size?: number;
helperText?: string;
};
export type ServiceConfig = {
entity: Entity;
table: keyof Database;
displayName: string;
createFields: FieldDescription[];
updateFields: FieldDescription[];
displayFields: FieldDescription[];
listColumns: GridColDef[];
};
export type Service = {
getAllBots: (req: NextRequest) => Promise<NextResponse>; getAllBots: (req: NextRequest) => Promise<NextResponse>;
getOneBot: (req: NextRequest) => Promise<NextResponse>; getOneBot: (req: NextRequest) => Promise<NextResponse>;
sendMessage: (req: NextRequest) => Promise<NextResponse>; sendMessage: (req: NextRequest) => Promise<NextResponse>;
@ -13,4 +49,4 @@ export interface Service {
createBot: (req: NextRequest) => Promise<NextResponse>; createBot: (req: NextRequest) => Promise<NextResponse>;
deleteBot: (req: NextRequest) => Promise<NextResponse>; deleteBot: (req: NextRequest) => Promise<NextResponse>;
handleWebhook: (req: NextRequest) => Promise<NextResponse>; handleWebhook: (req: NextRequest) => Promise<NextResponse>;
} };

View file

@ -55,7 +55,6 @@ export const List: FC<ListProps> = ({
cursor: "pointer", cursor: "pointer",
"&:hover": { "&:hover": {
backgroundColor: `${mediumBlue}22 !important`, backgroundColor: `${mediumBlue}22 !important`,
fontWeight: "bold",
}, },
}, },
".MuiDataGrid-row:nth-of-type(1n)": { ".MuiDataGrid-row:nth-of-type(1n)": {

File diff suppressed because one or more lines are too long