51 | >;
52 |
53 | return (
54 |
55 |
56 |
57 |
58 |
59 |
60 | {authenticated && (
61 |
62 | )}
63 | {children}
64 |
65 |
66 |
67 |
68 |
69 | );
70 | }
71 |
72 | export default function App() {
73 | return (
74 |
75 |
76 |
77 | );
78 | }
79 |
80 | export function CatchBoundary() {
81 | const { data, status } = useCatch();
82 |
83 | const message =
84 | typeof data === "string" && data ? data : messageFromStatus(status);
85 | // const icon = iconFromStatus(status);
86 |
87 | return (
88 |
89 |
90 |
{status}
91 |
{message}
92 |
93 |
94 | );
95 | }
96 |
97 | function messageFromStatus(status: number) {
98 | switch (status) {
99 | case 400:
100 | return "Bad Request";
101 | case 401:
102 | return "Unauthorized";
103 | case 403:
104 | return "Forbidden";
105 | case 404:
106 | return "Page not found";
107 | case 500:
108 | return "Something went wrong";
109 | }
110 | }
111 |
--------------------------------------------------------------------------------
/app/routes/dashboard._menu.$projectId.$.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import type { LoaderArgs } from "@remix-run/node";
3 | import { json } from "@remix-run/node";
4 | import type { ShouldReloadFunction } from "@remix-run/react";
5 | import { Link, useCatch, useLocation } from "@remix-run/react";
6 |
7 | import { authenticate } from "~/auth.server";
8 |
9 | import spritesHref from "~/sprites.svg";
10 |
11 | export async function loader({ request }: LoaderArgs) {
12 | await authenticate(request);
13 | return json(null, 404);
14 | }
15 |
16 | export const unstable_shouldReload: ShouldReloadFunction = ({ submission }) => {
17 | if (submission) {
18 | return true;
19 | }
20 |
21 | return false;
22 | };
23 |
24 | export function CatchBoundary() {
25 | const { status } = useCatch();
26 |
27 | return (
28 |
29 |
30 |
31 |
{status}
32 |
33 | Go to the project
34 |
35 |
36 |
37 |
38 | );
39 | }
40 |
41 | export default function DashboardIndex() {
42 | const location = useLocation();
43 |
44 | const openMenuLocation = React.useMemo(() => {
45 | const searchParams = new URLSearchParams(location.search);
46 | searchParams.set("open", "menu");
47 | return {
48 | pathname: location.pathname,
49 | search: `?${searchParams.toString()}`,
50 | };
51 | }, [location]);
52 |
53 | return (
54 |
55 |
56 |
57 |
58 |
77 |
78 |
79 |
80 |
404
81 |
82 | Go to the project
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 | );
92 | }
93 |
--------------------------------------------------------------------------------
/app/routes/dashboard_.new.tsx:
--------------------------------------------------------------------------------
1 | import type { ActionArgs, LoaderArgs } from "@remix-run/node";
2 | import type { ShouldReloadFunction } from "@remix-run/react";
3 | import { makeDomainFunction } from "remix-domains";
4 | import { Form, formAction } from "remix-forms";
5 | import { z } from "zod";
6 |
7 | import { authenticate } from "~/auth.server";
8 | import { DashboardDetailLayout } from "~/components/dashboard";
9 | import { createProject } from "~/models.server";
10 |
11 | export async function loader({ request }: LoaderArgs) {
12 | await authenticate(request);
13 | return null;
14 | }
15 |
16 | export const unstable_shouldReload: ShouldReloadFunction = ({
17 | submission,
18 | prevUrl,
19 | url,
20 | }) => {
21 | if (submission) {
22 | return true;
23 | }
24 |
25 | return prevUrl.pathname !== url.pathname;
26 | };
27 |
28 | export default function DashboardTest() {
29 | return (
30 |
31 |
32 |
75 |
76 |
77 | );
78 | }
79 |
80 | const schema = z.object({
81 | name: z.string().trim().min(1),
82 | description: z.string().trim().optional(),
83 | });
84 |
85 | export async function action({ request }: ActionArgs) {
86 | const user = await authenticate(request);
87 |
88 | return formAction({
89 | request,
90 | schema,
91 | successPath: (data: { id: string }) => `/dashboard/${data.id}`,
92 | mutation: makeDomainFunction(schema)(async (input) => {
93 | const project = await createProject({
94 | userId: user.id,
95 | name: input.name,
96 | description: input.description,
97 | });
98 |
99 | return { id: project.id };
100 | }),
101 | });
102 | }
103 |
--------------------------------------------------------------------------------
/app/routes/_layout.login.tsx:
--------------------------------------------------------------------------------
1 | import type { ActionArgs, LoaderArgs } from "@remix-run/node";
2 | import { redirect } from "@remix-run/node";
3 | import { makeDomainFunction } from "remix-domains";
4 | import { Form, formAction } from "remix-forms";
5 | import { z } from "zod";
6 |
7 | import type { AuthenticatedUser } from "~/auth.server";
8 | import { authenticate, setUser } from "~/auth.server";
9 | import { sessionStorage } from "~/session.server";
10 | import { findOrCreateUser } from "~/models.server";
11 |
12 | export async function loader({ request }: LoaderArgs) {
13 | return authenticate(request, { successRedirect: "/dashboard" });
14 | }
15 |
16 | export default function Login() {
17 | return (
18 |
19 |
Login
20 |
64 |
65 | );
66 | }
67 |
68 | const schema = z.object({
69 | username: z.string().trim().min(1),
70 | password: z.string().min(6),
71 | });
72 |
73 | export async function action({ request }: ActionArgs) {
74 | let user: AuthenticatedUser;
75 | return formAction({
76 | request,
77 | schema,
78 | mutation: makeDomainFunction(schema)(async ({ password, username }) => {
79 | const foundUser = await findOrCreateUser({ username, password });
80 | if (!foundUser) throw new Error("Invalid username or password.");
81 | user = foundUser;
82 | return foundUser;
83 | }),
84 | // This is to get around the lack of ability to set a cookie from a domain func.
85 | beforeSuccess: async (request) => {
86 | const session = await sessionStorage.getSession(
87 | request.headers.get("Cookie")
88 | );
89 | setUser(session, user);
90 |
91 | return redirect("/dashboard", {
92 | headers: {
93 | "Set-Cookie": await sessionStorage.commitSession(session),
94 | },
95 | });
96 | },
97 | });
98 | }
99 |
--------------------------------------------------------------------------------
/app/models/models.server.ts:
--------------------------------------------------------------------------------
1 | import { prisma } from "~/prisma.server";
2 |
3 | import type { DataTypes } from "~/app.config";
4 |
5 | export async function createModelForProject({
6 | userId,
7 | projectId,
8 | slug,
9 | name,
10 | description,
11 | fields,
12 | }: {
13 | userId: string;
14 | projectId: string;
15 | slug: string;
16 | name: string;
17 | description?: string;
18 | fields?: {
19 | name: string;
20 | type: DataTypes;
21 | required: boolean;
22 | array: boolean;
23 | }[];
24 | }) {
25 | const project = await prisma.project.findFirst({
26 | where: {
27 | id: projectId,
28 | users: {
29 | some: {
30 | id: userId,
31 | },
32 | },
33 | },
34 | select: {
35 | id: true,
36 | },
37 | });
38 |
39 | if (!project) return undefined;
40 |
41 | const model = await prisma.model.create({
42 | data: {
43 | slug,
44 | name,
45 | description,
46 | projectId,
47 | fields: fields?.length
48 | ? {
49 | create: fields,
50 | }
51 | : undefined,
52 | },
53 | });
54 |
55 | return { id: model.id };
56 | }
57 |
58 | export async function getFieldsForModel({
59 | modelId,
60 | userId,
61 | }: {
62 | modelId: string;
63 | userId: string;
64 | }) {
65 | const model = await prisma.model.findFirst({
66 | where: {
67 | id: modelId,
68 | project: {
69 | users: {
70 | some: {
71 | id: userId,
72 | },
73 | },
74 | },
75 | },
76 | select: {
77 | fields: {
78 | select: {
79 | id: true,
80 | name: true,
81 | type: true,
82 | required: true,
83 | array: true,
84 | typeReference: {
85 | select: {
86 | id: true,
87 | name: true,
88 | },
89 | },
90 | },
91 | },
92 | },
93 | });
94 |
95 | if (!model) return [];
96 |
97 | return model.fields.map((field) => ({
98 | id: field.id,
99 | name: field.name,
100 | type: field.type,
101 | required: field.required,
102 | array: field.array,
103 | typeReferenceId: field.typeReference?.id,
104 | typeReferenceName: field.typeReference?.name,
105 | }));
106 | }
107 |
108 | export async function getModelsForProject({
109 | projectId,
110 | userId,
111 | }: {
112 | projectId: string;
113 | userId: string;
114 | }) {
115 | const models = await prisma.model.findMany({
116 | where: {
117 | project: {
118 | id: projectId,
119 | users: {
120 | some: {
121 | id: userId,
122 | },
123 | },
124 | },
125 | },
126 | select: {
127 | id: true,
128 | slug: true,
129 | name: true,
130 | description: true,
131 | },
132 | });
133 |
134 | return models.map(({ id, slug, name, description }) => ({
135 | id,
136 | slug,
137 | name,
138 | description: description || undefined,
139 | }));
140 | }
141 |
142 | export async function getModelOverview({
143 | modelId,
144 | userId,
145 | }: {
146 | modelId: string;
147 | userId: string;
148 | }) {
149 | const model = await prisma.model.findFirst({
150 | where: {
151 | id: modelId,
152 | project: {
153 | users: {
154 | some: {
155 | id: userId,
156 | },
157 | },
158 | },
159 | },
160 | select: {
161 | id: true,
162 | slug: true,
163 | name: true,
164 | description: true,
165 | },
166 | });
167 |
168 | if (!model) return undefined;
169 |
170 | return {
171 | id: model.id,
172 | slug: model.slug,
173 | name: model.name,
174 | description: model.description,
175 | };
176 | }
177 |
178 | export async function countModels({
179 | userId,
180 | projectId,
181 | }: {
182 | userId: string;
183 | projectId: string;
184 | }): Promise {
185 | const project = await prisma.project.findFirst({
186 | where: {
187 | id: projectId,
188 | users: {
189 | some: {
190 | id: userId,
191 | },
192 | },
193 | },
194 | select: {
195 | _count: {
196 | select: {
197 | models: true,
198 | },
199 | },
200 | },
201 | });
202 |
203 | return project?._count.models || 0;
204 | }
205 |
--------------------------------------------------------------------------------
/app/routes/dashboard._menu.$projectId.models.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import type { LoaderArgs } from "@remix-run/node";
3 | import { defer } from "@remix-run/node";
4 | import type { ShouldReloadFunction } from "@remix-run/react";
5 | import {
6 | Await,
7 | Link,
8 | useCatch,
9 | useLoaderData,
10 | useParams,
11 | } from "@remix-run/react";
12 |
13 | import { authenticate } from "~/auth.server";
14 | import { DashboardListLayout, DashboardListItem } from "~/components/dashboard";
15 | import { getModelsForProject } from "~/models.server";
16 |
17 | import spritesHref from "~/sprites.svg";
18 |
19 | import { Layout } from "./dashboard._menu.$projectId.index";
20 |
21 | export async function loader({ request, params }: LoaderArgs) {
22 | const user = await authenticate(request);
23 |
24 | const modelsPromise = getModelsForProject({
25 | projectId: params.projectId!,
26 | userId: user.id,
27 | });
28 |
29 | return defer({
30 | models: modelsPromise,
31 | });
32 | }
33 |
34 | export const unstable_shouldReload: ShouldReloadFunction = ({ submission }) => {
35 | if (submission) {
36 | return true;
37 | }
38 |
39 | return false;
40 | };
41 |
42 | export function CatchBoundary() {
43 | const { status } = useCatch();
44 | return (
45 |
46 |
47 |
{status}
48 |
49 | Go to the project models
50 |
51 |
52 |
53 | );
54 | }
55 |
56 | export default function ModelsLayout() {
57 | const { models } = useLoaderData();
58 | const { modelId } = useParams();
59 |
60 | return (
61 |
67 |
72 |
79 |
80 | >
81 | }
82 | >
83 |
89 |
105 | Loading...
106 |
107 | }
108 | >
109 |
110 | {(models) =>
111 | models.map(({ id, slug, name, description }) => (
112 |
120 | ))
121 | }
122 |
123 |
124 |
125 | );
126 | }
127 |
--------------------------------------------------------------------------------
/app/routes/dashboard._menu.$projectId.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import type { LoaderArgs } from "@remix-run/node";
3 | import { defer, json } from "@remix-run/node";
4 | import type { ShouldReloadFunction } from "@remix-run/react";
5 | import { Await, Link, Outlet, useCatch, useLoaderData } from "@remix-run/react";
6 |
7 | import * as appConfig from "~/app.config";
8 | import { authenticate } from "~/auth.server";
9 | import { DashboardMenu } from "~/components/dashboard";
10 | import { countContent, countModels, projectExists } from "~/models.server";
11 |
12 | import spritesHref from "~/sprites.svg";
13 |
14 | export async function loader({ request, params }: LoaderArgs) {
15 | const user = await authenticate(request);
16 |
17 | if (
18 | !(await projectExists({ projectId: params.projectId!, userId: user.id }))
19 | ) {
20 | throw json(null, 404);
21 | }
22 |
23 | const contentCountPromise = countContent({
24 | projectId: params.projectId!,
25 | userId: user.id,
26 | });
27 | const modelsCountPromise = countModels({
28 | projectId: params.projectId!,
29 | userId: user.id,
30 | });
31 |
32 | return defer({
33 | contentCount: contentCountPromise,
34 | modelsCount: modelsCountPromise,
35 | });
36 | }
37 |
38 | export const unstable_shouldReload: ShouldReloadFunction = ({ submission }) => {
39 | if (submission) {
40 | return true;
41 | }
42 |
43 | return false;
44 | };
45 |
46 | export function CatchBoundary() {
47 | const { status } = useCatch();
48 |
49 | return (
50 |
51 |
52 |
53 |
{status}
54 |
55 | Go to the dashboard
56 |
57 |
58 |
59 |
60 | );
61 | }
62 |
63 | export default function DashboardMenuLayout() {
64 | const { contentCount, modelsCount } = useLoaderData();
65 |
66 | const menuContents = (
67 |
68 | -
69 |
73 | {" "}
76 |
Models
77 |
78 |
79 | {(count) => {count}}
80 |
81 |
82 |
83 |
84 | -
85 |
89 | {" "}
92 |
Content
93 |
94 |
95 | {(count) => {count}}
96 |
97 |
98 |
99 |
100 |
101 | );
102 |
103 | return (
104 | <>
105 |
106 |
107 |
108 |
109 |
110 |
115 |
{menuContents}
116 |
117 |
118 |
119 |
120 |
121 |
122 | -
123 |
127 | {" "}
130 | Docs
131 |
132 |
133 | -
134 |
144 |
145 |
146 |
147 |
148 |
149 |
150 |
151 |
152 |
153 | >
154 | );
155 | }
156 |
--------------------------------------------------------------------------------
/app/routes/dashboard._menu.$projectId.models.$modelId.$.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import type { LoaderArgs } from "@remix-run/node";
3 | import { defer, json } from "@remix-run/node";
4 | import {
5 | Await,
6 | Link,
7 | useLoaderData,
8 | useLocation,
9 | useNavigate,
10 | useParams,
11 | } from "@remix-run/react";
12 |
13 | import { authenticate } from "~/auth.server";
14 | import { getField } from "~/models.server";
15 |
16 | import spritesHref from "~/sprites.svg";
17 |
18 | export async function loader({ request, params }: LoaderArgs) {
19 | const user = await authenticate(request);
20 | const { "*": slug } = params;
21 |
22 | const { sections } = getSections(slug);
23 |
24 | if (!sections.length) {
25 | throw json({}, 404);
26 | }
27 |
28 | const trimmedSections: { id: string; type: "field"; to: string }[] = [];
29 | type SectionWithData = typeof sections[number] & {
30 | data: Promise>;
31 | };
32 | const sectionPromises = sections
33 | .map(({ type, id }, index) => {
34 | const to =
35 | `/dashboard/${params.projectId}/models/${params.modelId}/` +
36 | sections
37 | .slice(0, index + 1)
38 | .map((section) => `${section.type}/${section.id}`)
39 | .join("/");
40 | if (type === "field") {
41 | trimmedSections.push({ id, type, to });
42 | return {
43 | type: type,
44 | id,
45 | data: getField({ fieldId: id, userId: user.id }),
46 | };
47 | }
48 | return false as any;
49 | })
50 | .filter(Boolean) as SectionWithData[];
51 |
52 | return defer({
53 | sections: trimmedSections.reverse(),
54 | ...Object.fromEntries(
55 | sectionPromises.map(({ id, data }) => [`section_${id}` as const, data])
56 | ),
57 | } as Record>> & {
58 | sections: typeof trimmedSections;
59 | });
60 | }
61 |
62 | function getSections(splat?: string) {
63 | const splitSplat = (splat || "").split("/");
64 | let type = "";
65 | let returnToFieldId = undefined;
66 | const sections = [];
67 | for (const section of splitSplat) {
68 | if (section === "field") {
69 | type = section;
70 | continue;
71 | }
72 |
73 | if (!returnToFieldId && type === "field") {
74 | returnToFieldId = section;
75 | }
76 |
77 | if (type) {
78 | sections.push({ type, id: section });
79 | type = "";
80 | }
81 | }
82 | return { returnToFieldId, sections };
83 | }
84 |
85 | export default function FieldDashboard() {
86 | const loaderData = useLoaderData();
87 | const location = useLocation();
88 | const navigate = useNavigate();
89 | const { "*": splat, projectId, modelId } = useParams();
90 |
91 | const sectionData = React.useMemo(
92 | () =>
93 | loaderData.sections.reduce((acc, section) => {
94 | acc[section.id] = loaderData[`section_${section.id}`];
95 | return acc;
96 | }, {} as Record),
97 | [loaderData]
98 | );
99 |
100 | const { returnToFieldId } = React.useMemo(() => getSections(splat), [splat]);
101 |
102 | // This is here to trap focus in the dialog. The "open"
103 | // attribute doesn't do this for some stupid reason.
104 | const dialogRef = React.useRef(null);
105 | React.useEffect(() => {
106 | if (dialogRef.current) {
107 | dialogRef.current.close("focus");
108 | dialogRef.current.showModal();
109 | }
110 | }, [dialogRef, location.key]);
111 |
112 | const focusedSection = sectionData[loaderData.sections[0].id];
113 |
114 | return (
115 | <>
116 |
204 | >
205 | );
206 | }
207 |
--------------------------------------------------------------------------------
/app/components/dashboard.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import type { To } from "react-router-dom";
3 | import {
4 | Link,
5 | Outlet,
6 | useLocation,
7 | useMatches,
8 | useSearchParams,
9 | } from "@remix-run/react";
10 | import clsx from "clsx";
11 |
12 | import spritesHref from "~/sprites.svg";
13 |
14 | interface DashboardMenuProps {
15 | children: React.ReactNode;
16 | title: React.ReactNode;
17 | openIndicator: string;
18 | }
19 |
20 | export function DashboardMenu({
21 | children,
22 | title,
23 | openIndicator,
24 | }: DashboardMenuProps) {
25 | const location = useLocation();
26 | const [searchParams] = useSearchParams();
27 |
28 | const menuOpen = searchParams.get("open") === openIndicator;
29 |
30 | const closeMenuLocation = React.useMemo(() => {
31 | const searchParams = new URLSearchParams(location.search);
32 | searchParams.delete("open");
33 | return {
34 | pathname: location.pathname,
35 | search: `?${searchParams.toString()}`,
36 | };
37 | }, [location]);
38 |
39 | return (
40 |
74 | );
75 | }
76 |
77 | interface DashboardListLayoutProps {
78 | children: React.ReactNode;
79 | title: React.ReactNode;
80 | openIndicator: string;
81 | routeId: string;
82 | header?: React.ReactNode;
83 | }
84 |
85 | export function DashboardListLayout({
86 | children,
87 | title,
88 | openIndicator,
89 | routeId,
90 | header,
91 | }: DashboardListLayoutProps) {
92 | const location = useLocation();
93 | const matches = useMatches();
94 |
95 | const openMenuLocation = React.useMemo(() => {
96 | const searchParams = new URLSearchParams(location.search);
97 | searchParams.set("open", openIndicator);
98 | return {
99 | pathname: location.pathname,
100 | search: `?${searchParams.toString()}`,
101 | };
102 | }, [location, openIndicator]);
103 |
104 | const showRouteAsCritical = React.useMemo(() => {
105 | const selfIndex = matches.findIndex(({ id }) => id === routeId);
106 | const nextMatch = matches[selfIndex + 1];
107 | return (
108 | nextMatch?.id.endsWith("index") || nextMatch?.id.endsWith("$") || false
109 | );
110 | }, [matches, routeId]);
111 |
112 | return (
113 |
114 |
115 |
121 |
122 |
123 |
124 |
128 |
135 |
136 |
137 | {title}
138 |
139 | {header}
140 |
141 |
142 |
145 |
146 |
147 |
148 |
149 |
150 | );
151 | }
152 |
153 | export interface DashboardListItemProps {
154 | to: To;
155 | title: string;
156 | description?: string;
157 | header?: React.ReactNode;
158 | selected?: boolean;
159 | }
160 |
161 | export function DashboardListItem({
162 | to,
163 | title,
164 | description,
165 | header,
166 | selected,
167 | }: DashboardListItemProps) {
168 | return (
169 |
170 |
178 |
179 | {header}
180 |
181 | {title}
182 | {description && (
183 | {description}
184 | )}
185 |
186 |
187 |
188 | );
189 | }
190 |
191 | interface DashboardDetailLayoutProps {
192 | children: React.ReactNode;
193 | title?: React.ReactNode;
194 | }
195 |
196 | export function DashboardDetailLayout({
197 | children,
198 | title,
199 | }: DashboardDetailLayoutProps) {
200 | return (
201 |
202 |
203 |
204 |
205 |
227 | {children}
228 |
229 |
230 |
231 |
232 | );
233 | }
234 |
--------------------------------------------------------------------------------
/app/routes/dashboard._menu.$projectId.models.$modelId.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import type { LoaderArgs } from "@remix-run/node";
3 | import { defer, json } from "@remix-run/node";
4 | import type { ShouldReloadFunction } from "@remix-run/react";
5 | import {
6 | Await,
7 | Link,
8 | Outlet,
9 | useLoaderData,
10 | useLocation,
11 | } from "@remix-run/react";
12 | import { Form } from "remix-forms";
13 | import { z } from "zod";
14 | import clsx from "clsx";
15 |
16 | import { authenticate } from "~/auth.server";
17 | import { DashboardDetailLayout } from "~/components/dashboard";
18 | import { getModelOverview, getFieldsForModel } from "~/models.server";
19 |
20 | import spritesHref from "~/sprites.svg";
21 |
22 | export async function loader({ request, params }: LoaderArgs) {
23 | const user = await authenticate(request);
24 |
25 | const model = await getModelOverview({
26 | modelId: params.modelId!,
27 | userId: user.id,
28 | });
29 |
30 | const fieldsPromise = getFieldsForModel({
31 | modelId: params.modelId!,
32 | userId: user.id,
33 | });
34 |
35 | if (!model) {
36 | throw json(null, 404);
37 | }
38 |
39 | return defer({ model, fields: fieldsPromise });
40 | }
41 |
42 | export const unstable_shouldReload: ShouldReloadFunction = ({
43 | submission,
44 | prevUrl,
45 | url,
46 | }) => {
47 | if (submission) {
48 | return true;
49 | }
50 |
51 | return prevUrl.pathname !== url.pathname;
52 | };
53 |
54 | export default function DashboardTest() {
55 | const { model, fields } = useLoaderData();
56 | const location = useLocation();
57 |
58 | const backToFieldId = React.useMemo(() => {
59 | const { fieldId } = ((typeof location.state === "object"
60 | ? location.state
61 | : {}) || {}) as { fieldId?: string };
62 | return fieldId;
63 | }, [location]);
64 | const backToFieldLinkRef = React.useRef(null);
65 | React.useEffect(() => {
66 | if (backToFieldLinkRef.current) {
67 | backToFieldLinkRef.current.focus();
68 | }
69 | }, [backToFieldId, backToFieldLinkRef]);
70 |
71 | return (
72 |
73 |
74 |
115 |
116 |
117 | Fields
118 |
131 |
132 |
138 |
154 | Loading...
155 |
156 | }
157 | >
158 |
159 | {(fields) => (
160 |
161 | {fields.map(
162 | ({ id, name, array, required, type, typeReferenceName }) => (
163 | -
164 |
175 |
176 |
177 | {required ? "required" : "optional"}
178 |
179 |
180 | {name}
181 |
182 |
183 | {array && "Array<"}
184 | {typeReferenceName || type}
185 | {array && ">"}
186 |
187 |
188 |
189 |
190 | )
191 | )}
192 |
193 | )}
194 |
195 |
196 |
197 |
198 |
199 | );
200 | }
201 |
202 | const schema = z.object({
203 | name: z.string().trim().min(1),
204 | slug: z.string().trim().min(1),
205 | description: z.string().trim().optional(),
206 | });
207 |
--------------------------------------------------------------------------------
/app/routes/dashboard_.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import type { LoaderArgs } from "@remix-run/node";
3 | import { defer } from "@remix-run/node";
4 | import type { ShouldReloadFunction } from "@remix-run/react";
5 | import { Await, Link, Outlet, useLoaderData } from "@remix-run/react";
6 |
7 | import * as appConfig from "~/app.config";
8 | import { authenticate } from "~/auth.server";
9 | import { DashboardMenu } from "~/components/dashboard";
10 | import { getProjects } from "~/models.server";
11 |
12 | import spritesHref from "~/sprites.svg";
13 |
14 | export async function loader({ request, params }: LoaderArgs) {
15 | const user = await authenticate(request);
16 |
17 | const projectsPromise = getProjects({ userId: user.id });
18 |
19 | return defer({
20 | projects: projectsPromise,
21 | });
22 | }
23 |
24 | export const unstable_shouldReload: ShouldReloadFunction = ({ submission }) => {
25 | if (submission) {
26 | return true;
27 | }
28 |
29 | return false;
30 | };
31 |
32 | export default function DashboardMenuLayout() {
33 | const { projects } = useLoaderData();
34 |
35 | const menuContents = (
36 |
39 |
55 | Loading...
56 |
57 | }
58 | >
59 |
60 | {(projects) => (
61 |
87 | )}
88 |
89 |
90 | );
91 |
92 | return (
93 | <>
94 |
95 |
96 |
97 |
98 |
101 |
102 |
103 |
104 |
Projects
105 |
109 |
125 |
Loading...
126 |
127 |
128 |
129 |
130 |
131 |
132 | }
133 | >
134 |
135 |
136 |
141 |
{menuContents}
142 |
143 |
144 |
145 |
146 |
147 |
148 |
149 |
150 | -
151 |
155 | {" "}
158 | Docs
159 |
160 |
161 | -
162 |
172 |
173 |
174 |
175 |
176 |
177 |
178 |
179 |
180 |
181 | >
182 | );
183 | }
184 |
--------------------------------------------------------------------------------
/app/routes/dashboard._menu.$projectId.models.new.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import type { ActionArgs, LoaderArgs } from "@remix-run/node";
3 | import type { ShouldReloadFunction } from "@remix-run/react";
4 | import { useActionData } from "@remix-run/react";
5 | import { InputError, InputErrors, makeDomainFunction } from "remix-domains";
6 | import { Form, formAction } from "remix-forms";
7 | import { useFieldArray } from "react-hook-form";
8 | import { z } from "zod";
9 | import { zfd } from "zod-form-data";
10 | import clsx from "clsx";
11 |
12 | import * as appConfig from "~/app.config";
13 | import { authenticate } from "~/auth.server";
14 | import { DashboardDetailLayout } from "~/components/dashboard";
15 | import { createModelForProject } from "~/models.server";
16 |
17 | import spritesHref from "~/sprites.svg";
18 |
19 | export async function loader({ request }: LoaderArgs) {
20 | await authenticate(request);
21 | return null;
22 | }
23 |
24 | export const unstable_shouldReload: ShouldReloadFunction = ({
25 | submission,
26 | prevUrl,
27 | url,
28 | }) => {
29 | if (submission) {
30 | return true;
31 | }
32 |
33 | return prevUrl.pathname !== url.pathname;
34 | };
35 |
36 | function TmpErrors({
37 | formState,
38 | id,
39 | field,
40 | }: {
41 | formState: any;
42 | id: string;
43 | field: (string | number)[];
44 | }) {
45 | const errors: string[] | undefined = React.useMemo(
46 | () => getError(formState, field),
47 | [formState, field]
48 | );
49 | return errors?.length ? (
50 |
51 | {errors.map((error, index) => (
52 |
{error}
53 | ))}
54 |
55 | ) : null;
56 | }
57 |
58 | function getError(formState: any, field: (string | number)[]) {
59 | let value = field.length > 0 ? formState?.errors : null;
60 | for (let i = 0; i < field.length && value; i++) {
61 | value = value[field[i]] || value[String(field[i])];
62 | }
63 | return value;
64 | }
65 |
66 | function FieldsForm({
67 | Field,
68 | control,
69 | register,
70 | }: {
71 | Field: any;
72 | control: any;
73 | register: any;
74 | }) {
75 | const actionData = useActionData();
76 | const { fields, append, remove } = useFieldArray({ control, name: "fields" });
77 |
78 | return (
79 | <>
80 |
81 | Fields
82 |
92 |
93 | {fields.map((field, index) => {
94 | return (
95 |
206 | );
207 | })}
208 | >
209 | );
210 | }
211 |
212 | export default function DashboardTest() {
213 | return (
214 |
215 |
216 |
256 |
257 |
258 | );
259 | }
260 |
261 | const fieldsSchema = z
262 | .array(
263 | z.object({
264 | name: z.string().trim().min(1),
265 | type: z.enum(
266 | appConfig.dataTypes
267 | ),
268 | array: zfd.checkbox({ trueValue: "" }),
269 | required: zfd.checkbox({ trueValue: "" }),
270 | })
271 | )
272 | .optional();
273 |
274 | const schema = z.object({
275 | name: z.string().trim().min(1),
276 | slug: z.string().trim().min(1),
277 | description: z.string().trim().optional(),
278 | fields: z.any().optional(),
279 | });
280 |
281 | export async function action({ request, params }: ActionArgs) {
282 | const user = await authenticate(request);
283 |
284 | return formAction({
285 | request,
286 | schema,
287 | successPath: (data: { id: string }) =>
288 | `/dashboard/${params.projectId}/models/${data.id}`,
289 | mutation: makeDomainFunction(schema)(async (input) => {
290 | const fields = await fieldsSchema.safeParseAsync(input.fields);
291 | if (!fields.success) {
292 | throw new InputErrors(
293 | fields.error.issues.map((issue) => ({
294 | path: issue.path.reduce((acc, key, index) => {
295 | if (index === 0) {
296 | return `fields.${key}`;
297 | }
298 | if (typeof key === "number") {
299 | return `${acc}[${key}]`;
300 | }
301 | return `${acc}.${key}`;
302 | }, ""),
303 | message: issue.message,
304 | }))
305 | );
306 | }
307 |
308 | const model = await createModelForProject({
309 | userId: user.id,
310 | projectId: params.projectId!,
311 | slug: input.slug,
312 | name: input.name,
313 | description: input.description,
314 | fields: fields.data,
315 | });
316 |
317 | if (!model) throw new InputError("Failed to create a model", "name");
318 |
319 | return { id: model.id };
320 | }),
321 | });
322 | }
323 |
--------------------------------------------------------------------------------