From 082232fd136e1addcb3357c7dbdb995e771b8ccf Mon Sep 17 00:00:00 2001 From: Alex Anderson Date: Fri, 6 Oct 2023 20:23:44 -0400 Subject: [PATCH 1/2] feat(directus-extension): Add an example demonstrating hosting Remix inside a Directus extension. --- directus-extension/.env.example | 15 + directus-extension/.eslintrc.cjs | 4 + directus-extension/.gitignore | 8 + directus-extension/README.md | 79 +++ directus-extension/app/entry.client.tsx | 18 + directus-extension/app/entry.server.tsx | 137 +++++ directus-extension/app/root.tsx | 36 ++ directus-extension/app/routes/_index.tsx | 48 ++ .../app/routes/post.$slug._index.tsx | 47 ++ directus-extension/app/types.tsx | 10 + directus-extension/package.json | 50 ++ directus-extension/public/favicon.ico | Bin 0 -> 16958 bytes directus-extension/remix-frontend/.gitignore | 4 + .../remix-frontend/package.json | 32 ++ .../remix-frontend/src/handler.ts | 101 ++++ .../remix-frontend/src/index.ts | 7 + .../remix-frontend/tsconfig.json | 29 + directus-extension/remix.config.js | 8 + directus-extension/remix.env.d.ts | 2 + directus-extension/snapshot.yml | 524 ++++++++++++++++++ directus-extension/tsconfig.json | 23 + 21 files changed, 1182 insertions(+) create mode 100644 directus-extension/.env.example create mode 100644 directus-extension/.eslintrc.cjs create mode 100644 directus-extension/.gitignore create mode 100644 directus-extension/README.md create mode 100644 directus-extension/app/entry.client.tsx create mode 100644 directus-extension/app/entry.server.tsx create mode 100644 directus-extension/app/root.tsx create mode 100644 directus-extension/app/routes/_index.tsx create mode 100644 directus-extension/app/routes/post.$slug._index.tsx create mode 100644 directus-extension/app/types.tsx create mode 100644 directus-extension/package.json create mode 100644 directus-extension/public/favicon.ico create mode 100644 directus-extension/remix-frontend/.gitignore create mode 100644 directus-extension/remix-frontend/package.json create mode 100644 directus-extension/remix-frontend/src/handler.ts create mode 100644 directus-extension/remix-frontend/src/index.ts create mode 100644 directus-extension/remix-frontend/tsconfig.json create mode 100644 directus-extension/remix.config.js create mode 100644 directus-extension/remix.env.d.ts create mode 100644 directus-extension/snapshot.yml create mode 100644 directus-extension/tsconfig.json diff --git a/directus-extension/.env.example b/directus-extension/.env.example new file mode 100644 index 00000000..65ab850f --- /dev/null +++ b/directus-extension/.env.example @@ -0,0 +1,15 @@ +# These are the keys that have been overridden to make this work with Remix +# Add them to the top of the .env file generated by the directus init command. + +# Where to redirect to when navigating to /. Accepts a relative path, absolute URL, or false to disable ["./admin"] +ROOT_REDIRECT="false" +# Ensures Directus can't override whether we are in development or production. Set to `production` in production environments. +REMIX_ENV=development +# Only necessary in development environments. +REMIX_DEV_ORIGIN=http://0.0.0.0:3001/ +# Required for the Remix app to work +CONTENT_SECURITY_POLICY_DIRECTIVES__SCRIPT_SRC="array:'self','unsafe-inline','unsafe-eval'" +# Required for live reload to work. Not strictly necessary in production. +CONTENT_SECURITY_POLICY_DIRECTIVES__CONNECT_SRC="array:'self',https:,http:,wss:,ws:" +# Tells Directus to reload extensions when the source files change. Not required in production. +EXTENSIONS_AUTO_RELOAD=true diff --git a/directus-extension/.eslintrc.cjs b/directus-extension/.eslintrc.cjs new file mode 100644 index 00000000..2061cd22 --- /dev/null +++ b/directus-extension/.eslintrc.cjs @@ -0,0 +1,4 @@ +/** @type {import('eslint').Linter.Config} */ +module.exports = { + extends: ["@remix-run/eslint-config", "@remix-run/eslint-config/node"], +}; diff --git a/directus-extension/.gitignore b/directus-extension/.gitignore new file mode 100644 index 00000000..dc698e79 --- /dev/null +++ b/directus-extension/.gitignore @@ -0,0 +1,8 @@ +node_modules + +/.cache +/build +/public/build +.env +data.db +extensions \ No newline at end of file diff --git a/directus-extension/README.md b/directus-extension/README.md new file mode 100644 index 00000000..d5b96044 --- /dev/null +++ b/directus-extension/README.md @@ -0,0 +1,79 @@ +# Remix Directus Extension + +This example demonstrates how a Remix app can be embedded in a [Directus](https://directus.io) app using an endpoint extension. The app is comprised of three parts: + +- The Remix app +- The self-hosted Directus instance +- A Directus endpoint extension, called `remix-frontend` + +## What is Directus? + +Directus is a self-hosted CMS written in JavaScript and Vue. It allows you to connect a relational database, define data models, view and edit the data, upload and manage files, set up automated workflows and webhooks, view data panels, manage users, roles, and auth... it provides a lot. + +It's also highly extensible. Many parts of the UI and backend can be modified and changed using the Directus extension API. + +## How it Works + +Directus allows you to add API endpoints to a running Directus instance using a special extension. This extension exposes an Express router which we can use to pass requests to our Remix app. With a bit of clever configuration, we can make our app available at the root (eg. `/`) of our Directus instance and serve all requests (except those to the Directus admin at `/admin/*`) from our Remix app. + +We can also take advantage of Remix load context and pass Directus utilities to our Remix app. Things like the Directus services, which provide a convenient API for accessing Directus resources, accountability information about the currently logged in user, and direct access to the database. + +In this example, we use the `ItemsService`, which is accessed through load context, to pull our list of blog posts in our loader to render to the page. + +## Development + +This example includes an example environment variables file. + +Before doing anything else, you'll need to set up your Directus instance. From your terminal run: + +```sh +npx directus@latest init +``` + + +Follow the prompts to set up the Directus app how you want. If you're just trying it out, use the `sqlite` database driver. + +Once that's done you'll need to add the necessary environment variables at top of the `.env.example` file to your `.env` file. These are required to enable Remix to run from within Directus. + +You can apply the example snapshot by running: + +```sh +npx directus schema apply ./snapshot.yml +``` + +Make sure the extension has been built before the Directus instance starts for the first time. + +From your terminal: + +```sh +npm run build +``` + +then + +```sh +npm run dev +``` + +This will start the Remix dev server, the extension compiler in watch mode, and the Directus data studio in +development mode. + +You can access your app at `http://localhost:8055` and access the Directus data studio at `http://localhost:8055/admin`. + +Sign into the data studio, add a post, visit the site, and you'll see the post appear. + +## Deployment + +First, build your app for production: + +```sh +npm run build +``` + +Then run the app in production mode: + +```sh +npm start +``` + +Now you'll need to pick a host to deploy it to. It should work out-of-the-box with any Nixpack-compatible host, like Railway or Flightcontrol. It should also work with a simple Node-based Dockerfile on hosts that support that. Just use `npm run build` as the build command and `npm run start` as the start command, and make sure you've set up the necessary environment variables. diff --git a/directus-extension/app/entry.client.tsx b/directus-extension/app/entry.client.tsx new file mode 100644 index 00000000..94d5dc0d --- /dev/null +++ b/directus-extension/app/entry.client.tsx @@ -0,0 +1,18 @@ +/** + * By default, Remix will handle hydrating your app on the client for you. + * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨ + * For more information, see https://remix.run/file-conventions/entry.client + */ + +import { RemixBrowser } from "@remix-run/react"; +import { startTransition, StrictMode } from "react"; +import { hydrateRoot } from "react-dom/client"; + +startTransition(() => { + hydrateRoot( + document, + + + + ); +}); diff --git a/directus-extension/app/entry.server.tsx b/directus-extension/app/entry.server.tsx new file mode 100644 index 00000000..0c7712b0 --- /dev/null +++ b/directus-extension/app/entry.server.tsx @@ -0,0 +1,137 @@ +/** + * By default, Remix will handle generating the HTTP Response for you. + * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨ + * For more information, see https://remix.run/file-conventions/entry.server + */ + +import { PassThrough } from "node:stream"; + +import type { AppLoadContext, EntryContext } from "@remix-run/node"; +import { createReadableStreamFromReadable } from "@remix-run/node"; +import { RemixServer } from "@remix-run/react"; +import isbot from "isbot"; +import { renderToPipeableStream } from "react-dom/server"; + +const ABORT_DELAY = 5_000; + +export default function handleRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixContext: EntryContext, + loadContext: AppLoadContext +) { + return isbot(request.headers.get("user-agent")) + ? handleBotRequest( + request, + responseStatusCode, + responseHeaders, + remixContext + ) + : handleBrowserRequest( + request, + responseStatusCode, + responseHeaders, + remixContext + ); +} + +function handleBotRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixContext: EntryContext +) { + return new Promise((resolve, reject) => { + let shellRendered = false; + const { pipe, abort } = renderToPipeableStream( + , + { + onAllReady() { + shellRendered = true; + const body = new PassThrough(); + const stream = createReadableStreamFromReadable(body); + + responseHeaders.set("Content-Type", "text/html"); + + resolve( + new Response(stream, { + headers: responseHeaders, + status: responseStatusCode, + }) + ); + + pipe(body); + }, + onShellError(error: unknown) { + reject(error); + }, + onError(error: unknown) { + responseStatusCode = 500; + // Log streaming rendering errors from inside the shell. Don't log + // errors encountered during initial shell rendering since they'll + // reject and get logged in handleDocumentRequest. + if (shellRendered) { + console.error(error); + } + }, + } + ); + + setTimeout(abort, ABORT_DELAY); + }); +} + +function handleBrowserRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixContext: EntryContext +) { + return new Promise((resolve, reject) => { + let shellRendered = false; + const { pipe, abort } = renderToPipeableStream( + , + { + onShellReady() { + shellRendered = true; + const body = new PassThrough(); + const stream = createReadableStreamFromReadable(body); + + responseHeaders.set("Content-Type", "text/html"); + + resolve( + new Response(stream, { + headers: responseHeaders, + status: responseStatusCode, + }) + ); + + pipe(body); + }, + onShellError(error: unknown) { + reject(error); + }, + onError(error: unknown) { + responseStatusCode = 500; + // Log streaming rendering errors from inside the shell. Don't log + // errors encountered during initial shell rendering since they'll + // reject and get logged in handleDocumentRequest. + if (shellRendered) { + console.error(error); + } + }, + } + ); + + setTimeout(abort, ABORT_DELAY); + }); +} diff --git a/directus-extension/app/root.tsx b/directus-extension/app/root.tsx new file mode 100644 index 00000000..54bc2cdf --- /dev/null +++ b/directus-extension/app/root.tsx @@ -0,0 +1,36 @@ +import { cssBundleHref } from "@remix-run/css-bundle"; +import type { LinksFunction } from "@remix-run/node"; +import { + Links, + LiveReload, + Meta, + Outlet, + Scripts, + ScrollRestoration, +} from "@remix-run/react"; +import type { EndpointExtensionContext } from "@directus/types"; + +export interface AppLoadContext extends EndpointExtensionContext {} + +export const links: LinksFunction = () => [ + ...(cssBundleHref ? [{ rel: "stylesheet", href: cssBundleHref }] : []), +]; + +export default function App() { + return ( + + + + + + + + + + + + + + + ); +} diff --git a/directus-extension/app/routes/_index.tsx b/directus-extension/app/routes/_index.tsx new file mode 100644 index 00000000..e97841d1 --- /dev/null +++ b/directus-extension/app/routes/_index.tsx @@ -0,0 +1,48 @@ +import { + json, + type LoaderFunctionArgs, + type MetaFunction, +} from "@remix-run/node"; +import type { ItemsService as TItemsService } from "@directus/api/services/items"; +import { Link, useLoaderData } from "@remix-run/react"; +import type { Posts } from "../types"; +export const meta: MetaFunction = () => { + return [ + { title: "New Remix App" }, + { name: "description", content: "Welcome to Remix!" }, + ]; +}; + +export async function loader({ context }: LoaderFunctionArgs) { + const ItemsService = (context.services as any) + .ItemsService as typeof TItemsService; + const itemsService = new ItemsService("posts", { + schema: context.schema as any, + accountability: { admin: true, role: "" }, + }); + + const posts = await itemsService.readByQuery({ + fields: ["id", "slug", "title"], + filter: { status: { _eq: "published" } }, + sort: ["-date_published"], + limit: -1, + }); + return json({ posts }); +} + +export default function Index() { + const { posts } = useLoaderData(); + + return ( +
+

My Blog

+
    + {posts.map((post) => ( +
  • + {post.title} +
  • + ))} +
+
+ ); +} diff --git a/directus-extension/app/routes/post.$slug._index.tsx b/directus-extension/app/routes/post.$slug._index.tsx new file mode 100644 index 00000000..6927bcba --- /dev/null +++ b/directus-extension/app/routes/post.$slug._index.tsx @@ -0,0 +1,47 @@ +import { type LoaderFunctionArgs } from "@remix-run/node"; +import type { Posts } from "~/types"; +import type { ItemsService as TItemsService } from "@directus/api/services/items"; +import { Link, useLoaderData } from "@remix-run/react"; + +export async function loader({ context, params }: LoaderFunctionArgs) { + const ItemsService = (context.services as any) + .ItemsService as typeof TItemsService; + const itemsService = new ItemsService("posts", { + schema: context.schema as any, + accountability: { admin: true, role: "" }, + }); + + const [post] = await itemsService.readByQuery({ + limit: 1, + filter: { slug: { _eq: params.slug }, status: { _eq: "published" } }, + fields: ["*"], + }); + + if (!post) + throw new Response(null, { + status: 404, + statusText: "Not Found", + }); + + return { post }; +} + +const formatter = new Intl.DateTimeFormat("en", { dateStyle: "medium" }); +export default function Post() { + const { post } = useLoaderData(); + return ( +
+ Back +

{post.title}

+

+ Published: {formatter.format(Number(new Date(post.date_published)))} +

+ {post.title} +
+
+ ); +} diff --git a/directus-extension/app/types.tsx b/directus-extension/app/types.tsx new file mode 100644 index 00000000..38a4dcdb --- /dev/null +++ b/directus-extension/app/types.tsx @@ -0,0 +1,10 @@ +export interface Posts { + id: number; + slug: string; + title: string; + status: "draft" | "published"; + content: string; + image: string; + date_published: string; + excerpt: string; +} diff --git a/directus-extension/package.json b/directus-extension/package.json new file mode 100644 index 00000000..1ae4a3b0 --- /dev/null +++ b/directus-extension/package.json @@ -0,0 +1,50 @@ +{ + "name": "remix-directus", + "private": true, + "sideEffects": false, + "type": "module", + "scripts": { + "build": "run-s build:*", + "build:remix": "remix build", + "build:extension": "npm run build --workspace=remix-frontend", + "dev": "remix dev --manual -c \"run-p site-dev:*\"", + "site-dev:extension": "npm run dev --workspace=remix-frontend", + "site-dev:directus": "directus start", + "start": "directus start", + "typecheck": "tsc" + }, + "dependencies": { + "@remix-run/css-bundle": "^2.0.1", + "@remix-run/node": "^2.0.1", + "@remix-run/react": "^2.0.1", + "@remix-run/serve": "^2.0.1", + "directus": "^10.6.3", + "isbot": "^3.6.8", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-markdown": "^9.0.0", + "react-syntax-highlighter": "^15.5.0", + "rehype-raw": "^7.0.0", + "rehype-slug": "^6.0.0", + "remark-gfm": "^4.0.0", + "sqlite3": "^5.1.6", + "unified": "^11.0.3" + }, + "devDependencies": { + "@remix-run/dev": "^2.0.1", + "@remix-run/eslint-config": "^2.0.1", + "@types/react": "^18.2.20", + "@types/react-dom": "^18.2.7", + "@types/react-syntax-highlighter": "^15.5.7", + "directus-extension-seed": "^2.0.4", + "eslint": "^8.38.0", + "npm-run-all": "^4.1.5", + "typescript": "^5.1.6" + }, + "engines": { + "node": ">=18.0.0" + }, + "workspaces": [ + "remix-frontend" + ] +} diff --git a/directus-extension/public/favicon.ico b/directus-extension/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..8830cf6821b354114848e6354889b8ecf6d2bc61 GIT binary patch literal 16958 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 EKbET!0{{R3 literal 0 HcmV?d00001 diff --git a/directus-extension/remix-frontend/.gitignore b/directus-extension/remix-frontend/.gitignore new file mode 100644 index 00000000..2e354c27 --- /dev/null +++ b/directus-extension/remix-frontend/.gitignore @@ -0,0 +1,4 @@ +.DS_Store +node_modules +dist +index.js \ No newline at end of file diff --git a/directus-extension/remix-frontend/package.json b/directus-extension/remix-frontend/package.json new file mode 100644 index 00000000..e6ce0758 --- /dev/null +++ b/directus-extension/remix-frontend/package.json @@ -0,0 +1,32 @@ +{ + "name": "remix-frontend", + "description": "Please enter a description for your extension", + "icon": "extension", + "version": "1.0.0", + "keywords": [ + "directus", + "directus-extension", + "directus-custom-endpoint" + ], + "type": "module", + "directus:extension": { + "type": "endpoint", + "path": "../extensions/endpoints/remix-frontend/index.js", + "source": "src/index.ts", + "host": "^10.1.11" + }, + "scripts": { + "build": "directus-extension build", + "dev": "REMIX_ENV=development directus-extension build -w --no-minify" + }, + "devDependencies": { + "@directus/extensions-sdk": "10.1.11", + "@types/node": "^20.7.0", + "typescript": "^5.2.2" + }, + "dependencies": { + "@remix-run/express": "^2.0.1", + "@remix-run/node": "^2.0.1", + "serve-static": "^1.15.0" + } +} diff --git a/directus-extension/remix-frontend/src/handler.ts b/directus-extension/remix-frontend/src/handler.ts new file mode 100644 index 00000000..ad4999a1 --- /dev/null +++ b/directus-extension/remix-frontend/src/handler.ts @@ -0,0 +1,101 @@ +import { createRequestHandler } from "@remix-run/express"; +import * as path from "node:path"; +import serveStatic from "serve-static"; +import * as fs from "node:fs"; +import * as url from "node:url"; +import { broadcastDevReady } from "@remix-run/node"; +import type { Router } from "express"; +import type { EndpointExtensionContext } from "@directus/types"; + +/** @typedef {import('@remix-run/node').ServerBuild} ServerBuild */ + +const BUILD_PATH = path.resolve("./build/index.js"); +const VERSION_PATH = path.resolve("./build/version.txt"); +let initialBuild = await reimportServer(); + +const __dirname = process.cwd(); + +const serve = serveStatic(path.resolve(__dirname, "public"), { + maxAge: "1h", +}); +const serveBuild = serveStatic(path.resolve(__dirname, "public/build"), { + maxAge: "1y", + immutable: true, +}); + +function getLoadContext(context: EndpointExtensionContext) { + return (req: any) => { + return { + ...context, + schema: req.schema, + accountability: req.accountability, + }; + }; +} + +export async function handler( + router: Router, + context: EndpointExtensionContext +) { + const requestHandler = + process.env.REMIX_ENV === "development" + ? createDevRequestHandler(initialBuild, context) + : createRequestHandler({ + build: initialBuild, + getLoadContext: getLoadContext(context), + }); + router.all("*", (req, res, next) => { + // Handling for Directus URLs + if (req.url.startsWith("/auth/login") || req.url.startsWith("/admin")) { + return next(); + } + serveBuild(req, res, () => { + serve(req, res, () => { + requestHandler(req, res, next); + }); + }); + }); +} + +/** + * @returns {Promise} + */ +export async function reimportServer() { + const stat = fs.statSync(BUILD_PATH); + + // convert build path to URL for Windows compatibility with dynamic `import` + const BUILD_URL = url.pathToFileURL(BUILD_PATH).href; + + // use a timestamp query parameter to bust the import cache + return import(BUILD_URL + "?t=" + stat.mtimeMs); +} + +/** + * @param {ServerBuild} initialBuild + */ +function createDevRequestHandler( + b: typeof initialBuild, + context: EndpointExtensionContext +) { + let build = b; + async function handleServerUpdate() { + // 1. re-import the server build + build = await reimportServer(); + // 2. tell Remix that this app server is now up-to-date and ready + broadcastDevReady(build); + } + + fs.watch(VERSION_PATH, handleServerUpdate); + // wrap request handler to make sure its recreated with the latest build for every request + return async (req: any, res: any, next: any) => { + try { + return createRequestHandler({ + build, + mode: "development", + getLoadContext: getLoadContext(context), + })(req, res, next); + } catch (error) { + next(error); + } + }; +} diff --git a/directus-extension/remix-frontend/src/index.ts b/directus-extension/remix-frontend/src/index.ts new file mode 100644 index 00000000..53118222 --- /dev/null +++ b/directus-extension/remix-frontend/src/index.ts @@ -0,0 +1,7 @@ +import { defineEndpoint } from "@directus/extensions-sdk"; +import { handler } from "./handler"; + +export default defineEndpoint({ + id: "", + handler, +}); diff --git a/directus-extension/remix-frontend/tsconfig.json b/directus-extension/remix-frontend/tsconfig.json new file mode 100644 index 00000000..da188522 --- /dev/null +++ b/directus-extension/remix-frontend/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2019", "DOM"], + "module": "ES2022", + "moduleResolution": "node", + "strict": true, + "noFallthroughCasesInSwitch": true, + "esModuleInterop": true, + "noImplicitAny": true, + "noImplicitThis": true, + "noImplicitReturns": true, + "noUnusedLocals": true, + "noUncheckedIndexedAccess": true, + "noUnusedParameters": true, + "alwaysStrict": true, + "strictNullChecks": true, + "strictFunctionTypes": true, + "strictBindCallApply": true, + "strictPropertyInitialization": true, + "resolveJsonModule": false, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "allowSyntheticDefaultImports": true, + "isolatedModules": true, + "rootDir": "./src" + }, + "include": ["./src/**/*.ts"] +} diff --git a/directus-extension/remix.config.js b/directus-extension/remix.config.js new file mode 100644 index 00000000..7fac2d30 --- /dev/null +++ b/directus-extension/remix.config.js @@ -0,0 +1,8 @@ +/** @type {import('@remix-run/dev').AppConfig} */ +export default { + ignoredRouteFiles: ["**/.*"], + // appDirectory: "app", + // assetsBuildDirectory: "public/build", + // publicPath: "/build/", + // serverBuildPath: "build/index.js", +}; diff --git a/directus-extension/remix.env.d.ts b/directus-extension/remix.env.d.ts new file mode 100644 index 00000000..dcf8c45e --- /dev/null +++ b/directus-extension/remix.env.d.ts @@ -0,0 +1,2 @@ +/// +/// diff --git a/directus-extension/snapshot.yml b/directus-extension/snapshot.yml new file mode 100644 index 00000000..5839a039 --- /dev/null +++ b/directus-extension/snapshot.yml @@ -0,0 +1,524 @@ +version: 1 +directus: 13.1.1 +vendor: sqlite +collections: + - collection: posts + meta: + accountability: all + archive_app_filter: true + archive_field: status + archive_value: archived + collapse: open + collection: posts + color: null + display_template: '{{title}}' + group: null + hidden: false + icon: post_add + item_duplication_fields: null + note: null + preview_url: null + singleton: false + sort: null + sort_field: null + translations: null + unarchive_value: draft + schema: + name: posts +fields: + - collection: posts + field: content + type: text + meta: + collection: posts + conditions: null + display: null + display_options: null + field: content + group: null + hidden: false + interface: input-multiline + note: null + options: + folder: null + readonly: false + required: false + sort: 10 + special: null + translations: null + validation: null + validation_message: null + width: full + schema: + name: content + table: posts + data_type: text + default_value: null + max_length: null + numeric_precision: null + numeric_scale: null + is_nullable: true + is_unique: false + is_primary_key: false + is_generated: false + generation_expression: null + has_auto_increment: false + foreign_key_table: null + foreign_key_column: null + - collection: posts + field: date_created + type: timestamp + meta: + collection: posts + conditions: null + display: datetime + display_options: + relative: true + field: date_created + group: null + hidden: true + interface: datetime + note: null + options: null + readonly: true + required: false + sort: 6 + special: + - cast-timestamp + - date-created + translations: null + validation: null + validation_message: null + width: half + schema: + name: date_created + table: posts + data_type: datetime + default_value: null + max_length: null + numeric_precision: null + numeric_scale: null + is_nullable: true + is_unique: false + is_primary_key: false + is_generated: false + generation_expression: null + has_auto_increment: false + foreign_key_table: null + foreign_key_column: null + - collection: posts + field: date_published + type: dateTime + meta: + collection: posts + conditions: null + display: datetime + display_options: null + field: date_published + group: null + hidden: false + interface: datetime + note: null + options: null + readonly: false + required: false + sort: 8 + special: null + translations: null + validation: null + validation_message: null + width: half + schema: + name: date_published + table: posts + data_type: datetime + default_value: null + max_length: null + numeric_precision: null + numeric_scale: null + is_nullable: true + is_unique: false + is_primary_key: false + is_generated: false + generation_expression: null + has_auto_increment: false + foreign_key_table: null + foreign_key_column: null + - collection: posts + field: date_updated + type: timestamp + meta: + collection: posts + conditions: null + display: datetime + display_options: + relative: true + field: date_updated + group: null + hidden: true + interface: datetime + note: null + options: null + readonly: true + required: false + sort: 7 + special: + - cast-timestamp + - date-updated + translations: null + validation: null + validation_message: null + width: half + schema: + name: date_updated + table: posts + data_type: datetime + default_value: null + max_length: null + numeric_precision: null + numeric_scale: null + is_nullable: true + is_unique: false + is_primary_key: false + is_generated: false + generation_expression: null + has_auto_increment: false + foreign_key_table: null + foreign_key_column: null + - collection: posts + field: excerpt + type: text + meta: + collection: posts + conditions: null + display: null + display_options: null + field: excerpt + group: null + hidden: false + interface: input-multiline + note: null + options: + softLength: 160 + trim: true + readonly: false + required: false + sort: 11 + special: null + translations: null + validation: null + validation_message: null + width: full + schema: + name: excerpt + table: posts + data_type: text + default_value: null + max_length: null + numeric_precision: null + numeric_scale: null + is_nullable: true + is_unique: false + is_primary_key: false + is_generated: false + generation_expression: null + has_auto_increment: false + foreign_key_table: null + foreign_key_column: null + - collection: posts + field: id + type: integer + meta: + collection: posts + conditions: null + display: null + display_options: null + field: id + group: null + hidden: true + interface: input + note: null + options: null + readonly: true + required: false + sort: 4 + special: null + translations: null + validation: null + validation_message: null + width: half + schema: + name: id + table: posts + data_type: integer + default_value: null + max_length: null + numeric_precision: null + numeric_scale: null + is_nullable: false + is_unique: false + is_primary_key: true + is_generated: false + generation_expression: null + has_auto_increment: true + foreign_key_table: null + foreign_key_column: null + - collection: posts + field: image + type: uuid + meta: + collection: posts + conditions: null + display: null + display_options: null + field: image + group: null + hidden: false + interface: file-image + note: null + options: + folder: null + readonly: false + required: false + sort: 9 + special: + - file + translations: null + validation: null + validation_message: null + width: full + schema: + name: image + table: posts + data_type: char + default_value: null + max_length: 36 + numeric_precision: null + numeric_scale: null + is_nullable: true + is_unique: false + is_primary_key: false + is_generated: false + generation_expression: null + has_auto_increment: false + foreign_key_table: directus_files + foreign_key_column: id + - collection: posts + field: slug + type: string + meta: + collection: posts + conditions: null + display: null + display_options: null + field: slug + group: null + hidden: false + interface: input + note: null + options: + slug: true + readonly: false + required: true + sort: 2 + special: null + translations: null + validation: null + validation_message: null + width: half + schema: + name: slug + table: posts + data_type: varchar + default_value: null + max_length: 255 + numeric_precision: null + numeric_scale: null + is_nullable: false + is_unique: true + is_primary_key: false + is_generated: false + generation_expression: null + has_auto_increment: false + foreign_key_table: null + foreign_key_column: null + - collection: posts + field: status + type: string + meta: + collection: posts + conditions: null + display: labels + display_options: + choices: + - text: $t:published + value: published + foreground: '#FFFFFF' + background: var(--primary) + - text: $t:draft + value: draft + foreground: '#18222F' + background: '#D3DAE4' + - text: $t:archived + value: archived + foreground: '#FFFFFF' + background: var(--warning) + showAsDot: true + field: status + group: null + hidden: false + interface: select-dropdown + note: null + options: + choices: + - text: $t:published + value: published + - text: $t:draft + value: draft + - text: $t:archived + value: archived + readonly: false + required: false + sort: 3 + special: null + translations: null + validation: null + validation_message: null + width: half + schema: + name: status + table: posts + data_type: varchar + default_value: draft + max_length: 255 + numeric_precision: null + numeric_scale: null + is_nullable: false + is_unique: false + is_primary_key: false + is_generated: false + generation_expression: null + has_auto_increment: false + foreign_key_table: null + foreign_key_column: null + - collection: posts + field: title + type: string + meta: + collection: posts + conditions: null + display: null + display_options: null + field: title + group: null + hidden: false + interface: input + note: null + options: null + readonly: false + required: true + sort: 1 + special: null + translations: null + validation: null + validation_message: null + width: half + schema: + name: title + table: posts + data_type: varchar + default_value: null + max_length: 255 + numeric_precision: null + numeric_scale: null + is_nullable: true + is_unique: false + is_primary_key: false + is_generated: false + generation_expression: null + has_auto_increment: false + foreign_key_table: null + foreign_key_column: null + - collection: posts + field: user_created + type: string + meta: + collection: posts + conditions: null + display: user + display_options: null + field: user_created + group: null + hidden: true + interface: select-dropdown-m2o + note: null + options: + template: '{{avatar.$thumbnail}} {{first_name}} {{last_name}}' + readonly: true + required: false + sort: 5 + special: + - user-created + translations: null + validation: null + validation_message: null + width: half + schema: + name: user_created + table: posts + data_type: char + default_value: null + max_length: 36 + numeric_precision: null + numeric_scale: null + is_nullable: true + is_unique: false + is_primary_key: false + is_generated: false + generation_expression: null + has_auto_increment: false + foreign_key_table: directus_users + foreign_key_column: id +relations: + - collection: posts + field: image + related_collection: directus_files + meta: + junction_field: null + many_collection: posts + many_field: image + one_allowed_collections: null + one_collection: directus_files + one_collection_field: null + one_deselect_action: nullify + one_field: null + sort_field: null + schema: + table: posts + column: image + foreign_key_table: directus_files + foreign_key_column: id + constraint_name: null + on_update: NO ACTION + on_delete: SET NULL + - collection: posts + field: user_created + related_collection: directus_users + meta: + junction_field: null + many_collection: posts + many_field: user_created + one_allowed_collections: null + one_collection: directus_users + one_collection_field: null + one_deselect_action: nullify + one_field: null + sort_field: null + schema: + table: posts + column: user_created + foreign_key_table: directus_users + foreign_key_column: id + constraint_name: null + on_update: NO ACTION + on_delete: NO ACTION diff --git a/directus-extension/tsconfig.json b/directus-extension/tsconfig.json new file mode 100644 index 00000000..3afc0fb5 --- /dev/null +++ b/directus-extension/tsconfig.json @@ -0,0 +1,23 @@ +{ + "include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx"], + "compilerOptions": { + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "isolatedModules": true, + "esModuleInterop": true, + "jsx": "react-jsx", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "target": "ES2022", + "module": "ES2022", + "strict": true, + "allowJs": true, + "forceConsistentCasingInFileNames": true, + "baseUrl": ".", + "paths": { + "~/*": ["./app/*"] + }, + + // Remix takes care of building everything in `remix build`. + "noEmit": true + } +} From 25652a72b71d5e02d054f2a10d02c1cc96d10ef9 Mon Sep 17 00:00:00 2001 From: Alex Anderson Date: Sat, 7 Oct 2023 08:51:05 -0400 Subject: [PATCH 2/2] Patch the Vue types. --- directus-extension/README.md | 8 +++++++ directus-extension/package.json | 4 +++- directus-extension/patches/vue+3.3.4.patch | 26 ++++++++++++++++++++++ 3 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 directus-extension/patches/vue+3.3.4.patch diff --git a/directus-extension/README.md b/directus-extension/README.md index d5b96044..579a287c 100644 --- a/directus-extension/README.md +++ b/directus-extension/README.md @@ -20,6 +20,14 @@ We can also take advantage of Remix load context and pass Directus utilities to In this example, we use the `ItemsService`, which is accessed through load context, to pull our list of blog posts in our loader to render to the page. +### Patching Vue Types + +This example installs Directus directly, which includes `vue` as a dependency. Unfortunately, Vue uses interface overloading to alter the way TypeScript types JSX, causing errors in idiomatic React code. + +To solve this, this example uses `patch-package` to change the types in the Vue package so they don't interfere with React's JSX typings. + +This is handled automatically with a `postinstall` package.json script. + ## Development This example includes an example environment variables file. diff --git a/directus-extension/package.json b/directus-extension/package.json index 1ae4a3b0..0cbd69b8 100644 --- a/directus-extension/package.json +++ b/directus-extension/package.json @@ -11,7 +11,8 @@ "site-dev:extension": "npm run dev --workspace=remix-frontend", "site-dev:directus": "directus start", "start": "directus start", - "typecheck": "tsc" + "typecheck": "tsc", + "postinstall": "patch-package" }, "dependencies": { "@remix-run/css-bundle": "^2.0.1", @@ -39,6 +40,7 @@ "directus-extension-seed": "^2.0.4", "eslint": "^8.38.0", "npm-run-all": "^4.1.5", + "patch-package": "^8.0.0", "typescript": "^5.1.6" }, "engines": { diff --git a/directus-extension/patches/vue+3.3.4.patch b/directus-extension/patches/vue+3.3.4.patch new file mode 100644 index 00000000..db65f2eb --- /dev/null +++ b/directus-extension/patches/vue+3.3.4.patch @@ -0,0 +1,26 @@ +diff --git a/node_modules/vue/jsx-runtime/index.d.ts b/node_modules/vue/jsx-runtime/index.d.ts +index a44382c..93bcc3a 100644 +--- a/node_modules/vue/jsx-runtime/index.d.ts ++++ b/node_modules/vue/jsx-runtime/index.d.ts +@@ -12,7 +12,7 @@ import type { + */ + export { h as jsx, h as jsxDEV, Fragment } from '@vue/runtime-dom' + +-export namespace JSX { ++export namespace JSX_Vue { + export interface Element extends VNode {} + export interface ElementClass { + $props: {} +diff --git a/node_modules/vue/jsx.d.ts b/node_modules/vue/jsx.d.ts +index afc1039..47f0942 100644 +--- a/node_modules/vue/jsx.d.ts ++++ b/node_modules/vue/jsx.d.ts +@@ -8,7 +8,7 @@ import type { + } from '@vue/runtime-dom' + + declare global { +- namespace JSX { ++ namespace JSX_Vue { + export interface Element extends VNode {} + export interface ElementClass { + $props: {}