From b5794b3d7b2c582ae9d298c5af9978918b817597 Mon Sep 17 00:00:00 2001 From: David Thyresson Date: Fri, 21 May 2021 11:06:24 -0400 Subject: [PATCH] Adds option to cell generation for lists of a given model (#2569) * Adds list option to cell generation * Renders an unordered list * Fixes unordered list success rendering * Update snapshots/ schema issue. Destroy cell fails * Get to green on cell destroy * Refactored plural cell generator Co-authored-by: Daniel Choudhury * Update packages/cli/src/commands/generate/helpers.js Co-authored-by: Tobbe Lundberg * Force pluralisation is now many{Something} Co-authored-by: Daniel Choudhury Co-authored-by: Daniel Choudhury Co-authored-by: Tobbe Lundberg --- .../destroy/cell/__tests__/cell.test.js | 7 +- .../__tests__/__snapshots__/cell.test.js.snap | 130 +++++++++++++-- .../generate/cell/__tests__/cell.test.js | 157 +++++++++++++++++- .../cell/__tests__/fixtures/schema.prisma | 25 +++ .../cli/src/commands/generate/cell/cell.js | 82 +++++++-- .../generate/cell/templates/cell.js.template | 4 +- .../cell/templates/cellList.js.template | 27 +++ .../cell/templates/mockList.js.template | 8 + packages/cli/src/commands/generate/helpers.js | 20 +++ 9 files changed, 433 insertions(+), 27 deletions(-) create mode 100644 packages/cli/src/commands/generate/cell/__tests__/fixtures/schema.prisma create mode 100644 packages/cli/src/commands/generate/cell/templates/cellList.js.template create mode 100644 packages/cli/src/commands/generate/cell/templates/mockList.js.template diff --git a/packages/cli/src/commands/destroy/cell/__tests__/cell.test.js b/packages/cli/src/commands/destroy/cell/__tests__/cell.test.js index d1a67ba7c2f6..9fe034e3c787 100644 --- a/packages/cli/src/commands/destroy/cell/__tests__/cell.test.js +++ b/packages/cli/src/commands/destroy/cell/__tests__/cell.test.js @@ -1,4 +1,5 @@ global.__dirname = __dirname + jest.mock('fs') jest.mock('src/lib', () => { return { @@ -33,7 +34,11 @@ afterEach(() => { test('destroys cell files', async () => { const unlinkSpy = jest.spyOn(fs, 'unlinkSync') - const t = tasks({ componentName: 'cell', filesFn: files, name: 'User' }) + const t = tasks({ + componentName: 'cell', + filesFn: files, + name: 'User', + }) t.setRenderer('silent') return t.run().then(() => { diff --git a/packages/cli/src/commands/generate/cell/__tests__/__snapshots__/cell.test.js.snap b/packages/cli/src/commands/generate/cell/__tests__/__snapshots__/cell.test.js.snap index 4d51e2371568..6c3ab0119c4b 100644 --- a/packages/cli/src/commands/generate/cell/__tests__/__snapshots__/cell.test.js.snap +++ b/packages/cli/src/commands/generate/cell/__tests__/__snapshots__/cell.test.js.snap @@ -1,9 +1,61 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`"equipment" with list flag 1`] = ` +"export const QUERY = gql\` + query ManyEquipmentQuery { + manyEquipment { + id + } + } +\` + +export const Loading = () =>
Loading...
+ +export const Empty = () =>
Empty
+ +export const Failure = ({ error }) => ( +
Error: {error.message}
+) + +export const Success = ({ manyEquipment }) => { + return ( +
    + {manyEquipment.map((item) => { + return
  • {JSON.stringify(item)}
  • + })} +
+ ) +} +" +`; + +exports[`"equipment" withOUT list flag should find equipment by id 1`] = ` +"export const QUERY = gql\` + query FindEquipmentQuery($id: !) { + equipment: equipment(id: $id) { + id + } + } +\` + +export const Loading = () =>
Loading...
+ +export const Empty = () =>
Empty
+ +export const Failure = ({ error }) => ( +
Error: {error.message}
+) + +export const Success = ({ equipment }) => { + return
{JSON.stringify(equipment)}
+} +" +`; + exports[`creates a cell component with a camelCase word name 1`] = ` "export const QUERY = gql\` - query UserProfileQuery { - userProfile { + query FindUserProfileQuery($id: Int!) { + userProfile: userProfile(id: $id) { id } } @@ -25,8 +77,8 @@ export const Success = ({ userProfile }) => { exports[`creates a cell component with a kebabCase word name 1`] = ` "export const QUERY = gql\` - query UserProfileQuery { - userProfile { + query FindUserProfileQuery($id: Int!) { + userProfile: userProfile(id: $id) { id } } @@ -48,8 +100,8 @@ export const Success = ({ userProfile }) => { exports[`creates a cell component with a multi word name 1`] = ` "export const QUERY = gql\` - query UserProfileQuery { - userProfile { + query FindUserProfileQuery($id: Int!) { + userProfile: userProfile(id: $id) { id } } @@ -71,8 +123,8 @@ export const Success = ({ userProfile }) => { exports[`creates a cell component with a single word name 1`] = ` "export const QUERY = gql\` - query UserQuery { - user { + query FindUserQuery($id: Int!) { + user: user(id: $id) { id } } @@ -94,8 +146,8 @@ export const Success = ({ user }) => { exports[`creates a cell component with a snakeCase word name 1`] = ` "export const QUERY = gql\` - query UserProfileQuery { - userProfile { + query FindUserProfileQuery($id: Int!) { + userProfile: userProfile(id: $id) { id } } @@ -474,3 +526,61 @@ describe('UserProfileCell', () => { }) " `; + +exports[`generates list cells if list flag passed in 1`] = ` +"export const QUERY = gql\` + query MembersQuery { + members { + id + } + } +\` + +export const Loading = () =>
Loading...
+ +export const Empty = () =>
Empty
+ +export const Failure = ({ error }) => ( +
Error: {error.message}
+) + +export const Success = ({ members }) => { + return ( +
    + {members.map((item) => { + return
  • {JSON.stringify(item)}
  • + })} +
+ ) +} +" +`; + +exports[`generates list cells if name is plural 1`] = ` +"export const QUERY = gql\` + query MembersQuery { + members { + id + } + } +\` + +export const Loading = () =>
Loading...
+ +export const Empty = () =>
Empty
+ +export const Failure = ({ error }) => ( +
Error: {error.message}
+) + +export const Success = ({ members }) => { + return ( +
    + {members.map((item) => { + return
  • {JSON.stringify(item)}
  • + })} +
+ ) +} +" +`; diff --git a/packages/cli/src/commands/generate/cell/__tests__/cell.test.js b/packages/cli/src/commands/generate/cell/__tests__/cell.test.js index e0c6ededc24a..d393f3a11510 100644 --- a/packages/cli/src/commands/generate/cell/__tests__/cell.test.js +++ b/packages/cli/src/commands/generate/cell/__tests__/cell.test.js @@ -20,48 +20,87 @@ let singleWordFiles, camelCaseWordFiles, withoutTestFiles, withoutStoryFiles, - withoutTestAndStoryFiles + withoutTestAndStoryFiles, + listFlagPassedIn, + listInferredFromName, + modelPluralMatchesSingularWithList, + modelPluralMatchesSingularWithoutList beforeAll(async () => { singleWordFiles = await cell.files({ name: 'User', tests: true, stories: true, + list: false, }) multiWordFiles = await cell.files({ name: 'UserProfile', tests: true, stories: true, + list: false, }) snakeCaseWordFiles = await cell.files({ name: 'user_profile', tests: true, stories: true, + list: false, }) kebabCaseWordFiles = await cell.files({ name: 'user-profile', tests: true, stories: true, + list: false, }) camelCaseWordFiles = await cell.files({ name: 'userProfile', tests: true, stories: true, + list: false, }) withoutTestFiles = await cell.files({ name: 'User', tests: false, stories: true, + list: false, }) withoutStoryFiles = await cell.files({ name: 'User', tests: true, stories: false, + list: false, }) withoutTestAndStoryFiles = await cell.files({ name: 'User', tests: false, stories: false, + list: false, + }) + + listFlagPassedIn = await cell.files({ + name: 'Member', + tests: true, + stories: true, + list: true, + }) + + listInferredFromName = await cell.files({ + name: 'Members', + tests: true, + stories: true, + }) + + modelPluralMatchesSingularWithList = await cell.files({ + name: 'equipment', + tests: true, + stories: true, + list: true, + }) + + modelPluralMatchesSingularWithoutList = await cell.files({ + name: 'equipment', + tests: true, + stories: true, + list: false, }) }) @@ -301,3 +340,119 @@ test("doesn't include storybook and test files when --stories and --tests is set path.normalize('/path/to/project/web/src/components/UserCell/UserCell.js'), ]) }) + +test('generates list cells if list flag passed in', () => { + const CELL_PATH = path.normalize( + '/path/to/project/web/src/components/MembersCell/MembersCell.js' + ) + + const TEST_PATH = path.normalize( + '/path/to/project/web/src/components/MembersCell/MembersCell.test.js' + ) + + const STORY_PATH = path.normalize( + '/path/to/project/web/src/components/MembersCell/MembersCell.stories.js' + ) + + const MOCK_PATH = path.normalize( + '/path/to/project/web/src/components/MembersCell/MembersCell.mock.js' + ) + + // Check the file names + expect(Object.keys(listFlagPassedIn)).toEqual([ + MOCK_PATH, + TEST_PATH, + STORY_PATH, + CELL_PATH, + ]) + + // Check the contents + expect(listFlagPassedIn[CELL_PATH]).toMatchSnapshot() +}) + +test('generates list cells if name is plural', () => { + const CELL_PATH = path.normalize( + '/path/to/project/web/src/components/MembersCell/MembersCell.js' + ) + + const TEST_PATH = path.normalize( + '/path/to/project/web/src/components/MembersCell/MembersCell.test.js' + ) + + const STORY_PATH = path.normalize( + '/path/to/project/web/src/components/MembersCell/MembersCell.stories.js' + ) + + const MOCK_PATH = path.normalize( + '/path/to/project/web/src/components/MembersCell/MembersCell.mock.js' + ) + + // Check the file names + expect(Object.keys(listInferredFromName)).toEqual([ + MOCK_PATH, + TEST_PATH, + STORY_PATH, + CELL_PATH, + ]) + + // Check the contents + expect(listInferredFromName[CELL_PATH]).toMatchSnapshot() +}) + +test('"equipment" with list flag', () => { + const CELL_PATH = path.normalize( + '/path/to/project/web/src/components/ManyEquipmentCell/ManyEquipmentCell.js' + ) + + const TEST_PATH = path.normalize( + '/path/to/project/web/src/components/ManyEquipmentCell/ManyEquipmentCell.test.js' + ) + + const STORY_PATH = path.normalize( + '/path/to/project/web/src/components/ManyEquipmentCell/ManyEquipmentCell.stories.js' + ) + + const MOCK_PATH = path.normalize( + '/path/to/project/web/src/components/ManyEquipmentCell/ManyEquipmentCell.mock.js' + ) + + // Check the file names + expect(Object.keys(modelPluralMatchesSingularWithList)).toEqual([ + MOCK_PATH, + TEST_PATH, + STORY_PATH, + CELL_PATH, + ]) + + // Check the contents + expect(modelPluralMatchesSingularWithList[CELL_PATH]).toMatchSnapshot() +}) + +test('"equipment" withOUT list flag should find equipment by id', () => { + const CELL_PATH = path.normalize( + '/path/to/project/web/src/components/EquipmentCell/EquipmentCell.js' + ) + + const TEST_PATH = path.normalize( + '/path/to/project/web/src/components/EquipmentCell/EquipmentCell.test.js' + ) + + const STORY_PATH = path.normalize( + '/path/to/project/web/src/components/EquipmentCell/EquipmentCell.stories.js' + ) + + const MOCK_PATH = path.normalize( + '/path/to/project/web/src/components/EquipmentCell/EquipmentCell.mock.js' + ) + + // Check the file names + expect(Object.keys(modelPluralMatchesSingularWithoutList)).toEqual([ + MOCK_PATH, + TEST_PATH, + STORY_PATH, + CELL_PATH, + ]) + + // Check the contents + expect(modelPluralMatchesSingularWithoutList[CELL_PATH]).toMatchSnapshot() +}) diff --git a/packages/cli/src/commands/generate/cell/__tests__/fixtures/schema.prisma b/packages/cli/src/commands/generate/cell/__tests__/fixtures/schema.prisma new file mode 100644 index 000000000000..7990b7b80909 --- /dev/null +++ b/packages/cli/src/commands/generate/cell/__tests__/fixtures/schema.prisma @@ -0,0 +1,25 @@ +datasource db { + provider = "postgresql" + url = env("DB_HOST") +} + +generator client { + provider = "prisma-client-js" +} + +// Define your own models here and run yarn db:save to create +// migrations for them. +model User { + id Int @id @default(autoincrement()) + name String? + email String @unique + isAdmin Boolean @default(false) + profiles UserProfile[] +} + +model UserProfile { + id Int @id @default(autoincrement()) + username String @unique + userId Int + user User @relation(fields: [userId], references: [id]) +} diff --git a/packages/cli/src/commands/generate/cell/cell.js b/packages/cli/src/commands/generate/cell/cell.js index 1630aa1c28bd..e9a299f47310 100644 --- a/packages/cli/src/commands/generate/cell/cell.js +++ b/packages/cli/src/commands/generate/cell/cell.js @@ -1,8 +1,14 @@ import pascalcase from 'pascalcase' +import pluralize from 'pluralize' +import { getSchema } from 'src/lib' + +import { yargsDefaults } from '../../generate' import { templateForComponentFile, createYargsForComponentGeneration, + forcePluralizeWord, + isWordNonPluralizable, } from '../helpers' const COMPONENT_SUFFIX = 'Cell' @@ -18,17 +24,27 @@ const getCellOperationNames = async () => { .filter(Boolean) } -const uniqueOperationName = async (name, index = 1) => { - let operationName = - index <= 1 - ? `${pascalcase(name)}Query` - : `${pascalcase(name)}Query_${index}` +const uniqueOperationName = async (name, { index = 1, list = false }) => { + let operationName = pascalcase( + index <= 1 ? `find_${name}_query` : `find_${name}_query_${index}` + ) + + if (list) { + operationName = + index <= 1 + ? `${pascalcase(name)}Query` + : `${pascalcase(name)}Query_${index}` + } const cellOperationNames = await getCellOperationNames() if (!cellOperationNames.includes(operationName)) { return operationName } - return uniqueOperationName(name, index + 1) + return uniqueOperationName(name, { index: index + 1 }) +} + +const getIdType = (model) => { + return model.fields.find((field) => field.isId)?.type } export const files = async ({ @@ -36,43 +52,73 @@ export const files = async ({ typescript: generateTypescript, ...options }) => { + let cellName = name + let idType, + model = null + let templateNameSuffix = '' + // Create a unique operation name. - const operationName = await uniqueOperationName(name) + + const shouldGenerateList = + (isWordNonPluralizable(name) ? options.list : pluralize.isPlural(name)) || + options.list + + if (shouldGenerateList) { + cellName = forcePluralizeWord(name) + templateNameSuffix = 'List' + // override operationName so that its find_operationName + } else { + // needed for the singular cell GQL query find by id case + try { + model = await getSchema(pascalcase(pluralize.singular(name))) + idType = getIdType(model) + } catch { + // eat error so that the destroy cell generator doesn't raise when try to find prisma query engine in test runs + } + } + + const operationName = await uniqueOperationName(cellName, { + list: shouldGenerateList, + }) const cellFile = templateForComponentFile({ - name, + name: cellName, suffix: COMPONENT_SUFFIX, extension: generateTypescript ? '.tsx' : '.js', webPathSection: REDWOOD_WEB_PATH_NAME, generator: 'cell', - templatePath: 'cell.js.template', + templatePath: `cell${templateNameSuffix}.js.template`, templateVars: { operationName, + idType, }, }) + const testFile = templateForComponentFile({ - name, + name: cellName, suffix: COMPONENT_SUFFIX, extension: generateTypescript ? '.test.tsx' : '.test.js', webPathSection: REDWOOD_WEB_PATH_NAME, generator: 'cell', templatePath: 'test.js.template', }) + const storiesFile = templateForComponentFile({ - name, + name: cellName, suffix: COMPONENT_SUFFIX, extension: generateTypescript ? '.stories.tsx' : '.stories.js', webPathSection: REDWOOD_WEB_PATH_NAME, generator: 'cell', templatePath: 'stories.js.template', }) + const mockFile = templateForComponentFile({ - name, + name: cellName, suffix: COMPONENT_SUFFIX, extension: generateTypescript ? '.mock.ts' : '.mock.js', webPathSection: REDWOOD_WEB_PATH_NAME, generator: 'cell', - templatePath: 'mock.js.template', + templatePath: `mock${templateNameSuffix}.js.template`, }) const files = [cellFile] @@ -106,4 +152,14 @@ export const { command, description, builder, handler } = createYargsForComponentGeneration({ componentName: 'cell', filesFn: files, + optionsObj: { + ...yargsDefaults, + list: { + alias: 'l', + default: false, + description: + 'Use when you want to generate a cell for a list of the model name.', + type: 'boolean', + }, + }, }) diff --git a/packages/cli/src/commands/generate/cell/templates/cell.js.template b/packages/cli/src/commands/generate/cell/templates/cell.js.template index 55bf43df055e..d85134758086 100644 --- a/packages/cli/src/commands/generate/cell/templates/cell.js.template +++ b/packages/cli/src/commands/generate/cell/templates/cell.js.template @@ -1,6 +1,6 @@ export const QUERY = gql` - query ${operationName} { - ${camelName} { + query ${operationName}($id: ${idType}!) { + ${camelName}: ${camelName}(id: $id) { id } } diff --git a/packages/cli/src/commands/generate/cell/templates/cellList.js.template b/packages/cli/src/commands/generate/cell/templates/cellList.js.template new file mode 100644 index 000000000000..8be01ea26279 --- /dev/null +++ b/packages/cli/src/commands/generate/cell/templates/cellList.js.template @@ -0,0 +1,27 @@ +export const QUERY = gql` + query ${operationName} { + ${camelName} { + id + } + } +` + +export const Loading = () =>
Loading...
+ +export const Empty = () =>
Empty
+ +export const Failure = ({ error }) =>
Error: {error.message}
+ +export const Success = ({ ${camelName} }) => { + return ( +
    + {${camelName}.map((item) => { + return ( +
  • + {JSON.stringify(item)} +
  • + ) + })} +
+ ) +} diff --git a/packages/cli/src/commands/generate/cell/templates/mockList.js.template b/packages/cli/src/commands/generate/cell/templates/mockList.js.template new file mode 100644 index 000000000000..fc646c574870 --- /dev/null +++ b/packages/cli/src/commands/generate/cell/templates/mockList.js.template @@ -0,0 +1,8 @@ +// Define your own mock data here: +export const standard = (/* vars, { ctx, req } */) => ({ + ${camelName}: [ + { id: 42 }, + { id: 43 }, + { id: 44 } + ] + }) diff --git a/packages/cli/src/commands/generate/helpers.js b/packages/cli/src/commands/generate/helpers.js index ea79c34250f2..4ed3ed6f7dab 100644 --- a/packages/cli/src/commands/generate/helpers.js +++ b/packages/cli/src/commands/generate/helpers.js @@ -3,6 +3,7 @@ import path from 'path' import Listr from 'listr' import { paramCase } from 'param-case' import pascalcase from 'pascalcase' +import pluralize from 'pluralize' import terminalLink from 'terminal-link' import { ensurePosixPath, getConfig } from '@redwoodjs/internal' @@ -170,3 +171,22 @@ export const intForeignKeysForModel = (model) => { .filter((f) => f.name.match(/Id$/) && f.type === 'Int') .map((f) => f.name) } + +export const isWordNonPluralizable = (word) => { + return pluralize.isPlural(word) === pluralize.isSingular(word) +} + +/** + * Adds an s if it can't pluralize the word + */ +export const forcePluralizeWord = (word) => { + // If word is already plural, check if plural === singular, then add s + // else use plural + const shouldAddS = isWordNonPluralizable(word) // equipment === equipment + + if (shouldAddS) { + return pascalcase(`many_${word}`) + } + + return pluralize.plural(word) +}