diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..008d778 --- /dev/null +++ b/.eslintignore @@ -0,0 +1,3 @@ +/build/* +/node_modules/* +/public/* \ No newline at end of file diff --git a/.eslintrc.js b/.eslintrc.js index a724d54..0ac949f 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,6 +1,9 @@ /** @type {import('eslint').Linter.Config} */ module.exports = { extends: ["@remix-run/eslint-config", "@remix-run/eslint-config/node"], + rules: { + "@typescript-eslint/consistent-type-imports": "off", + }, }; // NOTE!!!! diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml new file mode 100644 index 0000000..3d12561 --- /dev/null +++ b/.github/actions/setup/action.yml @@ -0,0 +1,13 @@ +name: "Setup Action" +description: "Setup env for running actions" +runs: + using: "composite" + steps: + - name: Use Node.js + uses: actions/setup-node@v2 + with: + node-version: "20.5.1" + + - name: Install Dependencies + run: yarn install + shell: bash diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..f46a1c8 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,16 @@ +name: Lint + +on: [push] + +jobs: + run: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + + - name: Setup Repo + uses: ./.github/actions/setup + + - name: Run linting + run: yarn lint diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml new file mode 100644 index 0000000..779e1c4 --- /dev/null +++ b/.github/workflows/typecheck.yml @@ -0,0 +1,16 @@ +name: Typescript Type Check + +on: [push] + +jobs: + run: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + + - name: Setup Repo + uses: ./.github/actions/setup + + - name: Run tsc + run: yarn tsc diff --git a/.gitignore b/.gitignore index 43944b9..f12bc27 100644 --- a/.gitignore +++ b/.gitignore @@ -3,5 +3,5 @@ node_modules /.cache /build /public/build -.env -/app/@types +# .env + diff --git a/.vscode/extensions.json b/.vscode/extensions.json index daa127f..598007a 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -5,6 +5,7 @@ "graphql.vscode-graphql", "graphql.vscode-graphql-syntax", "esbenp.prettier-vscode", - "bradlc.vscode-tailwindcss" + "bradlc.vscode-tailwindcss", + "github.vscode-github-actions" ] } diff --git a/README.md b/README.md index 3a7335c..b291455 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,11 @@ -# Welcome to Remix! - -- [Remix Docs](https://remix.run/docs) +# Welcome to Performa! ## Development From your terminal: ```sh -npm run dev +yarn dev ``` This starts your app in development mode, rebuilding assets on file changes. @@ -17,13 +15,13 @@ This starts your app in development mode, rebuilding assets on file changes. First, build your app for production: ```sh -npm run build +yarn build ``` Then run the app in production mode: ```sh -npm start +yarn start ``` Now you'll need to pick a host to deploy it to. @@ -56,3 +54,21 @@ rm -rf app # copy your app over cp -R ../my-old-remix-app/app app ``` + +## Generate GraphQL types + +run `yarn graphql` +if it fails, run `yarn graphql --verbose` +Note: GraphQL is introspective. This means you can query a GraphQL schema for details about itself. + +## Helps / Docs + +- GraphQL Integration + - https://www.apollographql.com/blog/apollo-client/how-to-use-apollo-client-with-remix/ +- Authentication: + - Remix Auth: https://github.com/sergiodxa/remix-auth + - Google Auth Strategy: https://github.com/pbteja1998/remix-auth-google + +# TODOs + +Check todos here: https://github.com/mahmoudmoravej/testui/issues/2 diff --git a/app/@types/fragment-masking.ts b/app/@types/fragment-masking.ts new file mode 100644 index 0000000..2ba06f1 --- /dev/null +++ b/app/@types/fragment-masking.ts @@ -0,0 +1,66 @@ +import { ResultOf, DocumentTypeDecoration, TypedDocumentNode } from '@graphql-typed-document-node/core'; +import { FragmentDefinitionNode } from 'graphql'; +import { Incremental } from './graphql'; + + +export type FragmentType> = TDocumentType extends DocumentTypeDecoration< + infer TType, + any +> + ? [TType] extends [{ ' $fragmentName'?: infer TKey }] + ? TKey extends string + ? { ' $fragmentRefs'?: { [key in TKey]: TType } } + : never + : never + : never; + +// return non-nullable if `fragmentType` is non-nullable +export function useFragment( + _documentNode: DocumentTypeDecoration, + fragmentType: FragmentType> +): TType; +// return nullable if `fragmentType` is nullable +export function useFragment( + _documentNode: DocumentTypeDecoration, + fragmentType: FragmentType> | null | undefined +): TType | null | undefined; +// return array of non-nullable if `fragmentType` is array of non-nullable +export function useFragment( + _documentNode: DocumentTypeDecoration, + fragmentType: ReadonlyArray>> +): ReadonlyArray; +// return array of nullable if `fragmentType` is array of nullable +export function useFragment( + _documentNode: DocumentTypeDecoration, + fragmentType: ReadonlyArray>> | null | undefined +): ReadonlyArray | null | undefined; +export function useFragment( + _documentNode: DocumentTypeDecoration, + fragmentType: FragmentType> | ReadonlyArray>> | null | undefined +): TType | ReadonlyArray | null | undefined { + return fragmentType as any; +} + + +export function makeFragmentData< + F extends DocumentTypeDecoration, + FT extends ResultOf +>(data: FT, _fragment: F): FragmentType { + return data as FragmentType; +} +export function isFragmentReady( + queryNode: DocumentTypeDecoration, + fragmentNode: TypedDocumentNode, + data: FragmentType, any>> | null | undefined +): data is FragmentType { + const deferredFields = (queryNode as { __meta__?: { deferredFields: Record } }).__meta__ + ?.deferredFields; + + if (!deferredFields) return true; + + const fragDef = fragmentNode.definitions[0] as FragmentDefinitionNode | undefined; + const fragName = fragDef?.name?.value; + + const fields = (fragName && deferredFields[fragName]) || []; + return fields.length > 0 && fields.every(field => data && field in data); +} diff --git a/app/@types/graphql.ts b/app/@types/graphql.ts new file mode 100644 index 0000000..cbef657 --- /dev/null +++ b/app/@types/graphql.ts @@ -0,0 +1,290 @@ +import { gql } from '@apollo/client'; +import * as Apollo from '@apollo/client'; +export type Maybe = T | null; +export type InputMaybe = Maybe; +export type Exact = { [K in keyof T]: T[K] }; +export type MakeOptional = Omit & { [SubKey in K]?: Maybe }; +export type MakeMaybe = Omit & { [SubKey in K]: Maybe }; +export type MakeEmpty = { [_ in K]?: never }; +export type Incremental = T | { [P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never }; +const defaultOptions = {} as const; +/** All built-in and custom scalars, mapped to their actual values */ +export type Scalars = { + ID: { input: string; output: string; } + String: { input: string; output: string; } + Boolean: { input: boolean; output: boolean; } + Int: { input: number; output: number; } + Float: { input: number; output: number; } +}; + +export type Manager = { + __typename?: 'Manager'; + Id: Scalars['Int']['output']; + Name: Scalars['String']['output']; + Reports: ReportConnection; +}; + + +export type ManagerReportsArgs = { + after?: InputMaybe; + before?: InputMaybe; + first?: InputMaybe; + last?: InputMaybe; +}; + +/** The connection type for Manager. */ +export type ManagerConnection = { + __typename?: 'ManagerConnection'; + /** A list of edges. */ + edges?: Maybe>>; + /** A list of nodes. */ + nodes?: Maybe>>; + /** Information to aid in pagination. */ + pageInfo: PageInfo; +}; + +/** Autogenerated input type of ManagerCreate */ +export type ManagerCreateInput = { + /** A unique identifier for the client performing the mutation. */ + clientMutationId?: InputMaybe; + managerInput: ManagerInput; +}; + +/** Autogenerated return type of ManagerCreate. */ +export type ManagerCreatePayload = { + __typename?: 'ManagerCreatePayload'; + /** A unique identifier for the client performing the mutation. */ + clientMutationId?: Maybe; + manager: Manager; +}; + +/** An edge in a connection. */ +export type ManagerEdge = { + __typename?: 'ManagerEdge'; + /** A cursor for use in pagination. */ + cursor: Scalars['String']['output']; + /** The item at the end of the edge. */ + node?: Maybe; +}; + +export type ManagerInput = { + Id: Scalars['Int']['input']; + Name: Scalars['String']['input']; +}; + +/** Autogenerated input type of ManagerUpdate */ +export type ManagerUpdateInput = { + /** A unique identifier for the client performing the mutation. */ + clientMutationId?: InputMaybe; + id: Scalars['ID']['input']; + managerInput: ManagerInput; +}; + +/** Autogenerated return type of ManagerUpdate. */ +export type ManagerUpdatePayload = { + __typename?: 'ManagerUpdatePayload'; + /** A unique identifier for the client performing the mutation. */ + clientMutationId?: Maybe; + manager: Manager; +}; + +export type Mutation = { + __typename?: 'Mutation'; + /** Creates a new manager */ + managerCreate?: Maybe; + /** Updates a manager by id */ + managerUpdate?: Maybe; + /** Creates a new report */ + reportCreate?: Maybe; + /** Updates a report by id */ + reportUpdate?: Maybe; +}; + + +export type MutationManagerCreateArgs = { + input: ManagerCreateInput; +}; + + +export type MutationManagerUpdateArgs = { + input: ManagerUpdateInput; +}; + + +export type MutationReportCreateArgs = { + input: ReportCreateInput; +}; + + +export type MutationReportUpdateArgs = { + input: ReportUpdateInput; +}; + +/** Fields to order by and the sort direction */ +export type Order = { + direction: Scalars['String']['input']; + field: Scalars['String']['input']; +}; + +/** Information about pagination in a connection. */ +export type PageInfo = { + __typename?: 'PageInfo'; + /** When paginating forwards, the cursor to continue. */ + endCursor?: Maybe; + /** When paginating forwards, are there more items? */ + hasNextPage: Scalars['Boolean']['output']; + /** When paginating backwards, are there more items? */ + hasPreviousPage: Scalars['Boolean']['output']; + /** When paginating backwards, the cursor to continue. */ + startCursor?: Maybe; +}; + +export enum PerformanceCategory { + HighPositive = 'HIGH_POSITIVE', + New = 'NEW', + OffTrack = 'OFF_TRACK', + Positive = 'POSITIVE', + UsuallyMeets = 'USUALLY_MEETS' +} + +export type Query = { + __typename?: 'Query'; + /** Returns a list of managers */ + managers: ManagerConnection; + /** Returns a list of reports */ + reports: ReportConnection; +}; + + +export type QueryManagersArgs = { + after?: InputMaybe; + before?: InputMaybe; + first?: InputMaybe; + last?: InputMaybe; + orderBy?: InputMaybe>; +}; + + +export type QueryReportsArgs = { + after?: InputMaybe; + before?: InputMaybe; + first?: InputMaybe; + last?: InputMaybe; +}; + +export type Report = { + __typename?: 'Report'; + Id: Scalars['Int']['output']; + Manager: Manager; + ManagerId: Scalars['Int']['output']; + Name: Scalars['String']['output']; + Performance: PerformanceCategory; +}; + +/** The connection type for Report. */ +export type ReportConnection = { + __typename?: 'ReportConnection'; + /** A list of edges. */ + edges?: Maybe>>; + /** A list of nodes. */ + nodes?: Maybe>>; + /** Information to aid in pagination. */ + pageInfo: PageInfo; +}; + +/** Autogenerated input type of ReportCreate */ +export type ReportCreateInput = { + /** A unique identifier for the client performing the mutation. */ + clientMutationId?: InputMaybe; + reportInput: ReportInput; +}; + +/** Autogenerated return type of ReportCreate. */ +export type ReportCreatePayload = { + __typename?: 'ReportCreatePayload'; + /** A unique identifier for the client performing the mutation. */ + clientMutationId?: Maybe; + report: Report; +}; + +/** An edge in a connection. */ +export type ReportEdge = { + __typename?: 'ReportEdge'; + /** A cursor for use in pagination. */ + cursor: Scalars['String']['output']; + /** The item at the end of the edge. */ + node?: Maybe; +}; + +export type ReportInput = { + Id: Scalars['Int']['input']; + ManagerId: Scalars['Int']['input']; + Name: Scalars['String']['input']; + Performance: PerformanceCategory; +}; + +/** Autogenerated input type of ReportUpdate */ +export type ReportUpdateInput = { + /** A unique identifier for the client performing the mutation. */ + clientMutationId?: InputMaybe; + id: Scalars['ID']['input']; + reportInput: ReportInput; +}; + +/** Autogenerated return type of ReportUpdate. */ +export type ReportUpdatePayload = { + __typename?: 'ReportUpdatePayload'; + /** A unique identifier for the client performing the mutation. */ + clientMutationId?: Maybe; + report: Report; +}; + +export type ManagersQueryVariables = Exact<{ [key: string]: never; }>; + + +export type ManagersQuery = { __typename?: 'Query', managers: { __typename?: 'ManagerConnection', nodes?: Array<{ __typename?: 'Manager', id: number, name: string, reports: { __typename?: 'ReportConnection', nodes?: Array<{ __typename?: 'Report', id: number, name: string } | null> | null } } | null> | null } }; + + +export const ManagersDocument = gql` + query managers { + managers(first: 10, orderBy: {field: "name", direction: "ASC"}) { + nodes { + id: Id + name: Name + reports: Reports { + nodes { + id: Id + name: Name + } + } + } + } +} + `; + +/** + * __useManagersQuery__ + * + * To run a query within a React component, call `useManagersQuery` and pass it any options that fit your needs. + * When your component renders, `useManagersQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useManagersQuery({ + * variables: { + * }, + * }); + */ +export function useManagersQuery(baseOptions?: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(ManagersDocument, options); + } +export function useManagersLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(ManagersDocument, options); + } +export type ManagersQueryHookResult = ReturnType; +export type ManagersLazyQueryHookResult = ReturnType; +export type ManagersQueryResult = Apollo.QueryResult; \ No newline at end of file diff --git a/app/@types/index.ts b/app/@types/index.ts new file mode 100644 index 0000000..ddc0848 --- /dev/null +++ b/app/@types/index.ts @@ -0,0 +1 @@ +export * from "./fragment-masking"; diff --git a/app/entry.client.tsx b/app/entry.client.tsx index 43ca03e..5b212df 100644 --- a/app/entry.client.tsx +++ b/app/entry.client.tsx @@ -10,10 +10,29 @@ import { RemixBrowser } from "@remix-run/react"; import { startTransition, StrictMode } from "react"; import { hydrateRoot } from "react-dom/client"; +const process = { + env: { + GRAPHQL_SCHEMA_URL: "http://localhost:3000/graphql", + }, +}; + +function getToken() { + //Note: we use this function as this file is loaded only once. + + const token = sessionStorage.getItem("token"); + if (token == null) { + // throw new Error("Not Authorized!"); we should manage it. Throughing an error affects the whole page rendering process. + } + return token; +} + startTransition(() => { const client = new ApolloClient({ cache: new InMemoryCache().restore(window.__APOLLO_STATE__), - uri: "http://localhost:5225/graphql/", // the same uri in our entry.server file + uri: process.env.GRAPHQL_SCHEMA_URL || "GRAPHQL_SCHEMA_URL IS NOT SET", // the same uri in our entry.server file + headers: { + Authorization: `Bearer ${getToken()}`, + }, }); hydrateRoot( diff --git a/app/entry.server.tsx b/app/entry.server.tsx index b9d4caf..92ee524 100644 --- a/app/entry.server.tsx +++ b/app/entry.server.tsx @@ -4,14 +4,14 @@ * For more information, see https://remix.run/file-conventions/entry.server */ -import { PassThrough, Transform } from "node:stream"; +import { PassThrough } from "node:stream"; import type { AppLoadContext, EntryContext } from "@remix-run/node"; -import { Response } 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"; -import { renderToString } from "react-dom/server"; + import { ApolloProvider, ApolloClient, @@ -19,6 +19,8 @@ import { createHttpLink, } from "@apollo/client"; import { getDataFromTree } from "@apollo/client/react/ssr"; +import { authenticator } from "./services/auth.server"; +import type { ReactElement } from "react"; const ABORT_DELAY = 5_000; @@ -62,11 +64,12 @@ function handleBotRequest( onAllReady() { shellRendered = true; const body = new PassThrough(); + const stream = createReadableStreamFromReadable(body); responseHeaders.set("Content-Type", "text/html"); resolve( - new Response(body, { + new Response(stream, { headers: responseHeaders, status: responseStatusCode, }), @@ -100,77 +103,92 @@ function handleBrowserRequest( remixContext: EntryContext, ) { return new Promise(async (resolve, reject) => { - const client = new ApolloClient({ - ssrMode: true, - cache: new InMemoryCache(), - link: createHttpLink({ - uri: "http://localhost:5225/graphql/", // from Apollo's Voyage tutorial series (https://www.apollographql.com/tutorials/voyage-part1/) - headers: Object.fromEntries(request.headers), - credentials: request.credentials ?? "include", // or "same-origin" if your backend server is the same domain - }), - }); - let shellRendered = false; - const App = ( - + const { pipe, abort } = renderToPipeableStream( + await wrapRemixServerWithApollo( - - ); + />, + request, + ), + { + onShellReady() { + shellRendered = true; + const body = new PassThrough(); + const stream = createReadableStreamFromReadable(body); - await getDataFromTree(App); - - const { pipe, abort } = renderToPipeableStream(App, { - onShellReady() { - shellRendered = true; - const body = new PassThrough(); - - var state = new Transform({ - transform(chunk, encoding, callback) { - callback(null, chunk); - }, - flush(callback) { - // Extract the entirety of the Apollo Client cache's current state - const initialState = client.extract(); - - this.push( - ``, - ); - callback(); - }, - }); - - responseHeaders.set("Content-Type", "text/html"); - - resolve( - new Response(body, { - 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); - } + 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); }); } +async function wrapRemixServerWithApollo( + remixServer: ReactElement, + request: Request, +) { + const client = await getApolloClient(request); + + const app = {remixServer}; + + await getDataFromTree(app); + const initialState = client.extract(); + + const appWithData = ( + <> + {app} +