├── .tool-versions
├── .babelrc
├── .prettierrc
├── next.config.js
├── public
├── rh.png
├── favicon.ico
└── rh.svg
├── postcss.config.js
├── config
└── jest
│ └── cssTransform.js
├── pages
├── api
│ └── hello.js
├── _app.js
├── _document.js
├── index.js
└── g
│ ├── model.jsx
│ └── migration.jsx
├── .vscode
├── settings.json
└── launch.json
├── helpers
├── constants.js
├── FieldTypeSplitter.js
├── ga
│ └── index.js
├── FieldTypeSplitter.test.js
├── parseMigrationFormat.js
└── parseMigrationFormat.test.js
├── styles
├── globals.css
└── Home.module.css
├── setupTests.js
├── tailwind.config.js
├── .gitignore
├── layout
├── Header.jsx
└── Footer.jsx
├── components
├── ReferencesConfig.jsx
├── LimitConfig.jsx
├── CopyButton.test.jsx
├── ReferencesConfig.test.jsx
├── MigrationEditor
│ ├── CustomMigrationForm.jsx
│ ├── JoinTableForm.jsx
│ ├── AddColumnsForm.jsx
│ ├── RemoveColumnsForm.jsx
│ ├── index.jsx
│ └── index.test.jsx
├── CopyButton.jsx
├── FieldTypeInput.test.jsx
├── LimitConfig.test.jsx
├── FieldInput.test.jsx
├── PrecisionScaleConfig.test.jsx
├── PrecisionScaleConfig.jsx
├── Heading.jsx
├── Pill.jsx
├── JoinTableEditor
│ └── index.jsx
├── FieldInput.jsx
└── FieldTypeInput.jsx
├── README.md
├── .github
└── workflows
│ ├── pull_request.yml
│ └── main.yml
├── package.json
├── __tests__
└── pages
│ ├── migration.test.jsx
│ └── model.test.jsx
└── jest.config.js
/.tool-versions:
--------------------------------------------------------------------------------
1 | nodejs 16.5.0
2 |
--------------------------------------------------------------------------------
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": ["next/babel"]
3 | }
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "tabWidth": 2,
3 | "useTabs": false
4 | }
5 |
--------------------------------------------------------------------------------
/next.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | basePath: '/railshelp',
3 | }
4 |
--------------------------------------------------------------------------------
/public/rh.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wasabigeek/railshelp/HEAD/public/rh.png
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wasabigeek/railshelp/HEAD/public/favicon.ico
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/config/jest/cssTransform.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | process() {
3 | return 'module.exports = {};'
4 | },
5 | getCacheKey() {
6 | return 'cssTransform'
7 | },
8 | }
9 |
--------------------------------------------------------------------------------
/pages/api/hello.js:
--------------------------------------------------------------------------------
1 | // Next.js API route support: https://nextjs.org/docs/api-routes/introduction
2 |
3 | export default (req, res) => {
4 | res.status(200).json({ name: 'John Doe' })
5 | }
6 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "editor.defaultFormatter": "esbenp.prettier-vscode",
3 | "editor.formatOnSave": true,
4 | "search.exclude": {
5 | "**/.next": true,
6 | "**/out": true
7 | }
8 | }
--------------------------------------------------------------------------------
/helpers/constants.js:
--------------------------------------------------------------------------------
1 | export const MIGRATION_FORMATS = {
2 | ADD_COLUMNS: 'AddColumnsToTable',
3 | REMOVE_COLUMNS: 'RemoveColumnsFromTable',
4 | JOIN_TABLE: 'CreateModelJoinTable',
5 | CUSTOM: 'Custom Migration'
6 | }
7 |
--------------------------------------------------------------------------------
/styles/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 |
4 | input.text-input {
5 | @apply mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 sm:text-sm;
6 | }
7 |
8 | @tailwind utilities;
9 |
--------------------------------------------------------------------------------
/helpers/FieldTypeSplitter.js:
--------------------------------------------------------------------------------
1 | export default class FieldTypeSplitter {
2 | constructor({ text }) {
3 | this.text = text;
4 | }
5 |
6 | split() {
7 | const [type, ...configArray] = this.text.split(/({[a-z0-9|,]+})/);
8 | const config = configArray[0] ?? "";
9 | return [type, config];
10 | }
11 | }
--------------------------------------------------------------------------------
/setupTests.js:
--------------------------------------------------------------------------------
1 | // optional: configure or set up a testing framework before each test
2 | // if you delete this file, remove `setupFilesAfterEnv` from `jest.config.js`
3 |
4 | // used for __tests__/testing-library.js
5 | // learn more: https://github.com/testing-library/jest-dom
6 | import '@testing-library/jest-dom/extend-expect'
7 |
--------------------------------------------------------------------------------
/helpers/ga/index.js:
--------------------------------------------------------------------------------
1 | // log the pageview with their URL
2 | export const pageview = (url) => {
3 | window.gtag('config', process.env.NEXT_PUBLIC_GOOGLE_ANALYTICS, {
4 | page_path: url,
5 | })
6 | }
7 |
8 | // log specific events happening.
9 | export const event = ({ action, params }) => {
10 | window.gtag('event', action, params)
11 | }
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | const defaultTheme = require('tailwindcss/defaultTheme')
2 |
3 | module.exports = {
4 | content: [
5 | './pages/**/*.{js,ts,jsx,tsx}',
6 | './layout/**/*.{js,ts,jsx,tsx}',
7 | './components/**/*.{js,ts,jsx,tsx}',
8 | ],
9 | darkMode: false, // or 'media' or 'class'
10 | theme: {
11 | extend: {
12 | fontFamily: {
13 | sans: ['Inter var', ...defaultTheme.fontFamily.sans],
14 | },
15 | },
16 | },
17 | plugins: [
18 | require('@tailwindcss/forms'),
19 | ],
20 | }
21 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # next.js
12 | /.next/
13 | /out/
14 |
15 | # production
16 | /build
17 |
18 | # misc
19 | .DS_Store
20 | *.pem
21 |
22 | # debug
23 | npm-debug.log*
24 | yarn-debug.log*
25 | yarn-error.log*
26 |
27 | # local env files
28 | .env.local
29 | .env.development.local
30 | .env.test.local
31 | .env.production.local
32 |
33 | # vercel
34 | .vercel
35 |
--------------------------------------------------------------------------------
/layout/Header.jsx:
--------------------------------------------------------------------------------
1 | import Link from "next/link";
2 |
3 | const Header = () => (
4 |
17 | );
18 |
19 | export default Header;
20 |
--------------------------------------------------------------------------------
/components/ReferencesConfig.jsx:
--------------------------------------------------------------------------------
1 | const ReferencesConfig = ({ value, onChange }) => {
2 | return (
3 |
4 | onChange(e.target.checked ? "{polymorphic}" : "")}
9 | />
10 |
14 | polymorphic?
15 |
16 |
17 | );
18 | };
19 |
20 | export default ReferencesConfig;
21 |
--------------------------------------------------------------------------------
/components/LimitConfig.jsx:
--------------------------------------------------------------------------------
1 | const LimitConfig = ({ value, onChange }) => {
2 | return (
3 |
4 |
8 | Limit
9 |
10 | onChange(e.target.value ? `{${e.target.value}}` : "")}
18 | />
19 |
20 | );
21 | };
22 |
23 | export default LimitConfig;
24 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # About
2 |
3 | I wanted to create a resource on Rails generators that was more comprehensive and interactive than
4 | a cheatsheet. A GUI makes required arguments obvious, and exposes some lesser known options (e.g. `{polymorphic}`).
5 |
6 | ## References
7 |
8 | - [Model Generator README](https://github.com/rails/rails/blob/main/railties/lib/rails/generators/rails/model/USAGE)
9 |
10 | # Development
11 |
12 | Some VSCode debug configurations are provided in `.vscode`. I'm not sure why breakpoints don't seem to work very well, but adding a `debugger` expression works.
13 |
14 | The project runs on [Next.js](https://nextjs.org/docs) and was built with `yarn`.
15 |
--------------------------------------------------------------------------------
/components/CopyButton.test.jsx:
--------------------------------------------------------------------------------
1 | /**
2 | * @jest-environment jsdom
3 | */
4 |
5 | import { render, screen } from "@testing-library/react";
6 | import userEvent from "@testing-library/user-event";
7 | import CopyButton from "./CopyButton";
8 |
9 | it("copies command", async () => {
10 | Object.assign(navigator, {
11 | clipboard: {
12 | writeText: () => {},
13 | },
14 | });
15 | jest.spyOn(navigator.clipboard, "writeText");
16 |
17 | render( );
18 | const copyButton = screen.getByText("Copy");
19 | userEvent.click(copyButton);
20 | expect(navigator.clipboard.writeText).toHaveBeenCalledWith("Sample Text");
21 | });
22 |
--------------------------------------------------------------------------------
/.github/workflows/pull_request.yml:
--------------------------------------------------------------------------------
1 | # This is a basic workflow to help you get started with Actions
2 |
3 | name: pull request
4 |
5 | # Controls when the action will run.
6 | on:
7 | pull_request:
8 | branches: [main]
9 |
10 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel
11 | jobs:
12 | test:
13 | # The type of runner that the job will run on
14 | runs-on: ubuntu-latest
15 |
16 | steps:
17 | - uses: actions/checkout@v2
18 |
19 | - name: Setup Node.js environment
20 | uses: actions/setup-node@v2.1.4
21 | with:
22 | node-version: 12.13.0
23 |
24 | - name: Install JS dependencies
25 | run: yarn install
26 |
27 | - name: Install JS dependencies
28 | run: yarn test
29 |
--------------------------------------------------------------------------------
/pages/_app.js:
--------------------------------------------------------------------------------
1 | import '../styles/globals.css'
2 | import { useEffect } from 'react'
3 | import { useRouter } from 'next/router'
4 |
5 | import * as ga from '../helpers/ga'
6 |
7 | function MyApp({ Component, pageProps }) {
8 | const router = useRouter()
9 |
10 | useEffect(() => {
11 | const handleRouteChange = (url) => {
12 | ga.pageview(url)
13 | }
14 | //When the component is mounted, subscribe to router changes
15 | //and log those page views
16 | router.events.on('routeChangeComplete', handleRouteChange)
17 |
18 | // If the component is unmounted, unsubscribe
19 | // from the event with the `off` method
20 | return () => {
21 | router.events.off('routeChangeComplete', handleRouteChange)
22 | }
23 | }, [router.events])
24 |
25 | return
26 | }
27 |
28 | export default MyApp
29 |
--------------------------------------------------------------------------------
/components/ReferencesConfig.test.jsx:
--------------------------------------------------------------------------------
1 | /**
2 | * @jest-environment jsdom
3 | */
4 |
5 | import { render, screen, fireEvent } from '@testing-library/react'
6 | import ReferencesConfig from './ReferencesConfig'
7 |
8 |
9 | it('is not checked when empty value', async () => {
10 | render( )
11 |
12 | const checkbox = screen.getByRole('checkbox')
13 | expect(checkbox).not.toBeChecked()
14 | })
15 |
16 | it('is checked when value is {polymorphic}', async () => {
17 | render( )
18 |
19 | const checkbox = screen.getByRole('checkbox')
20 | expect(checkbox).toBeChecked()
21 | })
22 |
23 | it('calls onChange when clicked', async () => {
24 | const mockOnChange = jest.fn();
25 | render( )
26 |
27 | const checkbox = screen.getByRole('checkbox')
28 | fireEvent.click(checkbox)
29 |
30 | expect(mockOnChange).toHaveBeenCalled();
31 | })
32 |
--------------------------------------------------------------------------------
/helpers/FieldTypeSplitter.test.js:
--------------------------------------------------------------------------------
1 | import FieldTypeSplitter from './FieldTypeSplitter';
2 |
3 | describe('split()', () => {
4 | it('returns empty strings if input is empty string', () => {
5 | const splitter = new FieldTypeSplitter({ text: '' });
6 |
7 | expect(splitter.split()).toEqual(['', '']);
8 | });
9 | it('returns type if there is no config', () => {
10 | const splitter = new FieldTypeSplitter({ text: 'references' });
11 |
12 | expect(splitter.split()).toEqual(['references', '']);
13 | });
14 | it('returns type and single config', () => {
15 | const splitter = new FieldTypeSplitter({ text: 'references{polymorphic}' });
16 |
17 | expect(splitter.split()).toEqual(['references', '{polymorphic}']);
18 | });
19 | it('returns type and multiple configs', () => {
20 | const splitter = new FieldTypeSplitter({ text: 'decimal{10,2}' });
21 |
22 | expect(splitter.split()).toEqual(['decimal', '{10,2}']);
23 | });
24 | });
25 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "railshelp",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "devDebug": "NODE_OPTIONS='--inspect' next dev",
8 | "build": "next build && next export",
9 | "start": "next start",
10 | "test": "jest"
11 | },
12 | "dependencies": {
13 | "@headlessui/react": "^1.4.3",
14 | "@heroicons/react": "^1.0.5",
15 | "next": "11",
16 | "react": "17.0.1",
17 | "react-dom": "17.0.1",
18 | "react-use": "^17.1.1"
19 | },
20 | "devDependencies": {
21 | "@tailwindcss/forms": "^0.4.0",
22 | "@testing-library/jest-dom": "^5.11.9",
23 | "@testing-library/react": "^11.2.5",
24 | "@testing-library/user-event": "^12.7.3",
25 | "autoprefixer": "^10.4.2",
26 | "babel-jest": "^26.6.3",
27 | "identity-obj-proxy": "^3.0.0",
28 | "jest": "^26.6.3",
29 | "postcss": "^8.4.5",
30 | "react-test-renderer": "^17.0.1",
31 | "tailwindcss": "^3.0.15"
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/pages/_document.js:
--------------------------------------------------------------------------------
1 | import Document, { Html, Head, Main, NextScript } from 'next/document'
2 |
3 | export default class MyDocument extends Document {
4 | render() {
5 | return (
6 |
7 |
8 | {/* Global Site Tag (gtag.js) - Google Analytics */}
9 |
13 |
25 |
26 |
27 |
28 |
29 |
30 |
31 | )
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/components/MigrationEditor/CustomMigrationForm.jsx:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 |
3 | const CustomMigrationForm = ({ initialName, onChange }) => {
4 | const [name, setName] = useState(initialName);
5 |
6 | const handleChange = (changedName) => {
7 | setName(changedName);
8 |
9 | // TODO: show this validation to users
10 | if (changedName) {
11 | onChange(changedName);
12 | }
13 | };
14 |
15 | return (
16 |
17 |
21 | Name
22 |
23 |
24 |
handleChange(e.target.value)}
31 | />
32 |
33 | );
34 | };
35 |
36 | export default CustomMigrationForm;
37 |
--------------------------------------------------------------------------------
/components/MigrationEditor/JoinTableForm.jsx:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 |
3 | const JoinTableForm = ({ initialName, onChange }) => {
4 | const [name, setName] = useState(initialName);
5 |
6 | const handleChange = (newName) => {
7 | setName(newName);
8 |
9 | // TODO: show this validation to users
10 | if (newName) {
11 | onChange(`${newName}JoinTable`);
12 | }
13 | };
14 |
15 | return (
16 |
17 |
21 | Name
22 |
23 |
24 |
handleChange(e.target.value)}
31 | />
32 | JoinTable
33 |
34 | );
35 | };
36 |
37 | export default JoinTableForm;
38 |
--------------------------------------------------------------------------------
/components/CopyButton.jsx:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 | import Pill from "./Pill";
3 |
4 | const CopyButton = ({ text }) => {
5 | const [showCopying, setShowCopying] = useState(false);
6 |
7 | const copyToClipboard = () => {
8 | setShowCopying(true);
9 | navigator.clipboard.writeText(text);
10 | setTimeout(() => setShowCopying(false), 2000);
11 | };
12 |
13 | return (
14 |
27 |
33 |
34 | }
35 | />
36 | );
37 | };
38 |
39 | export default CopyButton;
40 |
--------------------------------------------------------------------------------
/helpers/parseMigrationFormat.js:
--------------------------------------------------------------------------------
1 | import { MIGRATION_FORMATS } from "./constants";
2 |
3 | const parseMigrationFormat = (text) => {
4 | const addColumnsMatches = text.match(/^Add(?\w+)To(?\w+)/);
5 | if (addColumnsMatches) {
6 | return [
7 | MIGRATION_FORMATS.ADD_COLUMNS,
8 | {
9 | columnsName: addColumnsMatches.groups.columnsName,
10 | tableName: addColumnsMatches.groups.tableName,
11 | }
12 | ];
13 | }
14 |
15 | const removeColumnsMatches = text.match(/^Remove(?\w+)From(?\w+)/);
16 | if (removeColumnsMatches) {
17 | return [
18 | MIGRATION_FORMATS.REMOVE_COLUMNS,
19 | {
20 | columnsName: removeColumnsMatches.groups.columnsName,
21 | tableName: removeColumnsMatches.groups.tableName,
22 | }
23 | ];
24 | }
25 |
26 | const joinTableMatches = text.match(/^(Create)?(?\w+)?JoinTable$/)
27 | if (joinTableMatches) {
28 | return [
29 | MIGRATION_FORMATS.JOIN_TABLE,
30 | {
31 | sourceTable: joinTableMatches.groups.sourceTable || null,
32 | }
33 | ]
34 | }
35 |
36 | return [MIGRATION_FORMATS.CUSTOM, { name: text }];
37 | }
38 |
39 | export default parseMigrationFormat
40 |
--------------------------------------------------------------------------------
/components/FieldTypeInput.test.jsx:
--------------------------------------------------------------------------------
1 | /**
2 | * @jest-environment jsdom
3 | */
4 |
5 | import { render, screen } from "@testing-library/react";
6 | import userEvent from "@testing-library/user-event";
7 | import FieldTypeInput from "./FieldTypeInput";
8 |
9 | it("renders empty inputs when no value is given", async () => {
10 | render( );
11 |
12 | const selects = screen.getAllByRole("combobox");
13 | expect(selects.length).toBe(1);
14 | });
15 |
16 | it("updates the field type when changed", async () => {
17 | const mockHandleChange = jest.fn();
18 | render( );
19 |
20 | const fieldTypeSelect = screen.getByRole("combobox");
21 | userEvent.selectOptions(fieldTypeSelect, "date");
22 | expect(mockHandleChange).toHaveBeenCalledWith("date");
23 | });
24 |
25 | it("resets the field type configs when changed", async () => {
26 | const mockHandleChange = jest.fn();
27 | render(
28 |
32 | );
33 |
34 | const fieldTypeSelect = screen.getByRole("combobox");
35 | userEvent.selectOptions(fieldTypeSelect, "date");
36 | expect(mockHandleChange).toHaveBeenCalledWith("date");
37 | });
38 |
--------------------------------------------------------------------------------
/components/LimitConfig.test.jsx:
--------------------------------------------------------------------------------
1 | /**
2 | * @jest-environment jsdom
3 | */
4 |
5 | import { render, screen } from "@testing-library/react";
6 | import userEvent from "@testing-library/user-event";
7 | import LimitConfig from "./LimitConfig";
8 |
9 | it("is not checked when empty value", async () => {
10 | render( );
11 |
12 | const input = screen.getByLabelText("Limit");
13 | expect(input.value).toBe("");
14 | });
15 |
16 | it("sets the limit when defined", async () => {
17 | render( );
18 |
19 | const input = screen.getByLabelText("Limit");
20 | expect(input).toHaveValue(5);
21 | });
22 |
23 | it("calls onChange when limit is changed", async () => {
24 | const mockOnChange = jest.fn();
25 | render( );
26 |
27 | const input = screen.getByLabelText("Limit");
28 | userEvent.type(input, "5");
29 | expect(mockOnChange).toHaveBeenCalledWith("{5}");
30 | });
31 |
32 | it("sets limit correctly when cleared", async () => {
33 | const mockOnChange = jest.fn();
34 | render( );
35 |
36 | const input = screen.getByLabelText("Limit");
37 | userEvent.clear(input);
38 | expect(mockOnChange).toHaveBeenCalledWith("");
39 | });
40 |
--------------------------------------------------------------------------------
/public/rh.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/components/FieldInput.test.jsx:
--------------------------------------------------------------------------------
1 | /**
2 | * @jest-environment jsdom
3 | */
4 |
5 | import { render, screen } from "@testing-library/react";
6 | import userEvent from "@testing-library/user-event";
7 | import FieldInput from "./FieldInput";
8 |
9 | it("renders empty inputs when no value is given", async () => {
10 | render( );
11 |
12 | const inputs = screen.getAllByRole("textbox");
13 | expect(inputs.length).toBe(1);
14 | const selects = screen.getAllByRole("combobox");
15 | expect(selects.length).toBe(2);
16 | });
17 |
18 | it("updates the field type when changed", async () => {
19 | const mockHandleUpdate = jest.fn();
20 | render( );
21 |
22 | const fieldTypeSelect = screen.getAllByRole("combobox")[0];
23 | userEvent.selectOptions(fieldTypeSelect, "date");
24 | expect(mockHandleUpdate).toHaveBeenCalledWith(":date");
25 | });
26 |
27 | it("updates the field name when changed", async () => {
28 | const mockHandleUpdate = jest.fn();
29 | render( );
30 |
31 | const fieldTypeSelect = screen.getAllByRole("combobox")[0];
32 | const fieldName = screen.getByLabelText("Name");
33 |
34 | userEvent.type(fieldName, "c");
35 | expect(mockHandleUpdate).toHaveBeenCalledWith("c:");
36 | });
37 |
--------------------------------------------------------------------------------
/components/PrecisionScaleConfig.test.jsx:
--------------------------------------------------------------------------------
1 | /**
2 | * @jest-environment jsdom
3 | */
4 |
5 | import { render, screen } from "@testing-library/react";
6 | import userEvent from "@testing-library/user-event";
7 | import PrecisionScaleConfig from "./PrecisionScaleConfig";
8 |
9 | it("renders inputs when empty value", async () => {
10 | render( );
11 |
12 | const precisionInput = screen.getByLabelText("Precision");
13 | expect(precisionInput).toBeTruthy();
14 | const scaleInput = screen.getByLabelText("Scale");
15 | expect(scaleInput).toBeTruthy();
16 | });
17 |
18 | it("renders input with values", async () => {
19 | render( );
20 |
21 | const precisionInput = screen.getByLabelText("Precision");
22 | expect(precisionInput).toHaveValue(5);
23 | const scaleInput = screen.getByLabelText("Scale");
24 | expect(scaleInput).toHaveValue(2);
25 | });
26 |
27 | it("calls onChange when precision and scale is changed", async () => {
28 | const mockOnChange = jest.fn();
29 | render( );
30 |
31 | // TODO: not very rigorous, probably needs a smarter mock
32 | const input = screen.getByLabelText("Scale");
33 | userEvent.type(input, "3");
34 | expect(mockOnChange).toHaveBeenCalledWith("{5,23}");
35 | });
36 |
--------------------------------------------------------------------------------
/.github/workflows/main.yml:
--------------------------------------------------------------------------------
1 | # This is a basic workflow to help you get started with Actions
2 |
3 | name: main
4 |
5 | # Controls when the action will run.
6 | on:
7 | push:
8 | branches: [main]
9 |
10 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel
11 | jobs:
12 | test-build-and-deploy:
13 | runs-on: ubuntu-latest
14 | environment: github-pages
15 |
16 | # Steps represent a sequence of tasks that will be executed as part of the job
17 | steps:
18 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
19 | - uses: actions/checkout@v2
20 |
21 | - name: Setup Node.js environment
22 | uses: actions/setup-node@v2.1.4
23 | with:
24 | node-version: 12.13.0
25 |
26 | - name: Install JS dependencies
27 | run: yarn install
28 |
29 | - name: Install JS dependencies
30 | run: yarn test
31 |
32 | - name: Build
33 | run: yarn build
34 | env:
35 | NEXT_PUBLIC_GOOGLE_ANALYTICS: ${{ secrets.NEXT_PUBLIC_GOOGLE_ANALYTICS }}
36 |
37 | - name: Prevent GitHub from using Jekyll
38 | run: touch out/.nojekyll
39 |
40 | - name: Deploy 🚀
41 | uses: JamesIves/github-pages-deploy-action@3.7.1
42 | with:
43 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
44 | BRANCH: gh-pages
45 | FOLDER: out
46 |
--------------------------------------------------------------------------------
/.vscode/launch.json:
--------------------------------------------------------------------------------
1 | {
2 | // Use IntelliSense to learn about possible attributes.
3 | // Hover to view descriptions of existing attributes.
4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
5 | "version": "0.2.0",
6 | "configurations": [
7 | {
8 | "name": "Start devserver",
9 | "request": "launch",
10 | "runtimeArgs": ["dev"],
11 | "runtimeExecutable": "yarn",
12 | "skipFiles": ["/**"],
13 | "type": "node"
14 | },
15 | {
16 | "type": "node",
17 | "request": "attach",
18 | "name": "Attach debugger",
19 | "skipFiles": ["/**"],
20 | "port": 9229
21 | },
22 | {
23 | "name": "Run all Jest Tests",
24 | "type": "node",
25 | "request": "launch",
26 | "runtimeArgs": [
27 | "--inspect-brk",
28 | "${workspaceRoot}/node_modules/.bin/jest",
29 | "--runInBand"
30 | ],
31 | "console": "integratedTerminal",
32 | "internalConsoleOptions": "neverOpen",
33 | "port": 9229
34 | },
35 | {
36 | "name": "Run Jest Tests in File",
37 | "type": "node",
38 | "request": "launch",
39 | "runtimeArgs": [
40 | "--inspect-brk",
41 | "${workspaceRoot}/node_modules/.bin/jest",
42 | "--runInBand",
43 | "${file}"
44 | ],
45 | "console": "integratedTerminal",
46 | "internalConsoleOptions": "neverOpen",
47 | "port": 9229
48 | }
49 | ],
50 | "compounds": [
51 | {
52 | "name": "Local",
53 | "configurations": ["Start devserver", "Attach debugger"]
54 | }
55 | ]
56 | }
57 |
--------------------------------------------------------------------------------
/layout/Footer.jsx:
--------------------------------------------------------------------------------
1 | const Footer = () => (
2 |
26 | );
27 |
28 | export default Footer;
29 |
--------------------------------------------------------------------------------
/components/MigrationEditor/AddColumnsForm.jsx:
--------------------------------------------------------------------------------
1 | import { useMap } from "react-use";
2 | import parseMigrationFormat from "../../helpers/parseMigrationFormat";
3 |
4 | const AddColumnsForm = ({ initialName, onChange }) => {
5 | const [_format, initialNameParts] = parseMigrationFormat(initialName);
6 | const [nameParts, { set: setNamePart }] = useMap(
7 | Object.assign({ columnsName: "", tableName: "" }, initialNameParts)
8 | );
9 |
10 | const handleChange = (changedNameParts) => {
11 | const newNameParts = Object.assign(nameParts, changedNameParts);
12 | // not sure why setAll doesn't trigger a re-render if the value is changed to ""
13 | Object.entries(changedNameParts).forEach(([key, value]) =>
14 | setNamePart(key, value)
15 | );
16 |
17 | // TODO: show this validation to users
18 | if (newNameParts.columnsName && newNameParts.tableName) {
19 | onChange(`Add${newNameParts.columnsName}To${newNameParts.tableName}`);
20 | }
21 | };
22 |
23 | return (
24 |
44 | );
45 | };
46 |
47 | export default AddColumnsForm;
48 |
--------------------------------------------------------------------------------
/components/PrecisionScaleConfig.jsx:
--------------------------------------------------------------------------------
1 | function PrecisionScaleConfig({ value, onChange }) {
2 | const [precision, scale] = value.replace(/{|}/g, "").split(",");
3 |
4 | // changes - Object with any keys in: precision, scale
5 | const updatePrecisionAndScale = (changes) => {
6 | const newData = Object.assign({ precision, scale }, changes);
7 | onChange(`{${newData.precision},${newData.scale}}`);
8 | };
9 |
10 | return (
11 |
12 |
46 |
47 | Note: depending on your shell, you may need to wrap this in quotes, see
48 |
53 | issue
54 | .
55 |
56 |
57 | );
58 | }
59 |
60 | export default PrecisionScaleConfig;
61 |
--------------------------------------------------------------------------------
/components/MigrationEditor/RemoveColumnsForm.jsx:
--------------------------------------------------------------------------------
1 | import { useMap } from "react-use";
2 | import parseMigrationFormat from "../../helpers/parseMigrationFormat";
3 |
4 | const RemoveColumnsForm = ({ initialName, onChange }) => {
5 | const [_format, initialNameParts] = parseMigrationFormat(initialName);
6 | const [nameParts, { set: setNamePart }] = useMap(
7 | Object.assign({ columnsName: "", tableName: "" }, initialNameParts)
8 | );
9 |
10 | const handleChange = (changedNameParts) => {
11 | const newNameParts = Object.assign(nameParts, changedNameParts);
12 | // not sure why setAll doesn't trigger a re-render if the value is changed to ""
13 | Object.entries(changedNameParts).forEach(([key, value]) =>
14 | setNamePart(key, value)
15 | );
16 |
17 | // TODO: show this validation to users
18 | if (newNameParts.columnsName && newNameParts.tableName) {
19 | onChange(
20 | `Remove${newNameParts.columnsName}From${newNameParts.tableName}`
21 | );
22 | }
23 | };
24 |
25 | return (
26 |
46 | );
47 | };
48 |
49 | export default RemoveColumnsForm;
50 |
--------------------------------------------------------------------------------
/components/Heading.jsx:
--------------------------------------------------------------------------------
1 | import { ChevronLeftIcon, ChevronRightIcon } from "@heroicons/react/solid";
2 | import Link from "next/link";
3 |
4 | export const Heading = ({ title }) => {
5 | return (
6 |
7 |
8 |
9 |
10 |
11 |
15 | Back
16 |
17 |
18 |
19 |
20 |
21 |
22 |
29 |
30 |
31 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 | {title}
51 |
52 |
53 |
54 |
55 | );
56 | };
57 |
--------------------------------------------------------------------------------
/components/Pill.jsx:
--------------------------------------------------------------------------------
1 | export default function Pill({
2 | heading,
3 | text,
4 | onClick,
5 | baseColor = "green",
6 | borderStyle = "solid",
7 | editable = true, // TODO: refactor to rightIcon
8 | leftIcon,
9 | selected,
10 | }) {
11 | /** purgecss: border-dashed */
12 | /** purgecss: border-blue-400 bg-blue-100 bg-blue-200 text-blue-800 hover:bg-blue-200 text-blue-500 */
13 | /** purgecss: border-yellow-400 bg-yellow-100 bg-yellow-200 text-yellow-800 hover:bg-yellow-200 text-yellow-500 */
14 | /** purgecss: border-gray-400 bg-gray-100 bg-gray-200 text-gray-800 hover:bg-gray-200 text-gray-500 */
15 | /** purgecss: border-green-400 bg-green-100 bg-green-200 text-green-800 hover:bg-green-200 text-green-500 */
16 | const defaultStyles = `border bg-${baseColor}-100`;
17 | const selectedStyles = `border-2 bg-${baseColor}-200 shadow-lg`;
18 |
19 | return (
20 |
27 |
33 | {heading}
34 |
35 |
36 | {/** Not sure why I need a fake svg to get alignment working */}
37 | {leftIcon || }
38 | {text || "......"}
39 | {editable && (
40 |
46 |
47 |
48 | )}
49 |
50 |
51 | );
52 | }
53 |
--------------------------------------------------------------------------------
/components/MigrationEditor/index.jsx:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 | import { MIGRATION_FORMATS } from "../../helpers/constants";
3 | import parseMigrationFormat from "../../helpers/parseMigrationFormat";
4 | import AddColumnsForm from "./AddColumnsForm";
5 | import CustomMigrationForm from "./CustomMigrationForm";
6 | import JoinTableForm from "./JoinTableForm";
7 | import RemoveColumnsForm from "./RemoveColumnsForm";
8 |
9 | /**
10 | * Factory that returns the right Form component based on format, and passes props to it.
11 | */
12 | const MigrationForm = ({ format, ...props }) => {
13 | let Component = CustomMigrationForm;
14 | switch (format) {
15 | case MIGRATION_FORMATS.ADD_COLUMNS:
16 | Component = AddColumnsForm;
17 | break;
18 | case MIGRATION_FORMATS.REMOVE_COLUMNS:
19 | Component = RemoveColumnsForm;
20 | break;
21 | case MIGRATION_FORMATS.JOIN_TABLE:
22 | Component = JoinTableForm;
23 | break;
24 | }
25 |
26 | return ;
27 | };
28 |
29 | const MigrationEditor = ({
30 | initialName = "",
31 | format = MIGRATION_FORMATS.CUSTOM,
32 | onChangeName,
33 | onChangeFormat,
34 | }) => {
35 | return (
36 |
37 |
38 | Edit Migration
39 |
40 |
41 |
42 |
46 | Format
47 |
48 | onChangeFormat(e.target.value)}
53 | >
54 | {Object.values(MIGRATION_FORMATS).map((type) => (
55 | {type}
56 | ))}
57 |
58 |
59 |
64 |
65 |
66 | );
67 | };
68 | export default MigrationEditor;
69 |
--------------------------------------------------------------------------------
/styles/Home.module.css:
--------------------------------------------------------------------------------
1 | .container {
2 | min-height: 100vh;
3 | padding: 0 0.5rem;
4 | display: flex;
5 | flex-direction: column;
6 | justify-content: center;
7 | align-items: center;
8 | }
9 |
10 | .main {
11 | padding: 5rem 0;
12 | flex: 1;
13 | display: flex;
14 | flex-direction: column;
15 | justify-content: center;
16 | }
17 |
18 | .footer {
19 | width: 100%;
20 | height: 100px;
21 | border-top: 1px solid #eaeaea;
22 | display: flex;
23 | justify-content: center;
24 | align-items: center;
25 | }
26 |
27 | .footer img {
28 | margin-left: 0.5rem;
29 | }
30 |
31 | .footer a {
32 | display: flex;
33 | justify-content: center;
34 | align-items: center;
35 | }
36 |
37 | .title a {
38 | color: #0070f3;
39 | text-decoration: none;
40 | }
41 |
42 | .title a:hover,
43 | .title a:focus,
44 | .title a:active {
45 | text-decoration: underline;
46 | }
47 |
48 | .title {
49 | margin: 0;
50 | line-height: 1.15;
51 | font-size: 4rem;
52 | }
53 |
54 | .description {
55 | text-align: center;
56 | }
57 |
58 | .description {
59 | line-height: 1.5;
60 | font-size: 1.5rem;
61 | }
62 |
63 | .code {
64 | background: #fafafa;
65 | border-radius: 5px;
66 | padding: 0.75rem;
67 | font-size: 1.1rem;
68 | font-family: Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono,
69 | Bitstream Vera Sans Mono, Courier New, monospace;
70 | }
71 |
72 | .grid {
73 | display: flex;
74 | align-items: center;
75 | justify-content: center;
76 | flex-wrap: wrap;
77 | max-width: 800px;
78 | margin-top: 3rem;
79 | }
80 |
81 | .card {
82 | margin: 1rem;
83 | flex-basis: 45%;
84 | padding: 1.5rem;
85 | text-align: left;
86 | color: inherit;
87 | text-decoration: none;
88 | border: 1px solid #eaeaea;
89 | border-radius: 10px;
90 | transition: color 0.15s ease, border-color 0.15s ease;
91 | }
92 |
93 | .card:hover,
94 | .card:focus,
95 | .card:active {
96 | color: #0070f3;
97 | border-color: #0070f3;
98 | }
99 |
100 | .card h3 {
101 | margin: 0 0 1rem 0;
102 | font-size: 1.5rem;
103 | }
104 |
105 | .card p {
106 | margin: 0;
107 | font-size: 1.25rem;
108 | line-height: 1.5;
109 | }
110 |
111 | .logo {
112 | height: 1em;
113 | }
114 |
115 | @media (max-width: 600px) {
116 | .grid {
117 | width: 100%;
118 | flex-direction: column;
119 | }
120 | }
121 |
--------------------------------------------------------------------------------
/components/JoinTableEditor/index.jsx:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 |
3 | const JoinTableEditor = ({ initialValue = "", onUpdate, selectedIndex }) => {
4 | const [initialTableName, initialIndexType] = initialValue.split(":");
5 | const [tableName, setTableName] = useState(initialTableName);
6 | const [indexType, setIndexType] = useState(initialIndexType || "");
7 |
8 | const updateField = (changes) => {
9 | if (changes.tableName) {
10 | setTableName(changes.tableName);
11 | }
12 | if (changes.indexType) {
13 | setIndexType(changes.indexType);
14 | }
15 |
16 | const newData = Object.assign({ tableName, indexType }, changes);
17 | if (newData.tableName) {
18 | onUpdate(
19 | `${newData.tableName}${newData.indexType && ":" + newData.indexType}`
20 | );
21 | }
22 | };
23 |
24 | return (
25 |
26 |
27 | Edit Table {selectedIndex}
28 |
29 |
30 |
31 |
35 | Name
36 |
37 | updateField({ tableName: e.target.value })}
42 | className="text-input focus:outline-none focus:ring-gray-900 focus:border-gray-900"
43 | />
44 |
45 |
46 |
47 | Index Type
48 | updateField({ indexType: e.target.value })}
52 | >
53 | -- optional --
54 | {["uniq", "index"].map((indexType) => (
55 |
56 | {indexType}
57 |
58 | ))}
59 |
60 |
61 |
62 |
63 | );
64 | };
65 |
66 | export default JoinTableEditor;
67 |
--------------------------------------------------------------------------------
/components/FieldInput.jsx:
--------------------------------------------------------------------------------
1 | import FieldTypeInput from "./FieldTypeInput";
2 |
3 | export default function FieldInput({ value, onUpdate, onDelete }) {
4 | const [fieldName, fieldType = "", indexType = ""] = value.split(":");
5 |
6 | // changes - Object with any keys in: fieldName, fieldType, indexType
7 | const updateField = (changes) => {
8 | const newData = Object.assign({ fieldName, fieldType, indexType }, changes);
9 | const requiredValues = [newData.fieldName, newData.fieldType];
10 | const optionalValue = newData.indexType;
11 | const values = !!optionalValue ? [...requiredValues, optionalValue] : requiredValues;
12 | onUpdate(values.join(":"));
13 | };
14 |
15 | return (
16 |
17 |
18 |
22 | Name
23 |
24 | updateField({ fieldName: e.target.value })}
29 | className="text-input focus:outline-none focus:ring-gray-900 focus:border-gray-900"
30 | />
31 |
32 |
33 | updateField({ fieldType: value })}
36 | />
37 |
38 |
39 |
40 | Index Type
41 | updateField({ indexType: e.target.value })}
45 | >
46 | -- optional --
47 | {["uniq", "index"].map((indexType) => (
48 |
49 | {indexType}
50 |
51 | ))}
52 |
53 |
54 |
55 |
59 | Delete
60 |
61 |
62 |
63 | );
64 | }
65 |
--------------------------------------------------------------------------------
/components/FieldTypeInput.jsx:
--------------------------------------------------------------------------------
1 | import LimitConfig from "./LimitConfig";
2 | import ReferencesConfig from "./ReferencesConfig";
3 | import FieldTypeSplitter from "../helpers/FieldTypeSplitter";
4 | import PrecisionScaleConfig from "./PrecisionScaleConfig";
5 |
6 | const FieldTypeConfig = ({ type, config, onChange }) => {
7 | switch (type) {
8 | // TODO: figure out how to clear values when swapping
9 | case "references":
10 | return ;
11 | case "integer":
12 | case "string":
13 | case "text":
14 | case "binary":
15 | return ;
16 | case "decimal":
17 | return ;
18 | default:
19 | return null;
20 | }
21 | };
22 |
23 | /**
24 | * Component for setting the FieldType.
25 | *
26 | * @param {Object} options
27 | * @param {string} options.value - e.g. "references{polymorphic}"
28 | * @param {function} options.onChange
29 | * @returns
30 | */
31 |
32 | export default function FieldTypeInput({ value, onChange }) {
33 | const [type, config] = new FieldTypeSplitter({ text: value }).split();
34 |
35 | const updateFieldType = ({ newType, newConfig }) => {
36 | onChange(`${newType || type}${newConfig ?? config}`);
37 | };
38 |
39 | return (
40 |
41 |
42 | Field Type
43 |
44 |
45 | Mostly the SQL column type, with some exceptions.
46 | {/* Select an option to find out more! */}
47 |
48 |
53 | updateFieldType({ newType: e.target.value, newConfig: "" })
54 | }
55 | >
56 |
57 | -- required --
58 |
59 | {[
60 | "primary_key",
61 | "float",
62 | "boolean",
63 | "date",
64 | "time",
65 | "datetime",
66 | "references",
67 | "digest",
68 | "token",
69 | "integer",
70 | "string",
71 | "text",
72 | "binary",
73 | "decimal",
74 | ].map((type) => (
75 | {type}
76 | ))}
77 |
78 |
updateFieldType({ newConfig: value })}
82 | />
83 |
84 | );
85 | }
86 |
--------------------------------------------------------------------------------
/helpers/parseMigrationFormat.test.js:
--------------------------------------------------------------------------------
1 | import { MIGRATION_FORMATS } from './constants';
2 | import parseMigrationFormat from './parseMigrationFormat';
3 |
4 | describe('parseMigrationFormat()', () => {
5 | it('parses AddColumnsToTable format correctly', () => {
6 | expect(parseMigrationFormat('AddXYZToABC')).toEqual([
7 | MIGRATION_FORMATS.ADD_COLUMNS,
8 | {
9 | columnsName: "XYZ",
10 | tableName: "ABC"
11 | }
12 | ]);
13 | expect(parseMigrationFormat('Add123ToABC')).toEqual([
14 | MIGRATION_FORMATS.ADD_COLUMNS,
15 | {
16 | columnsName: "123",
17 | tableName: "ABC",
18 | }
19 | ]);
20 | expect(parseMigrationFormat('AddTo')[0]).not.toEqual(MIGRATION_FORMATS.ADD_COLUMNS);
21 | expect(parseMigrationFormat('AddXYZTo')[0]).not.toEqual(MIGRATION_FORMATS.ADD_COLUMNS);
22 | expect(parseMigrationFormat('AddToABC')[0]).not.toEqual(MIGRATION_FORMATS.ADD_COLUMNS);
23 | });
24 |
25 | it('parses RemoveColumnsFromTable format correctly', () => {
26 | expect(parseMigrationFormat('RemoveXYZFromABC')).toEqual([
27 | MIGRATION_FORMATS.REMOVE_COLUMNS,
28 | {
29 | columnsName: "XYZ",
30 | tableName: "ABC",
31 | }
32 | ]);
33 | expect(parseMigrationFormat('Remove123FromABC')).toEqual([
34 | MIGRATION_FORMATS.REMOVE_COLUMNS,
35 | {
36 | columnsName: "123",
37 | tableName: "ABC",
38 | }
39 | ]);
40 | expect(parseMigrationFormat('RemoveFrom')[0]).not.toEqual(MIGRATION_FORMATS.REMOVE_COLUMNS);
41 | expect(parseMigrationFormat('RemoveXYZFrom')[0]).not.toEqual(MIGRATION_FORMATS.REMOVE_COLUMNS);
42 | expect(parseMigrationFormat('RemoveFromABC')[0]).not.toEqual(MIGRATION_FORMATS.REMOVE_COLUMNS);
43 | });
44 |
45 | it('parses CreateModelJoinTable formats correctly', () => {
46 | expect(parseMigrationFormat('123JoinTable')).toEqual([
47 | MIGRATION_FORMATS.JOIN_TABLE,
48 | {
49 | sourceTable: "123",
50 | }
51 | ]);
52 | // TODO: review if this is the actual behaviour in the generator
53 | expect(parseMigrationFormat('JoinTable')).toEqual([
54 | MIGRATION_FORMATS.JOIN_TABLE,
55 | {
56 | sourceTable: null,
57 | }
58 | ]);
59 | expect(parseMigrationFormat('CreateXYZJoinTable')).toEqual([
60 | MIGRATION_FORMATS.JOIN_TABLE,
61 | {
62 | sourceTable: "XYZ",
63 | }
64 | ]);
65 | expect(parseMigrationFormat('JoinABCTable')[0]).not.toEqual(MIGRATION_FORMATS.JOIN_TABLE);
66 | });
67 |
68 | it('returns Custom Migration for other formats', () => {
69 | expect(parseMigrationFormat('HelloThere')).toEqual([MIGRATION_FORMATS.CUSTOM, { name: "HelloThere" }]);
70 | });
71 |
72 | it('returns Custom Migration if input is empty string', () => {
73 | expect(parseMigrationFormat('')).toEqual([MIGRATION_FORMATS.CUSTOM, { name: "" }]);
74 | });
75 | });
76 |
--------------------------------------------------------------------------------
/__tests__/pages/migration.test.jsx:
--------------------------------------------------------------------------------
1 | /**
2 | * @jest-environment jsdom
3 | */
4 |
5 | import { render, screen, waitFor } from "@testing-library/react";
6 | import userEvent from "@testing-library/user-event";
7 | import { MIGRATION_FORMATS } from "../../helpers/constants";
8 | import MigrationPage from "../../pages/g/migration";
9 |
10 | it("renders", async () => {
11 | render( );
12 |
13 | expect(screen.getByText(/^bin\/rails g migration/)).toBeTruthy();
14 | expect(screen.getByText("AddExampleColumnsToExampleTable")).toBeTruthy();
15 | });
16 |
17 | it("sets the Migration", async () => {
18 | render( );
19 |
20 | const migrationButton = screen.getByText("AddExampleColumnsToExampleTable");
21 | userEvent.click(migrationButton);
22 |
23 | const input = screen.getAllByRole("textbox")[0];
24 | userEvent.clear(input);
25 | userEvent.type(input, "DifferentColumns");
26 | // What's a better matcher for this?
27 | expect(screen.getByText("AddDifferentColumnsToExampleTable")).toBeTruthy();
28 | });
29 |
30 | it("sets a field", async () => {
31 | render( );
32 |
33 | const addAttributeButton = screen.getByText("+ Attribute");
34 | userEvent.click(addAttributeButton);
35 |
36 | await waitFor(() => screen.getByText("Edit Attribute 1"));
37 | const attributeNameInput = screen.getByLabelText("Name");
38 | userEvent.type(attributeNameInput, "engine");
39 |
40 | const fieldTypeInput = screen.getAllByRole("combobox")[0];
41 | userEvent.selectOptions(fieldTypeInput, "references");
42 |
43 | const indexTypeInput = screen.getAllByRole("combobox")[1];
44 | userEvent.selectOptions(indexTypeInput, "index");
45 |
46 | expect(screen.getByText("engine:references:index")).toBeTruthy();
47 | });
48 |
49 | it("toggles args", async () => {
50 | render( );
51 | let migrationButton = screen.getByText("AddExampleColumnsToExampleTable");
52 | userEvent.click(migrationButton);
53 | expect(screen.queryByText("Edit Migration")).toBeTruthy();
54 |
55 | migrationButton = screen.getByText("AddExampleColumnsToExampleTable");
56 | userEvent.click(migrationButton);
57 | expect(screen.queryByText("Edit Migration")).toBeNull();
58 | });
59 |
60 | it("swaps between editors", async () => {
61 | render( );
62 |
63 | const modelButton = screen.getByText("AddExampleColumnsToExampleTable");
64 | userEvent.click(modelButton);
65 | expect(screen.queryByText("Edit Migration")).toBeTruthy();
66 |
67 | const attributeButton = screen.getByText("+ Attribute");
68 | userEvent.click(attributeButton);
69 | expect(screen.queryByText("Edit Attribute 1")).toBeTruthy();
70 | });
71 |
72 | it("copies command", async () => {
73 | Object.assign(navigator, {
74 | clipboard: {
75 | writeText: () => {},
76 | },
77 | });
78 | jest.spyOn(navigator.clipboard, "writeText");
79 |
80 | render( ); // refactor to take initialArgs for a more deterministic test
81 |
82 | const copyButton = screen.getByText("Copy");
83 | userEvent.click(copyButton);
84 | expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
85 | "bin/rails g migration AddExampleColumnsToExampleTable other_model:references"
86 | );
87 | });
88 |
89 | it("copies the right arguments when format is changed", async () => {
90 | Object.assign(navigator, {
91 | clipboard: {
92 | writeText: () => {},
93 | },
94 | });
95 | jest.spyOn(navigator.clipboard, "writeText");
96 |
97 | render( ); // refactor to take initialArgs for a more deterministic test
98 | const migrationButton = screen.getByText("AddExampleColumnsToExampleTable");
99 | userEvent.click(migrationButton);
100 |
101 | const formatDropdown = screen.getByLabelText("Format");
102 | userEvent.selectOptions(formatDropdown, MIGRATION_FORMATS.CUSTOM);
103 |
104 | const copyButton = screen.getByText("Copy");
105 | userEvent.click(copyButton);
106 | expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
107 | "bin/rails g migration AddExampleColumnsToExampleTable"
108 | );
109 | });
110 |
--------------------------------------------------------------------------------
/__tests__/pages/model.test.jsx:
--------------------------------------------------------------------------------
1 | /**
2 | * @jest-environment jsdom
3 | */
4 |
5 | import { render, screen, waitFor } from "@testing-library/react";
6 | import userEvent from "@testing-library/user-event";
7 | import ModelPage from "../../pages/g/model";
8 |
9 | it("renders", async () => {
10 | render( );
11 |
12 | expect(screen.getByText("rails.help")).toBeTruthy();
13 | expect(screen.getByText(/^bin\/rails g/)).toBeTruthy();
14 | expect(screen.getByText("ExampleModel")).toBeTruthy();
15 | expect(
16 | screen.getByText("other_model:references{polymorphic}:uniq")
17 | ).toBeTruthy();
18 | });
19 |
20 | it("sets the model", async () => {
21 | render( );
22 |
23 | const modelButton = screen.getByText("ExampleModel");
24 | userEvent.click(modelButton);
25 |
26 | await waitFor(() => screen.getByLabelText("Name"));
27 | const modelInput = screen.getByLabelText("Name");
28 | userEvent.clear(modelInput);
29 | userEvent.type(modelInput, "car");
30 | // What's a better matcher for this?
31 | expect(screen.getByText("car")).toBeTruthy();
32 | });
33 |
34 | it("sets a field", async () => {
35 | render( );
36 |
37 | const addAttributeButton = screen.getByText("+ Attribute");
38 | userEvent.click(addAttributeButton);
39 |
40 | await waitFor(() => screen.getByText("Edit Attribute 1"));
41 | const attributeNameInput = screen.getByLabelText("Name");
42 | userEvent.type(attributeNameInput, "engine");
43 |
44 | const fieldTypeInput = screen.getAllByRole("combobox")[0];
45 | userEvent.selectOptions(fieldTypeInput, "references");
46 |
47 | const indexTypeInput = screen.getAllByRole("combobox")[1];
48 | userEvent.selectOptions(indexTypeInput, "index");
49 |
50 | expect(screen.getByText("engine:references:index")).toBeTruthy();
51 | });
52 |
53 | it("sets the parent", async () => {
54 | render( );
55 |
56 | const button = screen.getByText("--parent");
57 | userEvent.click(button);
58 |
59 | await waitFor(() => screen.getByText("Edit Parent Model"));
60 | const nameInput = screen.getByLabelText("Name");
61 | userEvent.type(nameInput, "Woohoo");
62 |
63 | expect(screen.getByText("--parent Woohoo")).toBeTruthy();
64 | });
65 |
66 | it("toggles args", async () => {
67 | render( );
68 | let modelButton = screen.getByText("ExampleModel");
69 | userEvent.click(modelButton);
70 | expect(screen.queryByText("Edit Model")).toBeTruthy();
71 |
72 | modelButton = screen.getByText("ExampleModel");
73 | userEvent.click(modelButton);
74 | expect(screen.queryByText("Edit Model")).toBeNull();
75 | });
76 |
77 | it("swaps between editors", async () => {
78 | render( );
79 |
80 | const modelButton = screen.getByText("ExampleModel");
81 | userEvent.click(modelButton);
82 | expect(screen.queryByText("Edit Model")).toBeTruthy();
83 | expect(screen.queryByText(/Edit Attribute/)).toBeNull();
84 | expect(screen.queryByText("Edit Parent Model")).toBeNull();
85 |
86 | const attributeButton = screen.getByText("+ Attribute");
87 | userEvent.click(attributeButton);
88 | expect(screen.queryByText("Edit Model")).toBeNull();
89 | expect(screen.queryByText("Edit Attribute 1")).toBeTruthy();
90 | expect(screen.queryByText("Edit Parent Model")).toBeNull();
91 |
92 | const parentButton = screen.getByText("--parent");
93 | userEvent.click(parentButton);
94 | expect(screen.queryByText("Edit Model")).toBeNull();
95 | expect(screen.queryByText(/Edit Attribute/)).toBeNull();
96 | expect(screen.queryByText("Edit Parent Model")).toBeTruthy();
97 | });
98 |
99 | it("copies command", async () => {
100 | Object.assign(navigator, {
101 | clipboard: {
102 | writeText: () => {},
103 | },
104 | });
105 | jest.spyOn(navigator.clipboard, "writeText");
106 |
107 | render( ); // refactor to take initialArgs for a more deterministic test
108 |
109 | const copyButton = screen.getByText("Copy");
110 | userEvent.click(copyButton);
111 | expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
112 | "bin/rails g model ExampleModel other_model:references{polymorphic}:uniq"
113 | );
114 | });
115 |
--------------------------------------------------------------------------------
/pages/index.js:
--------------------------------------------------------------------------------
1 | import Head from "next/head"
2 | import Link from 'next/link'
3 |
4 | import Footer from "../layout/Footer";
5 |
6 | export default function Home() {
7 | return (
8 |
9 |
10 | {/* */}
11 |
Rails Generators GUI | rails.help
12 |
13 |
14 |
15 | {/* */}
16 |
17 |
18 |
19 |
20 |
21 | {/* */}
22 |
23 |
24 |
25 |
26 |
27 |
28 | {/* */}
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
42 |
43 | The missing GUI for Rails Generators.
44 |
45 |
46 |
47 |
48 |
49 |
84 |
85 |
86 |
87 |
88 | )
89 | }
90 |
--------------------------------------------------------------------------------
/components/MigrationEditor/index.test.jsx:
--------------------------------------------------------------------------------
1 | /**
2 | * @jest-environment jsdom
3 | */
4 |
5 | import { render, screen } from "@testing-library/react";
6 | import userEvent from "@testing-library/user-event";
7 | import { MIGRATION_FORMATS } from "../../helpers/constants";
8 | import MigrationEditor from ".";
9 |
10 | it("shows the format dropdown only", async () => {
11 | render( );
12 |
13 | const formatDropdown = screen.getByLabelText("Format");
14 | expect(formatDropdown.value).toBe(MIGRATION_FORMATS.CUSTOM);
15 |
16 | const selects = screen.getAllByRole("combobox");
17 | expect(selects.length).toEqual(1);
18 |
19 | const inputs = screen.getAllByRole("textbox");
20 | expect(inputs.length).toEqual(1);
21 | });
22 |
23 | it("switches the format", async () => {
24 | const mockHandleChangeFormat = jest.fn();
25 | render( );
26 |
27 | const formatDropdown = screen.getByLabelText("Format");
28 | userEvent.selectOptions(formatDropdown, MIGRATION_FORMATS.ADD_COLUMNS);
29 |
30 | expect(mockHandleChangeFormat).toHaveBeenCalledWith(
31 | MIGRATION_FORMATS.ADD_COLUMNS
32 | );
33 | });
34 |
35 | describe("when initialValue and format is specified", () => {
36 | it("prefills the correct format and data", () => {
37 | render(
38 |
42 | );
43 |
44 | // shows AddColumnsToTable editor
45 | expect(screen.getByTestId("add-columns-name")).toHaveValue("ABC");
46 | expect(screen.getByTestId("add-to-table-name")).toHaveValue("XYZ");
47 | });
48 | });
49 |
50 | describe("when AddColumnsToTable is selected", () => {
51 | it("shows inputs for column and table names", async () => {
52 | render( );
53 |
54 | expect(screen.getByTestId("add-columns-name")).toBeTruthy();
55 | expect(screen.getByTestId("add-to-table-name")).toBeTruthy();
56 | });
57 |
58 | it("updates parent component when inputs are updated", async () => {
59 | let value = "";
60 | const handleChange = (newValue) => {
61 | value = newValue;
62 | };
63 |
64 | render(
65 |
69 | );
70 |
71 | const columnsInput = screen.getByTestId("add-columns-name");
72 | userEvent.type(columnsInput, "NewColumn");
73 | const tableInput = screen.getByTestId("add-to-table-name");
74 | userEvent.type(tableInput, "ExistingTable");
75 | expect(value).toEqual("AddNewColumnToExistingTable");
76 | });
77 | });
78 |
79 | describe("when RemoveColumnsFromTable is selected", () => {
80 | it("shows inputs for column and table names", async () => {
81 | render( );
82 |
83 | expect(screen.getByTestId("remove-columns-name")).toBeTruthy();
84 | expect(screen.getByTestId("remove-from-table-name")).toBeTruthy();
85 | });
86 |
87 | it("updates parent component when inputs are updated", async () => {
88 | let value = "";
89 | const handleChange = (newValue) => {
90 | value = newValue;
91 | };
92 |
93 | render(
94 |
98 | );
99 |
100 | const columnsInput = screen.getByTestId("remove-columns-name");
101 | userEvent.type(columnsInput, "ExistingColumn");
102 | const tableInput = screen.getByTestId("remove-from-table-name");
103 | userEvent.type(tableInput, "ExistingTable");
104 | expect(value).toEqual("RemoveExistingColumnFromExistingTable");
105 | });
106 | });
107 |
108 | describe("when CreateJoinTable is selected", () => {
109 | it("shows inputs for column and table names", async () => {
110 | render( );
111 |
112 | expect(screen.getByTestId("migration-prefix")).toBeTruthy();
113 | });
114 |
115 | it("updates parent component when inputs are updated", async () => {
116 | let value = "";
117 | const handleChange = (newValue) => {
118 | value = newValue;
119 | };
120 |
121 | render(
122 |
126 | );
127 |
128 | const columnsInput = screen.getByTestId("migration-prefix");
129 | userEvent.type(columnsInput, "CreateMedia");
130 | expect(value).toEqual("CreateMediaJoinTable");
131 | });
132 | });
133 |
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | /*
2 | * For a detailed explanation regarding each configuration property, visit:
3 | * https://jestjs.io/docs/en/configuration.html
4 | */
5 |
6 | module.exports = {
7 | // All imported modules in your tests should be mocked automatically
8 | // automock: false,
9 |
10 | // Stop running tests after `n` failures
11 | // bail: 0,
12 |
13 | // The directory where Jest should store its cached dependency information
14 | // cacheDirectory: "/private/var/folders/jn/qxgfffhj59s15xl6lvk63tdh0000gn/T/jest_dx",
15 |
16 | // Automatically clear mock calls and instances between every test
17 | // clearMocks: false,
18 |
19 | // Indicates whether the coverage information should be collected while executing the test
20 | // collectCoverage: false,
21 |
22 | // An array of glob patterns indicating a set of files for which coverage information should be collected
23 | collectCoverageFrom: [
24 | '**/*.{js,jsx,ts,tsx}',
25 | '!**/*.d.ts',
26 | '!**/node_modules/**',
27 | ],
28 |
29 | // The directory where Jest should output its coverage files
30 | // coverageDirectory: undefined,
31 |
32 | // An array of regexp pattern strings used to skip coverage collection
33 | // coveragePathIgnorePatterns: [
34 | // "/node_modules/"
35 | // ],
36 |
37 | // Indicates which provider should be used to instrument code for coverage
38 | coverageProvider: "v8",
39 |
40 | // A list of reporter names that Jest uses when writing coverage reports
41 | // coverageReporters: [
42 | // "json",
43 | // "text",
44 | // "lcov",
45 | // "clover"
46 | // ],
47 |
48 | // An object that configures minimum threshold enforcement for coverage results
49 | // coverageThreshold: undefined,
50 |
51 | // A path to a custom dependency extractor
52 | // dependencyExtractor: undefined,
53 |
54 | // Make calling deprecated APIs throw helpful error messages
55 | // errorOnDeprecated: false,
56 |
57 | // Force coverage collection from ignored files using an array of glob patterns
58 | // forceCoverageMatch: [],
59 |
60 | // A path to a module which exports an async function that is triggered once before all test suites
61 | // globalSetup: undefined,
62 |
63 | // A path to a module which exports an async function that is triggered once after all test suites
64 | // globalTeardown: undefined,
65 |
66 | // A set of global variables that need to be available in all test environments
67 | // globals: {},
68 |
69 | // The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers.
70 | // maxWorkers: "50%",
71 |
72 | // An array of directory names to be searched recursively up from the requiring module's location
73 | // moduleDirectories: [
74 | // "node_modules"
75 | // ],
76 |
77 | // An array of file extensions your modules use
78 | // moduleFileExtensions: [
79 | // "js",
80 | // "json",
81 | // "jsx",
82 | // "ts",
83 | // "tsx",
84 | // "node"
85 | // ],
86 |
87 | // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module
88 | moduleNameMapper: {
89 | '^.+\\.module\\.(css|sass|scss)$': 'identity-obj-proxy',
90 | },
91 |
92 | // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader
93 | // modulePathIgnorePatterns: [],
94 |
95 | // Activates notifications for test results
96 | // notify: false,
97 |
98 | // An enum that specifies notification mode. Requires { notify: true }
99 | // notifyMode: "failure-change",
100 |
101 | // A preset that is used as a base for Jest's configuration
102 | // preset: undefined,
103 |
104 | // Run tests from one or more projects
105 | // projects: undefined,
106 |
107 | // Use this configuration option to add custom reporters to Jest
108 | // reporters: undefined,
109 |
110 | // Automatically reset mock state between every test
111 | // resetMocks: false,
112 |
113 | // Reset the module registry before running each individual test
114 | // resetModules: false,
115 |
116 | // A path to a custom resolver
117 | // resolver: undefined,
118 |
119 | // Automatically restore mock state between every test
120 | // restoreMocks: false,
121 |
122 | // The root directory that Jest should scan for tests and modules within
123 | // rootDir: undefined,
124 |
125 | // A list of paths to directories that Jest should use to search for files in
126 | // roots: [
127 | // ""
128 | // ],
129 |
130 | // Allows you to use a custom runner instead of Jest's default test runner
131 | // runner: "jest-runner",
132 |
133 | // The paths to modules that run some code to configure or set up the testing environment before each test
134 | // setupFiles: [],
135 |
136 | // A list of paths to modules that run some code to configure or set up the testing framework before each test
137 | setupFilesAfterEnv: ['/setupTests.js'],
138 |
139 | // The number of seconds after which a test is considered as slow and reported as such in the results.
140 | // slowTestThreshold: 5,
141 |
142 | // A list of paths to snapshot serializer modules Jest should use for snapshot testing
143 | // snapshotSerializers: [],
144 |
145 | // The test environment that will be used for testing
146 | testEnvironment: "node",
147 |
148 | // Options that will be passed to the testEnvironment
149 | // testEnvironmentOptions: {},
150 |
151 | // Adds a location field to test results
152 | // testLocationInResults: false,
153 |
154 | // The glob patterns Jest uses to detect test files
155 | // testMatch: [
156 | // "**/__tests__/**/*.[jt]s?(x)",
157 | // "**/?(*.)+(spec|test).[tj]s?(x)"
158 | // ],
159 |
160 | // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
161 | testPathIgnorePatterns: ['/node_modules/', '/.next/'],
162 |
163 | // The regexp pattern or array of patterns that Jest uses to detect test files
164 | // testRegex: [],
165 |
166 | // This option allows the use of a custom results processor
167 | // testResultsProcessor: undefined,
168 |
169 | // This option allows use of a custom test runner
170 | // testRunner: "jasmine2",
171 |
172 | // This option sets the URL for the jsdom environment. It is reflected in properties such as location.href
173 | // testURL: "http://localhost",
174 |
175 | // Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout"
176 | // timers: "real",
177 |
178 | // A map from regular expressions to paths to transformers
179 | transform: {
180 | '^.+\\.(js|jsx|ts|tsx)$': '/node_modules/babel-jest',
181 | '^.+\\.css$': '/config/jest/cssTransform.js',
182 | },
183 |
184 | // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
185 | transformIgnorePatterns: [
186 | '/node_modules/',
187 | '^.+\\.module\\.(css|sass|scss)$',
188 | ],
189 |
190 | // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them
191 | // unmockedModulePathPatterns: undefined,
192 |
193 | // Indicates whether each individual test should be reported during the run
194 | // verbose: undefined,
195 |
196 | // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode
197 | // watchPathIgnorePatterns: [],
198 |
199 | // Whether to use watchman for file crawling
200 | // watchman: true,
201 | };
202 |
--------------------------------------------------------------------------------
/pages/g/model.jsx:
--------------------------------------------------------------------------------
1 | import Head from "next/head";
2 | import { useState } from "react";
3 | import { useList } from "react-use";
4 |
5 | import FieldInput from "../../components/FieldInput";
6 | import Pill from "../../components/Pill";
7 | import CopyButton from "../../components/CopyButton";
8 | import Footer from "../../layout/Footer";
9 | import Header from "../../layout/Header";
10 | import { Heading } from "../../components/Heading";
11 |
12 | const ModelEditor = ({ value, onChange }) => (
13 |
14 |
Edit Model
15 |
16 |
20 | Name
21 |
22 |
23 | CamelCased or under_scored
24 |
25 |
onChange(e.target.value)}
31 | />
32 |
33 |
34 | );
35 |
36 | const ParentEditor = ({ value, onChange }) => (
37 |
38 |
39 | Edit Parent Model
40 |
41 |
42 | The is the optional superclass of the created model, used for Single Table
43 | Inheritance models.
44 |
45 |
46 |
50 | Name
51 |
52 |
53 | CamelCased or under_scored
54 |
55 |
onChange(e.target.value)}
61 | />
62 |
63 |
64 | );
65 |
66 | const ArgButton = ({ arg, index, selectedArg, setSelectedArg, insertArg }) => {
67 | const toggleSelectedArg = (index) => {
68 | if (selectedArg == index) {
69 | setSelectedArg(null);
70 | } else {
71 | setSelectedArg(index);
72 | }
73 | };
74 |
75 | const insertAndToggleArg = (index) => {
76 | setSelectedArg(index);
77 | insertArg(index, { type: argTypes.ATTRIBUTE, value: "" });
78 | };
79 |
80 | switch (arg.type) {
81 | case argTypes.MODEL:
82 | return (
83 | toggleSelectedArg(index)}
87 | selected={selectedArg === index}
88 | baseColor="yellow"
89 | />
90 | );
91 | case argTypes.ATTRIBUTE:
92 | return (
93 | toggleSelectedArg(index)}
97 | selected={selectedArg === index}
98 | baseColor="blue"
99 | />
100 | );
101 | // this one is less like the others >_<
102 | case argTypes.ADD_ATTRIBUTE:
103 | return (
104 | insertAndToggleArg(index)}
109 | editable={false}
110 | />
111 | );
112 | case argTypes.PARENT:
113 | return (
114 | toggleSelectedArg(index)}
120 | />
121 | );
122 | }
123 | };
124 |
125 | const argTypes = {
126 | MODEL: "model",
127 | ATTRIBUTE: "attribute",
128 | ADD_ATTRIBUTE: "add_attribute",
129 | PARENT: "parent",
130 | };
131 |
132 | export default function ModelPage() {
133 | const [
134 | args,
135 | { updateAt: updateArg, removeAt: removeArg, insertAt: insertArg },
136 | ] = useList([
137 | { type: argTypes.MODEL, value: "ExampleModel" },
138 | {
139 | type: argTypes.ATTRIBUTE,
140 | value: "other_model:references{polymorphic}:uniq",
141 | },
142 | { type: argTypes.ADD_ATTRIBUTE, value: null }, // hack for + Attribute button
143 | { type: argTypes.PARENT, value: "" },
144 | ]);
145 | const [selectedArg, setSelectedArg] = useState(null);
146 |
147 | const cliCommand = ["bin/rails g model", ...args.map((a) => a.value)]
148 | .filter((text) => !!text)
149 | .join(" ");
150 |
151 | const deleteArg = (index) => {
152 | setSelectedArg(null);
153 | removeArg(index);
154 | };
155 |
156 | const renderEditor = (selectedArg) => {
157 | switch (args[selectedArg].type) {
158 | case argTypes.MODEL:
159 | return (
160 |
161 |
164 | updateArg(selectedArg, { type: argTypes.MODEL, value })
165 | }
166 | />
167 |
168 | );
169 | case argTypes.ATTRIBUTE:
170 | return (
171 |
172 |
173 | Edit Attribute {selectedArg - 1}
174 |
175 |
178 | updateArg(selectedArg, { type: argTypes.ATTRIBUTE, value })
179 | }
180 | onDelete={() => deleteArg(selectedArg)}
181 | />
182 |
183 | );
184 | case argTypes.PARENT:
185 | return (
186 |
187 |
190 | updateArg(selectedArg, { type: argTypes.PARENT, value })
191 | }
192 | />
193 |
194 | );
195 | }
196 | };
197 |
198 | return (
199 |
200 |
201 | {/* */}
202 |
Rails Generators GUI | rails.help
203 |
204 |
208 |
209 | {/* */}
210 |
211 |
215 |
216 |
220 |
221 | {/* */}
222 |
223 |
227 |
231 |
235 |
236 |
237 |
238 |
239 |
240 |
241 |
242 |
243 |
244 |
245 |
246 |
247 | {/** mt-5 is a hack to mimic items-baseline, not sure why leftIcon messes that up */}
248 | bin/rails g model
249 | {args.map((arg, index) => (
250 |
259 | ))}
260 |
261 |
262 |
263 |
264 |
265 | {selectedArg != null && (
266 |
267 |
268 | {renderEditor(selectedArg)}
269 |
270 |
271 | )}
272 |
273 |
274 |
275 |
276 |
277 | );
278 | }
279 |
--------------------------------------------------------------------------------
/pages/g/migration.jsx:
--------------------------------------------------------------------------------
1 | import Head from "next/head";
2 | import { useState } from "react";
3 | import { useMap } from "react-use";
4 |
5 | import FieldInput from "../../components/FieldInput";
6 | import Pill from "../../components/Pill";
7 | import MigrationEditor from "../../components/MigrationEditor";
8 | import CopyButton from "../../components/CopyButton";
9 | import Footer from "../../layout/Footer";
10 | import Header from "../../layout/Header";
11 | import parseMigrationFormat from "../../helpers/parseMigrationFormat";
12 | import { MIGRATION_FORMATS } from "../../helpers/constants";
13 | import JoinTableEditor from "../../components/JoinTableEditor";
14 | import { Heading } from "../../components/Heading";
15 |
16 | const AttributeArguments = ({
17 | attributes,
18 | onAppend,
19 | onSelect,
20 | selectedIndex,
21 | }) => {
22 | return (
23 | <>
24 | {attributes.map((arg, index) => (
25 | onSelect(index)}
30 | selected={selectedIndex == index}
31 | baseColor="blue"
32 | />
33 | ))}
34 |
41 | >
42 | );
43 | };
44 |
45 | const JoinTableArguments = ({ args, onSelect, selectedIndex }) => {
46 | return (
47 | <>
48 | onSelect(0)}
52 | selected={selectedIndex == 0}
53 | baseColor="blue"
54 | />
55 | onSelect(1)}
59 | baseColor="blue"
60 | selected={selectedIndex == 1}
61 | />
62 | >
63 | );
64 | };
65 |
66 | const initialMigrationName = "AddExampleColumnsToExampleTable";
67 | const [initialFormat] = parseMigrationFormat(initialMigrationName);
68 |
69 | export default function MigrationPage() {
70 | const [selectedKey, setSelectedKey] = useState(null);
71 | const [selectedIndex, setSelectedIndex] = useState(0);
72 | const [migrationData, { set: setMigrationData }] = useMap({
73 | format: initialFormat,
74 | name: initialMigrationName,
75 | arguments: ["other_model:references"],
76 | });
77 |
78 | const cliCommand = [
79 | "bin/rails g migration",
80 | migrationData.name,
81 | ...migrationData.arguments,
82 | ]
83 | .filter((text) => !!text)
84 | .join(" ");
85 |
86 | const toggleKey = (key) => {
87 | if (selectedKey == "name") {
88 | setSelectedKey(null);
89 | } else {
90 | setSelectedKey(key);
91 | }
92 | };
93 |
94 | const toggleArg = (index) => {
95 | if (selectedKey == "arguments" && selectedIndex == index) {
96 | setSelectedKey(null);
97 | setSelectedIndex(0);
98 | } else {
99 | setSelectedKey("arguments");
100 | setSelectedIndex(index);
101 | }
102 | };
103 |
104 | const setArg = (index, value) => {
105 | let newArguments = migrationData.arguments.slice();
106 | newArguments[index] = value;
107 |
108 | setMigrationData("arguments", newArguments);
109 | };
110 |
111 | const addArg = () => {
112 | setSelectedIndex(migrationData.arguments.length);
113 | setSelectedKey("arguments");
114 | setMigrationData("arguments", migrationData.arguments.concat([""]));
115 | };
116 |
117 | const deleteArg = (index) => {
118 | let newArguments = [
119 | ...migrationData.arguments.slice(0, index),
120 | ...migrationData.arguments.slice(index + 1),
121 | ];
122 |
123 | setSelectedKey(null);
124 | setSelectedIndex(0);
125 | setMigrationData("arguments", newArguments);
126 | };
127 |
128 | const handleChangeFormat = (newFormat) => {
129 | console.log(newFormat);
130 | switch (newFormat) {
131 | case MIGRATION_FORMATS.JOIN_TABLE:
132 | setMigrationData("arguments", ["", ""]);
133 | break;
134 |
135 | default:
136 | setMigrationData("arguments", []);
137 | break;
138 | }
139 | setMigrationData("format", newFormat);
140 | };
141 |
142 | const renderEditor = (key, index) => {
143 | switch (key) {
144 | case "name":
145 | return (
146 |
147 | setMigrationData("name", name)}
152 | />
153 |
154 | );
155 | case "arguments":
156 | if (
157 | [
158 | MIGRATION_FORMATS.ADD_COLUMNS,
159 | MIGRATION_FORMATS.REMOVE_COLUMNS,
160 | ].includes(migrationData.format)
161 | ) {
162 | return (
163 |
164 |
165 | Edit Attribute {selectedIndex}
166 |
167 | setArg(index, value)}
170 | onDelete={() => deleteArg(index)}
171 | />
172 |
173 | );
174 | } else {
175 | return (
176 |
183 | setArg(index, value)}
186 | selectedIndex={selectedIndex}
187 | />
188 |
189 | );
190 | }
191 | }
192 | };
193 |
194 | return (
195 |
196 |
197 | {/* */}
198 |
Rails Migration Generator GUI | rails.help
199 |
200 |
204 |
205 | {/* */}
206 |
207 |
211 |
212 |
216 |
217 | {/* */}
218 |
219 |
223 |
227 |
231 |
232 |
233 |
234 |
235 |
236 |
237 |
238 |
239 |
240 |
241 |
242 |
243 | {/** mt-5 is a hack to mimic items-baseline, not sure why leftIcon messes that up */}
244 | bin/rails g migration
245 | toggleKey("name")}
249 | selected={selectedKey == "name"}
250 | baseColor="yellow"
251 | />
252 | {[
253 | MIGRATION_FORMATS.ADD_COLUMNS,
254 | MIGRATION_FORMATS.REMOVE_COLUMNS,
255 | ].includes(migrationData.format) ? (
256 |
264 | ) : MIGRATION_FORMATS.JOIN_TABLE == migrationData.format ? (
265 |
272 | ) : null}
273 |
274 |
275 |
276 |
277 |
278 | {selectedKey != null && (
279 |
280 |
281 | {renderEditor(selectedKey, selectedIndex)}
282 |
283 |
284 | )}
285 |
286 |
287 |
288 |
289 | );
290 | }
291 |
--------------------------------------------------------------------------------