- Creating Tables and Models
- Creating GraphQL Queries
- Creating React Components
- Using GraphQL in React Components
- Common Mistakes
- Quick Reference
Location: components/accounting/app/models/accounting/note.rb
Path breakdown:
components/accounting/- Your componentapp/models/- Models folderaccounting/- Namespace folder (critical!)note.rb- Singular model name
File path creates namespace:
accounting/note.rb→Accounting::Noteaccounting/foo/bar.rb→Accounting::Foo::Bar
module Accounting
class Note < Accounting::ApplicationRecord
belongs_to :project
belongs_to :user
end
endKey conventions:
-
Module wrapping required:
module AccountingcreatesAccounting::namespace- Must match component name exactly
- Prevents conflicts across Nitro
-
Inherit from component's ApplicationRecord:
Accounting::ApplicationRecord(not global)- Lives at:
components/accounting/app/models/accounting/application_record.rb - Allows component-specific config
-
Model name is singular:
- Class:
Note - File:
note.rb - Table:
accounting_notes(auto-inferred)
- Class:
-
belongs_to associations:
- Defines relationships
- Validates presence by default
- Enables:
note.project,note.user - Rails finds FK:
belongs_to :project→ looks forproject_id
Location: components/accounting/db/migrate/20251001174714_create_accounting_notes.rb
Path breakdown:
components/accounting/- Your componentdb/migrate/- Migration folderYYYYMMDDHHMMSS_- Timestamp (auto-generated)create_accounting_notes.rb- Descriptive name
class CreateAccountingNotes < ActiveRecord::Migration[7.1]
def change
create_table :accounting_notes do |t|
t.integer :project_id, null: false
t.text :content
t.integer :user_id, null: false
t.timestamps
end
add_index :accounting_notes, :project_id
add_index :accounting_notes, :user_id
end
endKey conventions:
-
Table name:
[component]_[model_plural]→accounting_notes- Always plural
- Component prefix prevents conflicts
-
Class name: Must match filename in CamelCase
- File:
create_accounting_notes.rb - Class:
CreateAccountingNotes
- File:
-
Indexes for performance:
add_indexspeeds up lookups- Add for any FK or frequently queried column
Command:
cd nitro-web # Main app directory
bin/cobra cmd accounting schema # Run migrations for accounting component from umbrella
bin/rails db:migrate #Run migration in the umbrella to build the dev databaseImportant:
- Must run from main app directory (not component directory)
bin/cobra [component] schemais Nitro's migration wrapper- Targets specific component's migrations
- Creates table in shared Nitro database
Verify migration ran:
# Check migration status
bin/cobra accounting schema:versionIn Rails console:
# Start console
rails c
# Check table exists and columns
Accounting::Note.column_names
# => ["id", "project_id", "content", "user_id", "created_at", "updated_at"]
# Create a test record
note = Accounting::Note.create(
content: "Test note",
project: Project.last,
user: User.last
)
# Verify it saved
note.persisted? # => true
note.id # => 1
# Access relationships
note.project.project_number
note.user.full_name
# Query records
Accounting::Note.all
Accounting::Note.where(project_id: 123)
Accounting::Note.order(created_at: :desc).first
# Check validations work
invalid_note = Accounting::Note.new
invalid_note.valid? # => false
invalid_note.errors.full_messages
# => ["Project must exist", "User must exist"]Why content shows [FILTERED]:
Accounting::Note.first
# => content: "[FILTERED]"
# This is Nitro's security feature
# Data is saved correctly, just hidden in output
# To see real value:
Accounting::Note.first.content
# => "Test note"Location: components/accounting/app/graphql/accounting/graphql/accounting_note_type.rb
Path breakdown:
components/accounting/app/graphql/- GraphQL folderaccounting/graphql/- Double namespace foldersaccounting_note_type.rb- Format:[component]_[model]_type.rbfollow the convention in your component
module Accounting
module Graphql
class AccountingNoteType < NitroGraphql::Types::BaseObject
graphql_name "AccountingNoteType"
description "An accounting note type"
# Database columns
field :id, ID, null: false
field :content, String, null: true
field :project_id, Integer, null: false
field :user_id, Integer, null: false
field :created_at, NitroGraphql::Types::DateTime, null: false
field :updated_at, NitroGraphql::Types::DateTime, null: false
# Relationships
belongs_to :user, ::Directory::Graphql::EmployeeType
belongs_to :project, ::CoreModels::Graphql::ProjectType
end
end
endKey conventions: FOLLOW YOUR COMPONENT'S CONVENTION
-
Double module wrapping:
module Accounting+module Graphql- Creates:
Accounting::Graphql::AccountingNoteType - Standard pattern in Nitro
-
Class name format:
[Component][Model]Type- Example:
AccountingNoteType - Includes component name to avoid conflicts
-
Inherit from NitroGraphql::Types::BaseObject:
- Nitro's base GraphQL type
- Shared across all components
-
Field definitions:
- Each DB column becomes a field
- Format:
field :name, Type, null: true/false - Names in snake_case (auto-converts to camelCase in GraphQL)
-
null: false vs null: true:
null: false= required (matchesNOT NULLin DB)null: true= optional (allows NULL)- Must match migration
-
Relationship types need full namespace:
- Start with
::for root namespace - Must find correct component for type
- Search:
grep -r "class ProjectType" components/*/app/graphql/
- Start with
Type mapping:
| Database | GraphQL |
|---|---|
integer, bigint |
Integer |
string, text |
String |
boolean |
Boolean |
datetime |
NitroGraphql::Types::DateTime |
date |
NitroGraphql::Types::Date |
decimal, float |
Float |
json, jsonb |
NitroGraphql::Types::Json |
| Primary key | ID |
Finding correct relationship namespaces:
# Find where ProjectType lives
grep -r "class ProjectType" components/*/app/graphql/
# Result: components/core_models/app/graphql/core_models/graphql/project_type.rb
# Use: ::CoreModels::Graphql::ProjectType
# Find where EmployeeType lives
grep -r "class EmployeeType" components/*/app/graphql/
# Result: components/directory/app/graphql/directory/graphql/employee_type.rb
# Use: ::Directory::Graphql::EmployeeTypeLocation: components/accounting/app/graphql/accounting/graphql/accounting_note_query.rb
Path breakdown:
- Same folder as type
- Format:
[component]_[model]_query.rb
Basic query (returns all):
module Accounting
module Graphql
class AccountingNoteQuery < NitroGraphql::BaseQuery
description "Returns Accounting Team Notes"
type [::Accounting::Graphql::AccountingNoteType], null: false
def resolve
Accounting::Note.all
end
end
end
endQuery with arguments (filter by project):
module Accounting
module Graphql
class AccountingNoteQuery < NitroGraphql::BaseQuery
description "Returns notes for a specific project"
type [::Accounting::Graphql::AccountingNoteType], null: false
argument :project_id, Integer, required: true
def resolve(project_id:)
Accounting::Note
.where(project_id: project_id)
.order(created_at: :desc)
end
end
end
endKey conventions:
-
Double module wrapping (same as type):
module Accounting+module Graphql
-
Class name format:
[Component][Model]Query- Example:
AccountingNoteQuery
-
Inherit from NitroGraphql::BaseQuery:
- For queries (read operations)
- Mutations use
NitroGraphql::BaseMutation
-
type declaration:
[Type]= returns arrayType= returns single object- Full namespace:
::Accounting::Graphql::AccountingNoteType
-
resolve method:
- Contains data-fetching logic
- Must return data matching
type - Use ActiveRecord queries
-
Arguments:
- Define:
argument :project_id, Integer, required: true - GraphQL uses:
projectId(camelCase) - Ruby receives:
project_id:(snake_case) - Auto-converted by Nitro
- Define:
Location: components/accounting/lib/accounting/graphql.rb
Path breakdown:
lib/accounting/- Library code (notapp/)graphql.rb- Standard name
module Accounting
module Graphql
extend NitroGraphql::Schema::Partial
queries do
field :accounting_state_coas, resolver: ::Accounting::Graphql::StateCoasQuery
field :order_to_cash_bonus_thresholds,
resolver: ::Accounting::Graphql::OrderToCashBonusThresholdsQuery,
access: { project_and_installation_services_territory_goals: :view_order_to_cash }
field :accounting_note, resolver: ::Accounting::Graphql::AccountingNoteQuery
end
mutations do
field :upsert_accounting_state_coa, resolver: ::Accounting::Graphql::UpsertStateCoaMutation
end
end
endKey conventions:
-
extend NitroGraphql::Schema::Partial:
- Makes this a partial schema
- Nitro merges all components at boot
-
queries block:
- All query fields for this component
- Each
fieldbecomes available in GraphQL
-
Field naming:
- Format:
field :accounting_note - Use snake_case
- GraphQL converts to:
accountingNote(camelCase) - Pattern:
[component]_[model]prevents conflicts
- Format:
-
Resolver specification:
- Full namespace:
resolver: ::Accounting::Graphql::AccountingNoteQuery - Leading
::for root
- Full namespace:
-
Optional access control:
access: { resource: :permission }- Integrates with Nitro's permission system
- If omitted, accessible to all authenticated users
Why lib/ not app/:
- Loads at boot time (not runtime)
- Configuration code
- Schema must exist before first request
1. Restart server (REQUIRED!):
cd nitro-web
# Stop server (Ctrl+C if running)
rails sWhy restart:
- GraphQL schema loads at boot
- Cached in memory
- Changes need fresh load
2. Test in GraphQL Playground:
Navigate to: http://localhost:3000/graphql/try
Basic query:
{
accountingNote {
id
content
createdAt
updatedAt
}
}With relationships:
{
accountingNote {
id
content
createdAt
user {
id
firstName
lastName
email
}
project {
id
projectNumber
ownerName
}
}
}With arguments:
{
accountingNote(projectId: 3879586) {
id
content
createdAt
user {
firstName
lastName
}
}
}Field name conversion:
- Schema defines:
:accounting_note(snake_case) - Query uses:
accountingNote(camelCase) - Same for arguments:
project_id→projectId
Expected response format:
{
"data": {
"accountingNote": [
{
"id": "1",
"content": "Test note",
"createdAt": "2025-10-02T15:26:47Z",
"user": {
"firstName": "John",
"lastName": "Doe"
},
"project": {
"projectNumber": "P-123456"
}
}
]
}
}Location: components/accounting/app/javascript/AccountingNoteApp/index.tsx
Path breakdown:
components/accounting/- Your componentapp/javascript/- JavaScript/React folderAccountingNoteApp/- Component name folder (PascalCase)index.tsx- Main component file
import React from "react";
const AccountingNoteApp = () => <div> Capstone Progress </div>;
export default AccountingNoteApp;Key conventions:
-
Component folder in PascalCase:
- Folder:
AccountingNoteApp/ - Component:
AccountingNoteApp - Matches React naming conventions
- Folder:
-
File is always
index.tsx:- Standard entry point for component folder
- TypeScript with JSX support (
.tsxextension)
-
Default export required:
export default AccountingNoteApp- Allows importing in entrypoint and exporting from index
-
Component naming:
- Format:
[Feature]Appor[Feature]Component - Example:
AccountingNoteApp,VolumeRangeApp,StateCoa
- Format:
Location:
_Check the package.json for location look for the mainline _
"main": "app/javascript/index.ts",
components/accounting/app/javascript/index.ts
Path breakdown:
components/accounting/- Your componentapp/javascript/- JavaScript exports folder as per package.jsonindex.ts- Main export file as per package.json
export { default as VolumeRangeApp } from "./VolumeRangeApp";
export { default as GrossMarginApp } from "./GrossMarginApp";
export { default as ImpersonateApp } from "./ImpersonateApp";
export { default as AcknowledgementFormApp } from "./AcknowledgementFormApp";
export { default as AccountingNoteApp } from "./AccountingNoteApp";Key conventions:
-
Named exports:
- Format:
export { default as [ComponentName] } - Allows importing from package:
@powerhome/accounting
- Format:
-
Import path is relative:
from "./AccountingNoteApp"- Points to component folder (index.tsx is implied)
-
One line per component:
- Each React app gets its own export
- Add new line for each new component
Why this file exists:
- Components are imported as:
import { AccountingNoteApp } from "@powerhome/accounting" @powerhome/accountingresolves to this file- Central export point for all component's React apps
Location: components/accounting/tsconfig.json
Path breakdown:
components/accounting/- Your componenttsconfig.json- TypeScript configuration
{
"extends": "../../tsconfig.json",
// Opt in as components get updated.
"include": ["./**/StateCoa/**/*.tsx", "./**/AccountingNoteApp/**/*.tsx"]
}Key conventions:
-
Extends main config:
"extends": "../../tsconfig.json"- Inherits Nitro's base TypeScript settings
-
Include specific component folders:
- Pattern:
"./**/[ComponentName]/**/*.tsx" - Must add each new React app explicitly
- Opt-in model (not all components use TypeScript yet)
- Pattern:
-
Why explicit includes:
- Performance (only compile what's needed)
- Gradual TypeScript adoption across Nitro
- Clear which components are TypeScript-enabled
Location: app/javascript/entrypoints/accounting/accounting_note.ts
Path breakdown:
app/javascript/entrypoints/- Main app entrypoints folder (not in component)accounting/- Component-specific subfolderaccounting_note.ts- Entrypoint file (snake_case)
import nitroReact from "@powerhome/nitro_react/renderer";
import { AccountingNoteApp } from "@powerhome/accounting";
nitroReact.register({
AccountingNoteApp,
});Key conventions:
-
Import nitroReact renderer:
from "@powerhome/nitro_react/renderer"- Nitro's React rendering system
-
Import component from package:
from "@powerhome/accounting"- Uses the export from Step 2
- Package name matches component folder name
-
Register component:
nitroReact.register({ AccountingNoteApp })- Makes component available to
render_apphelper - Object key must match component name exactly
-
File naming:
- Format:
[component]_[feature].ts(snake_case) - Example:
accounting_note.ts - Lives in
entrypoints/[component]/folder
- Format:
Why entrypoints:
- Vite needs explicit entry points to bundle JavaScript
- Each entrypoint creates a separate bundle
- Loaded only on pages that need them (performance optimization)
Location: components/accounting/app/views/accounting/project_details/show.html.erb
Path breakdown:
components/accounting/- Your componentapp/views/- Rails views folderaccounting/project_details/- Controller namespaceshow.html.erb- View file
<%= vite_client_tag %>
<%= vite_react_refresh_tag %>
<%= vite_javascript_tag "entrypoints/accounting/accounting_note" %>
<%
@page_title = "#{@project.project_number} Details"
project_presenter = Projects::ProjectPresenter.new @project, self
%>
<%= stylesheet_link_tag "accounting/project_details/show" %>
<%= pb_rails("button", props: { variant: "link",
padding: "none",
icon: "long-arrow-left",
aria: { label: "Link to Accounting Projects Index" },
tag: "a",
link: project_details_path }) do %>
Back to Accounting Projects Index
<% end %>
<%= render(Accounting::ProjectDetails::ShowHeaderComponent.new(project: @project, project_presenter: project_presenter)) %>
<%= render(Accounting::ProjectDetails::ShowProductInstallDetailsComponent.new(project: @project)) %>
<%= render(Accounting::ProjectDetails::ShowFinancingComponent.new(project: @project)) %>
<%= render(Accounting::ProjectDetails::ShowPaymentsComponent.new(project: @project)) %>
<%= render(Accounting::ProjectDetails::ShowProductsComponent.new(project: @project)) %>
<%= render_app "AccountingNoteApp", { projectId: @project.id } %>
<%= javascript_include_tag "accounting/project_details/add_copy_listeners" %>Key conventions:
-
Vite tags at top (before Ruby code):
vite_client_tag- Enables Vite dev featuresvite_react_refresh_tag- Hot reloading for Reactvite_javascript_tag "entrypoints/..."- Loads your entrypoint
-
Entrypoint path:
- Format:
"entrypoints/[component]/[file]" - Example:
"entrypoints/accounting/accounting_note" - No
.tsextension - Path relative to
app/javascript/
- Format:
-
render_app helper:
- Format:
<%= render_app "[ComponentName]", { props } %> - Component name must match registered name exactly
- Second argument is props object (optional)
- Props passed as camelCase:
{ projectId: @project.id }
- Format:
Order matters:
- Vite tags must be at the very top (before any Ruby code blocks)
- JavaScript must load before React components can render
render_appcan be anywhere after Vite tags
Start server:
cd nitro-web
rails sVisit page:
Navigate to the page with your component. For this example:
http://localhost:3000/accounting/project_details/3580709
Replace 3580709 with an actual project ID in your database.
What to check:
- Component renders on page
- No JavaScript errors in browser console (F12)
- Props are passed correctly (if using React DevTools)
- Hot reload works (edit component, see changes immediately)
Browser DevTools:
# Open browser console (F12)
# Check for errors in Console tab
# Check Network tab for JavaScript bundle loading
# Use React DevTools to inspect component treeTroubleshooting if not rendering:
- Check browser console for errors
- Verify all Vite tags are present and in correct order
- Verify component is exported in
app/javascript/index.ts - Verify component is included in
tsconfig.json - Verify entrypoint registers component correctly
- Restart server (server restart may be needed after new entrypoint)
Props are passed from Rails ERB views to React components using the render_app helper.
From ERB view:
<%= render_app "AccountingNoteApp", { projectId: @project.id } %>To React component:
const AccountingNoteApp = ({ projectId }: AccountingNoteAppProps) => {
// Use projectId here
};Location: components/accounting/app/javascript/AccountingNoteApp/index.tsx
Path breakdown:
components/accounting/- Your componentapp/javascript/- JavaScript/React folderAccountingNoteApp/- Component name folderindex.tsx- Main component file
import React from "react";
import { AccountingNoteAppProps } from "./types";
const AccountingNoteApp = ({ projectId }: AccountingNoteAppProps) => {
return <div>{/* Component content */}</div>;
};
export default AccountingNoteApp;Key conventions:
-
Destructure props:
- Format:
({ propName }: ComponentProps) - TypeScript validates prop types
- Enables autocomplete
- Format:
-
Type annotation:
- Use component-specific props type
- Import from
./types - Ensures type safety
-
Props flow down:
- Parent receives from ERB
- Parent passes to children
- One-way data flow
Location: components/accounting/app/javascript/AccountingNoteApp/types.ts
Path breakdown:
components/accounting/- Your componentapp/javascript/- JavaScript/React folderAccountingNoteApp/- Component foldertypes.ts- TypeScript types file
export type AccountingNoteAppProps = {
projectId: number;
};
export type NoteFormType = {
content?: string;
projectId: number;
};
export type AccountingNoteDisplay = {
id: number;
content: string;
user: {
name: string;
};
userTitle: {
name: string;
};
createdAt: string;
};Key conventions:
-
Props type naming:
- Format:
[ComponentName]Props - Example:
AccountingNoteAppProps - Matches component name exactly
- Format:
-
Form type naming:
- Format:
[Feature]FormType - Example:
NoteFormType - Matches mutation input structure
- Format:
-
Display type naming:
- Format:
[Feature]Display - Example:
AccountingNoteDisplay - Matches GraphQL query response structure
- Format:
-
Export all types:
- Use
export typefor each - Makes types reusable
- Centralized definitions
- Use
Type structure:
- Props from ERB: Simple types (number, string, boolean)
- Form types: Optional fields with
?, matches mutation input - Display types: Nested objects, matches GraphQL query response
Location: components/accounting/app/javascript/AccountingNoteApp/DisplayAccountingNotes/index.tsx
Path breakdown:
components/accounting/- Your componentapp/javascript/- JavaScript/React folderAccountingNoteApp/- Parent component folderDisplayAccountingNotes/- Child component folderindex.tsx- Component file
Location: components/accounting/app/javascript/AccountingNoteApp/graphql.ts
Path breakdown:
- Same folder as component
graphql.ts- GraphQL queries and mutations file
import gql from "graphql-tag";
export const ACCOUNTING_NOTES_QUERY = gql`
query ($projectId: Int!) {
accountingNotes(projectId: $projectId) {
id
content
user {
name
}
userTitle {
name
}
createdAt
}
}
`;Key conventions:
-
Import gql:
import gql from "graphql-tag"- Required for GraphQL syntax
- Parses GraphQL strings
-
Query naming:
- Format:
[FEATURE]_QUERY(SCREAMING_SNAKE_CASE) - Example:
ACCOUNTING_NOTES_QUERY - Export as constant
- Format:
-
Query structure:
- Define variables:
($projectId: Int!) - Call query:
accountingNotes(projectId: $projectId) - Must match backend query name
- Define variables:
-
Field selection:
- Only request fields you need
- Match your TypeScript display type
- Include nested relationships (user, userTitle)
Matches backend query created earlier:
From Creating GraphQL Queries section:
- Backend field:
:accounting_notes→ GraphQL:accountingNotes - Backend argument:
:project_id→ GraphQL:projectId - Backend resolver:
AccountingNotesQuerywithresolve(project_id:) - Returns: Array of
AccountingNoteTypeobjects
Name conversion:
| Backend (Ruby) | Frontend (GraphQL) |
|---|---|
field :accounting_notes |
accountingNotes |
argument :project_id |
projectId: $projectId |
Integer |
Int! |
resolve(project_id:) |
variables: { projectId } |
import React from "react";
import { useQuery } from "@apollo/react-hooks";
import { ACCOUNTING_NOTES_QUERY } from "../graphql";
import { AccountingNoteAppProps, AccountingNoteDisplay } from "../types";
const DisplayAccountingNotes = ({ projectId }: AccountingNoteAppProps) => {
const { loading, error, data } = useQuery(ACCOUNTING_NOTES_QUERY, {
variables: { projectId },
});
if (loading) return <div>Loading...</div>;
if (error) return <div>Error loading notes</div>;
const notes: AccountingNoteDisplay[] = data?.accountingNotes || [];
return (
<div>
{notes.map((note) => (
<div key={note.id}>
<p>{note.content}</p>
<small>
{note.user.name} - {note.createdAt}
</small>
</div>
))}
</div>
);
};
export default DisplayAccountingNotes;Key conventions:
-
Import useQuery:
from "@apollo/react-hooks"- Apollo's React hook
- Handles data fetching
-
Import query:
- Import from
../graphql - Use constant name
- Type-safe query
- Import from
-
Destructure response:
{ loading, error, data }- Standard Apollo pattern
- All queries return these
-
Pass variables:
variables: { projectId }- Matches query definition
($projectId: Int!) - Converts to
project_id:in backend resolver - Must send all required arguments - backend expects
project_id(required: true) - Must match types - backend expects Integer, send number
- Names auto-convert -
projectId→project_id
-
Handle states:
- Check
loadingfirst - Check
errorsecond - Render
datalast
- Check
-
Type the data:
const notes: AccountingNoteDisplay[]- Use display type from types.ts
- Array matches query return type
Data flow:
React: { projectId: 3879586 }
↓
GraphQL: { "projectId": 3879586 }
↓
Backend Resolver: project_id: 3879586
↓
Query: Accounting::Note.where(project_id: 3879586).order(created_at: :desc)
↓
Response: [{ id, content, user, ... }]
↓
React: notes: AccountingNoteDisplay[]
Location: components/accounting/app/javascript/AccountingNoteApp/AccountingNoteForm/index.tsx
Path breakdown:
components/accounting/- Your componentapp/javascript/- JavaScript/React folderAccountingNoteApp/- Parent component folderAccountingNoteForm/- Child component folderindex.tsx- Component file
Location: components/accounting/app/javascript/AccountingNoteApp/graphql.ts
Add to existing graphql.ts file:
export const CREATE_ACCOUNTING_NOTE_MUTATION = gql`
mutation CreateAccountingNote($input: AccountingNoteInput!) {
createAccountingNote(input: $input) {
accountingNote {
id
content
createdAt
}
errors
}
}
`;Connects to backend mutation from Creating GraphQL Queries:
- Backend:
field :create_accounting_note→ React:createAccountingNote - Backend:
argument :inputwithAccountingNoteInput→ React:$input: AccountingNoteInput! - Backend:
resolve(input:)receives{ project_id, content, user_id }→ React:{ projectId, content, userId } - Name conversion:
project_id→projectId,user_id→userId
import React, { useState } from "react";
import { useForm, Controller } from "react-hook-form";
import { useMutation } from "@apollo/react-hooks";
import {
CREATE_ACCOUNTING_NOTE_MUTATION,
ACCOUNTING_NOTES_QUERY,
} from "../graphql";
import { NoteFormType, AccountingNoteAppProps } from "../types";
const AccountingNoteForm = ({ projectId }: AccountingNoteAppProps) => {
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const { control, handleSubmit, reset } = useForm<NoteFormType>({
defaultValues: {
projectId: projectId,
content: "",
},
});
const [createAccountingNote] = useMutation(CREATE_ACCOUNTING_NOTE_MUTATION, {
onError: (error) => setErrorMessage(error.message),
onCompleted: () => {
reset();
setErrorMessage(null);
},
});
const onSubmit = (data: NoteFormType) => {
setErrorMessage(null);
createAccountingNote({
variables: { input: data },
refetchQueries: [
{ query: ACCOUNTING_NOTES_QUERY, variables: { projectId } },
],
});
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
{errorMessage && <div>{errorMessage}</div>}
<Controller
control={control}
name="content"
render={({ field }) => <textarea {...field} />}
rules={{ required: true, maxLength: 500 }}
/>
<button type="submit">Submit</button>
</form>
);
};
export default AccountingNoteForm;Key conventions:
-
Import useMutation:
from "@apollo/react-hooks"- Apollo's mutation hook
- Handles data updates
-
Import mutation and query:
- Mutation to create data
- Query to refetch after create
- Both from
../graphql
-
Setup mutation:
const [createAccountingNote] = useMutation(...)- Returns function to call
- Configure callbacks
-
onError callback:
- Runs when mutation fails
- Receives error object
- Set error state for display
- Catches backend validation errors
-
onCompleted callback:
- Runs when mutation succeeds
- No error parameter
- Reset form, clear errors
-
Call mutation:
createAccountingNote({ variables: { input: data } })- Pass form data as
input - Matches mutation definition
($input: AccountingNoteInput!)
-
Input structure:
{ input: { projectId: 3879586, content: "..." } }- Matches backend input type
- Field names convert:
projectId→project_id - Must send all required fields - backend expects
content,project_id,user_id - Must match types - backend expects String for content, Integer for IDs
- Must nest in input - backend expects
input:argument
-
refetchQueries:
- Array of queries to re-run after mutation succeeds
- Updates UI automatically with new data
- Pass same variables as original query
- Ensures list shows new note without manual refresh
- Example: After creating note, refetch notes list to display it
-
Form typing:
useForm<NoteFormType>- Type-safe form data
- Validates structure matches mutation input
Data flow:
Form Submit: { projectId: 3879586, content: "Test" }
↓
GraphQL Mutation: { "input": { "projectId": 3879586, "content": "Test" } }
↓
Backend Mutation Resolver: input: { project_id: 3879586, content: "Test" }
↓
Model Validation: Accounting::Note validates presence, length
↓
Success: { accountingNote: { id, content, ... }, errors: nil }
OR
Failure: { accountingNote: nil, errors: ["Content is too long"] }
↓
React: onCompleted() or onError()
↓
UI: Show success or display error message
Error handling:
- GraphQL errors: Network issues, syntax errors →
onError - Validation errors: Model validations →
onErrorwith message - Display errors: Set state, show to user
- Clear errors: Before new submission, after success
In browser:
- Open DevTools (F12)
- Go to Network tab
- Filter by "graphql"
- Perform action (load page, submit form)
- Check requests:
- Query sent with correct variables
- Mutation sent with correct input
- Response contains expected data
Check query:
Request URL: /graphql
Request Method: POST
Request Payload:
{
"query": "query ($projectId: Int!) { accountingNotes(...) }",
"variables": { "projectId": 3879586 }
}
Response:
{
"data": {
"accountingNotes": [
{ "id": "1", "content": "Test", ... }
]
}
}
Check mutation:
Request Payload:
{
"query": "mutation CreateAccountingNote($input: ...) { ... }",
"variables": {
"input": {
"projectId": 3879586,
"content": "Test note"
}
}
}
Response:
{
"data": {
"createAccountingNote": {
"accountingNote": { "id": "2", "content": "Test note", ... },
"errors": null
}
}
}
Verify:
- ✅ Query runs on component mount
- ✅ Data displays correctly
- ✅ Mutation runs on form submit
- ✅ UI updates after mutation (refetch works)
- ✅ Errors display when they occur
- ✅ Loading states show during requests
- ✅ Backend validations trigger errors in React
Accounting::AccountingNote.all # Wrong! (includes component name twice)
Accounting::AccountingNotes.all # Wrong! (plural)
Accounting::Note.all # Correct (singular, component prefix in namespace)### ❌ Forgot to restart server
- GraphQL changes require restart
- Schema loads once at boot
- Models/controllers hot-reload, GraphQL doesn't
### ❌ Field name doesn't match column
```ruby
# Database has: t.text :content
field :note_text, String # Wrong! No column named note_text
field :content, String # Correct
# Wrong: components/accounting/app/models/note.rb
# Creates: Note (global namespace - conflict!)
# Correct: components/accounting/app/models/accounting/note.rb
# Creates: Accounting::Note (properly namespaced)# Wrong - no namespace
class Note < Accounting::ApplicationRecord
end
# Correct - properly namespaced
module Accounting
class Note < Accounting::ApplicationRecord
end
end# Wrong order:
# 1. Create migration
# 2. Run bin/cobra accounting schema ❌ Model doesn't exist yet!
# 3. Create model
# Correct order:
# 1. Create model file
# 2. Create migration
# 3. Run bin/cobra accounting schema ✅| Type | Location | File Name | Class Name |
|---|---|---|---|
| Model | components/[comp]/app/models/[comp]/ |
[model].rb |
[Comp]::[Model] |
| Migration | components/[comp]/db/migrate/ |
YYYYMMDD_create_[comp]_[table].rb |
Create[Comp][Table] |
| GraphQL Type | components/[comp]/app/graphql/[comp]/graphql/ |
[comp]_[model]_type.rb |
[Comp]::Graphql::[Comp][Model]Type |
| GraphQL Query | components/[comp]/app/graphql/[comp]/graphql/ |
[comp]_[model]_query.rb |
[Comp]::Graphql::[Comp][Model]Query |
| Schema | components/[comp]/lib/[comp]/ |
graphql.rb |
[Comp]::Graphql |
| React Component | components/[comp]/app/javascript/ |
[ComponentName]/index.tsx |
[ComponentName] |
| Component Export | components/[comp]/app/javascript/ |
index.ts |
N/A (exports) |
| Vite Entrypoint | app/javascript/entrypoints/[comp]/ |
[comp]_[feature].ts |
N/A (registration) |
Backend:
- Model:
accounting/note.rb→Accounting::Note - Migration:
create_accounting_notes.rb→CreateAccountingNotes - Type:
accounting_note_type.rb→Accounting::Graphql::AccountingNoteType - Query:
accounting_note_query.rb→Accounting::Graphql::AccountingNoteQuery - Schema:
accounting/graphql.rb→Accounting::Graphql
Frontend:
- Component:
AccountingNoteApp/index.tsx→AccountingNoteApp - Export:
index.ts→export { default as AccountingNoteApp } - Entrypoint:
entrypoints/accounting/accounting_note.ts→ Registers component
# Run migrations for component (from main app directory)
cd nitro-web
bin/cobra accounting schema #use your component name not accounting
# Check migration status
bin/cobra accounting schema:version #use your component name not accounting
# Rollback last migration
bin/cobra accounting schema:rollback #use your component name not accounting
# Start Rails console
rails c
# Start Rails server
rails s
# Search for GraphQL types
grep -r "class ProjectType" components/*/app/graphql/
# Check what columns exist
rails c
> Accounting::Note.column_nameshttp://localhost:3000/graphql/try
Creating Tables and Models:
- Create model file FIRST in
app/models/[comp]/[model].rb - Add
module [Component]wrapper - Inherit from
[Component]::ApplicationRecord - Add
belongs_torelationships - Create migration in
components/[comp]/db/migrate/ - Use
t.integerfor foreign keys (nott.references) - Add
add_foreign_keyandadd_indexstatements - Run
bin/cobra [component] schemafrom main app - Test in console: create and query records
Creating GraphQL Queries:
- Create type in
app/graphql/[comp]/graphql/[comp]_[model]_type.rb - Use double module:
module [Comp]; module Graphql - Add fields matching database columns
- Find correct namespaces for relationships (grep search)
- Create query in
app/graphql/[comp]/graphql/[comp]_[model]_query.rb - Inherit from
NitroGraphql::BaseQuery - Implement
resolvemethod - Register in
lib/[comp]/graphql.rb - Use snake_case for field name
- Restart server
- Test in GraphQL playground at
http://localhost:3000/graphql/try
Creating React Components:
- Create component in
app/javascript/[ComponentName]/index.tsx - Use PascalCase for folder and component name
- Add
export default [ComponentName] - Export in
app/javascript/index.ts - Update
tsconfig.jsonto include component folder - Create entrypoint in
app/javascript/entrypoints/[comp]/[feature].ts - Import from
@powerhome/[component] - Register with
nitroReact.register({ [ComponentName] }) - Add Vite tags at top of ERB view
- Add
render_appcall with component name - Visit page and verify component renders