Full Code of apiannie/apiannie for AI

main f4e360467d29 cached
62 files
232.9 KB
59.3k tokens
92 symbols
1 requests
Download .txt
Showing preview only (250K chars total). Download the full file or copy to clipboard to get everything.
Repository: apiannie/apiannie
Branch: main
Commit: f4e360467d29
Files: 62
Total size: 232.9 KB

Directory structure:
gitextract_0qb6gbce/

├── .gitignore
├── .npmrc
├── LICENSE
├── README.md
├── app/
│   ├── context.tsx
│   ├── createEmotionCache.ts
│   ├── entry.client.tsx
│   ├── entry.server.tsx
│   ├── models/
│   │   ├── api.server.ts
│   │   ├── prisma.server.ts
│   │   ├── project.server.ts
│   │   ├── type.ts
│   │   └── user.server.ts
│   ├── root.tsx
│   ├── routes/
│   │   ├── home/
│   │   │   ├── ..lib/
│   │   │   │   ├── ColorModeButton.tsx
│   │   │   │   ├── Layout.tsx
│   │   │   │   ├── NotificationButton.tsx
│   │   │   │   └── UserMenuButton.tsx
│   │   │   ├── logout.tsx
│   │   │   ├── settings.tsx
│   │   │   ├── signin.tsx
│   │   │   └── signup.tsx
│   │   ├── index.tsx
│   │   ├── mock/
│   │   │   └── $projectId.$.tsx
│   │   └── projects/
│   │       ├── $projectId/
│   │       │   ├── activities.tsx
│   │       │   ├── apis/
│   │       │   │   ├── ..api.tsx
│   │       │   │   ├── ..editor.tsx
│   │       │   │   ├── ..postman.tsx
│   │       │   │   ├── details.$apiId.tsx
│   │       │   │   ├── groups.$groupId.tsx
│   │       │   │   └── index.tsx
│   │       │   ├── apis.tsx
│   │       │   ├── settings/
│   │       │   │   ├── index.tsx
│   │       │   │   └── members.tsx
│   │       │   └── settings.tsx
│   │       ├── $projectId.tsx
│   │       └── index.tsx
│   ├── session.server.ts
│   ├── theme.ts
│   ├── ui/
│   │   ├── AceEditor.tsx
│   │   ├── Form/
│   │   │   ├── FormCancelButton.tsx
│   │   │   ├── FormHInput.tsx
│   │   │   ├── FormInput.tsx
│   │   │   ├── FormModal.tsx
│   │   │   ├── FormSubmitButton.tsx
│   │   │   ├── ModalInput.tsx
│   │   │   ├── PathInput.tsx
│   │   │   └── type.ts
│   │   ├── Header.tsx
│   │   ├── _AceEditor.tsx
│   │   ├── dashboard.css
│   │   └── index.ts
│   └── utils/
│       ├── hooks.ts
│       ├── index.ts
│       ├── mock.ts
│       └── treeBuilder.ts
├── package.json
├── prisma/
│   ├── schema.prisma
│   └── seed.ts
├── remix.config.js
├── remix.env.d.ts
└── tsconfig.json

================================================
FILE CONTENTS
================================================

================================================
FILE: .gitignore
================================================
# Build
build/

# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*

# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json

# Runtime data
pids
*.pid
*.seed
*.pid.lock

# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov

# Coverage directory used by tools like istanbul
coverage
*.lcov

# nyc test coverage
.nyc_output

# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt

# Bower dependency directory (https://bower.io/)
bower_components

# node-waf configuration
.lock-wscript

# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release

# Dependency directories
node_modules/
jspm_packages/

# TypeScript v1 declaration files
typings/

# TypeScript cache
*.tsbuildinfo

# Optional npm cache directory
.npm

# Optional eslint cache
.eslintcache

# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/

# Optional REPL history
.node_repl_history

# Output of 'npm pack'
*.tgz

# Yarn Integrity file
.yarn-integrity

# dotenv environment variables file
.env
.env.test

# parcel-bundler cache (https://parceljs.org/)
.cache

# Next.js build output
.next

# Nuxt.js build / generate output
.nuxt
dist

# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and *not* Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public

# vuepress build output
.vuepress/dist

# Serverless directories
.serverless/

# FuseBox cache
.fusebox/

# DynamoDB Local files
.dynamodb/

# TernJS port file
.tern-port
.idea
yarn.lock
package-lock.json

================================================
FILE: .npmrc
================================================
legacy-peer-deps=true


================================================
FILE: LICENSE
================================================
                                 Apache License
                           Version 2.0, January 2004
                        http://www.apache.org/licenses/

   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION

   1. Definitions.

      "License" shall mean the terms and conditions for use, reproduction,
      and distribution as defined by Sections 1 through 9 of this document.

      "Licensor" shall mean the copyright owner or entity authorized by
      the copyright owner that is granting the License.

      "Legal Entity" shall mean the union of the acting entity and all
      other entities that control, are controlled by, or are under common
      control with that entity. For the purposes of this definition,
      "control" means (i) the power, direct or indirect, to cause the
      direction or management of such entity, whether by contract or
      otherwise, or (ii) ownership of fifty percent (50%) or more of the
      outstanding shares, or (iii) beneficial ownership of such entity.

      "You" (or "Your") shall mean an individual or Legal Entity
      exercising permissions granted by this License.

      "Source" form shall mean the preferred form for making modifications,
      including but not limited to software source code, documentation
      source, and configuration files.

      "Object" form shall mean any form resulting from mechanical
      transformation or translation of a Source form, including but
      not limited to compiled object code, generated documentation,
      and conversions to other media types.

      "Work" shall mean the work of authorship, whether in Source or
      Object form, made available under the License, as indicated by a
      copyright notice that is included in or attached to the work
      (an example is provided in the Appendix below).

      "Derivative Works" shall mean any work, whether in Source or Object
      form, that is based on (or derived from) the Work and for which the
      editorial revisions, annotations, elaborations, or other modifications
      represent, as a whole, an original work of authorship. For the purposes
      of this License, Derivative Works shall not include works that remain
      separable from, or merely link (or bind by name) to the interfaces of,
      the Work and Derivative Works thereof.

      "Contribution" shall mean any work of authorship, including
      the original version of the Work and any modifications or additions
      to that Work or Derivative Works thereof, that is intentionally
      submitted to Licensor for inclusion in the Work by the copyright owner
      or by an individual or Legal Entity authorized to submit on behalf of
      the copyright owner. For the purposes of this definition, "submitted"
      means any form of electronic, verbal, or written communication sent
      to the Licensor or its representatives, including but not limited to
      communication on electronic mailing lists, source code control systems,
      and issue tracking systems that are managed by, or on behalf of, the
      Licensor for the purpose of discussing and improving the Work, but
      excluding communication that is conspicuously marked or otherwise
      designated in writing by the copyright owner as "Not a Contribution."

      "Contributor" shall mean Licensor and any individual or Legal Entity
      on behalf of whom a Contribution has been received by Licensor and
      subsequently incorporated within the Work.

   2. Grant of Copyright License. Subject to the terms and conditions of
      this License, each Contributor hereby grants to You a perpetual,
      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      copyright license to reproduce, prepare Derivative Works of,
      publicly display, publicly perform, sublicense, and distribute the
      Work and such Derivative Works in Source or Object form.

   3. Grant of Patent License. Subject to the terms and conditions of
      this License, each Contributor hereby grants to You a perpetual,
      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      (except as stated in this section) patent license to make, have made,
      use, offer to sell, sell, import, and otherwise transfer the Work,
      where such license applies only to those patent claims licensable
      by such Contributor that are necessarily infringed by their
      Contribution(s) alone or by combination of their Contribution(s)
      with the Work to which such Contribution(s) was submitted. If You
      institute patent litigation against any entity (including a
      cross-claim or counterclaim in a lawsuit) alleging that the Work
      or a Contribution incorporated within the Work constitutes direct
      or contributory patent infringement, then any patent licenses
      granted to You under this License for that Work shall terminate
      as of the date such litigation is filed.

   4. Redistribution. You may reproduce and distribute copies of the
      Work or Derivative Works thereof in any medium, with or without
      modifications, and in Source or Object form, provided that You
      meet the following conditions:

      (a) You must give any other recipients of the Work or
          Derivative Works a copy of this License; and

      (b) You must cause any modified files to carry prominent notices
          stating that You changed the files; and

      (c) You must retain, in the Source form of any Derivative Works
          that You distribute, all copyright, patent, trademark, and
          attribution notices from the Source form of the Work,
          excluding those notices that do not pertain to any part of
          the Derivative Works; and

      (d) If the Work includes a "NOTICE" text file as part of its
          distribution, then any Derivative Works that You distribute must
          include a readable copy of the attribution notices contained
          within such NOTICE file, excluding those notices that do not
          pertain to any part of the Derivative Works, in at least one
          of the following places: within a NOTICE text file distributed
          as part of the Derivative Works; within the Source form or
          documentation, if provided along with the Derivative Works; or,
          within a display generated by the Derivative Works, if and
          wherever such third-party notices normally appear. The contents
          of the NOTICE file are for informational purposes only and
          do not modify the License. You may add Your own attribution
          notices within Derivative Works that You distribute, alongside
          or as an addendum to the NOTICE text from the Work, provided
          that such additional attribution notices cannot be construed
          as modifying the License.

      You may add Your own copyright statement to Your modifications and
      may provide additional or different license terms and conditions
      for use, reproduction, or distribution of Your modifications, or
      for any such Derivative Works as a whole, provided Your use,
      reproduction, and distribution of the Work otherwise complies with
      the conditions stated in this License.

   5. Submission of Contributions. Unless You explicitly state otherwise,
      any Contribution intentionally submitted for inclusion in the Work
      by You to the Licensor shall be under the terms and conditions of
      this License, without any additional terms or conditions.
      Notwithstanding the above, nothing herein shall supersede or modify
      the terms of any separate license agreement you may have executed
      with Licensor regarding such Contributions.

   6. Trademarks. This License does not grant permission to use the trade
      names, trademarks, service marks, or product names of the Licensor,
      except as required for reasonable and customary use in describing the
      origin of the Work and reproducing the content of the NOTICE file.

   7. Disclaimer of Warranty. Unless required by applicable law or
      agreed to in writing, Licensor provides the Work (and each
      Contributor provides its Contributions) on an "AS IS" BASIS,
      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
      implied, including, without limitation, any warranties or conditions
      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
      PARTICULAR PURPOSE. You are solely responsible for determining the
      appropriateness of using or redistributing the Work and assume any
      risks associated with Your exercise of permissions under this License.

   8. Limitation of Liability. In no event and under no legal theory,
      whether in tort (including negligence), contract, or otherwise,
      unless required by applicable law (such as deliberate and grossly
      negligent acts) or agreed to in writing, shall any Contributor be
      liable to You for damages, including any direct, indirect, special,
      incidental, or consequential damages of any character arising as a
      result of this License or out of the use or inability to use the
      Work (including but not limited to damages for loss of goodwill,
      work stoppage, computer failure or malfunction, or any and all
      other commercial damages or losses), even if such Contributor
      has been advised of the possibility of such damages.

   9. Accepting Warranty or Additional Liability. While redistributing
      the Work or Derivative Works thereof, You may choose to offer,
      and charge a fee for, acceptance of support, warranty, indemnity,
      or other liability obligations and/or rights consistent with this
      License. However, in accepting such obligations, You may act only
      on Your own behalf and on Your sole responsibility, not on behalf
      of any other Contributor, and only if You agree to indemnify,
      defend, and hold each Contributor harmless for any liability
      incurred by, or claims asserted against, such Contributor by reason
      of your accepting any such warranty or additional liability.

   END OF TERMS AND CONDITIONS

   APPENDIX: How to apply the Apache License to your work.

      To apply the Apache License to your work, attach the following
      boilerplate notice, with the fields enclosed by brackets "[]"
      replaced with your own identifying information. (Don't include
      the brackets!)  The text should be enclosed in the appropriate
      comment syntax for the file format. We also recommend that a
      file or class name and description of purpose be included on the
      same "printed page" as the copyright notice for easier
      identification within third-party archives.

   Copyright [yyyy] [name of copyright owner]

   Licensed under the Apache License, Version 2.0 (the "License");
   you may not use this file except in compliance with the License.
   You may obtain a copy of the License at

       http://www.apache.org/licenses/LICENSE-2.0

   Unless required by applicable law or agreed to in writing, software
   distributed under the License is distributed on an "AS IS" BASIS,
   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
   See the License for the specific language governing permissions and
   limitations under the License.


================================================
FILE: README.md
================================================
<h1 align="center">
    <a href="https://apiannie.com">
       <img alt="ApiAnnie" src="https://user-images.githubusercontent.com/4088232/199437591-23d65512-2d66-4ba6-ae77-5f1748e5bdca.png" width="500">
    </a>
    <br><br>
    <small>A lightweight web tool for API documentation and development</small>
</h1>

> ℹ️ Api Annie is currently still in pre-release mode.ℹ️

Api Annie helps your team to co-ordinate easier when developing a product which needs API comunication, by going through the following steps:
1. **[Define]** Define your API structure.
2. **[Mock]** Frontend developer makes their prototype by using the mock server automatically generated by Api Annie.
3. **[Execute]** Backend developer builds the backend services and test it by sending requests through Api Annie
4. **[Integrate]** When both frontend and backend development are finished, let frontend switch communication from Api Annie to your backend server URL. 

## Installation
### Cloud service
We recommend using our [cloud service](https://apiannie.com), where you can always get access to the latest features.

### Manual installation
If you prefer deploying Api Annie in your own machine, run:
```
git clone git@github.com:apiannie/apiannie.git

cd apiannie
```

Then create a file named `.env`, which should contain the following environment variables:
```
DATABASE_URL="mongodb+srv://USERNAME:PASSWORD@HOST/DATABASE"
SESSION_SECRET="replace-it-by-random-string"
```
For more info about connection to MongoDB, please refer to [this doc from Prisma](https://www.prisma.io/docs/concepts/database-connectors/mongodb)

Then install dependency packages, and initalize database:
```
npm install
npx prisma db push
```

or if you are using yarn
```
yarn install
yarn prisma db push
```

Up to now, you have your environment successfully setup, to run Api Annie in dev mode:

`npm run dev` or `yarn dev`

To run in release mode:
```
npm run build
npm start
```
or if you are using yarn
```
yarn build
yarn start
```

## Tech stack
- [Remix](https://github.com/remix-run/remix) as the primary full stack web framework
- Database ORM with [Prisma](https://github.com/prisma/prisma)
- Static Types with [TypeScript](https://www.typescriptlang.org/)
- UI components built with [Chakra-UI](https://github.com/chakra-ui/chakra-ui)
- Form validation with [remix-validated-form](https://github.com/airjp73/remix-validated-form) and [Zod](https://github.com/colinhacks/zod)
- [Chance.js](https://github.com/chancejs/chancejs) for random data generate in mock server

## License
This project is licensed under the Apache License 2.0 - see the [LICENSE](https://github.com/apiannie/apiannie/blob/readme-update/LICENSE) file for details.


================================================
FILE: app/context.tsx
================================================
// context.tsx
import React, { createContext } from "react";

export interface ServerStyleContextData {
  key: string;
  ids: Array<string>;
  css: string;
}

export const ServerStyleContext = createContext<
  ServerStyleContextData[] | null
>(null);

export interface ClientStyleContextData {
  reset: () => void;
}

export const ClientStyleContext = createContext<ClientStyleContextData | null>(
  null
);


================================================
FILE: app/createEmotionCache.ts
================================================
// createEmotionCache.ts
import createCache from "@emotion/cache";

export default function createEmotionCache() {
  return createCache({ key: "css" });
}


================================================
FILE: app/entry.client.tsx
================================================
// entry.client.tsx
import React, { useState } from "react";
import { hydrateRoot } from "react-dom/client";
import { CacheProvider } from "@emotion/react";
import { RemixBrowser } from "@remix-run/react";

import { ClientStyleContext } from "./context";
import createEmotionCache from "./createEmotionCache";

interface ClientCacheProviderProps {
  children: React.ReactNode;
}

function ClientCacheProvider({ children }: ClientCacheProviderProps) {
  const [cache, setCache] = useState(createEmotionCache());

  function reset() {
    setCache(createEmotionCache());
  }

  return (
    <ClientStyleContext.Provider value={{ reset }}>
      <CacheProvider value={cache}>{children}</CacheProvider>
    </ClientStyleContext.Provider>
  );
}

hydrateRoot(
  document,
  <ClientCacheProvider>
    <RemixBrowser />
  </ClientCacheProvider>
);


================================================
FILE: app/entry.server.tsx
================================================
// entry.server.tsx
import { renderToString } from "react-dom/server";
import { CacheProvider } from "@emotion/react";
import createEmotionServer from "@emotion/server/create-instance";
import { RemixServer } from "@remix-run/react";
import type { EntryContext } from "@remix-run/node"; // Depends on the runtime you choose

import { ServerStyleContext } from "./context";
import createEmotionCache from "./createEmotionCache";

export default function handleRequest(
  request: Request,
  responseStatusCode: number,
  responseHeaders: Headers,
  remixContext: EntryContext
) {
  const cache = createEmotionCache();
  const { extractCriticalToChunks } = createEmotionServer(cache);

  const html = renderToString(
    <ServerStyleContext.Provider value={null}>
      <CacheProvider value={cache}>
        <RemixServer context={remixContext} url={request.url} />
      </CacheProvider>
    </ServerStyleContext.Provider>
  );

  const chunks = extractCriticalToChunks(html);

  const markup = renderToString(
    <ServerStyleContext.Provider value={chunks.styles}>
      <CacheProvider value={cache}>
        <RemixServer context={remixContext} url={request.url} />
      </CacheProvider>
    </ServerStyleContext.Provider>
  );

  responseHeaders.set("Content-Type", "text/html");

  return new Response(`<!DOCTYPE html>${markup}`, {
    status: responseStatusCode,
    headers: responseHeaders,
  });
}


================================================
FILE: app/models/api.server.ts
================================================
import { RequestParam } from ".prisma/client";
import { ApiData, RequestMethod } from "@prisma/client";
import { prisma } from "./prisma.server";
import { getProjectById, Group } from "./project.server";

export const createGroup = async ({
  parentId,
  projectId,
  name,
}: {
  parentId?: string;
  projectId: string;
  name: string;
}) => {
  if (parentId === "") {
    parentId = undefined;
  }
  return prisma.group.create({
    data: {
      projectId: projectId,
      parentId: parentId,
      name: name,
    },
  });
};

export const updateGroup = async ({
  id,
  parentId,
  name,
  description,
}: {
  id: string;
  name?: string;
  parentId?: string | undefined;
  description?: string;
}) => {
  return prisma.group.update({
    where: {
      id: id,
    },
    data: {
      name,
      description,
      parentId,
    },
  });
};

export const getGroupById = async (id: string) => {
  let group = await prisma.group.findFirst({
    where: {
      id: id,
    },
  });
  if (!group) {
    return null;
  }
  group.description ||= "";
  return group;
};

export const createApi = async (
  projectId: string,
  groupId: string | undefined,
  data: {
    name: string;
    path: string;
    method: RequestMethod;
    pathParams: RequestParam[];
  }
) => {
  if (groupId === "") {
    groupId = undefined;
  }
  let api = await prisma.api.create({
    data: {
      projectId: projectId,
      groupId: groupId,
      data: data,
    },
  });
  return api;
};

export const getApiById = async (id: string) => {
  return prisma.api.findFirst({
    where: { id },
  });
};

export const getApiProjectId = async (apiId: string) => {
  let api = await prisma.api.findFirst({
    where: {
      id: apiId,
    },
    select: {
      projectId: true,
    },
  });

  return api?.projectId;
};

export const updateApi = async (
  id: string,
  data: {
    groupId?: string | undefined;
    data?: { name: string; path: string; method: RequestMethod };
  }
) => {
  return prisma.api.update({
    where: {
      id: id,
    },
    data: data,
  });
};

export const saveApiData = async (id: string, data: ApiData) => {
  return prisma.api.update({
    where: {
      id: id,
    },
    data: {
      data: data,
    },
  });
};

export const findApisForMock = async (
  projectId: string,
  method: RequestMethod
) => {
  return prisma.api.findMany({
    where: {
      projectId: projectId,
      data: {
        is: {
          method: method,
        },
      },
    },
    select: {
      id: true,
      data: {
        select: {
          path: true,
        },
      },
    },
  });
};

export const deleteApi = async (id: string) => {
  let api = await prisma.api.findFirst({
    where: { id },
  });
  await prisma.api.delete({
    where: { id },
  });
  return api;
};

export const deleteGroup = async (id: string) => {
  let group = await prisma.group.findFirst({
    where: { id },
  });
  if (!group) {
    return null;
  }
  let project = await getProjectById(group.projectId);
  if (!project) {
    return null;
  }
  let groups: Group[] = [project.root];
  let target: Group | undefined;
  while (groups.length > 0) {
    let g = groups.pop();
    if (!g) {
      return null;
    }
    if (g.id === id) {
      target = g;
      break;
    } else {
      groups = groups.concat(g.groups);
    }
  }
  if (!target) {
    return null;
  }
  groups = [target];
  let groupsToDelete = [target.id];
  let apisToDelete: string[] = [];
  while (groups.length > 0) {
    let g = groups.pop();
    if (!g) {
      return null;
    }
    groups = groups.concat(g.groups);
    groupsToDelete = groupsToDelete.concat(g.groups.map((item) => item.id));
    apisToDelete = apisToDelete.concat(g.apis.map((item) => item.id));
  }

  await prisma.$transaction([
    prisma.group.deleteMany({
      where: {
        id: {
          in: groupsToDelete,
        },
      },
    }),
    prisma.api.deleteMany({
      where: {
        id: {
          in: apisToDelete,
        },
      },
    }),
  ]);

  return {
    group,
    groupsToDelete,
    apisToDelete,
  };
};


================================================
FILE: app/models/prisma.server.ts
================================================
import { PrismaClient } from "@prisma/client";

let prisma: PrismaClient;
declare global {
  var __db: PrismaClient | undefined;
}

if (process.env.NODE_ENV === "production") {
  prisma = new PrismaClient();
  prisma.$connect();
} else {
  if (!global.__db) {
    global.__db = new PrismaClient();
    global.__db.$connect();
  }
  prisma = global.__db;
}

export { prisma };


================================================
FILE: app/models/project.server.ts
================================================
import { ProjectUserRole, User } from '@prisma/client';
import invariant from 'tiny-invariant';
import { checkRole } from '~/utils';
import { prisma } from './prisma.server';

export const createProject = async (user: User, name: string) => {
  let project = await prisma.project.create({
    data: {
      name: name,
      members: {
        id: user.id,
        role: ProjectUserRole.ADMIN,
      },
    },
  });
  await prisma.user.update({
    where: {
      id: user.id,
    },
    data: {
      projectIds: {
        push: project.id,
      },
    },
  });
  return project;
};

export const getProjectByIds = async (ids: string[]) => {
  let projects = await prisma.project.findMany({
    where: {
      id: {
        in: ids,
      },
      isDeleted: false,
    },
    select: {
      id: true,
      name: true,
      members: true,
      apis: {
        select: { id: true },
      },
    },
  });
  return projects;
};

const findProjectById = async (id: string) => {
  let project = await prisma.project.findFirst({
    where: {
      id: id,
      isDeleted: false,
    },
    include: {
      groups: {
        select: {
          id: true,
          name: true,
          parentId: true,
        },
      },
      apis: {
        select: {
          id: true,
          groupId: true,
          data: {
            select: {
              name: true,
              method: true,
              path: true,
            },
          },
        },
      },
    },
  });

  return project;
};

export type Api = NonNullable<Awaited<ReturnType<typeof findProjectById>>>['apis'][0];
export type PlainGroup = NonNullable<Awaited<ReturnType<typeof findProjectById>>>['groups'][0];
export type Group = PlainGroup & {
  apis: Api[];
  groups: Group[];
};

export const getProjectById = async (id: string) => {
  let project = await findProjectById(id);

  if (!project) {
    return null;
  }

  let groupMap = new Map<string, Group>();
  let groups = project.groups.map((group) => ({
    ...group,
    apis: new Array(),
    groups: new Array(),
  }));

  let root: Group = {
    id: 'root',
    name: 'root',
    parentId: null,
    apis: [],
    groups: [],
  };

  for (let group of groups) {
    groupMap.set(group.id, group);
  }
  for (let group of groups) {
    if (group.parentId) {
      let parent = groupMap.get(group.parentId);
      invariant(parent, 'parent is null');
      parent.groups.push(group);
    } else {
      root.groups.push(group);
    }
  }
  for (let api of project.apis) {
    if (api.groupId) {
      let group = groupMap.get(api.groupId);
      // TODO: add warning log
      group?.apis.push(api);
    } else {
      root.apis.push(api);
    }
  }

  let { apis, groups: _, ...rest } = project;
  return {
    ...rest,
    root: root,
  };
};

export type Project = NonNullable<Awaited<ReturnType<typeof getProjectById>>>;

export const updateProject = async (
  id: string,
  data: { name?: string; isDeleted?: boolean }
) => {
  return await prisma.project.update({
    where: { id: id },
    data: data,
  });
};

export const addMemberToProject = async (
  projectId: string,
  userId: string,
  role: ProjectUserRole
) => {
  return await prisma.$transaction([
    prisma.project.update({
      where: { id: projectId },
      data: {
        members: {
          push: {
            id: userId,
            role: role,
          },
        },
      },
    }),
    prisma.user.update({
      where: { id: userId },
      data: {
        projectIds: {
          push: projectId,
        },
      },
    }),
  ]);
};

export const findProjectMembersById = async (id: string) => {
  let project = await prisma.project.findFirst({
    where: {
      id: id,
    },
    select: {
      id: true,
      name: true,
      members: true,
    },
  });

  return project;
};

export const transferProject = async (
  project: NonNullable<Awaited<ReturnType<typeof findProjectMembersById>>>,
  transferUser: User,
  currentUser: User
) => {
  const transaction = [
    prisma.project.update({
      where: { id: project.id },
      data: {
        members: {
          deleteMany: {
            where: { id: currentUser.id },
          },
        },
      },
    }),
    prisma.user.update({
      where: { id: currentUser.id },
      data: {
        projectIds: currentUser.projectIds.filter((id) => id !== project.id),
      },
    }),
  ];
  if (project.members.every((member) => member.id !== transferUser.id)) {
    transaction.push(
      prisma.project.update({
        where: { id: project.id },
        data: {
          members: {
            push: {
              id: transferUser.id,
              role: ProjectUserRole.ADMIN,
            },
          },
        },
      })
    );
  } else {
    transaction.push(
      prisma.project.update({
        where: { id: project.id },
        data: {
          members: {
            updateMany: {
              where: { id: transferUser.id },
              data: {
                role: ProjectUserRole.ADMIN,
              }
            },
          },
        },
      })
    );
  }
  if (!transferUser.projectIds.includes(project.id)) {
    transaction.push(
      prisma.user.update({
        where: { id: transferUser.id },
        data: {
          projectIds: {
            push: project.id,
          },
        },
      })
    );
  }
  return await prisma.$transaction(transaction);
};

export const checkAuthority = async (
  userId: string,
  projectId: string,
  requiredRole: ProjectUserRole
) => {
  let project = await prisma.project.findFirst({
    where: {
      id: projectId,
    },
    select: {
      id: true,
      members: true,
    },
  });

  if (!project) {
    return false;
  }

  let memberRole = project?.members.find(
    (member) => member.id === userId
  )?.role;

  if (!memberRole) {
    return false;
  }

  return checkRole(memberRole, requiredRole);
};

export const changeProjectRole = async (
  projectId: string,
  userId: string,
  role: ProjectUserRole
) => {
  return await prisma.project.update({
    where: {
      id: projectId,
    },
    data: {
      members: {
        updateMany: {
          where: {
            id: userId,
          },
          data: {
            role: role,
          },
        },
      },
    },
  });
};

export const changeProjectMembers = async (
  projectId: string,
  userId: string
) => {
  const user = await prisma.user.findFirst({
    where: {
      id: userId,
    }
  })
  if (!user) {
    return null;
  }
  return await prisma.$transaction([prisma.user.update({
    where: {
      id: userId,
    },
    data: {
      projectIds: user.projectIds.filter(id => id !== projectId)
    },
  }), prisma.project.update({
    where: {
      id: projectId,
    },
    data: {
      members: {
        deleteMany: {
          where: {
            id: userId,
          }
        },
      },
    },
  })
  ]);
};



================================================
FILE: app/models/type.ts
================================================
import {
  ParamType,
  Prisma,
  ProjectUserRole,
  RequestMethod,
} from "@prisma/client";

export const JsonNodeType = [
  ParamType.OBJECT,
  ParamType.ARRAY,
  ParamType.STRING,
  ParamType.FLOAT,
  ParamType.INT,
  ParamType.BOOLEAN,
] as const;

export const RequestMethods = [
  RequestMethod.GET,
  RequestMethod.POST,
  RequestMethod.PUT,
  RequestMethod.PATCH,
  RequestMethod.DELETE,
] as const;

export interface JsonNode {
  name: string;
  isRequired: boolean;
  type: Exclude<ParamType, "FILE">;
  mock?: string;
  example?: string;
  description?: string;
  children: JsonNode[];
  arrayElem?: JsonNode;
}

export const ProjectUserRoles = [
  ProjectUserRole.READ,
  ProjectUserRole.WRITE,
  ProjectUserRole.ADMIN,
] as const;


================================================
FILE: app/models/user.server.ts
================================================
import { User } from "@prisma/client";
import { prisma } from "./prisma.server";
import bcrypt from "bcryptjs";
export type { User };

export async function getUserById(id: User["id"]) {
  return prisma.user.findUnique({ where: { id } });
}

export async function getUserByName(name: User["name"]) {
  return prisma.user.findFirst({ where: { name } });
}

export async function getUserByEmail(email: User["email"]) {
  return prisma.user.findUnique({ where: { email } });
}

export async function getUserInfoByIds(ids: string[]) {
  return prisma.user.findMany({
    where: {
      id: {
        in: ids,
      },
    },
    select: {
      id: true,
      name: true,
      email: true,
    },
  });
}

export async function createUser(
  email: User["email"],
  password: User["password"],
  name: User["name"]
) {
  const hashedPassword = await bcrypt.hash(password, 10);

  return prisma.user.create({
    data: {
      name,
      email,
      password: hashedPassword,
    },
  });
}

export async function verifyLogin(
  email: User["email"],
  password: User["password"]
) {
  const userWithPassword = await prisma.user.findUnique({
    where: { email },
  });

  if (!userWithPassword || !userWithPassword.password) {
    return null;
  }

  const isValid = await bcrypt.compare(password, userWithPassword.password);

  if (!isValid) {
    return null;
  }

  const { password: _password, ...userWithoutPassword } = userWithPassword;

  return userWithoutPassword;
}

export const updateUserInfo = async (
  id: string,
  {
    name,
  }: {
    name?: string;
  }
) => {
  const result = await prisma.user.update({
    where: { id: id },
    data: {
      name: name,
    },
  });

  return result;
};

export const updatePassword = async (id: string, password: string) => {
  const hashedPassword = await bcrypt.hash(password, 10);
  return await prisma.user.update({
    where: { id },
    data: { password: hashedPassword },
  });
};


================================================
FILE: app/root.tsx
================================================
// root.tsx
import {
  ChakraProvider,
  cookieStorageManagerSSR,
  localStorageManager,
  useConst,
} from "@chakra-ui/react";
import { withEmotionCache } from "@emotion/react";
import { json, LinksFunction, LoaderArgs, MetaFunction } from "@remix-run/node"; // Depends on the runtime you choose
import {
  Links,
  LiveReload,
  Meta,
  Outlet,
  Scripts,
  ScrollRestoration,
  useLoaderData,
} from "@remix-run/react";
import React, { useContext, useEffect } from "react";
import { ClientStyleContext, ServerStyleContext } from "./context";
import { getUser } from "./session.server";

export const loader = async ({ request }: LoaderArgs) => {
  let user = await getUser(request);
  return json({
    cookies: request.headers.get("cookie") ?? "",
    user: { ...user },
    url: request.url,
  });
};

export const meta: MetaFunction = () => ({
  charset: "utf-8",
  title: "Api Annie",
  viewport: "width=device-width,initial-scale=1",
});

export let links: LinksFunction = () => {
  return [
    { rel: "preconnect", href: "https://fonts.googleapis.com" },
    { rel: "preconnect", href: "https://fonts.gstatic.com" },
    {
      rel: "icon",
      href: "/favicon.ico",
      type: "image/png",
    },
  ];
};

interface DocumentProps {
  children: React.ReactNode;
}

const Document = withEmotionCache(
  ({ children }: DocumentProps, emotionCache) => {
    const serverStyleData = useContext(ServerStyleContext);
    const clientStyleData = useContext(ClientStyleContext);

    // Only executed on client
    useEffect(() => {
      // re-link sheet container
      emotionCache.sheet.container = document.head;
      // re-inject tags
      const tags = emotionCache.sheet.tags;
      emotionCache.sheet.flush();
      tags.forEach((tag) => {
        (emotionCache.sheet as any)._insertTag(tag);
      });
      // reset cache to reapply global styles
      clientStyleData?.reset();
    }, []);

    return (
      <html lang="en">
        <head>
          <Meta />
          <Links />
          {serverStyleData?.map(({ key, ids, css }) => (
            <style
              key={key}
              data-emotion={`${key} ${ids.join(" ")}`}
              dangerouslySetInnerHTML={{ __html: css }}
            />
          ))}
        </head>
        <body>
          {children}
          <ScrollRestoration />
          <Scripts />
          <LiveReload />
        </body>
      </html>
    );
  }
);

export default function App() {
  const { cookies } = useLoaderData<typeof loader>();
  const cookieManager = useConst(cookieStorageManagerSSR(cookies));
  return (
    <Document>
      <ChakraProvider
        colorModeManager={
          typeof cookies === "string" ? cookieManager : localStorageManager
        }
      >
        <Outlet />
      </ChakraProvider>
    </Document>
  );
}


================================================
FILE: app/routes/home/..lib/ColorModeButton.tsx
================================================
import { MoonIcon, SunIcon } from "@chakra-ui/icons";
import { Button, ButtonProps, useColorMode } from "@chakra-ui/react";

export default function ColorModeButton(props: ButtonProps) {
  const { colorMode, toggleColorMode } = useColorMode();
  return (
    <Button {...props} onClick={toggleColorMode}>
      {colorMode === "light" ? <MoonIcon /> : <SunIcon />}
    </Button>
  );
}


================================================
FILE: app/routes/home/..lib/Layout.tsx
================================================
import {
  ChevronDownIcon,
  ChevronRightIcon,
  CloseIcon,
  HamburgerIcon,
} from "@chakra-ui/icons";
import {
  Box,
  Button,
  Collapse,
  Container,
  Flex,
  Grid,
  Icon,
  IconButton,
  Image,
  Link,
  Popover,
  PopoverContent,
  PopoverTrigger,
  Stack,
  Text,
  useColorMode,
  useColorModeValue,
  useDisclosure,
  VisuallyHidden,
} from "@chakra-ui/react";
import { Link as RemixLink } from "@remix-run/react";
import { PropsWithChildren, ReactNode } from "react";
import { FaGithub, FaTwitter } from "react-icons/fa";
import logo from "~/images/logo_banner_sm.png";
import { useOptionalUser } from "~/utils";
import ColorModeButton from "./ColorModeButton";
import UserMenuButton from "./UserMenuButton";

export default function Layout({ children }: PropsWithChildren) {
  return (
    <Grid templateRows="73px 1fr 73px" minH="100vh">
      <Header />
      {children}
      <Footer />
    </Grid>
  );
}

function Header() {
  const { isOpen, onToggle } = useDisclosure();
  const { colorMode, toggleColorMode } = useColorMode();
  const user = useOptionalUser();
  return (
    <Box
      borderBottom={1}
      borderStyle={"solid"}
      borderColor={useColorModeValue("gray.200", "gray.900")}
    >
      <Flex
        bg={useColorModeValue("white", "gray.800")}
        color={useColorModeValue("gray.600", "white")}
        minH={"60px"}
        py={{ base: 4 }}
        px={{ base: 4 }}
        align={"center"}
        margin="0 auto"
        maxWidth={1200}
      >
        <Flex
          flex={{ base: 1, md: "auto" }}
          ml={{ base: -2 }}
          display={{ base: "flex", md: "none" }}
        >
          <IconButton
            onClick={onToggle}
            icon={
              isOpen ? <CloseIcon w={3} h={3} /> : <HamburgerIcon w={5} h={5} />
            }
            variant={"ghost"}
            aria-label={"Toggle Navigation"}
          />
        </Flex>
        <Flex flex={{ base: 1 }} justify={{ base: "center", md: "start" }}>
          <RemixLink to="/">
            <Image
              src={logo}
              display={{ base: "none", md: "flex" }}
              height="32px"
            />
          </RemixLink>
          <Flex display={{ base: "none", md: "flex" }} ml={10}>
            <DesktopNav />
          </Flex>
        </Flex>

        <Stack
          flex={{ base: 1, md: 0 }}
          justify={"flex-end"}
          direction={"row"}
          spacing={6}
        >
          <Button variant="link">
            <Link isExternal href={"https://github.com/apiannie/apiannie"}>
              <Icon as={FaGithub} w={5} h={5} />
            </Link>
          </Button>

          <ColorModeButton />
          {user ? (
            <UserMenuButton
              avatar={user.avatar || undefined}
              name={user.name}
            />
          ) : (
            <GuestMenuButtons />
          )}
        </Stack>
      </Flex>

      <Collapse in={isOpen} animateOpacity>
        <MobileNav />
      </Collapse>
    </Box>
  );
}

function GuestMenuButtons() {
  return (
    <>
      <Button
        fontSize={"sm"}
        fontWeight={400}
        variant={"link"}
        as={RemixLink}
        to="/home/signin"
      >
        Sign In
      </Button>
      <Button
        display={{ base: "none", md: "inline-flex" }}
        fontSize={"sm"}
        fontWeight={600}
        colorScheme="teal"
        as={RemixLink}
        to="/home/signup"
      >
        Sign up
      </Button>
    </>
  );
}

const DesktopNav = () => {
  const linkColor = useColorModeValue("gray.600", "gray.200");
  const linkHoverColor = useColorModeValue("gray.800", "white");
  const popoverContentBgColor = useColorModeValue("white", "gray.800");

  return (
    <Stack direction={"row"} spacing={4}>
      {NAV_ITEMS.map((navItem) => (
        <Box key={navItem.label}>
          <Popover trigger={"hover"} placement={"bottom-start"}>
            <PopoverTrigger>
              {/* <Link to="#">{navItem.label}</Link> */}
            </PopoverTrigger>

            {navItem.children && (
              <PopoverContent
                border={0}
                boxShadow={"xl"}
                bg={popoverContentBgColor}
                p={4}
                rounded={"xl"}
                minW={"sm"}
              >
                <Stack>
                  {navItem.children.map((child) => (
                    <DesktopSubNav key={child.label} {...child} />
                  ))}
                </Stack>
              </PopoverContent>
            )}
          </Popover>
        </Box>
      ))}
    </Stack>
  );
};

const DesktopSubNav = ({ label, href, subLabel }: NavItem) => {
  return (
    <Link
      role={"group"}
      display={"block"}
      p={2}
      rounded={"md"}
      _hover={{ bg: useColorModeValue("pink.50", "gray.900") }}
    >
      <Stack direction={"row"} align={"center"}>
        <Box>
          <Text
            transition={"all .3s ease"}
            _groupHover={{ color: "pink.400" }}
            fontWeight={500}
          >
            {label}
          </Text>
          <Text fontSize={"sm"}>{subLabel}</Text>
        </Box>
        <Flex
          transition={"all .3s ease"}
          transform={"translateX(-10px)"}
          opacity={0}
          _groupHover={{ opacity: "100%", transform: "translateX(0)" }}
          justify={"flex-end"}
          align={"center"}
          flex={1}
        >
          <Icon color={"pink.400"} w={5} h={5} as={ChevronRightIcon} />
        </Flex>
      </Stack>
    </Link>
  );
};

const MobileNav = () => {
  return (
    <Stack
      bg={useColorModeValue("white", "gray.800")}
      p={4}
      display={{ md: "none" }}
    >
      {NAV_ITEMS.map((navItem) => (
        <MobileNavItem key={navItem.label} {...navItem} />
      ))}
    </Stack>
  );
};

const MobileNavItem = ({ label, children, href }: NavItem) => {
  const { isOpen, onToggle } = useDisclosure();

  return (
    <Stack spacing={4} onClick={children && onToggle}>
      <Flex
        py={2}
        as={Link}
        to={href ?? "#"}
        justify={"space-between"}
        align={"center"}
        _hover={{
          textDecoration: "none",
        }}
      >
        <Text
          fontWeight={600}
          color={useColorModeValue("gray.600", "gray.200")}
        >
          {label}
        </Text>
        {children && (
          <Icon
            as={ChevronDownIcon}
            transition={"all .25s ease-in-out"}
            transform={isOpen ? "rotate(180deg)" : ""}
            w={6}
            h={6}
          />
        )}
      </Flex>

      <Collapse in={isOpen} animateOpacity style={{ marginTop: "0!important" }}>
        <Stack
          mt={2}
          pl={4}
          borderLeft={1}
          borderStyle={"solid"}
          borderColor={useColorModeValue("gray.200", "gray.700")}
          align={"start"}
        >
          {children &&
            children.map((child) => (
              <RemixLink to="#" key={child.label}>
                {child.label}
              </RemixLink>
            ))}
        </Stack>
      </Collapse>
    </Stack>
  );
};

interface NavItem {
  label: string;
  subLabel?: string;
  children?: Array<NavItem>;
  href?: string;
}

const NAV_ITEMS: Array<NavItem> = [];

const SocialButton = ({
  children,
  label,
  href,
}: {
  children: ReactNode;
  label: string;
  href: string;
}) => {
  return (
    <Button
      bg={useColorModeValue("blackAlpha.100", "whiteAlpha.100")}
      rounded={"full"}
      cursor={"pointer"}
      as={Link}
      href={href}
      isExternal
      display={"inline-flex"}
      alignItems={"center"}
      justifyContent={"center"}
      transition={"background 0.3s ease"}
      _hover={{
        bg: useColorModeValue("blackAlpha.200", "whiteAlpha.200"),
      }}
    >
      <VisuallyHidden>{label}</VisuallyHidden>
      {children}
    </Button>
  );
};

function Footer() {
  return (
    <Box
      bg={useColorModeValue("gray.50", "gray.900")}
      color={useColorModeValue("gray.700", "gray.200")}
    >
      <Box
        borderTopWidth={1}
        borderStyle={"solid"}
        borderColor={useColorModeValue("gray.200", "gray.700")}
      >
        <Container
          as={Stack}
          maxW={"6xl"}
          py={4}
          direction={{ base: "column", md: "row" }}
          spacing={4}
          justify={{ base: "center", md: "space-between" }}
          align={{ base: "center", md: "center" }}
        >
          <Text>© 2022 Api Annie. All rights reserved</Text>
          <Stack direction={"row"} spacing={6}>
            <SocialButton
              label={"Github"}
              href={"https://github.com/apiannie/apiannie"}
            >
              <Icon as={FaGithub} w={4} h={4} />
            </SocialButton>
            {/* <SocialButton label={"Twitter"} href={"#"}>
              <FaTwitter />
            </SocialButton> */}
          </Stack>
        </Container>
      </Box>
    </Box>
  );
}


================================================
FILE: app/routes/home/..lib/NotificationButton.tsx
================================================
import {
  Button,
  ButtonProps,
  Center,
  Icon,
  Popover,
  PopoverArrow,
  PopoverBody,
  PopoverCloseButton,
  PopoverContent,
  PopoverHeader,
  PopoverTrigger,
} from "@chakra-ui/react";
import React from "react";
import { FiBell } from "react-icons/fi";

const NotificationButton = ({ ...props }: ButtonProps) => {
  return (
    <Popover>
      <PopoverTrigger>
        <Button {...props}>
          <Icon aria-label="open menu" as={FiBell} />
        </Button>
      </PopoverTrigger>
      <PopoverContent>
        <PopoverArrow />
        {/* <PopoverCloseButton /> */}
        {/* <PopoverHeader>Confirmation!</PopoverHeader> */}
        <PopoverBody>
          <Center minH={48} color="gray.400">
            No notifications
          </Center>
        </PopoverBody>
      </PopoverContent>
    </Popover>
  );
};

export default NotificationButton;


================================================
FILE: app/routes/home/..lib/UserMenuButton.tsx
================================================
import {
  Avatar,
  AvatarProps,
  Box,
  Button,
  Menu,
  MenuButton,
  MenuDivider,
  MenuItem,
  MenuList,
  Text,
} from "@chakra-ui/react";
import { Link, useSubmit } from "@remix-run/react";

export default function UserMenuButton({
  size,
  avatar,
  name,
  ...props
}: Omit<AvatarProps, "avatar"> & {
  avatar?: string;
  name: string;
}) {
  const submit = useSubmit();

  return (
    <Menu>
      <MenuButton
        as={Button}
        rounded={"full"}
        variant={"link"}
        cursor={"pointer"}
        minW={0}
      >
        <Avatar size={size || "sm"} src={avatar} {...props}></Avatar>
      </MenuButton>
      <MenuList>
        <Box px={3} fontSize="sm">
          <Text>Signed in as</Text>
          <Text fontWeight={"bold"}>{name}</Text>
        </Box>
        <MenuDivider />
        <MenuItem as={Link} to="/home/settings">
          Settings
        </MenuItem>
        <MenuDivider />
        <MenuItem
          onClick={(e) =>
            submit(null, { method: "post", action: "/home/logout" })
          }
        >
          Sign out
        </MenuItem>
      </MenuList>
    </Menu>
  );
}


================================================
FILE: app/routes/home/logout.tsx
================================================
import type { ActionArgs, LoaderArgs } from "@remix-run/node";
import { redirect } from "@remix-run/node";

import { logout } from "~/session.server";

export async function action({ request }: ActionArgs) {
  return logout(request);
}

export async function loader({ request }: LoaderArgs) {
  return logout(request);
}


================================================
FILE: app/routes/home/settings.tsx
================================================
import {
  Flex,
  Heading,
  Text,
  Stack,
  useColorModeValue,
  Box,
  Container,
  Center,
  useToast,
} from "@chakra-ui/react";
import { ActionArgs } from "@remix-run/node";
import { withZod } from "@remix-validated-form/with-zod";
import { ValidatedForm, validationError } from "remix-validated-form";
import { z } from "zod";
import {
  getUserById,
  updatePassword,
  updateUserInfo,
} from "~/models/user.server";
import { getUser, requireUser, requireUserId } from "~/session.server";
import { FormHInput, FormSubmitButton, Header } from "~/ui";
import { httpResponse, useUser } from "~/utils";
import Layout from "./..lib/Layout";
import bcrypt from "bcryptjs";
import { json } from "remix-utils";
import { useActionData, useSubmit, useTransition } from "@remix-run/react";
import { useEffect } from "react";

export const action = async ({ request }: ActionArgs) => {
  let userId = await requireUserId(request);
  let formData = await request.formData();
  let action = formData.get("_action");
  switch (action) {
    case "updateInfo": {
      let result = await accountValidator.validate(formData);
      if (result.error) {
        return validationError(result.error);
      }
      await updateUserInfo(userId, result.data);
      break;
    }

    case "changePassword": {
      let result = await passwordValidator.validate(formData);
      if (result.error) {
        return validationError(result.error);
      }

      let { passwordCurrent, passwordConfirm, passwordNew } = result.data;
      if (passwordNew !== passwordConfirm) {
        return validationError(
          { fieldErrors: { passwordConfirm: "Password does not match" } },
          undefined,
          { status: 400 }
        );
      }

      let user = await getUserById(userId);
      if (!user) {
        return httpResponse.BadRequest;
      }

      const isValid = await bcrypt.compare(passwordCurrent, user.password);
      if (!isValid) {
        return validationError(
          {
            fieldErrors: {
              passwordCurrent: "Password does not match our record",
            },
          },
          undefined,
          { status: 400 }
        );
      }

      await updatePassword(userId, passwordNew);
      break;
    }

    default:
      return httpResponse.BadRequest;
  }
  return json({ action: action });
};

const accountValidator = withZod(
  z.object({
    name: z
      .string()
      .trim()
      .min(1, { message: "Please input your name" })
      .min(2, { message: "Name should contain at least 2 characters" }),
  })
);

const passwordValidator = withZod(
  z.object({
    passwordCurrent: z
      .string()
      .min(1, { message: "Please input your password" }),
    passwordNew: z
      .string()
      .min(1, { message: "Please input your new password" })
      .min(8, { message: "Length of password should be at least 8" })
      .max(32, "Length of password should not exceed 32"),
    passwordConfirm: z
      .string()
      .min(1, { message: "Please input your confirm password" }),
  })
);

export default function Settings() {
  const user = useUser();
  const bg = useColorModeValue("gray.100", "gray.700");
  const bgBW = useColorModeValue("white", "gray.900");
  const labelWidth = 280;
  const toast = useToast();
  let transition = useTransition();

  useEffect(() => {
    if (transition.state === "loading") {
      let action = transition.submission?.formData?.get("_action");
      if (action === "updateInfo") {
        toast({
          title: "Account updated.",
          status: "success",
          position: "top",
        });
      } else if (action === "changePassword") {
        toast({
          title: "Password changed.",
          status: "success",
          position: "top",
        });
      }
    }
  }, [transition.state]);

  return (
    <Layout>
      <Container mt={8} maxW="container.lg">
        <Header>Account</Header>
        <Box
          bg={bg}
          pr={labelWidth}
          pt={8}
          pb={12}
          as={ValidatedForm}
          validator={accountValidator}
          replace
          method="patch"
        >
          <FormHInput
            container={{ mt: 4 }}
            label="Email"
            name="email"
            p={2}
            labelWidth={labelWidth}
            as={Text}
            w="full"
          >
            {user.email}
          </FormHInput>
          <FormHInput
            container={{ mt: 4 }}
            label="Name"
            name="name"
            labelWidth={labelWidth}
            bg={bgBW}
            defaultValue={user.name}
          />

          <Center mt={4} pl={labelWidth}>
            <FormSubmitButton
              name="_action"
              value="updateInfo"
              w={160}
              colorScheme={"blue"}
            >
              Save
            </FormSubmitButton>
          </Center>
        </Box>

        <Header mt={12}>Password</Header>
        <Box
          bg={bg}
          pr={labelWidth}
          pt={8}
          pb={12}
          as={ValidatedForm}
          validator={passwordValidator}
          replace
          method="patch"
          resetAfterSubmit
        >
          <FormHInput
            container={{ mt: 4 }}
            label="Current password"
            name="passwordCurrent"
            labelWidth={labelWidth}
            bg={bgBW}
            type="password"
          />
          <FormHInput
            container={{ mt: 4 }}
            label="New password"
            name="passwordNew"
            labelWidth={labelWidth}
            bg={bgBW}
            type="password"
          />
          <FormHInput
            container={{ mt: 4 }}
            label="Confirm password"
            name="passwordConfirm"
            labelWidth={labelWidth}
            bg={bgBW}
            type="password"
          />
          <Center mt={4} pl={labelWidth}>
            <FormSubmitButton
              name="_action"
              value="changePassword"
              w={160}
              colorScheme={"blue"}
            >
              Save
            </FormSubmitButton>
          </Center>
        </Box>
      </Container>
    </Layout>
  );
}


================================================
FILE: app/routes/home/signin.tsx
================================================
import {
  Box,
  Button,
  Checkbox,
  Flex,
  Heading,
  InputRightElement,
  Stack,
  Text,
  useColorModeValue,
} from "@chakra-ui/react";

import { ViewIcon, ViewOffIcon } from "@chakra-ui/icons";
import {
  ActionArgs,
  json,
  LoaderArgs,
  MetaFunction,
  redirect,
} from "@remix-run/node";
import { Link, useActionData } from "@remix-run/react";
import { withZod } from "@remix-validated-form/with-zod";
import { useEffect, useRef, useState } from "react";
import { ValidatedForm, validationError } from "remix-validated-form";
import { z } from "zod";
import { verifyLogin } from "~/models/user.server";
import { createUserSession, getUserId } from "~/session.server";
import FormInput from "~/ui/Form/FormInput";
import FormSubmitButton from "~/ui/Form/FormSubmitButton";
import { safeRedirect } from "~/utils";
import Layout from "./..lib/Layout";

export async function loader({ request }: LoaderArgs) {
  const userId = await getUserId(request);
  if (userId) return redirect("/projects");
  return json({});
}

export async function action({ request }: ActionArgs) {
  const formData = await request.formData();
  const result = await validator.validate(formData);

  if (result.error) {
    return validationError(result.error);
  }

  const { email, password, remember } = result.data;
  const redirectTo = safeRedirect(formData.get("redirectTo"), "/projects");
  const user = await verifyLogin(email, password);

  if (!user) {
    return validationError(
      { fieldErrors: { password: "Incorrect password" } },
      result.submittedData,
      { status: 401 }
    );
  }

  return createUserSession({
    request,
    userId: user.id,
    remember: !!remember,
    redirectTo,
  });
}

export const meta: MetaFunction = () => {
  return {
    title: "Login | Api Annie",
  };
};

export const validator = withZod(
  z.object({
    email: z
      .string()
      .trim()
      .min(1, { message: "Please input your email" })
      .email(),
    password: z.string().min(1, "Please input your password"),
    remember: z.string().optional(),
  })
);

export default function Login() {
  const [showPassword, setShowPassword] = useState(false);
  const emailRef = useRef<HTMLInputElement>(null);
  const passwordRef = useRef<HTMLInputElement>(null);
  const actionData = useActionData();

  useEffect(() => {
    if (actionData?.fieldErrors?.email) {
      emailRef.current?.focus();
    } else if (actionData?.fieldErrors?.password) {
      passwordRef.current?.focus();
    }
  }, [actionData]);

  return (
    <Layout>
      <Flex minH={"100vh"} bg={useColorModeValue("gray.50", "gray.800")}>
        <Stack spacing={8} mx={"auto"} width="440px" maxW={"lg"} py={20} px={6}>
          <Stack align={"center"}>
            <Heading fontSize={"4xl"}>Sign in to your account</Heading>
            <Text fontSize={"lg"} color={"gray.600"}>
              to enjoy all of our cool <Link to="#">features</Link> ✌️
            </Text>
          </Stack>
          <Box
            rounded={"lg"}
            bg={useColorModeValue("white", "gray.700")}
            boxShadow={"lg"}
            p={8}
          >
            <ValidatedForm validator={validator} method="post">
              <Stack spacing={4}>
                <FormInput label="Email address" name="email" ref={emailRef} />
                <FormInput
                  label="Password"
                  name="password"
                  type={showPassword ? "text" : "password"}
                  ref={passwordRef}
                >
                  <InputRightElement h={"full"}>
                    <Button
                      variant={"ghost"}
                      onClick={() =>
                        setShowPassword((showPassword) => !showPassword)
                      }
                    >
                      {showPassword ? <ViewIcon /> : <ViewOffIcon />}
                    </Button>
                  </InputRightElement>
                </FormInput>
                <Stack spacing={10}>
                  <Stack
                    direction={{ base: "column", sm: "row" }}
                    align={"start"}
                    justify={"space-between"}
                  >
                    <Checkbox name="remember" value="on">
                      Remember me
                    </Checkbox>
                  </Stack>
                  <FormSubmitButton type="submit" colorScheme="teal">
                    Sign in
                  </FormSubmitButton>
                </Stack>
                <Stack pt={6}>
                  <Text align={"center"}>
                    New to Api Annie?{" "}
                    <Link to="/home/signup" color={"blue.400"}>
                      Sign up
                    </Link>
                  </Text>
                </Stack>
              </Stack>
            </ValidatedForm>
          </Box>
        </Stack>
      </Flex>
    </Layout>
  );
}


================================================
FILE: app/routes/home/signup.tsx
================================================
import {
  Box,
  Flex,
  Heading,
  Stack,
  Text,
  useColorModeValue,
} from "@chakra-ui/react";

import { ActionArgs, json, LoaderArgs, redirect } from "@remix-run/node";
import { Link, useActionData } from "@remix-run/react";
import { withZod } from "@remix-validated-form/with-zod";
import { ValidatedForm, validationError } from "remix-validated-form";
import { z } from "zod";
import { createUser, getUserByEmail } from "~/models/user.server";
import { createUserSession, getUserId } from "~/session.server";
import FormInput from "~/ui/Form/FormInput";
import FormSubmitButton from "~/ui/Form/FormSubmitButton";
import { safeRedirect } from "~/utils";
import Layout from "./..lib/Layout";

export async function loader({ request }: LoaderArgs) {
  const userId = await getUserId(request);
  if (userId) return redirect("/workspaces");
  return json({});
}

export async function action({ request }: ActionArgs) {
  const formData = await request.formData();
  const result = await validator.validate(formData);

  if (result.error) {
    return validationError(result.error);
  }

  const { name, email, password, passwordConfirm } = result.data;
  const redirectTo = safeRedirect(formData.get("redirectTo"), "/");

  if (password !== passwordConfirm) {
    return validationError(
      { fieldErrors: { passwordConfirm: "Password does not match" } },
      undefined,
      { status: 400 }
    );
  }

  const existingUser = await getUserByEmail(email);
  if (existingUser) {
    return validationError(
      { fieldErrors: { email: "A user already exists with this email" } },
      undefined,
      { status: 400 }
    );
  }

  const user = await createUser(email, password, name);

  return createUserSession({
    request,
    userId: user.id,
    remember: false,
    redirectTo,
  });
}

export const validator = withZod(
  z
    .object({
      name: z
        .string()
        .trim()
        .min(1, { message: "Please input your name" })
        .min(2, { message: "Name should contain at least 2 characters" }),
      email: z
        .string()
        .trim()
        .min(1, { message: "Please input your email" })
        .max(128, { message: "Email is too long (over 128)" })
        .email(),
      password: z
        .string()
        .min(1, { message: "Please input your password" })
        .min(8, { message: "Length of password should be at least 8" })
        .max(32, "Length of password should not exceed 32"),
      passwordConfirm: z
        .string()
        .min(1, { message: "Please input your confirm password" }),
    })
    .refine(({ password, passwordConfirm }) => password === passwordConfirm, {
      path: ["passwordConfirm"],
      message: "Passwords must match",
    })
);

export default function SignUp() {
  const actionData = useActionData<typeof action>();

  return (
    <Layout>
      <Flex
        minH={"100vh"}
        justify={"center"}
        bg={useColorModeValue("gray.50", "gray.800")}
      >
        <Stack width="440px" spacing={8} mx={"auto"} maxW={"lg"} py={12} px={6}>
          <Stack align={"center"}>
            <Heading fontSize={"4xl"} textAlign={"center"}>
              Sign up
            </Heading>
            <Text fontSize={"lg"} color={"gray.600"}>
              to enjoy all of our cool <Link to="#">features</Link> ✌️
            </Text>
          </Stack>
          <Box
            rounded={"lg"}
            bg={useColorModeValue("white", "gray.700")}
            boxShadow={"lg"}
            p={8}
          >
            <Stack spacing={4}>
              <ValidatedForm validator={validator} method="post">
                <FormInput name="name" label="Name" type="text" />
                <FormInput name="email" label="Email" type="email" />
                <FormInput name="password" label="Password" type="password" />
                <FormInput
                  name="passwordConfirm"
                  label="Confirm Password"
                  type="password"
                />

                <Stack spacing={10} pt={2}>
                  <FormSubmitButton size="lg" colorScheme="teal">
                    Sign up
                  </FormSubmitButton>
                </Stack>
              </ValidatedForm>

              <Stack pt={6}>
                <Text align={"center"}>
                  Already a user?{" "}
                  <Link to="/home/signin" color={"blue.400"}>
                    Sign in
                  </Link>
                </Text>
              </Stack>
            </Stack>
          </Box>
        </Stack>
      </Flex>
    </Layout>
  );
}


================================================
FILE: app/routes/index.tsx
================================================
import { Box, Button, Container, Heading, Stack, Text } from "@chakra-ui/react";
import { json, LoaderArgs, redirect } from "@remix-run/node";
import { Link as RemixLink } from "@remix-run/react";
import { getUserId } from "~/session.server";
import Layout from "./home/..lib/Layout";

export function links() {
  return [
    {
      rel: "stylesheet",
      href: "https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,300;0,400;0,500;0,600;0,700;0,800;1,300;1,400;1,500;1,600;1,700;1,800&display=swap",
    },
    {
      rel: "stylesheet",
      href: "https://fonts.googleapis.com/css2?family=Caveat:wght@700&display=swap",
    },
  ];
}

export async function loader({ request }: LoaderArgs) {
  const userId = await getUserId(request);
  if (userId) return redirect("/projects");
  return json({});
}

export default function () {
  return (
    <Layout>
      <Container maxW={"4xl"}>
        <Stack
          as={Box}
          textAlign={"center"}
          spacing={{ base: 8, md: 14 }}
          py={{ base: 20, md: 36 }}
        >
          <Heading
            fontWeight={600}
            fontSize={{ base: "2xl", sm: "4xl", md: "6xl" }}
            lineHeight={"110%"}
          >
            Lightweight platform
            <br />
            <Text
              fontSize={{ base: "2xl", sm: "4xl", md: "6xl" }}
              as={"span"}
              color={"teal.400"}
            >
              for API development
            </Text>
          </Heading>
          <Text fontSize={{ base: "1xl" }} color={"gray.500"}>
            API documentation, debuging, mocking and testing tool
            <br />
            for frontend developers, backend engineers and QAs
          </Text>
          <Text color={"gray.500"}></Text>
          <Stack
            direction={"column"}
            spacing={3}
            align={"center"}
            alignSelf={"center"}
            position={"relative"}
          >
            <RemixLink to="/home/signup">
              <Button colorScheme={"teal"} px={6}>
                Get Started
              </Button>
            </RemixLink>

            {/* <Button variant={"link"} colorScheme={"blue"} size={"sm"}>
              Learn more
            </Button> */}
          </Stack>
        </Stack>
      </Container>
    </Layout>
  );
}


================================================
FILE: app/routes/mock/$projectId.$.tsx
================================================
import { RequestMethod } from "@prisma/client";
import { ActionArgs, json, LoaderArgs, Response } from "@remix-run/node";
import { cors } from "remix-utils";
import invariant from "tiny-invariant";
import { findApisForMock, getApiById } from "~/models/api.server";
import { JsonNode } from "~/models/type";
import { httpResponse } from "~/utils";
import { mockJson } from "~/utils/mock";

export const loader = (args: LoaderArgs) => {
  return action(args);
};

export const action = async ({ params, request }: ActionArgs) => {
  if (request.method === RequestMethod.OPTIONS) {
    return await cors(request, new Response());
  }

  let projectId = params.projectId as string;
  let path = "/" + params["*"];
  let method = request.method as RequestMethod;
  let apis = await findApisForMock(projectId, method);
  let pathParam = findPathForRule(
    path,
    apis.map((api) => api.data.path)
  );
  if (!pathParam) {
    return httpResponse.NotFound;
  }
  let rule = pathParam.rule;
  let apiFound = apis.find((api) => api.data.path === rule);
  invariant(apiFound);
  let api = await getApiById(apiFound.id);
  invariant(api);

  let response: JsonNode | undefined = (api.data.response as any)?.["200"];
  let mocked = mockJson(response);

  return await cors(request, json(mocked));
};

function findPathForRule(path: string, rules: string[]) {
  let pathParams = rules
    .map((rule) => matchApi(path, rule))
    .filter((item): item is NonNullable<typeof item> => !!item);
  pathParams.sort((a, b) => b.weight - a.weight);
  return pathParams.length > 0 ? pathParams[0] : undefined;
}

/**
 *
 * @param {*} apiPath /user/tom
 * @param {*} apiRule /user/:username
 */
function matchApi(apiPath: string, apiRule: string) {
  let apiRules = apiRule.split("/");
  let apiPaths = apiPath.split("/");
  let pathParams = {
    rule: apiRule,
    weight: 0,
    params: {} as { [key in string]: string },
  };

  if (apiPaths.length !== apiRules.length) {
    return null;
  }
  for (let i = 0; i < apiRules.length; i++) {
    if (apiRules[i]) {
      apiRules[i] = apiRules[i].trim();
    } else {
      continue;
    }
    if (
      apiRules[i].length > 2 &&
      apiRules[i][0] === "{" &&
      apiRules[i][apiRules[i].length - 1] === "}"
    ) {
      pathParams.params[apiRules[i].substring(1, apiRules[i].length - 1)] =
        apiPaths[i];
    } else if (
      apiRules[i].length > 2 &&
      apiRules[i].indexOf("{") > -1 &&
      apiRules[i].indexOf("}") > -1
    ) {
      let params = [] as string[];
      apiRules[i] = apiRules[i].replace(/\{(.+?)\}/g, function (src, match) {
        params.push(match);
        return "([^\\/\\s]+)";
      });
      let regexp = new RegExp(apiRules[i]);
      if (!regexp.test(apiPaths[i])) {
        return null;
      }

      let matchs = apiPaths[i].match(regexp) || [];

      params.forEach((item, index) => {
        pathParams.params[item] = matchs[index + 1];
      });
    } else {
      if (apiRules[i] !== apiPaths[i]) {
        return null;
      } else {
        pathParams.weight++;
      }
    }
  }
  return pathParams;
}


================================================
FILE: app/routes/projects/$projectId/activities.tsx
================================================
export default function Activities() {
  return "Activities";
}


================================================
FILE: app/routes/projects/$projectId/apis/..api.tsx
================================================
import {
  Box,
  BoxProps,
  Center,
  Flex,
  Grid,
  Heading,
  Icon,
  Link,
  Table,
  TableContainer,
  Tbody,
  Td,
  Text,
  Th,
  Thead,
  Tr,
  useColorModeValue,
  useDisclosure,
} from "@chakra-ui/react";
import {
  ParamType,
  RequestBodyRaw,
  RequestBodyType,
  RequestParam,
} from "@prisma/client";
import { useLoaderData, useParams } from "@remix-run/react";
import React, { useEffect, useState } from "react";
import { loader } from "./details.$apiId";
import { Header } from "~/ui";
import { useMethodTag } from "../apis";
import { BsFillCaretDownFill, BsFillCaretRightFill } from "react-icons/bs";
import { JsonNode } from "~/models/type";
import { methodContainsBody, parsePath, useUrl } from "~/utils";
import invariant from "tiny-invariant";

const Api = () => {
  let { projectId } = useParams();
  let url = useUrl();
  invariant(projectId);
  const bg = useColorModeValue("gray.100", "gray.700");
  const fontColor = useColorModeValue("gray.600", "whiteAlpha.700");
  const labelWidth = "120px";
  const { api } = useLoaderData<typeof loader>();
  let { text, color } = useMethodTag(api.data.method);
  const method = api.data.method;
  const requestHasBody = methodContainsBody(method);

  const bodyJson = api.data.bodyJson as JsonNode | null;
  const bodyRaw = api.data.bodyRaw as RequestBodyRaw | null;
  const response200 = (api.data.response as any)?.["200"] as JsonNode | null;

  const parsedPath = parsePath(api.data.path);
  const pathParams = parsedPath.params.map<RequestParam>((param) => {
    let elem = api.data.pathParams.find((obj) => obj.name === param);
    return {
      name: param,
      example: elem?.example || null,
      description: elem?.description || null,
      isRequired: elem?.isRequired || false,
      type: elem?.type || ParamType.STRING,
    };
  });
  return (
    <Box key={`${api.id}-${api.updatedAt}`} position={"relative"} p={2} pb={10}>
      <Header>General</Header>
      <Box p={4}>
        <Flex>
          <Text as="strong" width={labelWidth}>
            Name:
          </Text>
          <Text color={fontColor}>{api.data.name}</Text>
        </Flex>
        <Flex mt={5}>
          <Text as="strong" width={labelWidth}>
            Path:
          </Text>
          <Flex color={fontColor}>
            <Box
              fontWeight={700}
              bg={color}
              px={2}
              mr={3}
              borderRadius={3}
              color={"white"}
              flexBasis="40px"
            >
              {text}
            </Box>
            {api.data.path}
          </Flex>
        </Flex>
        {pathParams.length > 0 && (
          <Flex mt={2}>
            <Text as="strong" width={labelWidth}>
              Params:
            </Text>
            <ParamTable
              flexGrow={1}
              size="sm"
              noPadding={true}
              defaultValue={pathParams}
            />
          </Flex>
        )}
        <Flex mt={5}>
          <Text as="strong" width={labelWidth}>
            Mock Server:
          </Text>
          <Link
            color={useColorModeValue("blue.700", "blue.200")}
            isExternal
            href={`/mock/${projectId}${api.data.path}`}
          >
            {`${url.origin}/mock/${projectId}${api.data.path}`}
          </Link>
        </Flex>
        {api.data.description && (
          <Flex mt={5}>
            <Text as="strong" width={labelWidth}>
              Description:
            </Text>
            {api.data.description}
          </Flex>
        )}
      </Box>

      <Header mt={12}>Request</Header>

      <Box p={4}>
        <Text as="strong" size={"sm"} mb={5}>
          Headers:
        </Text>
        {api.data.headers && api.data.headers.length > 0 ? (
          <ParamTable
            mt={1}
            borderWidth="1px"
            defaultValue={api.data.headers}
          />
        ) : (
          <Box mt={1} borderWidth={1} borderRadius={5} py={6}>
            <Text color="gray" textAlign={"center"}>
              No Data
            </Text>
          </Box>
        )}
      </Box>
      {api.data.queryParams && api.data.queryParams.length > 0 && (
        <Box mt={3} p={4}>
          <Text as={"strong"} size={"sm"} mb={5}>
            Query:
          </Text>
          <ParamTable
            mt={1}
            borderWidth="1px"
            defaultValue={api.data.queryParams}
          />
        </Box>
      )}
      {requestHasBody && (
        <Box mt={3} p={4}>
          <Text as="strong">Body ({api.data.bodyType}):</Text>
          <BodyEditor
            mt={1}
            type={api.data.bodyType}
            bodyJson={bodyJson || undefined}
            bodyForm={api.data.bodyForm}
            bodyRaw={bodyRaw || undefined}
            borderWidth="1px"
          />
        </Box>
      )}
      <Header mt={12}>Response</Header>
      <Box p={4}>
        <Box borderWidth={"1px"} p={4} borderRadius={5}>
          {response200 ? (
            <JsonRow
              depth={0}
              isParentOpen={true}
              defaultValues={response200 || undefined}
            />
          ) : (
            <Text color="gray" textAlign={"center"}>
              No Data
            </Text>
          )}
        </Box>
      </Box>
    </Box>
  );
};

const ParamTable = ({
  defaultValue,
  noPadding,
  size,
  ...rest
}: {
  defaultValue: RequestParam[];
  noPadding?: boolean;
  size?: string;
} & Omit<BoxProps, "defaultValue">) => {
  return (
    <TableContainer borderRadius={5} {...rest}>
      <Table size={size} variant="simple">
        <Thead>
          <Tr>
            <Th pl={noPadding ? 0 : undefined}>Name</Th>
            <Th>Required</Th>
            <Th>Type</Th>
            <Th>Example</Th>
            <Th pr={noPadding ? 0 : undefined}>Description</Th>
          </Tr>
        </Thead>

        <Tbody>
          {defaultValue.map((data, i) => (
            <Tr key={i}>
              <Td
                pl={noPadding ? 0 : undefined}
                borderBottomWidth={
                  i === defaultValue.length - 1 ? 0 : undefined
                }
              >
                {data?.name}
              </Td>
              <Td
                borderBottomWidth={
                  i === defaultValue.length - 1 ? 0 : undefined
                }
              >
                {data?.isRequired ? "YES" : "NO"}
              </Td>
              <Td
                borderBottomWidth={
                  i === defaultValue.length - 1 ? 0 : undefined
                }
              >
                {data?.type.toLowerCase()}
              </Td>
              <Td
                borderBottomWidth={
                  i === defaultValue.length - 1 ? 0 : undefined
                }
              >
                {data?.example}
              </Td>
              <Td
                pr={noPadding ? 0 : undefined}
                borderBottomWidth={
                  i === defaultValue.length - 1 ? 0 : undefined
                }
              >
                {data?.description}
              </Td>
            </Tr>
          ))}
        </Tbody>
      </Table>
    </TableContainer>
  );
};

const JsonRow = ({
  depth,
  isParentOpen,
  isArrayElem,
  keyId,
  defaultValues,
  hidden,
  ...rest
}: {
  depth: number;
  isParentOpen?: boolean;
  isArrayElem?: boolean;
  keyId?: number;
  defaultValues?: JsonNode;
} & BoxProps) => {
  const { isOpen, onToggle } = useDisclosure({
    defaultIsOpen: true,
  });
  const isRoot = depth === 0;
  const headerColor = useColorModeValue("gray.600", "gray.400");
  return (
    <>
      {isRoot && (
        <Grid
          templateColumns={"1fr 120px 1fr 1fr 1fr"}
          py={3}
          w="full"
          {...rest}
          borderBottomWidth={1}
          fontSize={"xs"}
        >
          <Flex flex="0 0 320px">
            <Text color={headerColor} fontWeight={"bold"}>
              NAME
            </Text>
          </Flex>
          <Center>
            <Text color={headerColor} fontWeight={"bold"}>
              REQUIRED
            </Text>
          </Center>
          <Center>
            <Text color={headerColor} fontWeight={"bold"}>
              TYPE
            </Text>
          </Center>
          <Center>
            <Text color={headerColor} fontWeight={"bold"}>
              MOCK
            </Text>
          </Center>
          <Center>
            <Text color={headerColor} fontWeight={"bold"}>
              DESCRIPTION
            </Text>
          </Center>
        </Grid>
      )}
      <Grid
        templateColumns={"1fr 120px 1fr 1fr 1fr"}
        py={3}
        hidden={hidden}
        w="full"
        {...rest}
        borderBottomWidth={1}
        borderColor={"blackAlpha"}
      >
        <Flex pl={`${depth * 24}px`} flex="0 0 320px">
          <Center cursor="pointer" onClick={onToggle}>
            {defaultValues?.type === ParamType.OBJECT ||
            defaultValues?.type === ParamType.ARRAY ? (
              <Icon
                fontSize={10}
                mr={2}
                as={isOpen ? BsFillCaretDownFill : BsFillCaretRightFill}
              />
            ) : undefined}
          </Center>
          <Text fontSize={"sm"}>
            {isRoot ? "Root" : isArrayElem ? "Items" : defaultValues?.name}
          </Text>
        </Flex>
        <Center>
          <Text fontSize={"sm"}>
            {defaultValues?.isRequired ? "YES" : "NO"}
          </Text>
        </Center>
        <Center>
          <Text fontSize={"sm"}>{defaultValues?.type.toLowerCase()}</Text>
        </Center>
        <Center>
          <Text fontSize={"sm"}>
            {isRoot || isArrayElem ? "Mock" : defaultValues?.mock}
          </Text>
        </Center>
        <Center>
          <Text fontSize={"sm"}>{defaultValues?.description}</Text>
        </Center>
      </Grid>
      {defaultValues?.type === "ARRAY" && (
        <JsonRow
          hidden={hidden || !isParentOpen || !isOpen}
          isParentOpen={isParentOpen && isOpen}
          depth={depth + 1}
          defaultValues={defaultValues.arrayElem}
        />
      )}
      {defaultValues?.type === "OBJECT" &&
        defaultValues?.children?.map((child, i) => (
          <JsonRow
            key={i}
            keyId={i}
            hidden={hidden || !isParentOpen || !isOpen}
            isParentOpen={isParentOpen && isOpen}
            depth={depth + 1}
            defaultValues={child}
          />
        ))}
    </>
  );
};

const BodyEditor = ({
  type,
  bodyJson,
  bodyForm,
  bodyRaw,
  ...rest
}: {
  bodyForm: RequestParam[];
  type: RequestBodyType;
  bodyJson?: JsonNode;
  bodyRaw?: RequestBodyRaw;
} & BoxProps) => {
  return (
    <Box {...rest}>
      {type === "FORM" &&
        (bodyForm.length > 0 ? (
          <ParamTable defaultValue={bodyForm} />
        ) : (
          <Box py={6}>
            <Text color="gray" textAlign={"center"}>
              No Data
            </Text>
          </Box>
        ))}
      {type === "JSON" && (
        <Box p={4}>
          <JsonRow depth={0} isParentOpen={true} defaultValues={bodyJson} />
        </Box>
      )}
      {type === "RAW" && (
        <>
          <Flex mt={3}>
            <Text width={120}>Example:</Text>
            {bodyRaw?.example}
          </Flex>
          <Flex mt={3}>
            <Text width={120}>Description:</Text>
            {bodyRaw?.description}
          </Flex>
        </>
      )}
    </Box>
  );
};

export default Api;


================================================
FILE: app/routes/projects/$projectId/apis/..editor.tsx
================================================
import {
  Box,
  BoxProps,
  Button,
  Center,
  Checkbox,
  Container,
  Divider,
  Flex,
  HStack,
  Icon,
  IconButton,
  Input,
  Menu,
  MenuButton,
  MenuItem,
  MenuList,
  Modal,
  ModalBody,
  ModalCloseButton,
  ModalContent,
  ModalFooter,
  ModalHeader,
  ModalOverlay,
  Radio,
  RadioGroup,
  RadioProps,
  Select,
  Tab,
  Table,
  TableContainer,
  TabList,
  TabPanel,
  TabPanels,
  Tabs,
  Tbody,
  Td,
  Text,
  Textarea,
  Th,
  Thead,
  Tooltip,
  Tr,
  useBoolean,
  useColorModeValue,
  useDisclosure,
  useMultiStyleConfig,
  useTab,
  useToast,
  VStack,
} from "@chakra-ui/react";
import {
  ApiData,
  ParamType,
  Prisma,
  RequestBodyType,
  RequestMethod,
  RequestParam,
} from "@prisma/client";
import { json, redirect } from "@remix-run/node";
import { useLoaderData, useTransition } from "@remix-run/react";
import { withZod } from "@remix-validated-form/with-zod";
import React, {
  PropsWithoutRef,
  ReactNode,
  useCallback,
  useEffect,
  useMemo,
  useRef,
  useState,
} from "react";
import { BsFillCaretDownFill, BsFillCaretRightFill } from "react-icons/bs";
import { FiEye, FiMinus, FiPlus, FiSettings, FiTrash2 } from "react-icons/fi";
import {
  useFormContext,
  ValidatedForm,
  validationError,
} from "remix-validated-form";
import invariant from "tiny-invariant";
import { string, z, ZodTypeDef } from "zod";
import { deleteApi, saveApiData } from "~/models/api.server";
import { JsonNode, JsonNodeType, RequestMethods } from "~/models/type";
import {
  AceEditor,
  FormCancelButton,
  FormHInput,
  FormInput,
  FormSubmitButton,
  Header,
  PathInput,
} from "~/ui";
import { FormInputProps } from "~/ui/Form/FormHInput";
import ModalInput from "~/ui/Form/ModalInput";
import { PathInputProps } from "~/ui/Form/PathInput";
import { useIds, usePath } from "~/utils";
import { mockJson } from "~/utils/mock";
import { loader } from "./details.$apiId";

type JsonNodeFormElem = Omit<
  Partial<JsonNode>,
  "children" | "arrayElem" | "isRequired"
> & {
  isRequired?: string;
};
type JsonNodeForm = JsonNodeFormElem & {
  arrayElem?: JsonNodeForm;
  children?: JsonNodeForm[];
};
type JsonNodeTransformedElem = Omit<JsonNodeFormElem, "isRequired"> & {
  isRequired: boolean;
};
type JsonNodeTransformed = JsonNodeTransformedElem & {
  arrayElem?: JsonNodeTransformed;
  children?: JsonNodeTransformed[];
};

export const saveApiAction = async (apiId: string, formData: FormData) => {
  let result = await validator.validate(formData);

  if (result.error) {
    return validationError(result.error);
  }

  let data = result.data;

  let pathParams: RequestParam[] = formatZodParam(data.pathParams);
  let queryParams: RequestParam[] = formatZodParam(data.queryParams);
  let headers: RequestParam[] = formatZodParam(data.headers);
  let bodyForm: RequestParam[] = formatZodParam(data.bodyForm);
  let bodyJson = formatZodJson(data.bodyJson);
  let responseJson = formatZodJson(data.response);

  let apiData: ApiData = {
    name: data.name,
    path: data.path,
    method: data.method,
    description: data.description || null,
    pathParams: pathParams,
    queryParams: queryParams,
    headers: headers,
    bodyType: data.bodyType,
    bodyForm: bodyForm,
    bodyRaw: {
      example: data.bodyRaw.example,
      description: data.bodyRaw.description,
    },
    bodyJson: bodyJson as unknown as Prisma.JsonValue,
    response: {
      "200": responseJson as unknown as Prisma.JsonValue,
    },
  };

  await saveApiData(apiId, apiData);
  return json({});
};

const deleteValidator = withZod(z.object({}));

const JsonNodeZod: z.ZodType<
  JsonNodeTransformedElem,
  ZodTypeDef,
  JsonNodeFormElem
> = z.lazy(() =>
  z.object({
    name: z.string().optional(),
    mock: z.string().optional(),
    example: z.string().optional(),
    isRequired: z
      .string()
      .optional()
      .transform((elem) => elem !== undefined),
    description: z.string().optional(),
    type: z.enum(JsonNodeType),
    children: z.array(JsonNodeZod).optional(),
    arrayElem: JsonNodeZod.optional(),
  })
);

const BodyTypes = [
  RequestBodyType.FORM,
  RequestBodyType.JSON,
  RequestBodyType.RAW,
] as const;

const zodParam = z
  .object({
    name: z.string().trim().optional(),
    example: z.string().trim().optional(),
    description: z.string().trim().optional(),
    isRequired: z
      .string()
      .optional()
      .transform((arg) => arg !== undefined),
    type: z.nativeEnum(ParamType).optional(),
  })
  .array()
  .optional();

const formatZodParam = (params: z.infer<typeof zodParam>) => {
  return (params || [])
    .filter((obj) => !!obj.name)
    .map((obj) => {
      const { name, example, description, ...rest } = obj;
      invariant(name);
      return {
        name: name,
        example: example || "",
        description: description || "",
        type: obj.type || ParamType.STRING,
        ...rest,
      };
    });
};

const formatZodJson = (json: JsonNodeTransformed): JsonNode => {
  json.name = "root";
  const formatZodJsonRec = (node: JsonNodeTransformed) => {
    let { children, arrayElem, ...rest } = node;
    let { name, description, type, isRequired, mock, example } = rest;

    invariant(type);

    if (!name) {
      return undefined;
    }

    let newChildren: JsonNode[] =
      children
        ?.map((elem) => formatZodJsonRec(elem))
        .filter((elem): elem is JsonNode => !!elem) || [];

    let newArrayElem = arrayElem ? formatZodJsonRec(arrayElem) : undefined;

    let retval: JsonNode = {
      name: name,
      type: type,
      description: description || "",
      example: example || "",
      mock: mock || "",
      isRequired: !!isRequired,
      children: newChildren,
      arrayElem: newArrayElem,
    };
    return retval;
  };

  return formatZodJsonRec(json) as JsonNode;
};

const validator = withZod(
  z.object({
    name: z.string().trim(),
    path: z.string().trim(),
    method: z.enum(RequestMethods),
    description: z.string().trim().optional(),
    pathParams: zodParam,
    queryParams: zodParam,
    headers: zodParam,
    bodyType: z.enum(BodyTypes),
    bodyForm: zodParam,
    bodyJson: JsonNodeZod,
    bodyRaw: z.object({
      example: z.string().trim(),
      description: z.string().trim(),
    }),
    response: JsonNodeZod,
  })
);

const RadioTab = React.forwardRef<HTMLInputElement, RadioProps>(
  (props, ref) => {
    const bgBW = useColorModeValue("white", "inherit");
    const tabProps = useTab({ ...props, ref });
    const isSelected = !!tabProps["aria-selected"];
    const styles = useMultiStyleConfig("Tabs", tabProps);
    const { children, ...rest } = tabProps;
    const { name, value, defaultChecked } = props;
    return (
      <Box {...rest}>
        <Flex as="label">
          <Radio
            bg={bgBW}
            ref={ref}
            isChecked={isSelected}
            __css={styles.tab}
            name={name}
            value={value}
            defaultChecked={defaultChecked}
          />
          <Box ml={2}>{children}</Box>
        </Flex>
      </Box>
    );
  }
);

const jsonNodeToForm = (json: JsonNode) => {
  const { type, children, arrayElem, isRequired, ...rest } = json;
  const newChildren = children.map((elem) => jsonNodeToForm(elem));
  const newArrayElem = arrayElem ? jsonNodeToForm(arrayElem) : undefined;
  let retval: JsonNodeForm = {
    type: type,
    children: type === "ARRAY" ? [] : newChildren,
    arrayElem: newArrayElem,
    isRequired: isRequired ? "true" : undefined,
    ...rest,
  };
  return retval;
};

const FormPathInput = ({
  labelWidth,
  method,
  bg,
  onMethodChange,
  defaultValue,
  defaultParams,
}: Partial<FormInputProps> &
  PathInputProps & {
    defaultParams?: RequestParam[];
  }) => {
  let { path, setPath, params } = usePath(
    typeof defaultValue === "string" ? defaultValue : ""
  );
  labelWidth ||= 0;
  let prefix = "pathParams";

  return (
    <Box>
      <FormHInput
        isRequired
        bg={bg}
        labelWidth={labelWidth}
        name="path"
        label="Path"
        as={PathInput}
        autoComplete="off"
        size="sm"
        method={method}
        onMethodChange={onMethodChange}
        value={path}
        onChange={(e) => setPath(e.target.value)}
      />
      {params.length > 0 && (
        <TableContainer mt={1} ml={labelWidth}>
          <Table variant="unstyled" size={"sm"} colorScheme="teal">
            <Tbody verticalAlign={"baseline"}>
              {params.map((param, i) => {
                let defaultValues = defaultParams?.find(
                  (elem) => elem.name === param
                );
                return (
                  <Tr key={param}>
                    <Td p={1} width="25%" pl={0}>
                      <HStack alignItems={"flex-start"}>
                        <Input
                          bg={bg}
                          size="sm"
                          value={param}
                          id={`${prefix}-${param}-name`}
                          name={`${prefix}[${i}].name`}
                          readOnly
                          cursor={"not-allowed"}
                        />
                        <Tooltip label="Required">
                          <Center h={8}>
                            <Checkbox
                              id={`${prefix}-${param}-required`}
                              name={`${prefix}[${i}].isRequired`}
                              bg={bg}
                              isChecked
                              type="hidden"
                              readOnly
                              cursor={"not-allowed"}
                            />
                          </Center>
                        </Tooltip>
                      </HStack>
                    </Td>
                    <Td p={1}>
                      <Select
                        bg={bg}
                        size="sm"
                        name={`${prefix}[${i}].type`}
                        defaultValue={defaultValues?.type || undefined}
                      >
                        {[ParamType.STRING, ParamType.INT, ParamType.FLOAT].map(
                          (type) => (
                            <option key={type} value={type}>
                              {type.toLowerCase()}
                            </option>
                          )
                        )}
                      </Select>
                    </Td>
                    <Td p={1}>
                      <FormInput
                        id={`${prefix}-${param}-example`}
                        bg={bg}
                        size="sm"
                        name={`${prefix}[${i}].example`}
                        placeholder="Example"
                        defaultValue={defaultValues?.example || undefined}
                      />
                    </Td>
                    <Td p={1} pr={0}>
                      <HStack>
                        <FormInput
                          id={`${prefix}-${param}-description`}
                          bg={bg}
                          size="sm"
                          name={`${prefix}[${i}].description`}
                          as={ModalInput}
                          modal={{ title: "Description" }}
                          placeholder="Description"
                          defaultValue={defaultValues?.description || undefined}
                        />
                      </HStack>
                    </Td>
                  </Tr>
                );
              })}
            </Tbody>
          </Table>
        </TableContainer>
      )}
    </Box>
  );
};

const HeaderWithSubmission = ({
  children,
  ...rest
}: { children: ReactNode } & BoxProps) => {
  return (
    <Flex justifyContent={"space-between"} alignItems="baseline" {...rest}>
      <Header>{children}</Header>
      <FormSubmitButton
        name="_action"
        value="saveApi"
        type="submit"
        colorScheme="blue"
        size="sm"
      >
        Save
      </FormSubmitButton>
    </Flex>
  );
};

const Editor = () => {
  const bg = useColorModeValue("gray.100", "gray.700");
  const bgBW = useColorModeValue("white", "gray.900");
  const gray = useColorModeValue("gray.300", "gray.600");
  const labelWidth = "100px";
  const { api } = useLoaderData<typeof loader>();
  let { response, bodyJson, ...rest } = api.data;
  let response200 = response ? (response as any)["200"] : undefined;

  const [method, setMethod] = useState(api.data.method);
  const [bodyTabIndex, setBodyTabIndex] = useState(0);

  const requestHasBody =
    method === "POST" ||
    method === "DELETE" ||
    method === "PUT" ||
    method === "PATCH";

  useEffect(() => {
    if (!requestHasBody) {
      setBodyTabIndex(1);
    }
  }, [requestHasBody]);

  let defaultValues = useMemo(() => {
    return {
      ...rest,
      response: response200
        ? jsonNodeToForm(response200 as unknown as JsonNode)
        : undefined,
      bodyJson: bodyJson
        ? jsonNodeToForm(bodyJson as unknown as JsonNode)
        : undefined,
    };
  }, [api]);

  const onMethodChange: React.ChangeEventHandler<HTMLSelectElement> =
    useCallback(
      function (e) {
        setMethod(e.target.value as RequestMethod);
      },
      [method]
    );

  const transition = useTransition();
  const toast = useToast();

  useEffect(() => {
    if (transition.state === "loading") {
      let action = transition.submission?.formData?.get("_action");
      if (action === "saveApi") {
        toast({
          title: "Your change has been saved.",
          status: "success",
          position: "top",
          isClosable: true,
        });
      }
    }
  }, [transition.state]);

  return (
    <Box>
      <Box
        id="api-form"
        key={`${api.id}-${api.updatedAt}`}
        position={"relative"}
        as={ValidatedForm}
        method="patch"
        validator={withZod(z.object({}))}
        replace={true}
        resetAfterSubmit
        p={2}
      >
        <HeaderWithSubmission>General</HeaderWithSubmission>
        <Box bg={bg} p={4}>
          <Container maxW="container.lg">
            <Box py={2}>
              <FormHInput
                isRequired
                bg={bgBW}
                labelWidth={labelWidth}
                name="name"
                label="Name"
                size="sm"
                as={Input}
                autoComplete="off"
                defaultValue={defaultValues.name}
              />
            </Box>
            <Box py={2}>
              <FormPathInput
                labelWidth={labelWidth}
                bg={bgBW}
                method={method}
                onMethodChange={onMethodChange}
                defaultValue={defaultValues.path}
                defaultParams={defaultValues.pathParams}
              />
            </Box>
            <Box py={2}>
              <FormHInput
                bg={bgBW}
                labelWidth={labelWidth}
                name="description"
                label="Description"
                as={Textarea}
                autoComplete="off"
                size="sm"
                rows={5}
                defaultValue={defaultValues.description || ""}
              />
            </Box>
          </Container>
        </Box>

        <HeaderWithSubmission mt={10}>Request</HeaderWithSubmission>
        <Box bg={bg} py={4}>
          <Tabs
            index={bodyTabIndex}
            onChange={setBodyTabIndex}
            variant="solid-rounded"
            colorScheme="cyan"
          >
            <TabList display={"flex"} justifyContent="center">
              <Tab hidden={!requestHasBody} flexBasis={"100px"}>
                Body
              </Tab>
              <Tab flexBasis={"100px"}>Query</Tab>
              <Tab flexBasis={"100px"}>Headers</Tab>
            </TabList>
            <Divider my={2} borderColor={gray} />
            <TabPanels>
              <TabPanel>
                <BodyEditor
                  type={defaultValues.bodyType}
                  bodyJson={defaultValues.bodyJson}
                  bodyForm={defaultValues.bodyForm}
                  bodyRaw={defaultValues.bodyRaw}
                />
              </TabPanel>
              <TabPanel>
                <ParamTable
                  defaultValue={defaultValues.queryParams}
                  prefix="queryParams"
                />
              </TabPanel>
              <TabPanel>
                <ParamTable
                  defaultValue={defaultValues.headers}
                  prefix="headers"
                />
              </TabPanel>
            </TabPanels>
          </Tabs>
        </Box>
        <HeaderWithSubmission mt={10}>Response</HeaderWithSubmission>
        <Box bg={bg} p={8}>
          <JsonEditor
            prefix="response"
            isMock={true}
            defaultValues={defaultValues.response}
          />
        </Box>
      </Box>
    </Box>
  );
};

const BodyEditor = React.memo(
  ({
    type,
    bodyJson,
    bodyForm,
    bodyRaw,
  }: {
    bodyForm: RequestParam[];
    type: RequestBodyType;
    bodyJson?: JsonNodeForm;
    bodyRaw: {
      example: string | null;
      description: string | null;
    } | null;
  }) => {
    const bgBW = useColorModeValue("white", "gray.900");
    return (
      <Tabs
        defaultIndex={[
          RequestBodyType.FORM,
          RequestBodyType.JSON,
          RequestBodyType.RAW,
        ].indexOf(type)}
      >
        <RadioGroup px={4} defaultValue={type}>
          <TabList border={"none"} display={"flex"} gap={4}>
            <RadioTab name="bodyType" value={RequestBodyType.FORM}>
              form-data
            </RadioTab>
            <RadioTab name="bodyType" value={RequestBodyType.JSON}>
              json
            </RadioTab>
            <RadioTab name="bodyType" value={RequestBodyType.RAW}>
              raw
            </RadioTab>
          </TabList>
        </RadioGroup>
        <TabPanels mt={4}>
          <TabPanel p={0}>
            <ParamTable
              defaultValue={bodyForm}
              prefix="bodyForm"
              types={[ParamType.STRING, ParamType.FILE]}
            />
          </TabPanel>
          <TabPanel>
            <JsonEditor
              defaultValues={bodyJson}
              prefix="bodyJson"
              isMock={false}
            />
          </TabPanel>
          <TabPanel>
            <Box>
              <FormInput
                bg={bgBW}
                as={Textarea}
                name="bodyRaw.example"
                label="Example"
                container={{
                  mb: 6,
                }}
                defaultValue={bodyRaw?.example || undefined}
              />
              <FormInput
                bg={bgBW}
                as={Textarea}
                name="bodyRaw.description"
                label="Description"
                defaultValue={bodyRaw?.description || undefined}
              />
            </Box>
          </TabPanel>
        </TabPanels>
      </Tabs>
    );
  }
);

const ParamTable = React.memo(
  ({
    prefix,
    types,
    defaultValue,
  }: {
    prefix: string;
    types?: string[];
    defaultValue: RequestParam[];
  }) => {
    const bgBW = useColorModeValue("white", "gray.900");
    const { ids, pushId, removeId } = useIds(
      Math.max(defaultValue.length, 1),
      1
    );
    return (
      <TableContainer>
        <Table size={"sm"} colorScheme="teal">
          <Thead>
            <Tr>
              <Th width={"20%"}>Name</Th>
              {types && <Th>Type</Th>}
              <Th width={"25%"}>Example</Th>
              <Th>Description</Th>
            </Tr>
          </Thead>
          <Tbody verticalAlign={"baseline"}>
            {ids.map((id, i) => (
              <Tr key={id}>
                <Td>
                  <HStack alignItems={"flex-start"}>
                    <FormInput
                      id={`${prefix}-${id}-name`}
                      bg={bgBW}
                      size="sm"
                      name={`${prefix}[${i}].name`}
                      defaultValue={defaultValue[id]?.name}
                    />
                    <Tooltip label="Required">
                      <Center h={8}>
                        <FormInput
                          as={Checkbox}
                          id={`${prefix}-${id}-required`}
                          bg={bgBW}
                          name={`${prefix}[${i}].isRequired`}
                          defaultChecked={defaultValue[id]?.isRequired}
                        />
                      </Center>
                    </Tooltip>
                  </HStack>
                </Td>
                {types && (
                  <Td>
                    <Select
                      bg={bgBW}
                      size="sm"
                      name={`${prefix}[${i}].type`}
                      defaultValue={defaultValue[id]?.type}
                    >
                      {types.map((type) => (
                        <option key={type} value={type}>
                          {type.toLowerCase()}
                        </option>
                      ))}
                    </Select>
                  </Td>
                )}
                <Td>
                  <FormInput
                    id={`${prefix}-${id}-example`}
                    bg={bgBW}
                    size="sm"
                    name={`${prefix}[${i}].example`}
                  />
                </Td>
                <Td>
                  <HStack>
                    <FormInput
                      id={`${prefix}-${id}-description`}
                      bg={bgBW}
                      size="sm"
                      name={`${prefix}[${i}].description`}
                      as={ModalInput}
                      modal={{ title: "Description" }}
                      defaultValue={defaultValue[id]?.description || ""}
                    />
                    <Button size="sm" onClick={() => removeId(id)}>
                      <Icon as={FiTrash2} />
                    </Button>
                  </HStack>
                </Td>
              </Tr>
            ))}
          </Tbody>
        </Table>
        <Box textAlign={"center"} mt={4}>
          <Button
            size="sm"
            colorScheme="blue"
            variant={"outline"}
            onClick={pushId}
          >
            <Icon as={FiPlus} /> Add
          </Button>
        </Box>
      </TableContainer>
    );
  }
);

const JsonEditor = React.memo(
  ({
    prefix,
    isMock,
    defaultValues,
  }: {
    prefix: string;
    isMock: boolean;
    defaultValues?: JsonNodeForm;
  }) => {
    const { isOpen, onOpen, onClose } = useDisclosure();
    return (
      <Box>
        <VStack>
          <JsonRow
            depth={0}
            isParentOpen={true}
            prefix={prefix}
            isMock={isMock}
            defaultValues={defaultValues}
          />
        </VStack>
        <Center mt={8}>
          <Button
            onClick={onOpen}
            colorScheme={"blue"}
            variant="outline"
            size="sm"
          >
            <Icon as={FiEye} />
            <Text ml={2}>View Example</Text>
          </Button>
          <JsonExampleModal isOpen={isOpen} onClose={onClose} prefix={prefix} />
        </Center>
      </Box>
    );
  }
);

const JsonExampleModal = ({
  isOpen,
  onClose,
  prefix,
}: {
  isOpen: boolean;
  onClose: () => void;
  prefix: string;
}) => {
  const form = useFormContext();
  let [data, setData] = useState<string | undefined>(undefined);

  const generateData = useCallback(async () => {
    if (!isOpen) {
      return;
    }
    let node: JsonNode | null = null;
    if (prefix === "bodyJson") {
      const result = await withZod(
        z.object({
          bodyJson: JsonNodeZod,
        })
      ).validate(form.getValues());
      if (result.data) {
        node = formatZodJson(result.data.bodyJson);
      }
    } else if (prefix === "response") {
      const result = await withZod(
        z.object({
          response: JsonNodeZod,
        })
      ).validate(form.getValues());
      if (result.data) {
        node = formatZodJson(result.data.response);
      }
    }
    let jsonString = "null";
    if (node) {
      let mock = mockJson(node);
      jsonString = JSON.stringify(mock, null, 2);
    }
    setData(jsonString);
  }, [isOpen]);

  useEffect(() => {
    generateData();
  }, [isOpen]);

  return (
    <Modal isOpen={isOpen} onClose={onClose} size="2xl">
      <ModalOverlay />
      <ModalContent>
        <ModalHeader>Example</ModalHeader>
        <ModalCloseButton />
        <Divider />
        <ModalBody p={0}>
          <AceEditor
            mode={"json"}
            editorProps={{ $blockScrolling: true }}
            showGutter={true}
            showPrintMargin={false}
            value={data}
            tabSize={2}
            height="500px"
            width="100%"
            readOnly
          />
        </ModalBody>
        <Divider />
        <ModalFooter>
          <Button
            onClick={generateData}
            m="auto"
            size="sm"
            variant="outline"
            colorScheme={"blue"}
          >
            <Icon as={FiEye} mr={2} /> Generate
          </Button>
        </ModalFooter>
      </ModalContent>
    </Modal>
  );
};

const JsonRow = React.memo(
  ({
    depth,
    isParentOpen,
    isArrayElem,
    keyId,
    onAddSibling,
    onDelete,
    prefix,
    isMock,
    defaultValues,
    hidden,
    ...rest
  }: {
    depth: number;
    isParentOpen?: boolean;
    isArrayElem?: boolean;
    onAddSibling?: (id: number) => void;
    onDelete?: (id: number) => void;
    prefix: string;
    keyId?: number;
    isMock: boolean;
    defaultValues?: JsonNodeForm;
  } & BoxProps) => {
    const types = [
      ParamType.OBJECT,
      ParamType.ARRAY,
      ParamType.STRING,
      ParamType.INT,
      ParamType.FLOAT,
      ParamType.BOOLEAN,
    ];
    const bgBW = useColorModeValue("white", "gray.900");
    const { isOpen, onToggle } = useDisclosure({
      defaultIsOpen: true,
    });
    const isRoot = depth === 0;
    const [type, setType] = useState<ParamType>(
      defaultValues?.type || (isRoot ? ParamType.OBJECT : ParamType.STRING)
    );
    const [touched, setTouched] = useBoolean(
      type === ParamType.OBJECT || type === ParamType.ARRAY
    );
    const { ids, pushId, removeId, insertAfterId } = useIds(
      defaultValues?.children?.length || 1,
      1
    );
    const blue = useColorModeValue("blue.500", "blue.200");
    const value = isRoot ? "root" : isArrayElem ? "items" : undefined;
    const readOnly = isRoot || isArrayElem;

    return (
      <>
        <HStack hidden={hidden} w="full" {...rest} alignItems="flex-start">
          <Center pl={`${depth * 24}px`} flex="0 0 320px">
            <Center w={4} h={4} cursor="pointer" onClick={onToggle}>
              {type === ParamType.OBJECT || type === ParamType.ARRAY ? (
                <Icon
                  fontSize={10}
                  as={isOpen ? BsFillCaretDownFill : BsFillCaretRightFill}
                />
              ) : undefined}
            </Center>
            <FormInput
              minW={16}
              size="sm"
              name={`${prefix}.name`}
              placeholder="Name"
              cursor={readOnly ? "not-allowed" : undefined}
              bg={readOnly ? undefined : bgBW}
              readOnly={readOnly}
              value={value}
              defaultValue={!!value ? undefined : defaultValues?.name || ""}
            />
          </Center>
          <Box>
            <Tooltip label="Required">
              <Center h={8}>
                <FormInput
                  as={Checkbox}
                  name={`${prefix}.isRequired`}
                  bg={bgBW}
                  isDisabled={isArrayElem}
                  defaultChecked={
                    isArrayElem || !defaultValues || !!defaultValues?.isRequired
                  }
                  // value="true"
                />
              </Center>
            </Tooltip>
          </Box>
          <Select
            onChange={(e) => {
              setTouched.on();
              setType(e.target.value as ParamType);
            }}
            bg={bgBW}
            size="sm"
            flex="0 0 100px"
            defaultValue={type}
            name={`${prefix}.type`}
          >
            {types.map((type) => (
              <option key={type} value={type}>
                {type.toLowerCase()}
              </option>
            ))}
          </Select>
          {isMock ? (
            <FormInput
              bg={bgBW}
              size="sm"
              name={`${prefix}.mock`}
              placeholder={"Mock"}
              as={ModalInput}
              modal={{ title: "Mock" }}
              isDisabled={type === "ARRAY" || type === "OBJECT"}
              defaultValue={defaultValues?.mock}
            />
          ) : (
            <FormInput
              bg={bgBW}
              size="sm"
              name={`${prefix}.example`}
              placeholder={"Example"}
              as={ModalInput}
              modal={{ title: "Example" }}
              isDisabled={type === "ARRAY" || type === "OBJECT"}
              defaultValue={defaultValues?.example}
            />
          )}

          <FormInput
            bg={bgBW}
            size="sm"
            name={`${prefix}.description`}
            placeholder="Description"
            as={ModalInput}
            modal={{ title: "Description" }}
            defaultValue={defaultValues?.description}
          />
          {isArrayElem && type !== "OBJECT" ? (
            <Box flexBasis={"64px"} flexShrink={0} flexGrow={0} />
          ) : (
            <Flex flexBasis={"64px"} flexShrink={0} flexGrow={0}>
              {isRoot ? (
                <Button p={0} size="sm" colorScheme={"green"} variant="ghost">
                  <Icon as={FiSettings} />
                </Button>
              ) : isArrayElem ? (
                <Box w={8} h={8}></Box>
              ) : (
                <Button
                  p={0}
                  size="sm"
                  colorScheme={"red"}
                  variant="ghost"
                  onClick={(e) => onDelete?.(keyId as number)}
                >
                  <Icon as={FiMinus} />
                </Button>
              )}
              {depth === 0 || isArrayElem || type !== ParamType.OBJECT ? (
                <Button
                  p={0}
                  size="sm"
                  colorScheme={"blue"}
                  variant="ghost"
                  onClick={(e) => {
                    if (depth === 0 || isArrayElem) {
                      pushId();
                    } else {
                      onAddSibling?.(keyId as number);
                    }
                  }}
                >
                  <Icon as={FiPlus} />
                </Button>
              ) : (
                <Menu size={"sm"} colorScheme={"blue"}>
                  <MenuButton
                    p={0}
                    as={IconButton}
                    icon={<FiPlus />}
                    colorScheme="blue"
                    size="sm"
                    variant={"ghost"}
                  />
                  <MenuList zIndex={5}>
                    <MenuItem onClick={pushId}>Add child node</MenuItem>
                    <MenuItem onClick={(e) => onAddSibling?.(keyId as number)}>
                      Add sibling node
                    </MenuItem>
                  </MenuList>
                </Menu>
              )}
            </Flex>
          )}
        </HStack>
        {touched && (
          <JsonRow
            isParentOpen={isParentOpen && isOpen}
            depth={depth + 1}
            hidden={
              hidden || !isParentOpen || !isOpen || type !== ParamType.ARRAY
            }
            isArrayElem
            onAddSibling={insertAfterId}
            onDelete={removeId}
            prefix={`${prefix}.arrayElem`}
            isMock={isMock}
            defaultValues={defaultValues?.arrayElem}
          />
        )}
        {touched &&
          ids.map((id, i) => (
            <JsonRow
              key={id}
              keyId={id}
              isParentOpen={isParentOpen && isOpen}
              depth={depth + 1}
              hidden={
                hidden || !isParentOpen || !isOpen || type !== ParamType.OBJECT
              }
              onAddSibling={insertAfterId}
              onDelete={removeId}
              prefix={`${prefix}.children[${i}]`}
              isMock={isMock}
              defaultValues={defaultValues?.children?.[id]}
            />
          ))}
      </>
    );
  }
);

export default Editor;


================================================
FILE: app/routes/projects/$projectId/apis/..postman.tsx
================================================
import { ApiData, RequestParam } from ".prisma/client";
import {
  Box,
  Button,
  Center,
  Checkbox,
  Divider,
  Flex,
  FormControl,
  FormLabel,
  Grid,
  GridItem,
  Heading,
  HStack,
  Icon,
  Input,
  InputGroup,
  InputLeftAddon,
  InputRightAddon,
  Select,
  Spacer,
  Tab,
  Table,
  TableContainer,
  TabList,
  TabPanel,
  TabPanels,
  Tabs,
  Tag,
  Tbody,
  Td,
  Text,
  Textarea,
  Th,
  Thead,
  Tooltip,
  Tr,
  useColorModeValue,
  useToast,
  VStack,
} from "@chakra-ui/react";
import { useLoaderData, useMatches, useParams } from "@remix-run/react";
import invariant from "tiny-invariant";
import { AceEditor, FormInput, ModalInput } from "~/ui";
import { loader } from "./details.$apiId";
import { SlRocket } from "react-icons/sl";
import { methodContainsBody, useIds, useUrl } from "~/utils";
import { FiAlertOctagon, FiPlus, FiRepeat, FiTrash2 } from "react-icons/fi";
import {
  useFormContext,
  ValidatedForm,
  ValidatorData,
} from "remix-validated-form";
import { withZod } from "@remix-validated-form/with-zod";
import { any, z } from "zod";
import { ClientOnly, json } from "remix-utils";
import {
  lazy,
  RefObject,
  useCallback,
  useEffect,
  useMemo,
  useRef,
  useState,
} from "react";
import axios, { AxiosError, AxiosRequestConfig, AxiosResponse } from "axios";
import { mockJson } from "~/utils/mock";
import { JsonNode } from "~/models/type";

const zodParam = z
  .object({
    checked: z
      .string()
      .optional()
      .transform((v) => v !== undefined),
    name: z.string().optional(),
    value: z.string().optional(),
  })
  .array()
  .default([]);

const validator = withZod(
  z.object({
    path: zodParam,
    query: zodParam,
    query_ext: zodParam,
    header: zodParam,
    header_ext: zodParam,
    bodyForm: zodParam,
    bodyForm_ext: zodParam,
  })
);

const Postman = () => {
  const { projectId } = useParams();
  invariant(projectId);
  let tabWidth = "150px";
  const bg = useColorModeValue("gray.50", "gray.800");
  const { api } = useLoaderData<typeof loader>();
  const url = useUrl();
  const [bodyValue, setBodyValue] = useState(
    api.data.bodyType === "RAW"
      ? api.data.bodyRaw?.example
      : JSON.stringify(
          mockJson(api.data.bodyJson as unknown as JsonNode, {
            useExample: true,
          }),
          null,
          2
        )
  );
  const regenerateJson = useCallback(() => {
    setBodyValue(
      JSON.stringify(
        mockJson(api.data.bodyJson as unknown as JsonNode),
        null,
        2
      )
    );
  }, [api]);
  const form = useFormContext("postman-form");
  const [location, setLocation] = useState("");
  const [response, setResponse] = useState<AxiosResponse | null>(null);
  const [error, setError] = useState<any>(null);
  const methodHasBody = methodContainsBody(api.data.method);
  const toast = useToast();

  useEffect(() => {
    let lastExecLocation = localStorage.getItem("postman.lastExeclocation");
    if (lastExecLocation) {
      setLocation(lastExecLocation);
    } else {
      setLocation(`${url.origin}/mock/${projectId}`);
    }
  }, [url]);

  const onSubmit: React.MouseEventHandler<HTMLButtonElement> =
    useCallback(async () => {
      let formData = form.getValues();
      let result = await validator.validate(formData);
      let data = result.data;
      if (!data) {
        // TODO
        return;
      }

      localStorage.setItem("postman.lastExeclocation", location);

      let paths = data.path;

      for (let path of paths) {
        if (!path.value || !path.value.trim()) {
          toast({
            title: "Could not send.",
            description: `Path param {${path.name}} is required`,
            status: "error",
            duration: 5000,
            isClosable: true,
            position: "top",
          });
        }
      }

      let finalPath = api.data.path;
      for (let path of paths) {
        invariant(path.value);
        finalPath = finalPath.replace(`{${path.name}}`, path.value);
      }

      let query = Array<typeof data.query[0]>()
        .concat(data.query, data.query_ext)
        .filter((param) => param.checked && !!param.name);

      let header = Array<typeof data.header[0]>()
        .concat(data.header, data.header_ext)
        .filter((param) => param.checked && !!param.name);

      let config: AxiosRequestConfig = {
        method: api.data.method,
        url: location + finalPath,
        maxRedirects: 0,
        validateStatus: () => true,
      };

      let queries: typeof config["params"] = {};
      for (let elem of query) {
        invariant(elem.name);
        queries[elem.name] = elem.value;
      }

      let headers: typeof config["headers"] = {};
      for (let elem of header) {
        invariant(elem.name);
        headers[elem.name] = elem.value;
      }

      config.headers = headers;
      config.params = queries;
      if (methodHasBody) {
        if (api.data.bodyType === "FORM") {
          let bodyForm = Array<typeof data.header[0]>()
            .concat(data.bodyForm, data.bodyForm_ext)
            .filter((param) => param.checked && !!param.name);
          let formD = new FormData();
          for (let elem of bodyForm) {
            invariant(elem.name);
            formD.append(elem.name, elem.value || "");
          }
          config.data = formD;
        } else if (api.data.bodyType === "RAW") {
          config.data = bodyValue;
        } else if (api.data.bodyType === "JSON") {
          try {
            let data = JSON.parse(bodyValue ?? "");
            config.data = data;
          } catch (err) {
            let errMsg = "";
            if (typeof err === "string") {
              errMsg = err;
            } else if (err instanceof Error) {
              errMsg = err.message;
            }
            toast({
              title: "Could not send.",
              description:
                "An error happened when parsing request body to JSON: " +
                errMsg,
              status: "error",
              duration: 5000,
              isClosable: true,
              position: "top",
            });
            return;
          }
        }
      }

      try {
        let res = await axios(config);
        setResponse(res);
        setError(null);
      } catch (err: any) {
        setError(err);
      }
    }, [form, location, bodyValue, api]);
  const responseDragRef = useRef<HTMLDivElement>();
  const gridContainerRef = useRef<HTMLDivElement>();
  const lastClientY = useRef(0);
  const lastResponseHeight = useRef(0);
  useEffect(() => {
    lastResponseHeight.current = (window.innerHeight - 112) / 2;
    const mousedown = (e: MouseEvent) => {
      if (e.target !== responseDragRef.current) {
        return;
      }
      lastClientY.current = e.clientY;
      const handleMove = ({ clientY }: { clientY: number }) => {
        const height =
          lastResponseHeight.current - clientY + lastClientY.current;
        lastClientY.current = clientY;
        lastResponseHeight.current = height < 0 ? 0 : height;
        if (gridContainerRef.current) {
          gridContainerRef.current.style.gridTemplateRows = `112px 1fr ${lastResponseHeight.current}px `;
        }
      };
      document.addEventListener("mousemove", handleMove);
      document.addEventListener("mouseup", () => {
        document.removeEventListener("mousemove", handleMove);
      });
    };
    document.addEventListener("mousedown", mousedown);
    return () => {
      document.removeEventListener("mousedown", mousedown);
    };
  }, []);
  return (
    <Grid
      h="full"
      ref={gridContainerRef as RefObject<HTMLDivElement>}
      templateRows={"112px calc(50% - 112px / 2) 1fr"}
      as={Tabs}
      colorScheme="blue"
      fontSize={"sm"}
    >
      <Box bg={bg}>
        <Flex p={4} as={InputGroup}>
          <InputLeftAddon fontSize={"sm"}>{api.data.method}</InputLeftAddon>
          <Input
            fontSize={"sm"}
            flex={"1"}
            w="auto"
            placeholder="http://example.com"
            value={location}
            onChange={(e) => setLocation(e.target.value)}
          />
          <InputRightAddon fontSize={"sm"} flex="1" w="auto">
            {api.data.path}
          </InputRightAddon>
          <Button ml="2" w="100px" colorScheme="blue" onClick={onSubmit}>
            Send
          </Button>
        </Flex>
        <Divider />
        <TabList bg={bg} textAlign="center" justifyContent={"center"}>
          {api.data.pathParams.length > 0 && (
            <Tab fontSize={"sm"} w={tabWidth}>
              Path
            </Tab>
          )}
          {methodHasBody && (
            <Tab fontSize={"sm"} w={tabWidth}>
              Body
            </Tab>
          )}

          <Tab fontSize={"sm"} w={tabWidth}>
            Query
          </Tab>
          <Tab fontSize={"sm"} w={tabWidth}>
            Headers
          </Tab>
          {/* <Tab fontSize={"sm"} w={tabWidth}>
           Cookies
           </Tab> */}
        </TabList>
      </Box>
      <TabPanels
        id="postman-form"
        as={ValidatedForm}
        validator={validator}
        bg={bg}
        overflowY={"auto"}
      >
        {api.data.pathParams.length > 0 && (
          <TabPanel overflowY="auto">
            <ParamTable prefix="path" data={api.data.pathParams} />
          </TabPanel>
        )}
        {methodHasBody && (
          <TabPanel h="full" p={0}>
            {api.data.bodyType === "FORM" ? (
              <Box p={4} h="full" overflowY="auto">
                <ParamTable prefix="bodyForm" data={api.data.bodyForm} />
              </Box>
            ) : (
              <BodyEditor
                value={bodyValue ?? undefined}
                onChange={setBodyValue}
                description={api.data.bodyRaw?.description || ""}
                isJson={api.data.bodyType === "JSON"}
                onRegenerate={regenerateJson}
              />
            )}
          </TabPanel>
        )}
        <TabPanel maxH="full" overflowY="auto">
          <ParamTable prefix="query" data={api.data.queryParams} />
        </TabPanel>
        <TabPanel maxH="full" overflowY="auto">
          <ParamTable prefix="header" data={api.data.headers} />
        </TabPanel>
        <TabPanel maxH="full" overflowY="auto">
          <ParamTable prefix="cookies" data={[]} />
        </TabPanel>
      </TabPanels>
      <Box bg={useColorModeValue("gray.100", "gray.700")} position={"relative"}>
        <Box
          ref={responseDragRef as RefObject<HTMLDivElement>}
          position={"absolute"}
          top={"-10px"}
          left={0}
          right={0}
          role="group"
          py={"10px"}
          cursor={"ns-resize"}
          zIndex={100}
        >
          <Box
            _groupHover={{ opacity: 1 }}
            opacity={0}
            height={"1px"}
            width={"100%"}
            bgColor={"blue.500"}
          />
        </Box>
        <Box h={"full"} overflowY={"auto"}>
          {error ? (
            <ErrorEesponse err={error} />
          ) : response ? (
            <Response response={response} />
          ) : (
            <EmptyResponse />
          )}
        </Box>
      </Box>
    </Grid>
  );
};

const ParamTable = ({
  prefix,
  data,
}: {
  prefix: string;
  data: RequestParam[];
}) => {
  const bgBW = useColorModeValue("white", "gray.900");
  const { ids, pushId, removeId } = useIds(data.length === 0 ? 1 : 0);
  return (
    <TableContainer>
      <Table size={"sm"} colorScheme="teal" css={{ tableLayout: "fixed" }}>
        <Thead>
          <Tr>
            <Th p={0} w={8}></Th>
            <Th width={"25%"} pl={0}>
              <Text ml={3}>Name</Text>
            </Th>
            <Th p={0}>
              <Text ml={3}>Value</Text>
            </Th>
            <Th width="30%">Description</Th>
          </Tr>
        </Thead>
        <Tbody verticalAlign={"baseline"}>
          {data.map((param, i) => (
            <Tr key={i}>
              <Td p={0} pl={1}>
                <Checkbox
                  mr={2}
                  isChecked={param.isRequired ? true : undefined}
                  isDisabled={param.isRequired}
                  defaultChecked
                  name={`${prefix}[${i}].checked`}
                />
                {param.isRequired && (
                  <input type={"hidden"} name={`${prefix}[${i}].checked`} />
                )}
              </Td>
              <Td pl={0}>
                <FormControl isRequired={param.isRequired}>
                  <FormLabel fontSize={"sm"} m={0}>
                    <Text ml={3} as={"span"}>
                      {param.name}
                    </Text>
                    <input
                      type={"hidden"}
                      name={`${prefix}[${i}].name`}
                      value={param.name}
                    />
                  </FormLabel>
                </FormControl>
              </Td>
              <Td p={0}>
                <Input
                  borderWidth={0}
                  bg={bgBW}
                  size="sm"
                  name={`${prefix}[${i}].value`}
                  defaultValue={param.example || undefined}
                  placeholder="Value"
                />
              </Td>
              <Td>
                <Text textOverflow={"ellipsis"} overflow="hidden">
                  {param.description}
                </Text>
              </Td>
            </Tr>
          ))}
          {ids.map((id, i) => (
            <Tr key={id}>
              <Td p={0} pl={1}>
                <Checkbox
                  mr={2}
                  defaultChecked
                  name={`${prefix}_ext[${i}].checked`}
                />
              </Td>
              <Td p={0} pr={4}>
                <FormInput
                  borderWidth={0}
                  bg={bgBW}
                  size="sm"
                  name={`${prefix}_ext[${i}].name`}
                  placeholder="Name"
                />
              </Td>
              <Td p={0}>
                <FormInput
                  borderWidth={0}
                  bg={bgBW}
                  size="sm"
                  name={`${prefix}_ext[${i}].value`}
                  placeholder="Value"
                />
              </Td>
              <Td p={0} pl={2}>
                <Button
                  variant={"ghost"}
                  size="sm"
                  onClick={() => removeId(id)}
                >
                  <Icon as={FiTrash2} />
                </Button>
              </Td>
            </Tr>
          ))}
        </Tbody>
      </Table>
      <Box textAlign={"center"} mt={4}>
        <Button
          size="sm"
          colorScheme="blue"
          variant={"outline"}
          onClick={pushId}
        >
          <Icon as={FiPlus} /> Add
        </Button>
      </Box>
    </TableContainer>
  );
};

const BodyEditor = ({
  description,
  onChange,
  isJson,
  value,
  onRegenerate,
}: {
  onChange?: (value: string, event?: any) => void;
  value?: string;
  description?: string;
  isJson?: boolean;
  onRegenerate?: () => void;
}) => {
  const bgBW = useColorModeValue("white", "gray.900");
  const [mode, setMode] = useState(isJson ? "json" : "plain_text");
  return (
    <Grid h="full" w="full" templateColumns={"3fr 1fr"}>
      <Box h="full">
        <ClientOnly>
          {() => (
            <AceEditor
              mode={mode}
              editorProps={{ $blockScrolling: true }}
              height="100%"
              width="100%"
              showGutter={true}
              showPrintMargin={false}
              value={value}
              onChange={onChange}
              tabSize={2}
            />
          )}
        </ClientOnly>
      </Box>

      {isJson ? (
        <Box h="full">
          <HStack>
            <Heading h={8} p={2} size="sm">
              Json
            </Heading>
            <Spacer />
          </HStack>
          <Divider />
          <Center h="calc(100% - 32px)">
            <Button
              size="sm"
              variant={"outline"}
              colorScheme="blue"
              onClick={onRegenerate}
            >
              <Icon as={FiRepeat} mr={2} />
              Generate
            </Button>
          </Center>
        </Box>
      ) : (
        <Box h="full">
          <HStack>
            <Heading p={2} size="sm">
              RAW
            </Heading>
            <Spacer />
            <Select
              variant={"unstyled"}
              width={20}
              size="sm"
              onChange={(e) => setMode(e.target.value)}
              value={mode}
            >
              <option value="plain_text">Text</option>
              <option value="json5">Json5</option>
              <option value="xml">XML</option>
            </Select>
          </HStack>
          <Divider />
          <Text p={2}>{description}</Text>
        </Box>
      )}
    </Grid>
  );
};

const ErrorEesponse = ({ err }: { err: any }) => {
  let msg =
    (err instanceof AxiosError ? err.code : undefined) || "unkown error";

  return (
    <Center position={"relative"} h="full">
      <Text color="gray.400" p={4} position={"absolute"} top={0} left={0}>
        Response
      </Text>
      <VStack>
        <Icon color="red.300" as={FiAlertOctagon} w={20} h={20} />
        <br />
        <Text>Could not send request</Text>
        <Tag px={4} colorScheme={"red"}>
          Error: {msg}
        </Tag>
      </VStack>
    </Center>
  );
};

const EmptyResponse = () => {
  return (
    <Center position={"relative"} h="full">
      <Text color="gray.400" p={4} position={"absolute"} top={0} left={0}>
        Response
      </Text>
      <VStack color="blue.300">
        <Icon as={SlRocket} w={20} h={20} />
        <br />
        <Text>Click the "Send" button to get the return results</Text>
      </VStack>
    </Center>
  );
};

const Response = ({ response }: { response: AxiosResponse }) => {
  let gray = useColorModeValue("gray.700", "gray.200");

  let contentType = (
    typeof response.headers.getContentType === "function"
      ? response.headers.getContentType() || ""
      : response.headers.getContentType || ""
  ).toString();

  let mode = contentType.startsWith("text/html")
    ? "html"
    : contentType.startsWith("application/json")
    ? "json"
    : "plain_text";

  let value = "";
  if (response.data) {
    if (typeof response.data === "object") {
      value = JSON.stringify(response.data, null, 2);
    } else {
      value = response.data;
    }
  }

  return (
    <Tabs size={"sm"} h="full">
      <TabList px={2} as={HStack} gap={0}>
        <Tab fontSize={"sm"}>Body</Tab>
        <Tab fontSize={"sm"}>Headers</Tab>
        {/* TODO */}
        {/* <Tab fontSize={"xs"}>Cookies</Tab> */}
        <Spacer />
        <Box px={2}>
          <Text fontSize={"xs"} color={gray}>
            Status: {response.status} {response.statusText}
          </Text>
        </Box>
      </TabList>

      <TabPanels h="calc(100% - 33px)" overflowY={"auto"}>
        <TabPanel h="full" p={0}>
          {value ? (
            <AceEditor
              mode={mode}
              editorProps={{ $blockScrolling: true }}
              height="100%"
              width="100%"
              showGutter={true}
              showPrintMargin={false}
              value={value}
              tabSize={2}
              readOnly
            />
          ) : (
            <Center h="full">
              <Text color={"gray.400"}>No Data</Text>
            </Center>
          )}
        </TabPanel>
        <TabPanel>
          <TableContainer>
            <Table size={"sm"} variant="simple" colorScheme={"teal"}>
              <Thead>
                <Tr>
                  <Th width={"4%"}></Th>
                  <Th width={"48%"}>Key</Th>
                  <Th width={"48%"}>Value</Th>
                </Tr>
              </Thead>
              <Tbody>
                {Object.entries(response.headers).map(([key, val], i) => (
                  <Tr key={i}>
                    <Td></Td>
                    <Td>{key}</Td>
                    <Td>{val}</Td>
                  </Tr>
                ))}
              </Tbody>
            </Table>
          </TableContainer>
        </TabPanel>
        <TabPanel>
          <p>three!</p>
        </TabPanel>
      </TabPanels>
    </Tabs>
  );
};

export default Postman;


================================================
FILE: app/routes/projects/$projectId/apis/details.$apiId.tsx
================================================
import { TabPanel, TabPanels } from "@chakra-ui/react";
import { ActionArgs, json, LoaderArgs } from "@remix-run/node";
import { RouteMatch, useMatches, useParams } from "@remix-run/react";
import invariant from "tiny-invariant";
import { getApiById, getApiProjectId } from "~/models/api.server";
import { httpResponse } from "~/utils";
import Editor, { saveApiAction } from "./..editor";
import Postman from "./..postman";
import Api from "./..api";
import { requireUserId } from "~/session.server";
import { checkAuthority } from "~/models/project.server";
import { ProjectUserRole } from "@prisma/client";

export const handle = {
  tabs: (matches: RouteMatch[]) => {
    const role = matches[1].data.role as ProjectUserRole;
    const readOnly = role === "READ";
    return readOnly ? ["Api", "Exec"] : ["Api", "Edit", "Exec"];
  },
};

export const loader = async ({ request, params }: LoaderArgs) => {
  let { apiId } = params;
  invariant(apiId);
  let userId = await requireUserId(request);

  let api = await getApiById(apiId);

  if (!api) {
    throw httpResponse.BadRequest;
  }

  if (!(await checkAuthority(userId, api.projectId, ProjectUserRole.READ))) {
    throw httpResponse.Forbidden;
  }
  return json({ api });
};

export const action = async ({ request, params }: ActionArgs) => {
  let userId = await requireUserId(request);
  let { apiId } = params;
  invariant(apiId);

  let projectId = await getApiProjectId(apiId);
  if (!projectId) {
    return httpResponse.BadRequest;
  }

  if (!(await checkAuthority(userId, projectId, "WRITE"))) {
    return httpResponse.Forbidden;
  }

  let formData = await request.formData();
  let action = formData.get("_action");
  if (action === "saveApi") {
    return await saveApiAction(apiId, formData);
  }
  throw httpResponse.NotFound;
};

export default function ApiInfo() {
  const matches = useMatches();
  const role = matches[1].data.role as ProjectUserRole;
  const readOnly = role === "READ";
  const { apiId } = useParams();
  return (
    <TabPanels h="full" overflowY={"hidden"} key={apiId}>
      <TabPanel h="full" overflowY={"auto"}>
        <Api />
      </TabPanel>
      {!readOnly && (
        <TabPanel h="full" overflow={"auto"}>
          <Editor />
        </TabPanel>
      )}
      <TabPanel h="full" p={0}>
        <Postman />
      </TabPanel>
      <TabPanel p={0}>Mock</TabPanel>
    </TabPanels>
  );
}


================================================
FILE: app/routes/projects/$projectId/apis/groups.$groupId.tsx
================================================
import {
  Box,
  Center,
  Input,
  TabPanel,
  TabPanels,
  Textarea,
  useToast,
  VStack,
} from "@chakra-ui/react";
import { ProjectUserRole } from "@prisma/client";
import { ActionArgs, json, LoaderArgs } from "@remix-run/node";
import { useLoaderData, useMatches, useTransition } from "@remix-run/react";
import { withZod } from "@remix-validated-form/with-zod";
import { useEffect, useRef } from "react";
import { ValidatedForm, validationError } from "remix-validated-form";
import invariant from "tiny-invariant";
import { z } from "zod";
import { getGroupById, updateGroup } from "~/models/api.server";
import { checkAuthority } from "~/models/project.server";
import { requireUserId } from "~/session.server";
import FormHInput from "~/ui/Form/FormHInput";
import FormSubmitButton from "~/ui/Form/FormSubmitButton";
import { httpResponse } from "~/utils";

export const handle = {
  tabs: ["Edit Group"],
};

export const loader = async ({ request, params }: LoaderArgs) => {
  let userId = await requireUserId(request);
  let { groupId } = params;
  if (!groupId) {
    throw httpResponse.NotFound;
  }

  let group = await getGroupById(groupId);

  invariant(group);

  if (!(await checkAuthority(userId, group.projectId, "READ"))) {
    throw httpResponse.Forbidden;
  }

  return json({ group: group });
};

export const action = async ({ request, params }: ActionArgs) => {
  let { groupId } = params;
  invariant(groupId);
  let userId = await requireUserId(request);

  let group = await getGroupById(groupId);
  if (!group) {
    return httpResponse.BadRequest;
  }

  if (!(await checkAuthority(userId, group.projectId, "WRITE"))) {
    return httpResponse.Forbidden;
  }

  let formData = await request.formData();
  let result = await validator.validate(formData);

  if (result.error) {
    throw validationError(result.error);
  }

  let updated = await updateGroup({
    id: groupId,
    name: result.data.name,
    description: result.data.description,
  });

  return json(updated);
};

const validator = withZod(
  z.object({
    name: z.string().trim().min(1, "Group name is required"),
    description: z.string().trim(),
  })
);

export default function ApiGroup() {
  const matches = useMatches();
  const role = matches[1].data.role as ProjectUserRole;
  const readOnly = role === "READ";
  let { group } = useLoaderData<typeof loader>();
  let defaultValue = {
    name: group.name,
    description: group.description,
  };
  const formRef = useRef<HTMLFormElement>(null);
  useEffect(() => {
    formRef.current?.reset();
  }, [group.id]);

  const transition = useTransition();
  const toast = useToast();

  useEffect(() => {
    if (transition.state === "loading") {
      let action = transition.submission?.formData?.get("_action");
      if (action === "saveGroup") {
        toast({
          title: "Your change has been saved.",
          status: "success",
          position: "top",
          isClosable: true,
        });
      }
    }
  }, [transition.state]);

  return (
    <TabPanels overflowY={"auto"}>
      <Box as={TabPanel} pb={5} pt={20} pr={20} mx="auto" maxW={"64rem"}>
        <ValidatedForm
          validator={validator}
          method="patch"
          defaultValues={defaultValue}
          formRef={formRef}
        >
          <VStack spacing={6}>
            <FormHInput
              labelWidth="200px"
              name="name"
              label="Group name"
              isRequired
              as={Input}
              readOnly={readOnly}
            />
            <FormHInput
              labelWidth="200px"
              name="description"
              label="Description"
              as={Textarea}
              readOnly={readOnly}
            />
            <Center>
              <FormSubmitButton
                name="_action"
                value="saveGroup"
                colorScheme="blue"
                px={12}
                disabled={readOnly}
              >
                Save
              </FormSubmitButton>
            </Center>
          </VStack>
        </ValidatedForm>
      </Box>
    </TabPanels>
  );
}


================================================
FILE: app/routes/projects/$projectId/apis/index.tsx
================================================
import {
  Box,
  Button,
  Center,
  Divider,
  Flex,
  Heading,
  Icon,
  Link,
  Spacer,
  Table,
  TableContainer,
  TabPanel,
  TabPanels,
  Tbody,
  Td,
  Text,
  Th,
  Thead,
  Tr,
  useColorModeValue,
  useDisclosure,
} from "@chakra-ui/react";
import { RequestMethod } from "@prisma/client";
import { Link as RemixLink, useMatches, useParams } from "@remix-run/react";
import { FiPlus } from "react-icons/fi";
import invariant from "tiny-invariant";
import { Api, Group, Project } from "~/models/project.server";
import { Header } from "~/ui";
import { NewApiModal, useMethodTag } from "../apis";

export const handle = {
  tabs: ["Overview"],
};

export default function ApiOverview() {
  const matches = useMatches();
  const { projectId } = useParams();
  const { isOpen, onClose, onOpen } = useDisclosure();
  const blue = useColorModeValue("blue.700", "blue.200");
  let project = matches[1].data.project as Project;
  invariant(project);
  invariant(projectId);
  let apis: Api[] = [];
  let groupMap = new Map<string, Group>();
  let stack = [project.root];
  while (stack.length > 0) {
    let group = stack.pop();
    invariant(group);
    groupMap.set(group.id, group);
    stack = stack.concat(group.groups);
    apis = apis.concat(group.apis);
  }

  if (apis.length === 0) {
    return (
      <Center>
        <Box textAlign={"center"}>
          <Heading>Create your first API definition to continue</Heading>
          <Box pb={20} mt={40}>
            <Button onClick={onOpen} colorScheme={"teal"} p={10} size="lg">
              New API Definition
            </Button>
          </Box>
        </Box>
        <NewApiModal isOpen={isOpen} onClose={onClose} />
      </Center>
    );
  }

  return (
    <TabPanels h="full" overflowY={"auto"} py={8} px={12} fontSize="sm">
      <TabPanel>
        <Flex>
          <Header>{apis.length} API definition</Header>
          <Spacer />
          <Button size="sm" colorScheme={"blue"} onClick={onOpen}>
            <Icon as={FiPlus} mr={1} /> New API
          </Button>
          <NewApiModal isOpen={isOpen} onClose={onClose} />
        </Flex>
        <Divider />
        <TableContainer mt={4}>
          <Table variant="striped">
            <Thead>
              <Tr>
                <Th>Name</Th>
                <Th>Path</Th>
                <Th isNumeric>Group</Th>
              </Tr>
            </Thead>
            <Tbody>
              {apis.map((api) => (
                <Tr key={api.id}>
                  <Td>
                    <Link
                      color={blue}
                      as={RemixLink}
                      to={`/projects/${projectId}/apis/details/${api.id}`}
                    >
                      {api.data.name}
                    </Link>
                  </Td>
                  <Td>
                    <Flex alignItems={"center"}>
                      <MethodTag method={api.data.method} />
                      <Text ml={2}>{api.data.path}</Text>
                    </Flex>
                  </Td>
                  <Td isNumeric>{groupMap.get(api.groupId || "")?.name}</Td>
                </Tr>
              ))}
            </Tbody>
          </Table>
        </TableContainer>
      </TabPanel>
    </TabPanels>
  );
}

const MethodTag = ({ method }: { method: RequestMethod }) => {
  let { text, color } = useMethodTag(method);

  return (
    <Text fontWeight={700} fontSize="sm" color={color}>
      {method.toUpperCase()}
    </Text>
  );
};


================================================
FILE: app/routes/projects/$projectId/apis.tsx
================================================
import Tree, {
  ItemId,
  moveItemOnTree,
  mutateTree,
  RenderItemParams,
  TreeData,
  TreeDestinationPosition,
  TreeSourcePosition,
} from '@atlaskit/tree';
// @ts-ignore
import { resetServerContext } from 'react-beautiful-dnd-next';
import {
  Box,
  Button,
  Center,
  Divider,
  Flex,
  Grid,
  GridItem,
  Heading,
  HStack,
  Icon,
  IconButton,
  Input,
  ListItem,
  Modal,
  ModalBody,
  ModalCloseButton,
  ModalContent,
  ModalFooter,
  ModalHeader,
  ModalOverlay,
  ModalProps,
  Spacer,
  Text,
  Tooltip,
  UnorderedList,
  useColorMode,
  useColorModeValue,
  useDisclosure,
  VStack,
} from '@chakra-ui/react';
import { ProjectUserRole, RequestMethod, RequestParam } from '@prisma/client';
import { ActionArgs, redirect } from '@remix-run/node';
import {
  Link as RemixLink,
  useCatch,
  useFetcher,
  useMatches,
} from '@remix-run/react';
import { withZod } from '@remix-validated-form/with-zod';
import { useEffect, useState } from 'react';
import {
  BsFillCaretDownFill,
  BsFillCaretRightFill,
  BsFolder2Open,
} from 'react-icons/bs';
import {
  FiFilePlus,
  FiFolder,
  FiFolderPlus,
  FiTrash,
} from 'react-icons/fi';
import { Outlet, useParams } from 'react-router-dom';
import { ValidatedForm, validationError } from 'remix-validated-form';
import invariant from 'tiny-invariant';
import { z } from 'zod';
import {
  createApi,
  createGroup,
  deleteApi,
  deleteGroup,
  updateApi,
  updateGroup,
} from '~/models/api.server';
import { Api, checkAuthority, Group, Project } from '~/models/project.server';
import { RequestMethods } from '~/models/type';
import { requireUserId } from '~/session.server';
import { PathInput } from '~/ui';
import FormCancelButton from '~/ui/Form/FormCancelButton';
import FormHInput from '~/ui/Form/FormHInput';
import FormInput from '~/ui/Form/FormInput';
import FormModal from '~/ui/Form/FormModal';
import FormSubmitButton from '~/ui/Form/FormSubmitButton';
import { httpResponse, parsePath, useUrl } from '~/utils';
import TreeBuilder from '~/utils/treeBuilder';
import { ProjecChangeButton } from '../$projectId';

export const handle = {
  sideNav: <SideNav />,
};

enum Action {
  NEW_GROUP = 'NEW_GROUP',
  NEW_API = 'NEW_API',
  UPDATE_API = 'UPDATE_API',
  UPDATE_GROUP = 'UPDATE_GROUP',
  DELETE_API = 'DELETE_API',
  DELETE_GROUP = 'DELETE_GROUP',
}

export const action = async ({ request, params }: ActionArgs) => {
  let userId = await requireUserId(request);
  let formData = await request.formData();
  let { projectId } = params;
  invariant(projectId);

  if (!(await checkAuthority(userId, projectId, ProjectUserRole.WRITE))) {
    return httpResponse.Forbidden;
  }

  switch (formData.get('_action')) {
    case Action.NEW_GROUP:
      return await newGroupAction(formData, projectId);
    case Action.UPDATE_GROUP:
      return await updateGroupAction(formData);
    case Action.NEW_API:
      return await newApiAction(formData, projectId);
    case Action.UPDATE_API:
      return await updateApiAction(formData);
    case Action.DELETE_API:
      return await deleteApiAction(formData);
    case Action.DELETE_GROUP:
      return await deleteGroupAction(formData);

    default:
      console.info('_action:', formData.get('_action'));
      return httpResponse.NotFound;
  }
};

const updateApiAction = async (formData: FormData) => {
  const result = await withZod(
    z.object({
      id: z.string().min(1, 'id is required'),
      groupId: z.string(),
      data: z.any(),
    })
  ).validate(formData);
  if (result.error) {
    return validationError(result.error);
  }
  return await updateApi(result.data.id, {
    groupId: result.data.groupId,
    data: result.data.data,
  });
};

const newGroupAction = async (formData: FormData, projectId: string) => {
  const result = await newGroupValidator.validate(formData);
  if (result.error) {
    return validationError(result.error);
  }

  const { parentId, name } = result.data;
  let group = await createGroup({
    parentId: parentId,
    projectId: projectId,
    name,
  });

  return redirect(`/projects/${group.projectId}/apis/groups/${group.id}`);
};

const updateGroupAction = async (formData: FormData) => {
  const result = await withZod(
    z.object({
      id: z.string().min(1, 'id is required'),
      parentId: z.string(),
      name: z.string(),
      description: z.string(),
    })
  ).validate(formData);
  if (result.error) {
    return validationError(result.error);
  }
  const { id, name, description, parentId } = result.data;
  return await updateGroup({ id, name, description, parentId });
};

const newApiAction = async (formData: FormData, projectId: string) => {
  const result = await newApiValidator.validate(formData);
  if (result.error) {
    return validationError(result.error);
  }

  const { name, path, method, groupId } = result.data;

  let { params } = parsePath(path);

  let api = await createApi(projectId, groupId, {
    name,
    path,
    method,
    pathParams: params.map<RequestParam>((param) => ({
      name: param,
      example: '',
      description: '',
      isRequired: true,
      type: 'STRING',
    })),
  });

  return redirect(`/projects/${projectId}/apis/details/${api.id}`);
};

export const deleteApiAction = async (formData: FormData) => {
  let id = formData.get('id')?.toString();
  let apiId = formData.get('apiId')?.toString();
  if (!id) {
    return httpResponse.BadRequest;
  }
  let url = formData.get('url')?.toString() ?? '/';
  let api = await deleteApi(id);
  if (apiId === id) {
    if (api?.groupId) {
      return redirect(`/projects/${api.projectId}/apis/groups/${api.groupId}`);
    } else if (api?.projectId) {
      return redirect(`/projects/${api.projectId}/apis`);
    } else {
      return redirect(`/projects`);
    }
  }
  return redirect(url);
};

export const deleteGroupAction = async (formData: FormData) => {
  let id = formData.get('id')?.toString();
  let apiId = formData.get('apiId')?.toString();
  let groupId = formData.get('groupId')?.toString();
  if (!id) {
    return httpResponse.BadRequest;
  }
  let url = formData.get('url')?.toString() ?? '/';

  let data = await deleteGroup(id);

  if (!data) {
    return httpResponse.BadRequest;
  }

  let { group, groupsToDelete, apisToDelete } = data;

  if (
    (groupId && groupsToDelete.indexOf(groupId) !== -1) ||
    (apiId && apisToDelete.indexOf(apiId) !== -1)
  ) {
    if (group.parentId) {
      return redirect(
        `/projects/${group.projectId}/apis/groups/${group.parentId}`
      );
    } else {
      return redirect(`/projects/${group.projectId}/apis`);
    }
  }

  return redirect(url);
};

function SideNav() {
  const groupModal = useDisclosure();
  const apiModal = useDisclosure();

  return (
    <Grid templateRows="50px 40px minmax(0, 1fr)" h="100vh" overflowX={'auto'}>
      <ProjecChangeButton />
      <GridItem>
        <HStack px={2}>
          <Heading ml="2" fontWeight={'500'} size={'sm'} color="gray.400">
            APIs
          </Heading>
          <Spacer />
          <Box>
            {/* <Tooltip label="Clone">
             <IconButton
             aria-label="clone"
             icon={<FiCopy />}
             variant="ghost"
             colorScheme="gray"
             />
             </Tooltip> */}
            <Tooltip label="New group">
              <IconButton
                aria-label="add group"
                icon={<FiFolderPlus />}
                variant="ghost"
                colorScheme="gray"
                onClick={(e) => {
                  if (e.target instanceof HTMLElement) {
                    e.target.blur();
                  }
                  groupModal.onOpen();
                }}
              />
            </Tooltip>
            <NewGroupModal
              isOpen={groupModal.isOpen}
              onClose={groupModal.onClose}
            />
            <Tooltip label="New Api">
              <IconButton
                aria-label="add api"
                icon={<FiFilePlus />}
                variant="ghost"
                colorScheme="gray"
                onClick={(e) => {
                  if (e.target instanceof HTMLElement) {
                    e.target.blur();
                  }
                  apiModal.onOpen();
                }}
              />
            </Tooltip>
            <NewApiModal isOpen={apiModal.isOpen} onClose={apiModal.onClose} />
          </Box>
        </HStack>
        <Divider />
      </GridItem>
      <GridItem overflowY={'auto'}>
        <SideNavContent />
      </GridItem>
    </Grid>
  );
}

const newGroupValidator = withZod(
  z.object({
    name: z.string().min(1, 'group name is required'),
    parentId: z.string().optional(),
  })
);

const NewGroupModal = ({
  isOpen,
  onClose,
}: {
  isOpen: boolean;
  onClose: (data?: any) => void;
}) => {
  const params = useParams();
  return (
    <FormModal
      isOpen={isOpen}
      onClose={onClose}
      validator={newGroupValidator}
      replace
      method="post"
      size="lg"
      action={`/projects/${params.projectId}/apis`}
    >
      <ModalOverlay />

      <ModalContent>
        <ModalHeader>New Group</ModalHeader>
        <ModalCloseButton />
        <ModalBody pb={6}>
          <FormInput
            name="name"
            label="Name"
            placeholder="Group name"
            autoComplete="off"
          />
          <input
            name="parentId"
            value={params.groupId || undefined}
            type="hidden"
          />
        </ModalBody>

        <ModalFooter>
          <FormCancelButton onClick={onClose} mr={3}>
            Cancel
          </FormCancelButton>
          <FormSubmitButton
            type="submit"
            name="_action"
            value={Action.NEW_GROUP}
            colorScheme="blue"
          >
            Create
          </FormSubmitButton>
        </ModalFooter>
      </ModalContent>
    </FormModal>
  );
};

const newApiValidator = withZod(
  z.object({
    name: z.string().trim().min(1, 'api name is required'),
    path: z.string().trim().min(1, 'path is required'),
    method: z.enum(RequestMethods),
    groupId: z.string().trim().optional(),
  })
);

export const NewApiModal = ({
  isOpen,
  onClose,
}: {
  isOpen: ModalProps['isOpen'];
  onClose: ModalProps['onClose'];
}) => {
  const matches = useMatches();
  const params = useParams();
  const gray = useColorModeValue('gray.200', 'gray.700');
  let groupId = '';
  if (params.groupId) {
    groupId = params.groupId;
  } else if (params.apiId) {
    groupId = matches?.[3].data?.api?.groupId;
  }

  let labelWidth = '60px';
  return (
    <FormModal
      isOpen={isOpen}
      onClose={onClose}
      validator={newApiValidator}
      replace
      method="post"
      size="xl"
      action={`/projects/${params.projectId}/apis`}
    >
      <ModalOverlay />

      <ModalContent>
        <ModalHeader>New Api</ModalHeader>
        <ModalCloseButton />
        <ModalBody pb={6}>
          <VStack spacing={5}>
            <FormHInput
              labelWidth={labelWidth}
              name="name"
              label="Name"
              size="sm"
              as={Input}
              autoComplete="off"
            />
            <FormHInput
              labelWidth={labelWidth}
              name="path"
              label="Path"
              as={PathInput}
              autoComplete="off"
              size="sm"
            />
            <Flex width={'full'} pl={labelWidth} flexDir={'row'}>
              <UnorderedList fontSize={'sm'}>
                <ListItem>
                  The API path starts with{' '}
                  <Text borderRadius={4} px={1} as="span" bg={gray}>
                    /
                  </Text>
                </ListItem>
                <ListItem>
                  Use curly braces to indicate Path Params, such as
                  <Text borderRadius={4} px={1} as="span" bg={gray}>
                    {'/users/{id}'}
                  </Text>
                </ListItem>
              </UnorderedList>
            </Flex>
          </VStack>

          <input type={'hidden'} name="groupId" value={groupId} />
        </ModalBody>

        <ModalFooter>
          <FormCancelButton onClick={onClose} mr={3}>
            Cancel
          </FormCancelButton>
          <FormSubmitButton
            type="submit"
            name="_action"
            value={Action.NEW_API}
            colorScheme="blue"
          >
            Create
          </FormSubmitButton>
        </ModalFooter>
      </ModalContent>
    </FormModal>
  );
};

const SideNavContent = () => {
  const matches = useMatches();
  const params = useParams();
  const project = matches[1].data.project as Project;
  const [treeData, setTreeData] = useState<TreeData>(
    new TreeBuilder('1', null)
  );
  const fetcher = useFetcher();
  invariant(project);
  useEffect(() => {
    const complexTree = new TreeBuilder(1, null);
    const generateTreeData = (group: Group, builder: TreeBuilder) => {
      for (let g of group.groups) {
        const childTree = new TreeBuilder(g.id, g);
        generateTreeData(g, childTree);
        builder.withSubTree(childTree);
      }
      for (let api of group.apis) {
        builder.withLeaf(api.id, api);
      }
    };
    generateTreeData(project.root, complexTree);
    const buildData = complexTree.build();
    Object.keys(buildData.items).some(itemId => {
      const itemData = buildData.items[itemId].data;
      if (itemData && (itemData.id === params.groupId || (itemData.data && itemData.id === params.apiId))) {
        Object.keys(buildData.items).some(key => {
          if(`${buildData.items[itemId].id}`.startsWith(key)){
            buildData.items[key].isExpanded = true;
          }
        });
        return true;
      }
    });
    setTreeData(buildData);
  }, [params.projectId, params.groupId, params.apiId]);
  invariant(project);
  const renderItem = ({
    item,
    onExpand,
    onCollapse,
    provided,
  }: RenderItemParams) => {
    return (
      <Box
        ref={provided.innerRef}
        {...provided.draggableProps}
      >
        <Box {...provided.dragHandleProps}>
          {item.data.data ? (
            <File key={item.id} api={item.data} />
          ) : (
            <Folder
              key={item.id}
              group={item.data}
              isExpanded={item.isExpanded}
              itemId={item.id}
              onExpand={onExpand}
              onCollapse={onCollapse}
              onAdd={() => {}}
              onDelete={() => {}}
            />
          )}
        </Box>
      </Box>
    );
  };

  const onExpand = (itemId: ItemId) => {
    setTreeData(mutateTree(treeData, itemId, { isExpanded: true }));
  };

  const onCollapse = (itemId: ItemId) => {
    setTreeData(mutateTree(treeData, itemId, { isExpanded: false }));
  };
  const onDragEnd = (
    source: TreeSourcePosition,
    destination?: TreeDestinationPosition
  ) => {
    if (!destination) {
      return;
    }
    const itemData =
      treeData.items[treeData.items[source.parentId].children[source.index]].data;
    const destItem = treeData.items[destination.parentId].data;
    if (destItem.data) {
      return;
    }
    itemData.data
      ? fetcher.submit(
        {
          id: itemData.id,
          groupId: destItem.id,
          _action: Action.UPDATE_API,
        },
        {
          method: 'patch',
          action: `/projects/${params.projectId}/apis`,
        }
      )
      : fetcher.submit(
        {
          id: itemData.id,
          name: itemData.name,
          description: itemData.description,
          parentId: destItem.id,
          _action: Action.UPDATE_GROUP,
        },
        {
          method: 'patch',
          action: `/projects/${params.projectId}/apis`,
        }
      );
    setTreeData(moveItemOnTree(treeData, source, destination));
  };
  resetServerContext();
  return (
    <Flex flexDir={'column'}>
      <Tree
        tree={treeData}
        renderItem={renderItem}
        onExpand={onExpand}
        onCollapse={onCollapse}
        onDragEnd={onDragEnd}
        isDragEnabled
        isNestingEnabled
      />
    </Flex>
  );
};

const Folder = ({
  itemId,
  group,
  onDelete,
  onAdd,
  onExpand,
  onCollapse,
  isExpanded,
}: {
  itemId: ItemId;
  group: Group;
  onDelete: (value: string) => void;
  isExpanded?: boolean;
  onExpand: (itemId: ItemId) => void;
  onCollapse: (itemId: ItemId) => void;
  onAdd: (value: string) => void;
}) => {
  const { projectId, groupId } = useParams<{
    projectId: string;
    groupId: string;
  }>();
  const isActive = groupId === group.id;
  const bg = useColorModeValue('blue.200', 'blue.600');
  const iconColor = useColorModeValue('blackAlpha.600', 'whiteAlpha.800');
  const hoverColor = useColorModeValue('blue.100', 'blue.800');
  const deleteModal = useDisclosure();
  return (
    <Flex border={'none'} flexDir="column">
      <HStack
        spacing={0}
        w="full"
        borderRadius={2}
        px={2}
        _hover={{ background: isActive ? undefined : hoverColor }}
        cursor="pointer"
        role="group"
        h={8}
        bg={isActive ? bg : undefined}
        onClick={(_e) =>
          isActive
            ? isExpanded
              ? onDelete(group.id)
              : onAdd(group.id)
            : undefined
        }
      >
        <Center
          mr={1}
          w="4"
          h="4"
          borderRadius={'full'}
          _groupHover={{ background: 'blackAlpha.50' }}
          onClick={() => (isExpanded ? onCollapse(itemId) : onExpand(itemId))}
        >
          <Icon
            as={isExpanded ? BsFillCaretDownFill : BsFillCaretRightFill}
            color={iconColor}
            fontSize={10}
          />
        </Center>
        <Box
          as={RemixLink}
          flexGrow={1}
          display="flex"
          alignItems={'center'}
          to={`/projects/${projectId}/apis/groups/${group.id}`}
        >
          <Icon
            as={isExpanded ? BsFolder2Open : FiFolder}
            fontWeight="100"
            color={iconColor}
            mr={2}
          />
          <Text py={1} userSelect={'none'}>
            {group.name}
          </Text>
        </Box>
        <Spacer />
        <DeleteButton onOpen={deleteModal.onOpen} />
        <DeleteApiDialog
          isOpen={deleteModal.isOpen}
          onClose={deleteModal.onClose}
          name={group.name}
          id={group.id}
          isGroup={true}
        />
      </HStack>
    </Flex>
  );
};

export const useMethodTag = (method: string) => {
  let colorMode = useColorMode();
  let color = '';
  let [value, setValue] = useState(generator(method));

  useEffect(() => {
    setValue(generator(method));
  }, [method, colorMode.colorMode]);

  function generator(method: string) {
    let text: string = method;

    switch (method) {
      case RequestMethod.GET:
        color = 'green';
        break;
      case RequestMethod.POST:
        color = 'orange';
        break;
      case RequestMethod.PUT:
        color = 'blue';
        break;
      case RequestMethod.PATCH:
        color = 'teal';
        text = 'PAT';
        break;
      case RequestMethod.DELETE:
        color = 'red';
        text = 'DEL';
        break;
      case RequestMethod.HEAD:
        color = 'purple';
        break;
      case RequestMethod.OPTIONS:
        color = 'cyan';
    }

    color += colorMode.colorMode === 'light' ? '.600' : '.300';

    return { text, color };
  }

  return value;
};

const MethodTag = ({ method }: { method: RequestMethod }) => {
  let { text, color } = useMethodTag(method);

  return (
    <Text
      fontWeight={700}
      fontSize="xs"
      mt={0.25}
      color={color}
      flexBasis="40px"
      flexShrink={0}
      flexGrow={0}
    >
      {text}
    </Text>
  );
};

export function CatchBoundary() {
  const caught = useCatch();
  return (
    <Center pt={10}>
      {caught.status}-{caught.statusText}
    </Center>
  );
}

const DeleteButton = ({ onOpen }: { onOpen: () => void }) => {
  return (
    <Button
      display="none"
      _groupHover={{
        display: 'inline-flex',
      }}
      colorScheme={'teal'}
      variant={'ghost'}
      p={0}
      size="xs"
      onClick={(e) => {
        e.preventDefault();
        e.stopPropagation();
        onOpen();
      }}
    >
      <Icon as={FiTrash} />
    </Button>
  );
};

const File = ({ api }: { api: Api }) => {
  const { projectId, apiId } = useParams();
  const bg = useColorModeValue('blue.200', 'blue.600');
  const isActive = api.id === apiId;
  invariant(projectId);
  const hoverColor = useColorModeValue('blue.100', 'blue.800');
  const deleteModal = useDisclosure();
  return (
    <Flex
      as={RemixLink}
      to={`/projects/${projectId}/apis/details/${api.id}`}
      h="8"
      px={4}
      borderRadius={2}
      _hover={{ background: isActive ? undefined : hoverColor }}
      bg={isActive ? bg : undefined}
      cursor="pointer"
      role={'group'}
    >
      <HStack w="full" spacing={0} pr={2}>
        <MethodTag method={api.data.method} />
        <Text noOfLines={1}>{api.data.name}</Text>
        <Spacer />
        <DeleteButton onOpen={deleteModal.onOpen} />
        <DeleteApiDialog
          isOpen={deleteModal.isOpen}
          onClose={deleteModal.onClose}
          name={api.data.name}
          id={api.id}
          isGroup={false}
        />
      </HStack>
    </Flex>
  );
};

const deleteValidator = withZod(z.object({}));
const DeleteApiDialog: React.FC<{
  isOpen: boolean;
  onClose: () => any;
  name: string;
  id: string;
  isGroup: boolean;
}> = ({ isOpen, onClose, name, id, isGroup }) => {
  const params = useParams();
  const url = useUrl();
  return (
    <Modal size={'lg'} isOpen={isOpen} onClose={onClose}>
      <ModalOverlay />
      <ModalContent>
        <ModalHeader>Please confirm</ModalHeader>
        <ModalCloseButton />
        <Divider />
        <ModalBody py={8} textAlign={'center'}>
          <Text>
            Are you sure to delete '<strong>{name}</strong>'
            {isGroup && <span> and it's contents</span>}?
          </Text>
          <Text>
            This action <strong>cannot</strong> be undone
          </Text>
        </ModalBody>
        <Divider />
        <ModalFooter
          as={ValidatedForm}
          method="delete"
          resetAfterSubmit
          validator={deleteValidator}
          action={`/projects/${params.projectId}/apis`}
        >
          <input type="hidden" name="id" value={id} />
          <input type="hidden" name="url" value={url.href} />
          <input
            type="hidden"
            name={isGroup ? 'groupId' : 'apiId'}
            value={id}
          />
          <FormCancelButton onClick={onClose} mr={3}>
            Cancel
          </FormCancelButton>
          <FormSubmitButton
            colorScheme="red"
            name="_action"
            value={isGroup ? Action.DELETE_GROUP : Action.DELETE_API}
          >
            Delete
          </FormSubmitButton>
        </ModalFooter>
      </ModalContent>
    </Modal>
  );
};

export default function Apis() {
  return <Outlet />;
}


================================================
FILE: app/routes/projects/$projectId/settings/index.tsx
================================================
import {
  Box,
  Button,
  Container,
  Flex,
  HStack,
  Text,
  Spacer,
  useColorModeValue,
  useToast,
  Modal,
  ModalOverlay,
  ModalContent,
  ModalCloseButton,
  ModalHeader,
  ModalBody,
  Input,
  Center,
  Divider,
  Alert,
  AlertIcon,
} from "@chakra-ui/react";
import { ActionArgs, redirect } from "@remix-run/node";
import { Form, useMatches, useTransition } from "@remix-run/react";
import { withZod } from "@remix-validated-form/with-zod";
import { useEffect, useState } from "react";
import { json } from "remix-utils";
import { ValidatedForm, validationError } from "remix-validated-form";
import invariant from "tiny-invariant";
import { z } from "zod";
import {
  Project,
  updateProject,
  findProjectMembersById,
  transferProject,
  checkAuthority,
} from "~/models/project.server";
import { FormInput, FormSubmitButton, Header } from "~/ui";
import { getUserById, getUserByName } from "~/models/user.server";
import { requireUserId } from "~/session.server";
import { httpResponse } from "~/utils";
import { ProjectUserRole } from "@prisma/client";

export const action = async ({ params, request }: ActionArgs) => {
  let formData = await request.formData();
  let { projectId } = params;
  let userId = await requireUserId(request);
  invariant(projectId);

  if (!(await checkAuthority(userId, projectId, ProjectUserRole.ADMIN))) {
    return httpResponse.Forbidden;
  }

  let action = formData.get("_action");
  if (action === "renameProject") {
    let result = await renameValidator.validate(formData);
    if (result.error) {
      return validationError(result.error);
    }
    await updateProject(projectId, { name: result.data.projectName });
  } else if (action === "transferProject") {
    let result = await transferValidator.validate(formData);
    if (result.error) {
      return validationError(result.error);
    }
    const transferUser = await getUserByName(result.data.userName);
    if (!transferUser) {
      return validationError(
        { fieldErrors: { userName: "User does not exist" } },
        result.submittedData,
        { status: 404 }
      );
    }
    let project = await findProjectMembersById(projectId);
    if (!project) {
      return httpResponse.BadRequest;
    }
    if (userId === transferUser.id) {
      return validationError(
        { fieldErrors: { userName: "You can't choose yourself" } },
        result.submittedData,
        { status: 403 }
      );
    }
    let currentUser = await getUserById(userId);
    if (!currentUser) {
      return httpResponse.BadRequest;
    }
    await transferProject(project, transferUser, currentUser);
    return redirect("/projects");
  } else if (action === "deleteProject") {
    await updateProject(projectId, { isDeleted: true });
    return redirect("/projects");
  }
  return json({ success: true });
};

const renameValidator = withZod(
  z.object({
    projectName: z.string().trim().min(1, "Please input project name"),
  })
);

const deleteValidator = withZod(z.object({}));

const transferValidator = withZod(
  z.object({
    userName: z.string().trim().min(1, "Please input user name"),
  })
);

const TransferDialog: React.FC<{
  isOpen: boolean;
  onClose: () => any;
  project: Project;
}> = ({ isOpen, onClose, project }) => {
  const [isDisabled, setIsDisabled] = useState(true);
  const bgBW = useColorModeValue("white", "gray.900");
  return (
    <Modal size={"lg"} isOpen={isOpen} onClose={onClose}>
      <ModalOverlay />
      <ModalContent>
        <ModalHeader>Transfer Project</ModalHeader>
        <ModalCloseButton />
        <ModalBody
          pb={6}
          as={ValidatedForm}
          replace
          method="patch"
          resetAfterSubmit
          validator={transferValidator}
        >
          <Alert status="warning">
            <AlertIcon />
            This action{" "}
            <Text display={"inline-block"} fontWeight={"bold"} mx={1}>
              cannot
            </Text>{" "}
            be undone.
          </Alert>

          <Text mt={5}>{"New owner's user name"}</Text>
          <FormInput label=" " name="userName" bg={bgBW} />
          <Text mt={5}>
            Type <Text as="strong">{project.name}</Text> to confirm.
          </Text>
          <Input
            mt={1}
            bg={bgBW}
            onChange={(e) => setIsDisabled(e.target.value !== project.name)}
          />
          <Divider orientation="horizontal" />
          <Center mt={10}>
            <FormSubmitButton
              colorScheme="red"
              isDisabled={isDisabled}
              name="_action"
              value="transferProject"
              ml={3}
            >
              I understand, transfer this project.
            </FormSubmitButton>
          </Center>
        </ModalBody>
      </ModalContent>
    </Modal>
  );
};

const DeleteDialog: React.FC<{
  isOpen: boolean;
  onClose: () => any;
  project: Project;
}> = ({ isOpen, onClose, project }) => {
  const [isDisabled, setIsDisabled] = useState(true);
  const bgBW = useColorModeValue("white", "gray.900");
  return (
    <Modal size={"lg"} isOpen={isOpen} onClose={onClose}>
      <ModalOverlay />
      <ModalContent>
        <ModalHeader fontSize="lg" fontWeight="bold">
          Are you absolutely sure?
        </ModalHeader>
        <ModalCloseButton />
        <ModalBody pb={10}>
          <Alert status="warning">
            <AlertIcon />
            Unexpected bad things will happen if you don't read this!
          </Alert>
          <Box mt={5}>
            This action{" "}
            <Text display={"inline-block"} fontWeight={"bold"} mx={1}>
              cannot
            </Text>{" "}
            be undone. This will permanently delete the
            <Text display={"inline-block"} fontWeight={"bold"} mx={1}>
              {project.name}
            </Text>{" "}
            project and remove all data.
          </Box>
          <Flex mt={5}>
            Please type{" "}
            <Text fontWeight={"bold"} mx={2}>
              {project.name}
            </Text>{" "}
            to confirm.
          </Flex>
          <Input
            mt={2}
            bg={bgBW}
            onChange={(e) => setIsDisabled(e.target.value !== project.name)}
          />
          <Center
            mt={10}
            as={ValidatedForm}
            replace
            method="patch"
            resetAfterSubmit
            validator={deleteValidator}
          >
            <FormSubmitButton
              colorScheme="red"
              isDisabled={isDisabled}
              name="_action"
              value="deleteProject"
              ml={3}
            >
              I understand the consequences, delete this project
            </FormSubmitButton>
          </Center>
        </ModalBody>
      </ModalContent>
    </Modal>
  );
};

export default function () {
  const matches = useMatches();
  const project = matches[1].data.project as Project;
  invariant(project);
  const role = matches[1].data.role as ProjectUserRole;
  const isAdmin = role === "ADMIN";
  const bg = useColorModeValue("gray.100", "gray.700");
  const bgBW = useColorModeValue("white", "gray.900");
  const [deleteVisible, setDeleteVisible] = useState(false);
  const [transferVisible, setTransferVisible] = useState(false);
  const transition = useTransition();
  const toast = useToast();
  useEffect(() => {
    if (transition.state === "loading") {
      let action = transition.submission?.formData?.get("_action");
      if (action === "renameProject") {
        toast({
          title: "Project successfully renamed.",
          status: "success",
          position: "top",
          isClosable: true,
        });
      } else if (action === "changePassword") {
        toast({
          title: "Password changed.",
          status: "success",
          position: "top",
          isClosable: true,
        });
      } else if (action === "deleteProject") {
        transition.type === "actionRedirect" &&
          toast({
            title: "Project successfully deleted",
            status: "success",
            position: "top",
            isClosable: true,
          });
      } else if (action === "transferProject") {
        transition.type === "actionRedirect" &&
          toast({
            title: "Project successfully transferred",
            status: "success",
            position: "top",
            isClosable: true,
          });
      }
    }
  }, [transition.state]);

  return (
    <Box h="100%" overflowY={"auto"}>
      <Container maxW={"container.md"} p={8} pb={10}>
        <Header>General</Header>
        <Box
          bg={bg}
          as={ValidatedForm}
          validator={renameValidator}
          replace
          method="patch"
          resetAfterSubmit
          p={8}
        >
          <HStack alignItems={"start"}>
            <FormInput
              isRequired
              label="Project name"
              name="projectName"
              bg={bgBW}
              defaultValue={project.name}
              readOnly={!isAdmin}
            />
            <Spacer />
            <Box>
              <FormSubmitButton
                mt={8}
                name="_action"
                value="renameProject"
                colorScheme={"blue"}
                disabled={!isAdmin}
              >
                Rename
              </FormSubmitButton>
            </Box>
          </HStack>
        </Box>

        <Header mt={12}>Danger Zone</Header>
        <Box bg={bg} p={8} w="full">
          <HStack as={Form} replace method="patch">
            <Flex flexDir={"column"}>
              <Text fontWeight={"medium"}>Transfer Ownership</Text>
              <Text w="full">Transfer this project to another user</Text>
            </Flex>
            <Spacer />
            <Button
              px={6}
              variant={"outline"}
              colorScheme={"red"}
              onClick={() => setTransferVisible(true)}
              disabled={!isAdmin}
            >
              Transfer
              <TransferDialog
                isOpen={transferVisible}
                onClose={() => setTransferVisible(false)}
                project={project}
              />
            </Button>
          </HStack>
          <HStack mt={6} as={Form} replace method="patch">
            <Flex flexDir={"column"}>
              <Text fontWeight={"medium"}>Delete this project</Text>
              <Text w="full">
                Once you delete a project, there is no going back. Please be
                certain.
              </Text>
            </Flex>
            <Spacer />
            <Button
              px={6}
              variant={"outline"}
              colorScheme={"red"}
              onClick={() => setDeleteVisible(true)}
              disabled={!isAdmin}
            >
              Delete
              <DeleteDialog
                isOpen={deleteVisible}
                onClose={() => setDeleteVisible(false)}
                project={project}
              />
            </Button>
          </HStack>
        </Box>
      </Container>
    </Box>
  );
}


================================================
FILE: app/routes/projects/$projectId/settings/members.tsx
================================================
import {
  Box,
  Button,
  Divider,
  Flex,
  Icon,
  Spacer,
  Table,
  TableContainer,
  Tbody,
  Td,
  Th,
  Thead,
  Tr,
  Text,
  useDisclosure,
  Select,
  ModalOverlay,
  ModalContent,
  ModalCloseButton,
  ModalBody,
  ModalFooter,
  Center,
  RadioGroup,
  VStack,
  Radio,
  useToast,
  Spinner,
  AlertDialog,
  AlertDialogOverlay, AlertDialogContent, AlertDialogFooter, AlertDialogHeader, AlertDialogBody,
} from '@chakra-ui/react';
import { ProjectUser, ProjectUserRole, User } from '@prisma/client';
import { ActionArgs, json, LoaderArgs } from '@remix-run/node';
import {
  useCatch,
  useFetcher,
  useLoaderData,
  useMatches,
} from '@remix-run/react';
import { withZod } from '@remix-validated-form/with-zod';
import React, { RefObject, useEffect, useRef, useState } from 'react';
import { FiBook, FiChevronDown, FiPlus, FiTrash } from 'react-icons/fi';
import { validationError, } from 'remix-validated-form';
import invariant from 'tiny-invariant';
import { z } from 'zod';
import {
  addMemberToProject, changeProjectMembers,
  changeProjectRole,
  findProjectMembersById,
  getProjectById,
  Project,
} from '~/models/project.server';
import { ProjectUserRoles } from '~/models/type';
import { getUserByEmail, getUserInfoByIds } from '~/models/user.server';
import { requireUserId } from '~/session.server';
import { FormInput, FormModal, FormSubmitButton, Header } from '~/ui';
import { httpResponse } from '~/utils';
import { FocusableElement } from '@chakra-ui/utils';

export const loader = async ({ params }: LoaderArgs) => {
  let { projectId } = params;
  invariant(projectId);
  let project = await getProjectById(projectId);
  if (!project) {
    throw httpResponse.BadRequest;
  }
  let members = await getUserInfoByIds(project.members.map((item) => item.id));
  let roleMap = project.members.reduce((prev, curr) => {
    prev[curr.id] = curr.role;
    return prev;
  }, {} as { [key: string]: ProjectUserRole });
  let retval = members.map(
    (member) =>
      ({
        ...member,
        role: roleMap[member.id],
      } as typeof member & { role: ProjectUserRole })
  );
  return json({ members: retval });
};

export const action = async ({ request, params }: ActionArgs) => {
  let userId = await requireUserId(request);
  let formData = await request.formData();
  let { projectId } = params;
  invariant(projectId);

  let project = await findProjectMembersById(projectId);
  if (!project) {
    return httpResponse.BadRequest;
  }

  if (
    project.members.find((member) => member.id === userId)?.role !== 'ADMIN'
  ) {
    return httpResponse.Forbidden;
  }

  let action = formData.get('_action');
  switch (action) {
    case 'addMember':
      return addMemberAction(project, formData);
    case 'changeRole':
      return changeRoleAction(project, formData);
    case 'deleteMember':
      return deleteMemberAction(project, formData);
    default:
      return httpResponse.BadRequest;
  }
};

const addMemberAction = async (
  project: {
    id: string;
    members: ProjectUser[];
    name: string;
  },
  formData: FormData
) => {
  let result = await addMemberValidator.validate(formData);
  if (result.error) {
    return validationError(result.error);
  }

  let user = await getUserByEmail(result.data.email);
  if (!user) {
    return validationError({
      formId: result.formId,
      fieldErrors: {
        email: `${result.data.email} is not a registered user.`,
      },
    });
  }

  for (let member of project.members) {
    if (member.id === user.id) {
      return validationError({
        formId: result.formId,
        fieldErrors: {
          email: `${result.data.email} is already a member of ${project.name}.`,
        },
      });
    }
  }

  await addMemberToProject(project.id, user.id, result.data.role);

  return httpResponse.OK;
};

const changeRoleAction = async (
  project: {
    id: string;
    members: ProjectUser[];
  },
  formData: FormData
) => {
  let result = await withZod(
    z.object({
      id: z.string(),
      role: z.enum(ProjectUserRoles),
      _action: z.string(),
    })
  ).validate(formData);

  if (result.error) {
    return validationError(result.error);
  }

  let data = result.data;

  let numOfAdmins = project.members.filter((member) =>
    member.id === data.id ? data.role === 'ADMIN' : member.role === 'ADMIN'
  ).length;

  if (numOfAdmins === 0) {
    return validationError(
      {
        formId: result.formId,
        fieldErrors: {
          role: `Project should have at least 1 admin`,
        },
      },
      data
    );
  }

  await changeProjectRole(project.id, data.id, data.role);
  return json(result.data);
};

const deleteMemberAction = async (
  project: {
    id: string;
    members: ProjectUser[];
  },
  formData: FormData
) => {
  let result = await withZod(
    z.object({
      id: z.string(),
      _action: z.string(),
    })
  ).validate(formData);

  if (result.error) {
    return validationError(result.error);
  }

  let data = result.data;

  let numOfAdmins = project.members.filter((member) =>
    member.id !== data.id && member.role === 'ADMIN'
  ).length;

  if (numOfAdmins === 0) {
    return validationError(
      {
        formId: result.formId,
        fieldErrors: {
          role: `Project should have at least 1 admin`,
        },
      },
      data
    );
  }
  await changeProjectMembers(project.id, data.id);
  return json(result.data);
};

export function CatchBoundary() {
  const caught = useCatch();

  return (
    <>
      <div>
        ERROR: {caught.statusText} {caught.status}
      </div>
      <div>{caught.data.message}</div>
    </>
  );
}

export default function () {
  const { isOpen, onOpen, onClose } = useDisclosure();
  const matches = useMatches();
  const project = matches[1].data.project as Project;
  const role = matches[1].data.role as ProjectUserRole;
  let { members } = useLoaderData<typeof loader>();
  const fetcher = useFetcher();
  const toast = useToast();
  const isAdmin = role === 'ADMIN';
  const [deleteDialogVisible, setDeleteDialogVisible] = useState(false);
  const deleteMember = useRef<{id: string; name: string}>();
  const onRoleChange = (id: string, role: ProjectUserRole) => {
    fetcher.submit(
      {
        id: id,
        role: role,
        _action: 'changeRole',
      },
      {
        method: 'patch',
        replace: true,
      }
    );
  };

  const onDelete = () => {
    fetcher.submit(
      {
        id: deleteMember.current!.id,
        _action: 'deleteMember',
      },
      {
        method: 'patch',
        replace: true,
      }
    );
  };

  const isLoading = fetcher.state !== 'idle';

  useEffect(() => {
    if (fetcher.type === 'done') {
      let errMsg = fetcher.data?.fieldErrors?.role;
      if (errMsg) {
        toast({
          title: 'Could not change role.',
          description: errMsg,
          status: 'error',
          duration: 5000,
          isClosable: true,
          position: 'top',
        });
      } else {
        const { id, role, _action } = fetcher.data;
        if (_action === 'changeRole') {
          const member = members.find((elem) => elem.id === id);
          toast({
            title: 'Action Succeed',
            description: `User ${member?.name} changed to ${role}`,
            status: 'success',
            duration: 5000,
            isClosable: true,
            position: 'top',
          });
        }else if (_action === 'deleteMember') {
          setDeleteDialogVisible(false);
          toast({
            title: 'Action Succeed',
            description: `User ${deleteMember.current?.name} is deleted`,
            status: 'success',
            duration: 5000,
            isClosable: true,
            position: 'top',
          });
        }
      }
    }
  }, [fetcher.type]);

  return (
    <Box h="100%" overflowY={'auto'} px={12} py={9} fontSize="sm">
      <Flex>
        <Header>{1} Members</Header>
        <Spacer />
        <Button
          disabled={!isAdmin}
          size="sm"
          colorScheme={'blue'}
          onClick={onOpen}
        >
          <Icon as={FiPlus} mr={1} /> Member
        </Button>
        <AddMemberModal isOpen={isOpen} onClose={onClose} project={project} />
      </Flex>
      <Divider />
      <TableContainer mt={'10px'}>
        <Table size={'sm'}>
          <Thead>
            <Tr>
              <Th>Name</Th>
              <Th>Email</Th>
              <Th w={40} isNumeric>
                Role
              </Th>
              <Th px={0} w={10}></Th>
            </Tr>
          </Thead>
          <Tbody>
            {members.map((member) => (
              <Tr key={member.id}>
                <Td>{member.name}</Td>
                <Td>
                  <Text>{member.email}</Text>
                </Td>
                <Td isNumeric>
                  <Select
                    value={member.role}
                    onChange={(e) =>
                      onRoleChange(member.id, e.target.value as ProjectUserRole)
                    }
                    size="sm"
                    icon={
                      isLoading ? <Spinner size={'sm'} /> : <FiChevronDown />
                    }
                    disabled={!isAdmin || isLoading}
                  >
                    <option value={ProjectUserRole.ADMIN}>Admin</option>
                    <option value={ProjectUserRole.WRITE}>Write</option>
                    <option value={ProjectUserRole.READ}>Read</option>
                  </Select>
                </Td>
                <Td px={0}>
                  <Button
                    disabled={!isAdmin}
                    colorScheme={'red'}
                    size="sm"
                    variant={'ghost'}
                    onClick={() => {
                      setDeleteDialogVisible(true);
                      deleteMember.current = member;
                    }}
                  >
                    <Icon as={FiTrash} />
                  </Button>
                </Td>
              </Tr>
            ))}
          </Tbody>
        </Table>
      </TableContainer>
      <DeleteDialog isLoading={isLoading} isOpen={deleteDialogVisible} onClose={() => setDeleteDialogVisible(false)} onDelete={onDelete} />
    </Box>
  );
}

const DeleteDialog: React.FC<{
  isOpen: boolean;
  onClose: () => any;
  onDelete: () => any;
  isLoading: boolean;
}> = ({ isOpen, onClose, onDelete, isLoading }) => {
  const cancelRef = useRef<FocusableElement>();
  return <AlertDialog
    isOpen={isOpen}
    onClose={onClose}
    leastDestructiveRef={cancelRef as RefObject<FocusableElement>}
  >
    <AlertDialogOverlay>
      <AlertDialogContent>
        <AlertDialogHeader fontSize="lg" fontWeight="bold">
          Delete Confirm
        </AlertDialogHeader>
        <AlertDialogBody>
          Are you sure? You can't undo this action afterwards.
        </AlertDialogBody>
        <AlertDialogFooter>
          <Button ref={cancelRef as RefObject<HTMLButtonElement>} onClick={onClose}>
            Cancel
          </Button>
          <Button colorScheme="red" isLoading={isLoading} ml={3} onClick={onDelete}>
            Delete
          </Button>
        </AlertDialogFooter>
      </AlertDialogContent>
    </AlertDialogOverlay>
  </AlertDialog>;
};

const addMemberValidator = withZod(
  z.object({
    email: z.string().trim().min(1, 'Please input the email address of the member to invite.').email('Invalid email format'),
    role: z.enum(ProjectUserRoles),
  })
);

const AddMemberModal = ({
  isOpen,
  onClose,
  project,
}: {
  isOpen: boolean;
  onClose: () => void;
  project: Project;
}) => {
  return (
    <FormModal
      isOpen={isOpen}
      onClose={onClose}
      validator={addMemberValidator}
      replace
      method="post"
      size="2xl"
    >
      <ModalOverlay />
      <ModalContent>
        <ModalCloseButton />
        <Center pt={20} pb={12} flexDir={'column'}>
          <Icon as={FiBook} display="block" w={12} h={12} color="gray.500" />
          <Text fontSize="lg" mt={4}>
            Add people to{' '}
            <Text as="b" fontWeight={'bold'}>
              {project.name}
            </Text>
          </Text>
        </Center>
        <ModalBody px={0} pb={6}>
          <Box px={8}>
            <FormInput name="email" placeholder="Email" />
          </Box>
          <Text fontSize={'sm'} px={8} mt={8} color="gray.500">
            Choose a role
          </Text>
          <Divider />
          <RadioGroup size={'sm'} defaultValue={ProjectUserRole.READ}>
            <VStack alignItems={'baseline'}>
              <Radio px={8} py={2} name="role" value={ProjectUserRole.READ}>
                <Flex flexDir={'column'}>
                  <Text fontWeight={'bold'}>Read</Text>
                  <Text>
                    Recommended for non-code contributors who want to view or
                    discuss your project.
                  </Text>
                </Flex>
              </Radio>
              <Divider style={{ marginTop: 0 }} />
              <Radio px={8} py={2} name="role" value={ProjectUserRole.WRITE}>
                <Flex flexDir={'column'}>
                  <Text fontWeight={'bold'}>Write</Text>
                  <Text>
                    Recommended for contributors who actively edit to your
                    project.
                  </Text>
                </Flex>
              </Radio>
              <Divider style={{ marginTop: 0 }} />
              <Radio px={8} py={2} name="role" value={ProjectUserRole.ADMIN}>
                <Flex flexDir={'column'}>
                  <Text fontWeight={'bold'}>Admin</Text>
                  <Text>
                    Recommended for people who need full access to the project,
                    including sensitive and destructive actions like managing
                    members or deleting a project.
                  </Text>
                </Flex>
              </Radio>
              <Divider style={{ marginTop: 0 }} />
            </VStack>
          </RadioGroup>
        </ModalBody>
        <ModalFooter mb={6} justifyContent={'center'}>
          <FormSubmitButton
            px={12}
            type="submit"
            colorScheme="blue"
            name="_action"
            value="addMember"
          >
            Add
          </FormSubmitButton>
        </ModalFooter>
      </ModalContent>
    </FormModal>
  );
};


================================================
FILE: app/routes/projects/$projectId/settings.tsx
================================================
import {
  Box,
  Divider,
  Flex,
  FlexProps,
  Grid,
  Icon,
  Link,
  Spacer,
  Text,
  useColorModeValue,
  VStack,
} from "@chakra-ui/react";
import { NavLink, Outlet, useParams } from "@remix-run/react";
import { ReactNode } from "react";
import { IconType } from "react-icons";
import { FiSettings, FiUsers } from "react-icons/fi";
import invariant from "tiny-invariant";
import { ProjecChangeButton } from "../$projectId";

export const handle = {
  sideNav: <SideNav />,
  tabs: ["Settings"],
};

function SideNav() {
  const { projectId } = useParams();
  invariant(projectId);
  return (
    <Grid
      position={"relative"}
      bg={useColorModeValue("gray.50", "gray.800")}
      templateRows="50px 1px minmax(0, 1fr)"
      h="100vh"
    >
      <ProjecChangeButton />
      <Divider />
      <VStack spacing={0}>
        <NavItem
          to={`/projects/${projectId}/settings`}
          w="full"
          icon={FiSettings}
          end
        >
          General
        </NavItem>
        <NavItem
          to={`/projects/${projectId}/settings/members`}
          w="full"
          icon={FiUsers}
        >
          Members
        </NavItem>
      </VStack>
    </Grid>
  );
}

interface NavItemProps extends FlexProps {
  icon: IconType;
  children: ReactNode;
  isActive?: boolean;
  to: string;
  end?: boolean;
}

const NavItem = ({ icon, children, to, end, ...rest }: NavItemProps) => {
  const hoverBG = useColorModeValue("cyan.100", "cyan.800");
  const activeBG = useColorModeValue("blue.200", "blue.700");
  return (
    <NavLink to={to} style={{ width: "100%" }} end={end}>
      {({ isActive }) => (
        <Flex
          w="full"
          align="center"
          py={2}
          px={4}
          // borderRadius="lg"
          cursor="pointer"
          _hover={{
            bg: isActive ? activeBG : hoverBG,
          }}
          bg={isActive ? activeBG : undefined}
          {...rest}
        >
          {icon && <Icon mr="4" fontSize="16" as={icon} />}
          <Text
            style={{ textDecoration: "none" }}
            _focus={{ boxShadow: "none" }}
          >
            {children}
          </Text>
        </Flex>
      )}
    </NavLink>
  );
};

export default function Settings() {
  return <Outlet />;
}


================================================
FILE: app/routes/projects/$projectId.tsx
================================================
import {
  Box,
  BoxProps,
  Button,
  Divider,
  Flex,
  Grid,
  GridItem,
  Heading,
  HStack,
  Icon,
  Image,
  List,
  ListIcon,
  ListItem,
  Modal,
  ModalBody,
  ModalContent,
  ModalFooter,
  ModalHeader,
  ModalOverlay,
  Skeleton,
  Spacer,
  Stack,
  Tab,
  TabList,
  Tabs,
  Text,
  useColorModeValue,
  useDisclosure,
} from "@chakra-ui/react";
import { ActionArgs, json, LoaderArgs, SerializeFrom } from "@remix-run/node";
import {
  Link as RemixLink,
  NavLink,
  Outlet,
  useFetcher,
  useLoaderData,
  useMatches,
} from "@remix-run/react";
import { ReactNode, RefObject, useEffect, useRef } from "react";
import { IconType } from "react-icons";
import {
  FiChevronDown,
  FiChevronUp,
  FiGrid,
  FiList,
  FiSettings,
} from "react-icons/fi";
import logo from "~/images/logo_64.png";
import { getProjectById, getProjectByIds } from "~/models/project.server";
import { requireUser } from "~/session.server";
import { httpResponse, useUser } from "~/utils";
import ColorModeButton from "../home/..lib/ColorModeButton";
import UserMenuButton from "../home/..lib/UserMenuButton";
import { 
Download .txt
gitextract_0qb6gbce/

├── .gitignore
├── .npmrc
├── LICENSE
├── README.md
├── app/
│   ├── context.tsx
│   ├── createEmotionCache.ts
│   ├── entry.client.tsx
│   ├── entry.server.tsx
│   ├── models/
│   │   ├── api.server.ts
│   │   ├── prisma.server.ts
│   │   ├── project.server.ts
│   │   ├── type.ts
│   │   └── user.server.ts
│   ├── root.tsx
│   ├── routes/
│   │   ├── home/
│   │   │   ├── ..lib/
│   │   │   │   ├── ColorModeButton.tsx
│   │   │   │   ├── Layout.tsx
│   │   │   │   ├── NotificationButton.tsx
│   │   │   │   └── UserMenuButton.tsx
│   │   │   ├── logout.tsx
│   │   │   ├── settings.tsx
│   │   │   ├── signin.tsx
│   │   │   └── signup.tsx
│   │   ├── index.tsx
│   │   ├── mock/
│   │   │   └── $projectId.$.tsx
│   │   └── projects/
│   │       ├── $projectId/
│   │       │   ├── activities.tsx
│   │       │   ├── apis/
│   │       │   │   ├── ..api.tsx
│   │       │   │   ├── ..editor.tsx
│   │       │   │   ├── ..postman.tsx
│   │       │   │   ├── details.$apiId.tsx
│   │       │   │   ├── groups.$groupId.tsx
│   │       │   │   └── index.tsx
│   │       │   ├── apis.tsx
│   │       │   ├── settings/
│   │       │   │   ├── index.tsx
│   │       │   │   └── members.tsx
│   │       │   └── settings.tsx
│   │       ├── $projectId.tsx
│   │       └── index.tsx
│   ├── session.server.ts
│   ├── theme.ts
│   ├── ui/
│   │   ├── AceEditor.tsx
│   │   ├── Form/
│   │   │   ├── FormCancelButton.tsx
│   │   │   ├── FormHInput.tsx
│   │   │   ├── FormInput.tsx
│   │   │   ├── FormModal.tsx
│   │   │   ├── FormSubmitButton.tsx
│   │   │   ├── ModalInput.tsx
│   │   │   ├── PathInput.tsx
│   │   │   └── type.ts
│   │   ├── Header.tsx
│   │   ├── _AceEditor.tsx
│   │   ├── dashboard.css
│   │   └── index.ts
│   └── utils/
│       ├── hooks.ts
│       ├── index.ts
│       ├── mock.ts
│       └── treeBuilder.ts
├── package.json
├── prisma/
│   ├── schema.prisma
│   └── seed.ts
├── remix.config.js
├── remix.env.d.ts
└── tsconfig.json
Download .txt
SYMBOL INDEX (92 symbols across 37 files)

FILE: app/context.tsx
  type ServerStyleContextData (line 4) | interface ServerStyleContextData {
  type ClientStyleContextData (line 14) | interface ClientStyleContextData {

FILE: app/createEmotionCache.ts
  function createEmotionCache (line 4) | function createEmotionCache() {

FILE: app/entry.client.tsx
  type ClientCacheProviderProps (line 10) | interface ClientCacheProviderProps {
  function ClientCacheProvider (line 14) | function ClientCacheProvider({ children }: ClientCacheProviderProps) {

FILE: app/entry.server.tsx
  function handleRequest (line 11) | function handleRequest(

FILE: app/models/project.server.ts
  type Api (line 82) | type Api = NonNullable<Awaited<ReturnType<typeof findProjectById>>>['api...
  type PlainGroup (line 83) | type PlainGroup = NonNullable<Awaited<ReturnType<typeof findProjectById>...
  type Group (line 84) | type Group = PlainGroup & {
  type Project (line 140) | type Project = NonNullable<Awaited<ReturnType<typeof getProjectById>>>;

FILE: app/models/type.ts
  type JsonNode (line 25) | interface JsonNode {

FILE: app/models/user.server.ts
  function getUserById (line 6) | async function getUserById(id: User["id"]) {
  function getUserByName (line 10) | async function getUserByName(name: User["name"]) {
  function getUserByEmail (line 14) | async function getUserByEmail(email: User["email"]) {
  function getUserInfoByIds (line 18) | async function getUserInfoByIds(ids: string[]) {
  function createUser (line 33) | async function createUser(
  function verifyLogin (line 49) | async function verifyLogin(

FILE: app/root.tsx
  type DocumentProps (line 50) | interface DocumentProps {
  function App (line 97) | function App() {

FILE: app/routes/home/..lib/ColorModeButton.tsx
  function ColorModeButton (line 4) | function ColorModeButton(props: ButtonProps) {

FILE: app/routes/home/..lib/Layout.tsx
  function Layout (line 36) | function Layout({ children }: PropsWithChildren) {
  function Header (line 46) | function Header() {
  function GuestMenuButtons (line 124) | function GuestMenuButtons() {
  type NavItem (line 290) | interface NavItem {
  constant NAV_ITEMS (line 297) | const NAV_ITEMS: Array<NavItem> = [];
  function Footer (line 330) | function Footer() {

FILE: app/routes/home/..lib/UserMenuButton.tsx
  function UserMenuButton (line 15) | function UserMenuButton({

FILE: app/routes/home/logout.tsx
  function action (line 6) | async function action({ request }: ActionArgs) {
  function loader (line 10) | async function loader({ request }: LoaderArgs) {

FILE: app/routes/home/settings.tsx
  function Settings (line 113) | function Settings() {

FILE: app/routes/home/signin.tsx
  function loader (line 33) | async function loader({ request }: LoaderArgs) {
  function action (line 39) | async function action({ request }: ActionArgs) {
  function Login (line 85) | function Login() {

FILE: app/routes/home/signup.tsx
  function loader (line 22) | async function loader({ request }: LoaderArgs) {
  function action (line 28) | async function action({ request }: ActionArgs) {
  function SignUp (line 95) | function SignUp() {

FILE: app/routes/index.tsx
  function links (line 7) | function links() {
  function loader (line 20) | async function loader({ request }: LoaderArgs) {

FILE: app/routes/mock/$projectId.$.tsx
  function findPathForRule (line 42) | function findPathForRule(path: string, rules: string[]) {
  function matchApi (line 55) | function matchApi(apiPath: string, apiRule: string) {

FILE: app/routes/projects/$projectId.tsx
  function Layout (line 101) | function Layout({ children }: { children: ReactNode }) {
  type SidebarProps (line 111) | interface SidebarProps extends BoxProps {}
  type SubMenuItemProps (line 316) | interface SubMenuItemProps extends BoxProps {

FILE: app/routes/projects/$projectId/activities.tsx
  function Activities (line 1) | function Activities() {

FILE: app/routes/projects/$projectId/apis.tsx
  type Action (line 93) | enum Action {
  function SideNav (line 260) | function SideNav() {
  function generator (line 718) | function generator(method: string) {
  function CatchBoundary (line 772) | function CatchBoundary() {
  function Apis (line 896) | function Apis() {

FILE: app/routes/projects/$projectId/apis/..editor.tsx
  type JsonNodeFormElem (line 99) | type JsonNodeFormElem = Omit<
  type JsonNodeForm (line 105) | type JsonNodeForm = JsonNodeFormElem & {
  type JsonNodeTransformedElem (line 109) | type JsonNodeTransformedElem = Omit<JsonNodeFormElem, "isRequired"> & {
  type JsonNodeTransformed (line 112) | type JsonNodeTransformed = JsonNodeTransformedElem & {

FILE: app/routes/projects/$projectId/apis/details.$apiId.tsx
  function ApiInfo (line 61) | function ApiInfo() {

FILE: app/routes/projects/$projectId/apis/groups.$groupId.tsx
  function ApiGroup (line 85) | function ApiGroup() {

FILE: app/routes/projects/$projectId/apis/index.tsx
  function ApiOverview (line 36) | function ApiOverview() {

FILE: app/routes/projects/$projectId/settings.tsx
  function SideNav (line 26) | function SideNav() {
  type NavItemProps (line 59) | interface NavItemProps extends FlexProps {
  function Settings (line 99) | function Settings() {

FILE: app/routes/projects/$projectId/settings/members.tsx
  function CatchBoundary (line 231) | function CatchBoundary() {

FILE: app/session.server.ts
  constant USER_SESSION_KEY (line 21) | const USER_SESSION_KEY = "userId";
  function getSession (line 23) | async function getSession(request: Request) {
  function getUserId (line 28) | async function getUserId(
  function getUser (line 39) | async function getUser(request: Request) {
  function requireUserId (line 51) | async function requireUserId(
  function requireUser (line 63) | async function requireUser(request: Request) {
  function createUserSession (line 72) | async function createUserSession({
  function logout (line 96) | async function logout(request: Request) {

FILE: app/ui/Form/FormCancelButton.tsx
  type FormCancelButtonProps (line 4) | interface FormCancelButtonProps extends ButtonProps {}
  function FormCancelButton (line 6) | function FormCancelButton({

FILE: app/ui/Form/FormHInput.tsx
  type FormInputProps (line 16) | interface FormInputProps

FILE: app/ui/Form/FormInput.tsx
  type FormInputProps (line 18) | interface FormInputProps

FILE: app/ui/Form/FormModal.tsx
  type FormModalProps (line 10) | type FormModalProps<DataType> = Omit<
  function FormModal (line 17) | function FormModal<DataType>({

FILE: app/ui/Form/FormSubmitButton.tsx
  type FormButtonProps (line 4) | interface FormButtonProps extends ButtonProps {}
  function FormSubmitButton (line 6) | function FormSubmitButton({

FILE: app/ui/Form/PathInput.tsx
  type PathInputProps (line 11) | interface PathInputProps extends InputProps {

FILE: app/ui/Form/type.ts
  type MinimalInputProps (line 1) | type MinimalInputProps = {

FILE: app/utils/index.ts
  constant DEFAULT_REDIRECT (line 9) | const DEFAULT_REDIRECT = "/";
  function safeRedirect (line 18) | function safeRedirect(
  function useMatchesData (line 39) | function useMatchesData(
  function isUser (line 50) | function isUser(user: any): user is User {
  function useOptionalUser (line 54) | function useOptionalUser(): User | undefined {
  function useUser (line 62) | function useUser(): User {
  function useUrl (line 72) | function useUrl() {

FILE: app/utils/treeBuilder.ts
  class TreeBuilder (line 4) | class TreeBuilder {
    method constructor (line 9) | constructor(rootId: ItemId, data: Group | Api | null) {
    method withLeaf (line 17) | withLeaf(id: ItemId, data: Group | Api | null) {
    method withSubTree (line 24) | withSubTree(tree: TreeBuilder) {
    method build (line 42) | build() {
    method _addItemToRoot (line 49) | _addItemToRoot(id: ItemId) {

FILE: prisma/seed.ts
  function main (line 3) | async function main() {
Condensed preview — 62 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (254K chars).
[
  {
    "path": ".gitignore",
    "chars": 1659,
    "preview": "# Build\nbuild/\n\n# Logs\nlogs\n*.log\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\nlerna-debug.log*\n\n# Diagnostic reports "
  },
  {
    "path": ".npmrc",
    "chars": 22,
    "preview": "legacy-peer-deps=true\n"
  },
  {
    "path": "LICENSE",
    "chars": 11357,
    "preview": "                                 Apache License\n                           Version 2.0, January 2004\n                   "
  },
  {
    "path": "README.md",
    "chars": 2703,
    "preview": "<h1 align=\"center\">\n    <a href=\"https://apiannie.com\">\n       <img alt=\"ApiAnnie\" src=\"https://user-images.githubuserco"
  },
  {
    "path": "app/context.tsx",
    "chars": 408,
    "preview": "// context.tsx\nimport React, { createContext } from \"react\";\n\nexport interface ServerStyleContextData {\n  key: string;\n "
  },
  {
    "path": "app/createEmotionCache.ts",
    "chars": 155,
    "preview": "// createEmotionCache.ts\nimport createCache from \"@emotion/cache\";\n\nexport default function createEmotionCache() {\n  ret"
  },
  {
    "path": "app/entry.client.tsx",
    "chars": 840,
    "preview": "// entry.client.tsx\nimport React, { useState } from \"react\";\nimport { hydrateRoot } from \"react-dom/client\";\nimport { Ca"
  },
  {
    "path": "app/entry.server.tsx",
    "chars": 1405,
    "preview": "// entry.server.tsx\nimport { renderToString } from \"react-dom/server\";\nimport { CacheProvider } from \"@emotion/react\";\ni"
  },
  {
    "path": "app/models/api.server.ts",
    "chars": 4077,
    "preview": "import { RequestParam } from \".prisma/client\";\nimport { ApiData, RequestMethod } from \"@prisma/client\";\nimport { prisma "
  },
  {
    "path": "app/models/prisma.server.ts",
    "chars": 376,
    "preview": "import { PrismaClient } from \"@prisma/client\";\n\nlet prisma: PrismaClient;\ndeclare global {\n  var __db: PrismaClient | un"
  },
  {
    "path": "app/models/project.server.ts",
    "chars": 6886,
    "preview": "import { ProjectUserRole, User } from '@prisma/client';\nimport invariant from 'tiny-invariant';\nimport { checkRole } fro"
  },
  {
    "path": "app/models/type.ts",
    "chars": 744,
    "preview": "import {\n  ParamType,\n  Prisma,\n  ProjectUserRole,\n  RequestMethod,\n} from \"@prisma/client\";\n\nexport const JsonNodeType "
  },
  {
    "path": "app/models/user.server.ts",
    "chars": 1946,
    "preview": "import { User } from \"@prisma/client\";\nimport { prisma } from \"./prisma.server\";\nimport bcrypt from \"bcryptjs\";\nexport t"
  },
  {
    "path": "app/root.tsx",
    "chars": 2805,
    "preview": "// root.tsx\nimport {\n  ChakraProvider,\n  cookieStorageManagerSSR,\n  localStorageManager,\n  useConst,\n} from \"@chakra-ui/"
  },
  {
    "path": "app/routes/home/..lib/ColorModeButton.tsx",
    "chars": 385,
    "preview": "import { MoonIcon, SunIcon } from \"@chakra-ui/icons\";\nimport { Button, ButtonProps, useColorMode } from \"@chakra-ui/reac"
  },
  {
    "path": "app/routes/home/..lib/Layout.tsx",
    "chars": 8963,
    "preview": "import {\n  ChevronDownIcon,\n  ChevronRightIcon,\n  CloseIcon,\n  HamburgerIcon,\n} from \"@chakra-ui/icons\";\nimport {\n  Box,"
  },
  {
    "path": "app/routes/home/..lib/NotificationButton.tsx",
    "chars": 868,
    "preview": "import {\n  Button,\n  ButtonProps,\n  Center,\n  Icon,\n  Popover,\n  PopoverArrow,\n  PopoverBody,\n  PopoverCloseButton,\n  Po"
  },
  {
    "path": "app/routes/home/..lib/UserMenuButton.tsx",
    "chars": 1136,
    "preview": "import {\n  Avatar,\n  AvatarProps,\n  Box,\n  Button,\n  Menu,\n  MenuButton,\n  MenuDivider,\n  MenuItem,\n  MenuList,\n  Text,\n"
  },
  {
    "path": "app/routes/home/logout.tsx",
    "chars": 321,
    "preview": "import type { ActionArgs, LoaderArgs } from \"@remix-run/node\";\nimport { redirect } from \"@remix-run/node\";\n\nimport { log"
  },
  {
    "path": "app/routes/home/settings.tsx",
    "chars": 6191,
    "preview": "import {\n  Flex,\n  Heading,\n  Text,\n  Stack,\n  useColorModeValue,\n  Box,\n  Container,\n  Center,\n  useToast,\n} from \"@cha"
  },
  {
    "path": "app/routes/home/signin.tsx",
    "chars": 4905,
    "preview": "import {\n  Box,\n  Button,\n  Checkbox,\n  Flex,\n  Heading,\n  InputRightElement,\n  Stack,\n  Text,\n  useColorModeValue,\n} fr"
  },
  {
    "path": "app/routes/home/signup.tsx",
    "chars": 4583,
    "preview": "import {\n  Box,\n  Flex,\n  Heading,\n  Stack,\n  Text,\n  useColorModeValue,\n} from \"@chakra-ui/react\";\n\nimport { ActionArgs"
  },
  {
    "path": "app/routes/index.tsx",
    "chars": 2312,
    "preview": "import { Box, Button, Container, Heading, Stack, Text } from \"@chakra-ui/react\";\nimport { json, LoaderArgs, redirect } f"
  },
  {
    "path": "app/routes/mock/$projectId.$.tsx",
    "chars": 3092,
    "preview": "import { RequestMethod } from \"@prisma/client\";\nimport { ActionArgs, json, LoaderArgs, Response } from \"@remix-run/node\""
  },
  {
    "path": "app/routes/projects/$projectId/activities.tsx",
    "chars": 64,
    "preview": "export default function Activities() {\n  return \"Activities\";\n}\n"
  },
  {
    "path": "app/routes/projects/$projectId/apis/..api.tsx",
    "chars": 11478,
    "preview": "import {\n  Box,\n  BoxProps,\n  Center,\n  Flex,\n  Grid,\n  Heading,\n  Icon,\n  Link,\n  Table,\n  TableContainer,\n  Tbody,\n  T"
  },
  {
    "path": "app/routes/projects/$projectId/apis/..editor.tsx",
    "chars": 32839,
    "preview": "import {\n  Box,\n  BoxProps,\n  Button,\n  Center,\n  Checkbox,\n  Container,\n  Divider,\n  Flex,\n  HStack,\n  Icon,\n  IconButt"
  },
  {
    "path": "app/routes/projects/$projectId/apis/..postman.tsx",
    "chars": 20436,
    "preview": "import { ApiData, RequestParam } from \".prisma/client\";\nimport {\n  Box,\n  Button,\n  Center,\n  Checkbox,\n  Divider,\n  Fle"
  },
  {
    "path": "app/routes/projects/$projectId/apis/details.$apiId.tsx",
    "chars": 2397,
    "preview": "import { TabPanel, TabPanels } from \"@chakra-ui/react\";\nimport { ActionArgs, json, LoaderArgs } from \"@remix-run/node\";\n"
  },
  {
    "path": "app/routes/projects/$projectId/apis/groups.$groupId.tsx",
    "chars": 4123,
    "preview": "import {\n  Box,\n  Center,\n  Input,\n  TabPanel,\n  TabPanels,\n  Textarea,\n  useToast,\n  VStack,\n} from \"@chakra-ui/react\";"
  },
  {
    "path": "app/routes/projects/$projectId/apis/index.tsx",
    "chars": 3480,
    "preview": "import {\n  Box,\n  Button,\n  Center,\n  Divider,\n  Flex,\n  Heading,\n  Icon,\n  Link,\n  Spacer,\n  Table,\n  TableContainer,\n "
  },
  {
    "path": "app/routes/projects/$projectId/apis.tsx",
    "chars": 23180,
    "preview": "import Tree, {\n  ItemId,\n  moveItemOnTree,\n  mutateTree,\n  RenderItemParams,\n  TreeData,\n  TreeDestinationPosition,\n  Tr"
  },
  {
    "path": "app/routes/projects/$projectId/settings/index.tsx",
    "chars": 11116,
    "preview": "import {\n  Box,\n  Button,\n  Container,\n  Flex,\n  HStack,\n  Text,\n  Spacer,\n  useColorModeValue,\n  useToast,\n  Modal,\n  M"
  },
  {
    "path": "app/routes/projects/$projectId/settings/members.tsx",
    "chars": 14396,
    "preview": "import {\n  Box,\n  Button,\n  Divider,\n  Flex,\n  Icon,\n  Spacer,\n  Table,\n  TableContainer,\n  Tbody,\n  Td,\n  Th,\n  Thead,\n"
  },
  {
    "path": "app/routes/projects/$projectId/settings.tsx",
    "chars": 2274,
    "preview": "import {\n  Box,\n  Divider,\n  Flex,\n  FlexProps,\n  Grid,\n  Icon,\n  Link,\n  Spacer,\n  Text,\n  useColorModeValue,\n  VStack,"
  },
  {
    "path": "app/routes/projects/$projectId.tsx",
    "chars": 9661,
    "preview": "import {\n  Box,\n  BoxProps,\n  Button,\n  Divider,\n  Flex,\n  Grid,\n  GridItem,\n  Heading,\n  HStack,\n  Icon,\n  Image,\n  Lis"
  },
  {
    "path": "app/routes/projects/index.tsx",
    "chars": 6488,
    "preview": "import {\n  Box,\n  Button,\n  Container,\n  Divider,\n  Heading,\n  HStack,\n  Icon,\n  Image,\n  Input,\n  InputGroup,\n  InputLe"
  },
  {
    "path": "app/session.server.ts",
    "chars": 2541,
    "preview": "import { createCookieSessionStorage, redirect } from \"@remix-run/node\";\nimport { ObjectID } from \"bson\";\nimport invarian"
  },
  {
    "path": "app/theme.ts",
    "chars": 324,
    "preview": "// theme.ts\n\n// 1. import `extendTheme` function\nimport { extendTheme, type ThemeConfig } from \"@chakra-ui/react\";\n\n// 2"
  },
  {
    "path": "app/ui/AceEditor.tsx",
    "chars": 339,
    "preview": "import { Spinner } from \"@chakra-ui/react\";\nimport { lazy, Suspense } from \"react\";\nimport { IAceEditorProps } from \"rea"
  },
  {
    "path": "app/ui/Form/FormCancelButton.tsx",
    "chars": 368,
    "preview": "import { Button, ButtonProps } from \"@chakra-ui/react\";\nimport { useIsSubmitting } from \"remix-validated-form\";\n\nexport "
  },
  {
    "path": "app/ui/Form/FormHInput.tsx",
    "chars": 1634,
    "preview": "import {\n  Alert,\n  AlertIcon,\n  Box,\n  Flex,\n  FormControl,\n  FormControlProps,\n  FormLabel,\n  forwardRef,\n  Input,\n  I"
  },
  {
    "path": "app/ui/Form/FormInput.tsx",
    "chars": 1463,
    "preview": "import {\n  Alert,\n  AlertIcon,\n  Box,\n  Checkbox,\n  ComponentWithAs,\n  FormControl,\n  FormControlProps,\n  FormLabel,\n  f"
  },
  {
    "path": "app/ui/Form/FormModal.tsx",
    "chars": 1493,
    "preview": "import { Modal, ModalProps } from \"@chakra-ui/react\";\nimport { useFetcher } from \"@remix-run/react\";\nimport { useEffect "
  },
  {
    "path": "app/ui/Form/FormSubmitButton.tsx",
    "chars": 475,
    "preview": "import { Button, ButtonProps } from \"@chakra-ui/react\";\nimport { useIsSubmitting } from \"remix-validated-form\";\n\nexport "
  },
  {
    "path": "app/ui/Form/ModalInput.tsx",
    "chars": 1919,
    "preview": "import {\n  Button,\n  Icon,\n  Input,\n  InputGroup,\n  InputProps,\n  InputRightElement,\n  Modal,\n  ModalBody,\n  ModalCloseB"
  },
  {
    "path": "app/ui/Form/PathInput.tsx",
    "chars": 978,
    "preview": "import {\n  Input,\n  InputGroup,\n  InputLeftAddon,\n  InputProps,\n  Select,\n} from \"@chakra-ui/react\";\nimport { RequestMet"
  },
  {
    "path": "app/ui/Form/type.ts",
    "chars": 208,
    "preview": "export type MinimalInputProps = {\n    onChange?: (...args: any[]) => void;\n    onBlur?: (...args: any[]) => void;\n    de"
  },
  {
    "path": "app/ui/Header.tsx",
    "chars": 264,
    "preview": "import { Heading, HeadingProps } from \"@chakra-ui/react\";\n\nconst Header = (props: HeadingProps) => {\n  return (\n    <Hea"
  },
  {
    "path": "app/ui/_AceEditor.tsx",
    "chars": 877,
    "preview": "import AceEditor, { IAceEditorProps } from \"react-ace\";\n\nimport \"ace-builds/src-noconflict/mode-plain_text\";\nimport \"ace"
  },
  {
    "path": "app/ui/dashboard.css",
    "chars": 38,
    "preview": "body {\n    /* overflow-y: hidden; */\n}"
  },
  {
    "path": "app/ui/index.ts",
    "chars": 567,
    "preview": "import { lazy } from \"react\";\nimport FormCancelButton from \"./Form/FormCancelButton\";\nimport FormHInput from \"./Form/For"
  },
  {
    "path": "app/utils/hooks.ts",
    "chars": 2131,
    "preview": "import { parsePath } from \"./index\";\nimport { useCallback, useEffect, useRef, useState } from \"react\";\n\nexport const use"
  },
  {
    "path": "app/utils/index.ts",
    "chars": 3430,
    "preview": "import { ProjectUserRole } from \"@prisma/client\";\nimport { json } from \"@remix-run/node\";\nimport { useMatches } from \"@r"
  },
  {
    "path": "app/utils/mock.ts",
    "chars": 1273,
    "preview": "import Chance from \"chance\";\nimport { JsonNode } from \"~/models/type\";\n\nconst chance = new Chance();\n\nconst mockJsonHelp"
  },
  {
    "path": "app/utils/treeBuilder.ts",
    "chars": 1584,
    "preview": "import { ItemId, TreeItem } from \"@atlaskit/tree\";\nimport { Api, Group } from \"~/models/project.server\";\n\nexport default"
  },
  {
    "path": "package.json",
    "chars": 1706,
    "preview": "{\n  \"private\": true,\n  \"sideEffects\": false,\n  \"scripts\": {\n    \"build\": \"remix build\",\n    \"deploy\": \"fly deploy --remo"
  },
  {
    "path": "prisma/schema.prisma",
    "chars": 3643,
    "preview": "// This is your Prisma schema file,\n// learn more about it in the docs: https://pris.ly/d/prisma-schema\n\ngenerator clien"
  },
  {
    "path": "prisma/seed.ts",
    "chars": 179,
    "preview": "import { PrismaClient } from \"@prisma/client\";\n\nasync function main() {\n  let prisma = new PrismaClient();\n  prisma.$con"
  },
  {
    "path": "remix.config.js",
    "chars": 282,
    "preview": "/** @type {import('@remix-run/dev').AppConfig} */\nmodule.exports = {\n  ignoredRouteFiles: [\"**/.*\"],\n  // appDirectory: "
  },
  {
    "path": "remix.env.d.ts",
    "chars": 91,
    "preview": "/// <reference types=\"@remix-run/dev\" />\n/// <reference types=\"@remix-run/node/globals\" />\n"
  },
  {
    "path": "tsconfig.json",
    "chars": 579,
    "preview": "{\n  \"include\": [\"remix.env.d.ts\", \"**/*.ts\", \"**/*.tsx\", \"**/..*.tsx\"],\n  \"compilerOptions\": {\n    \"module\": \"esnext\",\n "
  }
]

About this extraction

This page contains the full source code of the apiannie/apiannie GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 62 files (232.9 KB), approximately 59.3k tokens, and a symbol index with 92 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!