diff --git a/__fixtures__/test-project/api/db/migrations/20220913212918_create_post_user/migration.sql b/__fixtures__/test-project/api/db/migrations/20220918091756_create_post_user/migration.sql similarity index 100% rename from __fixtures__/test-project/api/db/migrations/20220913212918_create_post_user/migration.sql rename to __fixtures__/test-project/api/db/migrations/20220918091756_create_post_user/migration.sql diff --git a/__fixtures__/test-project/api/db/migrations/20220913212929_create_contact/migration.sql b/__fixtures__/test-project/api/db/migrations/20220918091807_create_contact/migration.sql similarity index 100% rename from __fixtures__/test-project/api/db/migrations/20220913212929_create_contact/migration.sql rename to __fixtures__/test-project/api/db/migrations/20220918091807_create_contact/migration.sql diff --git a/__fixtures__/test-project/api/src/services/posts/posts.scenarios.ts b/__fixtures__/test-project/api/src/services/posts/posts.scenarios.ts index 42cc9a13d002..db7e35d2dd4a 100644 --- a/__fixtures__/test-project/api/src/services/posts/posts.scenarios.ts +++ b/__fixtures__/test-project/api/src/services/posts/posts.scenarios.ts @@ -10,7 +10,7 @@ export const standard = defineScenario({ body: 'String', author: { create: { - email: 'String8713726', + email: 'String4857147', hashedPassword: 'String', fullName: 'String', salt: 'String', @@ -24,7 +24,7 @@ export const standard = defineScenario({ body: 'String', author: { create: { - email: 'String8610091', + email: 'String1125871', hashedPassword: 'String', fullName: 'String', salt: 'String', diff --git a/__fixtures__/test-project/api/src/services/users/users.scenarios.ts b/__fixtures__/test-project/api/src/services/users/users.scenarios.ts index c27ef5149da3..26207f6aaaad 100644 --- a/__fixtures__/test-project/api/src/services/users/users.scenarios.ts +++ b/__fixtures__/test-project/api/src/services/users/users.scenarios.ts @@ -6,7 +6,7 @@ export const standard = defineScenario({ user: { one: { data: { - email: 'String7466649', + email: 'String4815975', hashedPassword: 'String', fullName: 'String', salt: 'String', @@ -14,7 +14,7 @@ export const standard = defineScenario({ }, two: { data: { - email: 'String5806082', + email: 'String3376651', hashedPassword: 'String', fullName: 'String', salt: 'String', diff --git a/__fixtures__/test-project/web/package.json b/__fixtures__/test-project/web/package.json index 67fa4a3522b5..f21062076f02 100644 --- a/__fixtures__/test-project/web/package.json +++ b/__fixtures__/test-project/web/package.json @@ -22,7 +22,7 @@ "react-dom": "17.0.2" }, "devDependencies": { - "autoprefixer": "^10.4.10", + "autoprefixer": "^10.4.11", "postcss": "^8.4.16", "postcss-loader": "^7.0.1", "prettier-plugin-tailwindcss": "^0.1.13", diff --git a/__fixtures__/test-project/web/src/components/Contact/Contact/Contact.tsx b/__fixtures__/test-project/web/src/components/Contact/Contact/Contact.tsx index 3354dbe8f3f5..9c158a46424d 100644 --- a/__fixtures__/test-project/web/src/components/Contact/Contact/Contact.tsx +++ b/__fixtures__/test-project/web/src/components/Contact/Contact/Contact.tsx @@ -1,4 +1,3 @@ -import humanize from 'humanize-string' import type { DeleteContactMutationVariables, FindContactById, @@ -8,6 +7,8 @@ import { Link, routes, navigate } from '@redwoodjs/router' import { useMutation } from '@redwoodjs/web' import { toast } from '@redwoodjs/web/toast' +import { timeTag } from 'src/lib/formatters' + const DELETE_CONTACT_MUTATION = gql` mutation DeleteContactMutation($id: Int!) { deleteContact(id: $id) { @@ -16,39 +17,6 @@ const DELETE_CONTACT_MUTATION = gql` } ` -const formatEnum = (values: string | string[] | null | undefined) => { - if (values) { - if (Array.isArray(values)) { - const humanizedValues = values.map((value) => humanize(value)) - return humanizedValues.join(', ') - } else { - return humanize(values as string) - } - } -} - -const jsonDisplay = (obj: unknown) => { - return ( -
-      {JSON.stringify(obj, null, 2)}
-    
- ) -} - -const timeTag = (datetime?: string) => { - return ( - datetime && ( - - ) - ) -} - -const checkboxInputTag = (checked: boolean) => { - return -} - interface Props { contact: NonNullable } diff --git a/__fixtures__/test-project/web/src/components/Contact/Contacts/Contacts.tsx b/__fixtures__/test-project/web/src/components/Contact/Contacts/Contacts.tsx index 405d20056d6a..15a026762614 100644 --- a/__fixtures__/test-project/web/src/components/Contact/Contacts/Contacts.tsx +++ b/__fixtures__/test-project/web/src/components/Contact/Contacts/Contacts.tsx @@ -1,4 +1,3 @@ -import humanize from 'humanize-string' import type { DeleteContactMutationVariables, FindContacts, @@ -9,6 +8,7 @@ import { useMutation } from '@redwoodjs/web' import { toast } from '@redwoodjs/web/toast' import { QUERY } from 'src/components/Contact/ContactsCell' +import { timeTag, truncate } from 'src/lib/formatters' const DELETE_CONTACT_MUTATION = gql` mutation DeleteContactMutation($id: Int!) { @@ -18,45 +18,6 @@ const DELETE_CONTACT_MUTATION = gql` } ` -const MAX_STRING_LENGTH = 150 - -const formatEnum = (values: string | string[] | null | undefined) => { - if (values) { - if (Array.isArray(values)) { - const humanizedValues = values.map((value) => humanize(value)) - return humanizedValues.join(', ') - } else { - return humanize(values as string) - } - } -} - -const truncate = (value: string | number) => { - const output = value?.toString() - if (output?.length > MAX_STRING_LENGTH) { - return output.substring(0, MAX_STRING_LENGTH) + '...' - } - return output ?? '' -} - -const jsonTruncate = (obj: unknown) => { - return truncate(JSON.stringify(obj, null, 2)) -} - -const timeTag = (datetime?: string) => { - return ( - datetime && ( - - ) - ) -} - -const checkboxInputTag = (checked: boolean) => { - return -} - const ContactsList = ({ contacts }: FindContacts) => { const [deleteContact] = useMutation(DELETE_CONTACT_MUTATION, { onCompleted: () => { diff --git a/__fixtures__/test-project/web/src/components/Post/Post/Post.tsx b/__fixtures__/test-project/web/src/components/Post/Post/Post.tsx index 115b7d6a13e8..2fc068d337fa 100644 --- a/__fixtures__/test-project/web/src/components/Post/Post/Post.tsx +++ b/__fixtures__/test-project/web/src/components/Post/Post/Post.tsx @@ -1,10 +1,11 @@ -import humanize from 'humanize-string' import type { DeletePostMutationVariables, FindPostById } from 'types/graphql' import { Link, routes, navigate } from '@redwoodjs/router' import { useMutation } from '@redwoodjs/web' import { toast } from '@redwoodjs/web/toast' +import { timeTag } from 'src/lib/formatters' + const DELETE_POST_MUTATION = gql` mutation DeletePostMutation($id: Int!) { deletePost(id: $id) { @@ -13,39 +14,6 @@ const DELETE_POST_MUTATION = gql` } ` -const formatEnum = (values: string | string[] | null | undefined) => { - if (values) { - if (Array.isArray(values)) { - const humanizedValues = values.map((value) => humanize(value)) - return humanizedValues.join(', ') - } else { - return humanize(values as string) - } - } -} - -const jsonDisplay = (obj: unknown) => { - return ( -
-      {JSON.stringify(obj, null, 2)}
-    
- ) -} - -const timeTag = (datetime?: string) => { - return ( - datetime && ( - - ) - ) -} - -const checkboxInputTag = (checked: boolean) => { - return -} - interface Props { post: NonNullable } diff --git a/__fixtures__/test-project/web/src/components/Post/Posts/Posts.tsx b/__fixtures__/test-project/web/src/components/Post/Posts/Posts.tsx index 818a85b4446d..168e24165a36 100644 --- a/__fixtures__/test-project/web/src/components/Post/Posts/Posts.tsx +++ b/__fixtures__/test-project/web/src/components/Post/Posts/Posts.tsx @@ -1,4 +1,3 @@ -import humanize from 'humanize-string' import type { DeletePostMutationVariables, FindPosts } from 'types/graphql' import { Link, routes } from '@redwoodjs/router' @@ -6,6 +5,7 @@ import { useMutation } from '@redwoodjs/web' import { toast } from '@redwoodjs/web/toast' import { QUERY } from 'src/components/Post/PostsCell' +import { timeTag, truncate } from 'src/lib/formatters' const DELETE_POST_MUTATION = gql` mutation DeletePostMutation($id: Int!) { @@ -15,45 +15,6 @@ const DELETE_POST_MUTATION = gql` } ` -const MAX_STRING_LENGTH = 150 - -const formatEnum = (values: string | string[] | null | undefined) => { - if (values) { - if (Array.isArray(values)) { - const humanizedValues = values.map((value) => humanize(value)) - return humanizedValues.join(', ') - } else { - return humanize(values as string) - } - } -} - -const truncate = (value: string | number) => { - const output = value?.toString() - if (output?.length > MAX_STRING_LENGTH) { - return output.substring(0, MAX_STRING_LENGTH) + '...' - } - return output ?? '' -} - -const jsonTruncate = (obj: unknown) => { - return truncate(JSON.stringify(obj, null, 2)) -} - -const timeTag = (datetime?: string) => { - return ( - datetime && ( - - ) - ) -} - -const checkboxInputTag = (checked: boolean) => { - return -} - const PostsList = ({ posts }: FindPosts) => { const [deletePost] = useMutation(DELETE_POST_MUTATION, { onCompleted: () => { diff --git a/__fixtures__/test-project/web/src/lib/formatters.test.tsx b/__fixtures__/test-project/web/src/lib/formatters.test.tsx new file mode 100644 index 000000000000..d4e8dc5a19c8 --- /dev/null +++ b/__fixtures__/test-project/web/src/lib/formatters.test.tsx @@ -0,0 +1,115 @@ +import { render, waitFor, screen } from '@redwoodjs/testing/web' + +import { + formatEnum, + jsonTruncate, + truncate, + timeTag, + checkboxInputTag, +} from './formatters' + +describe('formatEnum', () => { + it('handles nullish values', () => { + expect(formatEnum(null)).toEqual('') + expect(formatEnum('')).toEqual('') + expect(formatEnum(undefined)).toEqual('') + }) + + it('formats a list of values', () => { + expect( + formatEnum(['RED', 'ORANGE', 'YELLOW', 'GREEN', 'BLUE', 'VIOLET']) + ).toEqual('Red, Orange, Yellow, Green, Blue, Violet') + }) + + it('formats a single value', () => { + expect(formatEnum('DARK_BLUE')).toEqual('Dark blue') + }) + + it('returns an empty string for values of the wrong type (for JS projects)', () => { + // @ts-expect-error - Testing JS scenario + expect(formatEnum(5)).toEqual('') + }) +}) + +describe('truncate', () => { + it('truncates really long strings', () => { + expect(truncate('na '.repeat(1000) + 'batman').length).toBeLessThan(1000) + expect(truncate('na '.repeat(1000) + 'batman')).not.toMatch(/batman/) + }) + + it('does not modify short strings', () => { + expect(truncate('Short strinG')).toEqual('Short strinG') + }) + + it('adds ... to the end of truncated strings', () => { + expect(truncate('repeat'.repeat(1000))).toMatch(/\w\.\.\.$/) + }) + + it('accepts numbers', () => { + expect(truncate(123)).toEqual('123') + expect(truncate(0)).toEqual('0') + expect(truncate(0o000)).toEqual('0') + }) + + it('handles arguments of invalid type', () => { + // @ts-expect-error - Testing JS scenario + expect(truncate(false)).toEqual('false') + + expect(truncate(undefined)).toEqual('') + expect(truncate(null)).toEqual('') + }) +}) + +describe('jsonTruncate', () => { + it('truncates large json structures', () => { + expect( + jsonTruncate({ + foo: 'foo', + bar: 'bar', + baz: 'baz', + kittens: 'kittens meow', + bazinga: 'Sheldon', + nested: { + foobar: 'I have no imagination', + two: 'Second nested item', + }, + five: 5, + bool: false, + }) + ).toMatch(/.+\n.+\w\.\.\.$/s) + }) +}) + +describe('timeTag', () => { + it('renders a date', async () => { + render(
{timeTag(new Date('1970-08-20').toUTCString())}
) + + await waitFor(() => screen.getByText(/1970.*00:00:00/)) + }) + + it('can take an empty input string', async () => { + expect(timeTag('')).toEqual('') + }) +}) + +describe('checkboxInputTag', () => { + it('can be checked', () => { + render(checkboxInputTag(true)) + expect(screen.getByRole('checkbox')).toBeChecked() + }) + + it('can be unchecked', () => { + render(checkboxInputTag(false)) + expect(screen.getByRole('checkbox')).not.toBeChecked() + }) + + it('is disabled when checked', () => { + render(checkboxInputTag(true)) + expect(screen.getByRole('checkbox')).toBeDisabled() + }) + + it('is disabled when unchecked', () => { + render(checkboxInputTag(false)) + expect(screen.getByRole('checkbox')).toBeDisabled() + }) +}) diff --git a/__fixtures__/test-project/web/src/lib/formatters.tsx b/__fixtures__/test-project/web/src/lib/formatters.tsx new file mode 100644 index 000000000000..30552293578e --- /dev/null +++ b/__fixtures__/test-project/web/src/lib/formatters.tsx @@ -0,0 +1,50 @@ +import React from 'react' + +import humanize from 'humanize-string' + +const MAX_STRING_LENGTH = 150 + +export const formatEnum = (values: string | string[] | null | undefined) => { + let output = '' + + if (Array.isArray(values)) { + const humanizedValues = values.map((value) => humanize(value)) + output = humanizedValues.join(', ') + } else if (typeof values === 'string') { + output = humanize(values) + } + + return output +} + +export const truncate = (value: string | number) => { + let output = value?.toString() ?? '' + + if (output.length > MAX_STRING_LENGTH) { + output = output.substring(0, MAX_STRING_LENGTH) + '...' + } + + return output +} + +export const jsonTruncate = (obj: unknown) => { + return truncate(JSON.stringify(obj, null, 2)) +} + +export const timeTag = (dateTime?: string) => { + let output: string | JSX.Element = '' + + if (dateTime) { + output = ( + + ) + } + + return output +} + +export const checkboxInputTag = (checked: boolean) => { + return +} diff --git a/packages/cli/package.json b/packages/cli/package.json index 5cccd46f93ca..fcbe0e9f25b2 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -48,7 +48,6 @@ "execa": "5.1.1", "fast-glob": "3.2.12", "fs-extra": "10.1.0", - "humanize-string": "2.1.0", "latest-version": "5.1.0", "listr": "0.14.3", "listr-verbose-renderer": "0.6.0", diff --git a/packages/cli/src/commands/destroy/scaffold/__tests__/scaffold.test.js b/packages/cli/src/commands/destroy/scaffold/__tests__/scaffold.test.js index 0945c6999d66..202c0fcf26fb 100644 --- a/packages/cli/src/commands/destroy/scaffold/__tests__/scaffold.test.js +++ b/packages/cli/src/commands/destroy/scaffold/__tests__/scaffold.test.js @@ -10,6 +10,7 @@ import { files } from '../../../generate/scaffold/scaffold' import { tasks } from '../scaffold' jest.mock('fs') +jest.mock('execa') jest.mock('../../../../lib', () => { return { diff --git a/packages/cli/src/commands/destroy/scaffold/__tests__/scaffoldNoNest.test.js b/packages/cli/src/commands/destroy/scaffold/__tests__/scaffoldNoNest.test.js index be37a5f654ee..c9a20893fa25 100644 --- a/packages/cli/src/commands/destroy/scaffold/__tests__/scaffoldNoNest.test.js +++ b/packages/cli/src/commands/destroy/scaffold/__tests__/scaffoldNoNest.test.js @@ -10,6 +10,7 @@ import { files } from '../../../generate/scaffold/scaffold' import { tasks } from '../scaffold' jest.mock('fs') +jest.mock('execa') jest.mock('../../../../lib', () => { return { diff --git a/packages/cli/src/commands/generate/helpers.js b/packages/cli/src/commands/generate/helpers.js index 99d0e0d0a501..a3fe4bf4326c 100644 --- a/packages/cli/src/commands/generate/helpers.js +++ b/packages/cli/src/commands/generate/helpers.js @@ -17,7 +17,7 @@ import { pluralize, isPlural, isSingular } from '../../lib/rwPluralize' import { yargsDefaults } from '../generate' /** - * Returns the path to a custom generator template, if found in the app. + * Returns the full path to a custom generator template, if found in the app. * Otherwise the default Redwood template. */ export const customOrDefaultTemplatePath = ({ diff --git a/packages/cli/src/commands/generate/scaffold/__tests__/__snapshots__/scaffold.test.js.snap b/packages/cli/src/commands/generate/scaffold/__tests__/__snapshots__/scaffold.test.js.snap index b9524dc1469d..dd519db77e7c 100644 --- a/packages/cli/src/commands/generate/scaffold/__tests__/__snapshots__/scaffold.test.js.snap +++ b/packages/cli/src/commands/generate/scaffold/__tests__/__snapshots__/scaffold.test.js.snap @@ -273,6 +273,180 @@ export default PostForm " `; +exports[`in javascript (default) mode creates a formatters function file 1`] = ` +"import React from 'react' + +import humanize from 'humanize-string' + +const MAX_STRING_LENGTH = 150 + +export const formatEnum = (values) => { + let output = '' + + if (Array.isArray(values)) { + const humanizedValues = values.map((value) => humanize(value)) + output = humanizedValues.join(', ') + } else if (typeof values === 'string') { + output = humanize(values) + } + + return output +} + +export const truncate = (value) => { + let output = value?.toString() ?? '' + + if (output.length > MAX_STRING_LENGTH) { + output = output.substring(0, MAX_STRING_LENGTH) + '...' + } + + return output +} + +export const jsonTruncate = (obj) => { + return truncate(JSON.stringify(obj, null, 2)) +} + +export const timeTag = (dateTime) => { + let output = '' + + if (dateTime) { + output = ( + + ) + } + + return output +} + +export const checkboxInputTag = (checked) => { + return +} +" +`; + +exports[`in javascript (default) mode creates a formatters function test file 1`] = ` +"import { render, waitFor, screen } from '@redwoodjs/testing/web' + +import { + formatEnum, + jsonTruncate, + truncate, + timeTag, + checkboxInputTag, +} from './formatters' + +describe('formatEnum', () => { + it('handles nullish values', () => { + expect(formatEnum(null)).toEqual('') + expect(formatEnum('')).toEqual('') + expect(formatEnum(undefined)).toEqual('') + }) + + it('formats a list of values', () => { + expect( + formatEnum(['RED', 'ORANGE', 'YELLOW', 'GREEN', 'BLUE', 'VIOLET']) + ).toEqual('Red, Orange, Yellow, Green, Blue, Violet') + }) + + it('formats a single value', () => { + expect(formatEnum('DARK_BLUE')).toEqual('Dark blue') + }) + + it('returns an empty string for values of the wrong type (for JS projects)', () => { + // @ts-expect-error - Testing JS scenario + expect(formatEnum(5)).toEqual('') + }) +}) + +describe('truncate', () => { + it('truncates really long strings', () => { + expect(truncate('na '.repeat(1000) + 'batman').length).toBeLessThan(1000) + expect(truncate('na '.repeat(1000) + 'batman')).not.toMatch(/batman/) + }) + + it('does not modify short strings', () => { + expect(truncate('Short strinG')).toEqual('Short strinG') + }) + + it('adds ... to the end of truncated strings', () => { + expect(truncate('repeat'.repeat(1000))).toMatch(/\\w\\.\\.\\.$/) + }) + + it('accepts numbers', () => { + expect(truncate(123)).toEqual('123') + expect(truncate(0)).toEqual('0') + expect(truncate(0o000)).toEqual('0') + }) + + it('handles arguments of invalid type', () => { + // @ts-expect-error - Testing JS scenario + expect(truncate(false)).toEqual('false') + + expect(truncate(undefined)).toEqual('') + expect(truncate(null)).toEqual('') + }) +}) + +describe('jsonTruncate', () => { + it('truncates large json structures', () => { + expect( + jsonTruncate({ + foo: 'foo', + bar: 'bar', + baz: 'baz', + kittens: 'kittens meow', + bazinga: 'Sheldon', + nested: { + foobar: 'I have no imagination', + two: 'Second nested item', + }, + + five: 5, + bool: false, + }) + ).toMatch(/.+\\n.+\\w\\.\\.\\.$/s) + }) +}) + +describe('timeTag', () => { + it('renders a date', async () => { + render(
{timeTag(new Date('1970-08-20').toUTCString())}
) + + await waitFor(() => screen.getByText(/1970.*00:00:00/)) + }) + + it('can take an empty input string', async () => { + expect(timeTag('')).toEqual('') + }) +}) + +describe('checkboxInputTag', () => { + it('can be checked', () => { + render(checkboxInputTag(true)) + expect(screen.getByRole('checkbox')).toBeChecked() + }) + + it('can be unchecked', () => { + render(checkboxInputTag(false)) + expect(screen.getByRole('checkbox')).not.toBeChecked() + }) + + it('is disabled when checked', () => { + render(checkboxInputTag(true)) + expect(screen.getByRole('checkbox')).toBeDisabled() + }) + + it('is disabled when unchecked', () => { + render(checkboxInputTag(false)) + expect(screen.getByRole('checkbox')).toBeDisabled() + }) +}) +" +`; + exports[`in javascript (default) mode creates a index page 1`] = ` "import PostsCell from 'src/components/Post/PostsCell' @@ -455,12 +629,12 @@ export const Success = ({ post }) => { `; exports[`in javascript (default) mode creates a show component 1`] = ` -"import humanize from 'humanize-string' - -import { Link, routes, navigate } from '@redwoodjs/router' +"import { Link, routes, navigate } from '@redwoodjs/router' import { useMutation } from '@redwoodjs/web' import { toast } from '@redwoodjs/web/toast' +import { checkboxInputTag, jsonDisplay, timeTag } from 'src/lib/formatters' + const DELETE_POST_MUTATION = gql\` mutation DeletePostMutation($id: Int!) { deletePost(id: $id) { @@ -469,39 +643,6 @@ const DELETE_POST_MUTATION = gql\` } \` -const formatEnum = (values) => { - if (values) { - if (Array.isArray(values)) { - const humanizedValues = values.map((value) => humanize(value)) - return humanizedValues.join(', ') - } else { - return humanize(values) - } - } -} - -const jsonDisplay = (obj) => { - return ( -
-      {JSON.stringify(obj, null, 2)}
-    
- ) -} - -const timeTag = (datetime) => { - return ( - datetime && ( - - ) - ) -} - -const checkboxInputTag = (checked) => { - return -} - const Post = ({ post }) => { const [deletePost] = useMutation(DELETE_POST_MUTATION, { onCompleted: () => { @@ -1145,13 +1286,17 @@ export const Success = ({ posts }) => { `; exports[`in javascript (default) mode creates an index component 1`] = ` -"import humanize from 'humanize-string' - -import { Link, routes } from '@redwoodjs/router' +"import { Link, routes } from '@redwoodjs/router' import { useMutation } from '@redwoodjs/web' import { toast } from '@redwoodjs/web/toast' import { QUERY } from 'src/components/Post/PostsCell' +import { + checkboxInputTag, + jsonTruncate, + timeTag, + truncate, +} from 'src/lib/formatters' const DELETE_POST_MUTATION = gql\` mutation DeletePostMutation($id: Int!) { @@ -1161,45 +1306,6 @@ const DELETE_POST_MUTATION = gql\` } \` -const MAX_STRING_LENGTH = 150 - -const formatEnum = (values) => { - if (values) { - if (Array.isArray(values)) { - const humanizedValues = values.map((value) => humanize(value)) - return humanizedValues.join(', ') - } else { - return humanize(values) - } - } -} - -const truncate = (value) => { - const output = value?.toString() - if (output?.length > MAX_STRING_LENGTH) { - return output.substring(0, MAX_STRING_LENGTH) + '...' - } - return output ?? '' -} - -const jsonTruncate = (obj) => { - return truncate(JSON.stringify(obj, null, 2)) -} - -const timeTag = (datetime) => { - return ( - datetime && ( - - ) - ) -} - -const checkboxInputTag = (checked) => { - return -} - const PostsList = ({ posts }) => { const [deletePost] = useMutation(DELETE_POST_MUTATION, { onCompleted: () => { @@ -1640,6 +1746,179 @@ export default PostForm " `; +exports[`in typescript mode creates a formatters function file 1`] = ` +"import React from 'react' + +import humanize from 'humanize-string' + +const MAX_STRING_LENGTH = 150 + +export const formatEnum = (values: string | string[] | null | undefined) => { + let output = '' + + if (Array.isArray(values)) { + const humanizedValues = values.map((value) => humanize(value)) + output = humanizedValues.join(', ') + } else if (typeof values === 'string') { + output = humanize(values) + } + + return output +} + +export const truncate = (value: string | number) => { + let output = value?.toString() ?? '' + + if (output.length > MAX_STRING_LENGTH) { + output = output.substring(0, MAX_STRING_LENGTH) + '...' + } + + return output +} + +export const jsonTruncate = (obj: unknown) => { + return truncate(JSON.stringify(obj, null, 2)) +} + +export const timeTag = (dateTime?: string) => { + let output: string | JSX.Element = '' + + if (dateTime) { + output = ( + + ) + } + + return output +} + +export const checkboxInputTag = (checked: boolean) => { + return +} +" +`; + +exports[`in typescript mode creates a formatters function test file 1`] = ` +"import { render, waitFor, screen } from '@redwoodjs/testing/web' + +import { + formatEnum, + jsonTruncate, + truncate, + timeTag, + checkboxInputTag, +} from './formatters' + +describe('formatEnum', () => { + it('handles nullish values', () => { + expect(formatEnum(null)).toEqual('') + expect(formatEnum('')).toEqual('') + expect(formatEnum(undefined)).toEqual('') + }) + + it('formats a list of values', () => { + expect( + formatEnum(['RED', 'ORANGE', 'YELLOW', 'GREEN', 'BLUE', 'VIOLET']) + ).toEqual('Red, Orange, Yellow, Green, Blue, Violet') + }) + + it('formats a single value', () => { + expect(formatEnum('DARK_BLUE')).toEqual('Dark blue') + }) + + it('returns an empty string for values of the wrong type (for JS projects)', () => { + // @ts-expect-error - Testing JS scenario + expect(formatEnum(5)).toEqual('') + }) +}) + +describe('truncate', () => { + it('truncates really long strings', () => { + expect(truncate('na '.repeat(1000) + 'batman').length).toBeLessThan(1000) + expect(truncate('na '.repeat(1000) + 'batman')).not.toMatch(/batman/) + }) + + it('does not modify short strings', () => { + expect(truncate('Short strinG')).toEqual('Short strinG') + }) + + it('adds ... to the end of truncated strings', () => { + expect(truncate('repeat'.repeat(1000))).toMatch(/\\w\\.\\.\\.$/) + }) + + it('accepts numbers', () => { + expect(truncate(123)).toEqual('123') + expect(truncate(0)).toEqual('0') + expect(truncate(0o000)).toEqual('0') + }) + + it('handles arguments of invalid type', () => { + // @ts-expect-error - Testing JS scenario + expect(truncate(false)).toEqual('false') + + expect(truncate(undefined)).toEqual('') + expect(truncate(null)).toEqual('') + }) +}) + +describe('jsonTruncate', () => { + it('truncates large json structures', () => { + expect( + jsonTruncate({ + foo: 'foo', + bar: 'bar', + baz: 'baz', + kittens: 'kittens meow', + bazinga: 'Sheldon', + nested: { + foobar: 'I have no imagination', + two: 'Second nested item', + }, + five: 5, + bool: false, + }) + ).toMatch(/.+\\n.+\\w\\.\\.\\.$/s) + }) +}) + +describe('timeTag', () => { + it('renders a date', async () => { + render(
{timeTag(new Date('1970-08-20').toUTCString())}
) + + await waitFor(() => screen.getByText(/1970.*00:00:00/)) + }) + + it('can take an empty input string', async () => { + expect(timeTag('')).toEqual('') + }) +}) + +describe('checkboxInputTag', () => { + it('can be checked', () => { + render(checkboxInputTag(true)) + expect(screen.getByRole('checkbox')).toBeChecked() + }) + + it('can be unchecked', () => { + render(checkboxInputTag(false)) + expect(screen.getByRole('checkbox')).not.toBeChecked() + }) + + it('is disabled when checked', () => { + render(checkboxInputTag(true)) + expect(screen.getByRole('checkbox')).toBeDisabled() + }) + + it('is disabled when unchecked', () => { + render(checkboxInputTag(false)) + expect(screen.getByRole('checkbox')).toBeDisabled() + }) +}) +" +`; + exports[`in typescript mode creates a index page 1`] = ` "import PostsCell from 'src/components/Post/PostsCell' @@ -1843,12 +2122,13 @@ export const Success = ({ post }: CellSuccessProps) => { `; exports[`in typescript mode creates a show component 1`] = ` -"import humanize from 'humanize-string' - +" import { Link, routes, navigate } from '@redwoodjs/router' import { useMutation } from '@redwoodjs/web' import { toast } from '@redwoodjs/web/toast' +import { checkboxInputTag, jsonDisplay, timeTag, } from 'src/lib/formatters' + import type { DeletePostMutationVariables, FindPostById } from 'types/graphql' const DELETE_POST_MUTATION = gql\` @@ -1859,39 +2139,6 @@ const DELETE_POST_MUTATION = gql\` } \` -const formatEnum = (values: string | string[] | null | undefined) => { - if (values) { - if (Array.isArray(values)) { - const humanizedValues = values.map((value) => humanize(value)) - return humanizedValues.join(', ') - } else { - return humanize(values as string) - } - } -} - -const jsonDisplay = (obj: unknown) => { - return ( -
-      {JSON.stringify(obj, null, 2)}
-    
- ) -} - -const timeTag = (datetime?: string) => { - return ( - datetime && ( - - ) - ) -} - -const checkboxInputTag = (checked: boolean) => { - return -} - interface Props { post: NonNullable } @@ -2620,13 +2867,12 @@ export const Success = ({ posts }: CellSuccessProps) => { `; exports[`in typescript mode creates an index component 1`] = ` -"import humanize from 'humanize-string' - -import { Link, routes } from '@redwoodjs/router' +"import { Link, routes } from '@redwoodjs/router' import { useMutation } from '@redwoodjs/web' import { toast } from '@redwoodjs/web/toast' import { QUERY } from 'src/components/Post/PostsCell' +import { checkboxInputTag, jsonTruncate, timeTag, truncate } from 'src/lib/formatters' import type { DeletePostMutationVariables, FindPosts } from 'types/graphql' @@ -2638,46 +2884,6 @@ const DELETE_POST_MUTATION = gql\` } \` -const MAX_STRING_LENGTH = 150 - -const formatEnum = (values: string | string[] | null | undefined) => { - if (values) { - if (Array.isArray(values)) { - const humanizedValues = values.map((value) => humanize(value)) - return humanizedValues.join(', ') - } else { - return humanize(values as string) - } - } -} - -const truncate = (value: string | number) => { - const output = value?.toString() - if (output?.length > MAX_STRING_LENGTH) { - return output.substring(0, MAX_STRING_LENGTH) + '...' - } - return output ?? '' -} - - -const jsonTruncate = (obj: unknown) => { - return truncate(JSON.stringify(obj, null, 2)) -} - -const timeTag = (datetime?: string) => { - return ( - datetime && ( - - ) - ) -} - -const checkboxInputTag = (checked: boolean) => { - return -} - const PostsList = ({ posts }: FindPosts) => { const [deletePost] = useMutation(DELETE_POST_MUTATION, { onCompleted: () => { diff --git a/packages/cli/src/commands/generate/scaffold/__tests__/__snapshots__/scaffoldNoNest.test.js.snap b/packages/cli/src/commands/generate/scaffold/__tests__/__snapshots__/scaffoldNoNest.test.js.snap index f23aedc31aa2..83b11cce0492 100644 --- a/packages/cli/src/commands/generate/scaffold/__tests__/__snapshots__/scaffoldNoNest.test.js.snap +++ b/packages/cli/src/commands/generate/scaffold/__tests__/__snapshots__/scaffoldNoNest.test.js.snap @@ -411,12 +411,12 @@ export const Success = ({ post }) => { `; exports[`in javascript (default) mode creates a show component 1`] = ` -"import humanize from 'humanize-string' - -import { Link, routes, navigate } from '@redwoodjs/router' +"import { Link, routes, navigate } from '@redwoodjs/router' import { useMutation } from '@redwoodjs/web' import { toast } from '@redwoodjs/web/toast' +import { checkboxInputTag, jsonDisplay, timeTag } from 'src/lib/formatters' + const DELETE_POST_MUTATION = gql\` mutation DeletePostMutation($id: Int!) { deletePost(id: $id) { @@ -425,39 +425,6 @@ const DELETE_POST_MUTATION = gql\` } \` -const formatEnum = (values) => { - if (values) { - if (Array.isArray(values)) { - const humanizedValues = values.map((value) => humanize(value)) - return humanizedValues.join(', ') - } else { - return humanize(values) - } - } -} - -const jsonDisplay = (obj) => { - return ( -
-      {JSON.stringify(obj, null, 2)}
-    
- ) -} - -const timeTag = (datetime) => { - return ( - datetime && ( - - ) - ) -} - -const checkboxInputTag = (checked) => { - return -} - const Post = ({ post }) => { const [deletePost] = useMutation(DELETE_POST_MUTATION, { onCompleted: () => { @@ -1101,13 +1068,17 @@ export const Success = ({ posts }) => { `; exports[`in javascript (default) mode creates an index component 1`] = ` -"import humanize from 'humanize-string' - -import { Link, routes } from '@redwoodjs/router' +"import { Link, routes } from '@redwoodjs/router' import { useMutation } from '@redwoodjs/web' import { toast } from '@redwoodjs/web/toast' import { QUERY } from 'src/components/PostsCell' +import { + checkboxInputTag, + jsonTruncate, + timeTag, + truncate, +} from 'src/lib/formatters' const DELETE_POST_MUTATION = gql\` mutation DeletePostMutation($id: Int!) { @@ -1117,45 +1088,6 @@ const DELETE_POST_MUTATION = gql\` } \` -const MAX_STRING_LENGTH = 150 - -const formatEnum = (values) => { - if (values) { - if (Array.isArray(values)) { - const humanizedValues = values.map((value) => humanize(value)) - return humanizedValues.join(', ') - } else { - return humanize(values) - } - } -} - -const truncate = (value) => { - const output = value?.toString() - if (output?.length > MAX_STRING_LENGTH) { - return output.substring(0, MAX_STRING_LENGTH) + '...' - } - return output ?? '' -} - -const jsonTruncate = (obj) => { - return truncate(JSON.stringify(obj, null, 2)) -} - -const timeTag = (datetime) => { - return ( - datetime && ( - - ) - ) -} - -const checkboxInputTag = (checked) => { - return -} - const PostsList = ({ posts }) => { const [deletePost] = useMutation(DELETE_POST_MUTATION, { onCompleted: () => { @@ -1750,12 +1682,13 @@ export const Success = ({ post }: CellSuccessProps) => { `; exports[`in typescript mode creates a show component 1`] = ` -"import humanize from 'humanize-string' - +" import { Link, routes, navigate } from '@redwoodjs/router' import { useMutation } from '@redwoodjs/web' import { toast } from '@redwoodjs/web/toast' +import { checkboxInputTag, jsonDisplay, timeTag, } from 'src/lib/formatters' + import type { DeletePostMutationVariables, FindPostById } from 'types/graphql' const DELETE_POST_MUTATION = gql\` @@ -1766,39 +1699,6 @@ const DELETE_POST_MUTATION = gql\` } \` -const formatEnum = (values: string | string[] | null | undefined) => { - if (values) { - if (Array.isArray(values)) { - const humanizedValues = values.map((value) => humanize(value)) - return humanizedValues.join(', ') - } else { - return humanize(values as string) - } - } -} - -const jsonDisplay = (obj: unknown) => { - return ( -
-      {JSON.stringify(obj, null, 2)}
-    
- ) -} - -const timeTag = (datetime?: string) => { - return ( - datetime && ( - - ) - ) -} - -const checkboxInputTag = (checked: boolean) => { - return -} - interface Props { post: NonNullable } @@ -2527,13 +2427,12 @@ export const Success = ({ posts }: CellSuccessProps) => { `; exports[`in typescript mode creates an index component 1`] = ` -"import humanize from 'humanize-string' - -import { Link, routes } from '@redwoodjs/router' +"import { Link, routes } from '@redwoodjs/router' import { useMutation } from '@redwoodjs/web' import { toast } from '@redwoodjs/web/toast' import { QUERY } from 'src/components/PostsCell' +import { checkboxInputTag, jsonTruncate, timeTag, truncate } from 'src/lib/formatters' import type { DeletePostMutationVariables, FindPosts } from 'types/graphql' @@ -2545,46 +2444,6 @@ const DELETE_POST_MUTATION = gql\` } \` -const MAX_STRING_LENGTH = 150 - -const formatEnum = (values: string | string[] | null | undefined) => { - if (values) { - if (Array.isArray(values)) { - const humanizedValues = values.map((value) => humanize(value)) - return humanizedValues.join(', ') - } else { - return humanize(values as string) - } - } -} - -const truncate = (value: string | number) => { - const output = value?.toString() - if (output?.length > MAX_STRING_LENGTH) { - return output.substring(0, MAX_STRING_LENGTH) + '...' - } - return output ?? '' -} - - -const jsonTruncate = (obj: unknown) => { - return truncate(JSON.stringify(obj, null, 2)) -} - -const timeTag = (datetime?: string) => { - return ( - datetime && ( - - ) - ) -} - -const checkboxInputTag = (checked: boolean) => { - return -} - const PostsList = ({ posts }: FindPosts) => { const [deletePost] = useMutation(DELETE_POST_MUTATION, { onCompleted: () => { diff --git a/packages/cli/src/commands/generate/scaffold/__tests__/editableColumns.test.js b/packages/cli/src/commands/generate/scaffold/__tests__/editableColumns.test.js index 1802d4c172ce..4c2fe38fb634 100644 --- a/packages/cli/src/commands/generate/scaffold/__tests__/editableColumns.test.js +++ b/packages/cli/src/commands/generate/scaffold/__tests__/editableColumns.test.js @@ -8,6 +8,8 @@ import { getDefaultArgs } from '../../../../lib' import { yargsDefaults as defaults } from '../../../generate' import * as scaffold from '../scaffold' +jest.mock('execa') + describe('editable columns', () => { let files let form diff --git a/packages/cli/src/commands/generate/scaffold/__tests__/scaffold.test.js b/packages/cli/src/commands/generate/scaffold/__tests__/scaffold.test.js index 4c1cba67c735..2d4f02aae367 100644 --- a/packages/cli/src/commands/generate/scaffold/__tests__/scaffold.test.js +++ b/packages/cli/src/commands/generate/scaffold/__tests__/scaffold.test.js @@ -8,6 +8,8 @@ import { getDefaultArgs } from '../../../../lib' import { yargsDefaults as defaults } from '../../../generate' import * as scaffold from '../scaffold' +jest.mock('execa') + describe('in javascript (default) mode', () => { let files @@ -20,8 +22,8 @@ describe('in javascript (default) mode', () => { }) }) - test('returns exactly 17 files', async () => { - expect(Object.keys(files).length).toEqual(17) + test('returns exactly 19 files', async () => { + expect(Object.keys(files).length).toEqual(19) }) // SDL @@ -392,6 +394,20 @@ describe('in javascript (default) mode', () => { ] ).toMatchSnapshot() }) + + // Formatters + + test('creates a formatters function file', () => { + expect( + files[path.normalize('/path/to/project/web/src/lib/formatters.js')] + ).toMatchSnapshot() + }) + + test('creates a formatters function test file', () => { + expect( + files[path.normalize('/path/to/project/web/src/lib/formatters.test.js')] + ).toMatchSnapshot() + }) }) describe('in typescript mode', () => { @@ -407,8 +423,8 @@ describe('in typescript mode', () => { }) }) - test('returns exactly 17 files', () => { - expect(Object.keys(tsFiles).length).toEqual(17) + test('returns exactly 19 files', () => { + expect(Object.keys(tsFiles).length).toEqual(19) }) // SDL @@ -680,6 +696,22 @@ describe('in typescript mode', () => { ] ).toMatchSnapshot() }) + + // Formatters + + test('creates a formatters function file', () => { + expect( + tsFiles[path.normalize('/path/to/project/web/src/lib/formatters.tsx')] + ).toMatchSnapshot() + }) + + test('creates a formatters function test file', () => { + expect( + tsFiles[ + path.normalize('/path/to/project/web/src/lib/formatters.test.tsx') + ] + ).toMatchSnapshot() + }) }) describe('tailwind flag', () => { diff --git a/packages/cli/src/commands/generate/scaffold/__tests__/scaffoldNoNest.test.js b/packages/cli/src/commands/generate/scaffold/__tests__/scaffoldNoNest.test.js index 298f883ac50b..46cf81af5c4c 100644 --- a/packages/cli/src/commands/generate/scaffold/__tests__/scaffoldNoNest.test.js +++ b/packages/cli/src/commands/generate/scaffold/__tests__/scaffoldNoNest.test.js @@ -8,6 +8,8 @@ import { getDefaultArgs } from '../../../../lib' import { yargsDefaults as defaults } from '../../../generate' import * as scaffold from '../scaffold' +jest.mock('execa') + describe('in javascript (default) mode', () => { let files @@ -20,8 +22,8 @@ describe('in javascript (default) mode', () => { }) }) - test('returns exactly 17 files', () => { - expect(Object.keys(files).length).toEqual(17) + test('returns exactly 19 files', () => { + expect(Object.keys(files).length).toEqual(19) }) // SDL @@ -297,8 +299,8 @@ describe('in typescript mode', () => { }) }) - test('returns exactly 17 files', () => { - expect(Object.keys(tsFiles).length).toEqual(17) + test('returns exactly 19 files', () => { + expect(Object.keys(tsFiles).length).toEqual(19) }) // SDL diff --git a/packages/cli/src/commands/generate/scaffold/__tests__/scaffoldPath.test.js b/packages/cli/src/commands/generate/scaffold/__tests__/scaffoldPath.test.js index 1f04db4f52c8..91ee10f7b5da 100644 --- a/packages/cli/src/commands/generate/scaffold/__tests__/scaffoldPath.test.js +++ b/packages/cli/src/commands/generate/scaffold/__tests__/scaffoldPath.test.js @@ -5,6 +5,8 @@ import '../../../../lib/test' import * as scaffold from '../scaffold' +jest.mock('execa') + let filesLower, filesUpper beforeAll(async () => { @@ -24,8 +26,8 @@ beforeAll(async () => { describe('admin/post', () => { describe('creates the correct files with the correct imports', () => { - test('returns exactly 17 files', () => { - expect(Object.keys(filesLower).length).toEqual(17) + test('returns exactly 19 files', () => { + expect(Object.keys(filesLower).length).toEqual(19) }) // Layout @@ -344,8 +346,8 @@ describe('admin/post', () => { describe('Admin/Post', () => { describe('creates the correct files with the correct imports', () => { - test('returns exactly 17 files', () => { - expect(Object.keys(filesUpper).length).toEqual(17) + test('returns exactly 19 files', () => { + expect(Object.keys(filesUpper).length).toEqual(19) }) // Layout diff --git a/packages/cli/src/commands/generate/scaffold/__tests__/scaffoldPathMulti.test.js b/packages/cli/src/commands/generate/scaffold/__tests__/scaffoldPathMulti.test.js index 0aa93a001a5e..ffd39f0427b6 100644 --- a/packages/cli/src/commands/generate/scaffold/__tests__/scaffoldPathMulti.test.js +++ b/packages/cli/src/commands/generate/scaffold/__tests__/scaffoldPathMulti.test.js @@ -5,6 +5,8 @@ import '../../../../lib/test' import * as scaffold from '../scaffold' +jest.mock('execa') + let filesNestedLower, filesNestedUpper beforeAll(async () => { @@ -24,8 +26,8 @@ beforeAll(async () => { describe('admin/pages/post', () => { describe('creates the correct files with the correct imports', () => { - test('returns exactly 17 files', () => { - expect(Object.keys(filesNestedLower).length).toEqual(17) + test('returns exactly 19 files', () => { + expect(Object.keys(filesNestedLower).length).toEqual(19) }) // Layout @@ -354,8 +356,8 @@ describe('admin/pages/post', () => { describe('Admin/Pages/Post/Post', () => { describe('creates the correct files with the correct imports', () => { - test('returns exactly 17 files', () => { - expect(Object.keys(filesNestedUpper).length).toEqual(17) + test('returns exactly 19 files', () => { + expect(Object.keys(filesNestedUpper).length).toEqual(19) }) // Layout diff --git a/packages/cli/src/commands/generate/scaffold/__tests__/scaffoldPathMultiNoNest.test.js b/packages/cli/src/commands/generate/scaffold/__tests__/scaffoldPathMultiNoNest.test.js index 56764a1ee3ba..6f9b0c0997b3 100644 --- a/packages/cli/src/commands/generate/scaffold/__tests__/scaffoldPathMultiNoNest.test.js +++ b/packages/cli/src/commands/generate/scaffold/__tests__/scaffoldPathMultiNoNest.test.js @@ -5,6 +5,8 @@ import '../../../../lib/test' import * as scaffold from '../scaffold' +jest.mock('execa') + let filesNestedLower, filesNestedUpper beforeAll(async () => { @@ -24,8 +26,8 @@ beforeAll(async () => { describe('admin/pages/post', () => { describe('creates the correct files with the correct imports', () => { - test('returns exactly 17 files', () => { - expect(Object.keys(filesNestedLower).length).toEqual(17) + test('returns exactly 19 files', () => { + expect(Object.keys(filesNestedLower).length).toEqual(19) }) // Layout @@ -346,8 +348,8 @@ describe('admin/pages/post', () => { describe('Admin/Pages/Post/Post', () => { describe('creates the correct files with the correct imports', () => { - test('returns exactly 17 files', () => { - expect(Object.keys(filesNestedUpper).length).toEqual(17) + test('returns exactly 19 files', () => { + expect(Object.keys(filesNestedUpper).length).toEqual(19) }) // Layout diff --git a/packages/cli/src/commands/generate/scaffold/__tests__/scaffoldPathMultiword.test.js b/packages/cli/src/commands/generate/scaffold/__tests__/scaffoldPathMultiword.test.js index bf8f9b478ab3..50fc75e00075 100644 --- a/packages/cli/src/commands/generate/scaffold/__tests__/scaffoldPathMultiword.test.js +++ b/packages/cli/src/commands/generate/scaffold/__tests__/scaffoldPathMultiword.test.js @@ -5,6 +5,8 @@ import '../../../../lib/test' import * as scaffold from '../scaffold' +jest.mock('execa') + let filesMultiwordUpper let filesMultiwordDash let filesMultiwordUnderscore @@ -32,8 +34,8 @@ beforeAll(async () => { describe('AdminPages/Post', () => { describe('creates the correct files with the correct imports', () => { - test('returns exactly 17 files', () => { - expect(Object.keys(filesMultiwordUpper).length).toEqual(17) + test('returns exactly 19 files', () => { + expect(Object.keys(filesMultiwordUpper).length).toEqual(19) }) // Layout @@ -362,8 +364,8 @@ describe('AdminPages/Post', () => { describe('admin-pages/Post', () => { describe('creates the correct files with the correct imports', () => { - test('returns exactly 17 files', () => { - expect(Object.keys(filesMultiwordDash).length).toEqual(17) + test('returns exactly 19 files', () => { + expect(Object.keys(filesMultiwordDash).length).toEqual(19) }) // Layout @@ -692,8 +694,8 @@ describe('admin-pages/Post', () => { describe('admin_pages/Post', () => { describe('creates the correct files with the correct imports', () => { - test('returns exactly 17 files', () => { - expect(Object.keys(filesMultiwordUnderscore).length).toEqual(17) + test('returns exactly 19 files', () => { + expect(Object.keys(filesMultiwordUnderscore).length).toEqual(19) }) // Layout diff --git a/packages/cli/src/commands/generate/scaffold/__tests__/scaffoldPathMultiwordNoNest.test.js b/packages/cli/src/commands/generate/scaffold/__tests__/scaffoldPathMultiwordNoNest.test.js index a6268bf0b08b..f44a5eea6666 100644 --- a/packages/cli/src/commands/generate/scaffold/__tests__/scaffoldPathMultiwordNoNest.test.js +++ b/packages/cli/src/commands/generate/scaffold/__tests__/scaffoldPathMultiwordNoNest.test.js @@ -5,6 +5,8 @@ import '../../../../lib/test' import * as scaffold from '../scaffold' +jest.mock('execa') + let filesMultiwordUpper let filesMultiwordDash let filesMultiwordUnderscore @@ -32,8 +34,8 @@ beforeAll(async () => { describe('AdminPages/Post', () => { describe('creates the correct files with the correct imports', () => { - test('returns exactly 17 files', () => { - expect(Object.keys(filesMultiwordUpper).length).toEqual(17) + test('returns exactly 19 files', () => { + expect(Object.keys(filesMultiwordUpper).length).toEqual(19) }) // Layout @@ -354,8 +356,8 @@ describe('AdminPages/Post', () => { describe('admin-pages/Post', () => { describe('creates the correct files with the correct imports', () => { - test('returns exactly 17 files', () => { - expect(Object.keys(filesMultiwordDash).length).toEqual(17) + test('returns exactly 19 files', () => { + expect(Object.keys(filesMultiwordDash).length).toEqual(19) }) // Layout @@ -676,8 +678,8 @@ describe('admin-pages/Post', () => { describe('admin_pages/Post', () => { describe('creates the correct files with the correct imports', () => { - test('returns exactly 17 files', () => { - expect(Object.keys(filesMultiwordUnderscore).length).toEqual(17) + test('returns exactly 19 files', () => { + expect(Object.keys(filesMultiwordUnderscore).length).toEqual(19) }) // Layout diff --git a/packages/cli/src/commands/generate/scaffold/__tests__/scaffoldPathNoNest.test.js b/packages/cli/src/commands/generate/scaffold/__tests__/scaffoldPathNoNest.test.js index 76fb9f08fad7..5f19e1b188b1 100644 --- a/packages/cli/src/commands/generate/scaffold/__tests__/scaffoldPathNoNest.test.js +++ b/packages/cli/src/commands/generate/scaffold/__tests__/scaffoldPathNoNest.test.js @@ -5,6 +5,8 @@ import '../../../../lib/test' import * as scaffold from '../scaffold' +jest.mock('execa') + let filesLower, filesUpper beforeAll(async () => { @@ -24,8 +26,8 @@ beforeAll(async () => { describe('admin/Post', () => { describe('creates the correct files with the correct imports', () => { - test('returns exactly 17 files', () => { - expect(Object.keys(filesLower).length).toEqual(17) + test('returns exactly 19 files', () => { + expect(Object.keys(filesLower).length).toEqual(19) }) // Layout @@ -342,8 +344,8 @@ describe('admin/Post', () => { describe('Admin/Post', () => { describe('creates the correct files with the correct imports', () => { - test('returns exactly 17 files', () => { - expect(Object.keys(filesUpper).length).toEqual(17) + test('returns exactly 19 files', () => { + expect(Object.keys(filesUpper).length).toEqual(19) }) // Layout diff --git a/packages/cli/src/commands/generate/scaffold/scaffold.js b/packages/cli/src/commands/generate/scaffold/scaffold.js index 280330dfc8cf..d42355de33ac 100644 --- a/packages/cli/src/commands/generate/scaffold/scaffold.js +++ b/packages/cli/src/commands/generate/scaffold/scaffold.js @@ -2,6 +2,7 @@ import fs from 'fs' import path from 'path' import camelcase from 'camelcase' +import execa from 'execa' import humanize from 'humanize-string' import Listr from 'listr' import { paramCase } from 'param-case' @@ -181,6 +182,7 @@ export const files = async ({ typescript, })), ...assetFiles(name, tailwind), + ...(await formatters(name, typescript)), ...layoutFiles(name, pascalScaffoldPath, typescript, templateStrings), ...(await pageFiles( name, @@ -236,6 +238,55 @@ const assetFiles = (name, tailwind) => { return fileList } +const formatters = async (name, isTypescript) => { + const outputPath = path.join( + getPaths().web.src, + 'lib', + isTypescript ? 'formatters.tsx' : 'formatters.js' + ) + const outputPathTest = path.join( + getPaths().web.src, + 'lib', + isTypescript ? 'formatters.test.tsx' : 'formatters.test.js' + ) + + // skip files that already exist on disk, never worry about overwriting + if (fs.existsSync(outputPath)) { + return + } + + const template = generateTemplate( + customOrDefaultTemplatePath({ + side: 'web', + generator: 'scaffold', + templatePath: path.join('lib', 'formatters.tsx.template'), + }), + { + name, + } + ) + + const templateTest = generateTemplate( + customOrDefaultTemplatePath({ + side: 'web', + generator: 'scaffold', + templatePath: path.join('lib', 'formatters.test.tsx.template'), + }), + { + name, + } + ) + + return { + [outputPath]: isTypescript + ? template + : transformTSToJS(outputPath, template), + [outputPathTest]: isTypescript + ? templateTest + : transformTSToJS(outputPathTest, templateTest), + } +} + const layoutFiles = ( name, pascalScaffoldPath = '', @@ -487,6 +538,20 @@ const componentFiles = async ( }) ) + const formattersImports = columns + .map((column) => column.displayFunction) + .sort() + // filter out duplicates, so we only keep unique import names + .filter((name, index, array) => array.indexOf(name) === index) + .join(', ') + + const listFormattersImports = columns + .map((column) => column.listDisplayFunction) + .sort() + // filter out duplicates, so we only keep unique import names + .filter((name, index, array) => array.indexOf(name) === index) + .join(', ') + await asyncForEach(components, (component) => { const outputComponentName = component .replace(/Names/, pluralName) @@ -518,6 +583,8 @@ const componentFiles = async ( idType, intForeignKeys, pascalScaffoldPath, + listFormattersImports, + formattersImports, ...templateStrings, } ) @@ -598,6 +665,21 @@ const addLayoutImport = ({ model: name, path: scaffoldPath = '' }) => { } } +const addHelperPackages = async (task) => { + const packageJsonPath = path.join(getPaths().web.base, 'package.json') + const packageJson = require(packageJsonPath) + + // Skip if humanize-string is already installed + if (packageJson.dependencies['humanize-string']) { + return task.skip('Skipping. Already installed') + } + + // Has to be v2.1.0 because v3 switched to ESM module format, which we don't + // support yet (2022-09-20) + // TODO: Update to latest version when RW supports ESMs + await execa('yarn', ['workspace', 'web', 'add', 'humanize-string@2.1.0']) +} + const addSetImport = (task) => { const routesPath = getPaths().web.routes const routesContent = readFile(routesPath).toString() @@ -696,6 +778,10 @@ export const tasks = ({ return writeFilesTask(f, { overwriteExisting: force }) }, }, + { + title: 'Install helper packages', + task: (_, task) => addHelperPackages(task), + }, { title: 'Adding layout import...', task: async () => addLayoutImport({ model, path }), diff --git a/packages/cli/src/commands/generate/scaffold/templates/components/Name.tsx.template b/packages/cli/src/commands/generate/scaffold/templates/components/Name.tsx.template index 4ab4a6e25c06..3ea1e078bb90 100644 --- a/packages/cli/src/commands/generate/scaffold/templates/components/Name.tsx.template +++ b/packages/cli/src/commands/generate/scaffold/templates/components/Name.tsx.template @@ -1,9 +1,10 @@ -import humanize from 'humanize-string' import { Link, routes, navigate } from '@redwoodjs/router' import { useMutation } from '@redwoodjs/web' import { toast } from '@redwoodjs/web/toast' +import { ${formattersImports} } from 'src/lib/formatters' + import type { Delete${singularPascalName}MutationVariables, Find${singularPascalName}ById } from 'types/graphql' const DELETE_${singularConstantName}_MUTATION = gql` @@ -14,39 +15,6 @@ const DELETE_${singularConstantName}_MUTATION = gql` } ` -const formatEnum = (values: string | string[] | null | undefined) => { - if (values) { - if (Array.isArray(values)) { - const humanizedValues = values.map((value) => humanize(value)) - return humanizedValues.join(', ') - } else { - return humanize(values as string) - } - } -} - -const jsonDisplay = (obj: unknown) => { - return ( -
-      {JSON.stringify(obj, null, 2)}
-    
- ) -} - -const timeTag = (datetime?: string) => { - return ( - datetime && ( - - ) - ) -} - -const checkboxInputTag = (checked: boolean) => { - return -} - interface Props { ${singularCamelName}: NonNullable } diff --git a/packages/cli/src/commands/generate/scaffold/templates/components/Names.tsx.template b/packages/cli/src/commands/generate/scaffold/templates/components/Names.tsx.template index 87d6f8720b10..161d817229c7 100644 --- a/packages/cli/src/commands/generate/scaffold/templates/components/Names.tsx.template +++ b/packages/cli/src/commands/generate/scaffold/templates/components/Names.tsx.template @@ -1,10 +1,9 @@ -import humanize from 'humanize-string' - import { Link, routes } from '@redwoodjs/router' import { useMutation } from '@redwoodjs/web' import { toast } from '@redwoodjs/web/toast' import { QUERY } from '${importComponentNamesCell}' +import { ${listFormattersImports} } from 'src/lib/formatters' import type { Delete${singularPascalName}MutationVariables, Find${pluralPascalName} } from 'types/graphql' @@ -16,46 +15,6 @@ const DELETE_${singularConstantName}_MUTATION = gql` } ` -const MAX_STRING_LENGTH = 150 - -const formatEnum = (values: string | string[] | null | undefined) => { - if (values) { - if (Array.isArray(values)) { - const humanizedValues = values.map((value) => humanize(value)) - return humanizedValues.join(', ') - } else { - return humanize(values as string) - } - } -} - -const truncate = (value: string | number) => { - const output = value?.toString() - if (output?.length > MAX_STRING_LENGTH) { - return output.substring(0, MAX_STRING_LENGTH) + '...' - } - return output ?? '' -} - - -const jsonTruncate = (obj: unknown) => { - return truncate(JSON.stringify(obj, null, 2)) -} - -const timeTag = (datetime?: string) => { - return ( - datetime && ( - - ) - ) -} - -const checkboxInputTag = (checked: boolean) => { - return -} - const ${pluralPascalName}List = ({ ${pluralCamelName} }: Find${pluralPascalName}) => { const [delete${singularPascalName}] = useMutation(DELETE_${singularConstantName}_MUTATION, { onCompleted: () => { diff --git a/packages/cli/src/commands/generate/scaffold/templates/lib/formatters.test.tsx.template b/packages/cli/src/commands/generate/scaffold/templates/lib/formatters.test.tsx.template new file mode 100644 index 000000000000..d4e8dc5a19c8 --- /dev/null +++ b/packages/cli/src/commands/generate/scaffold/templates/lib/formatters.test.tsx.template @@ -0,0 +1,115 @@ +import { render, waitFor, screen } from '@redwoodjs/testing/web' + +import { + formatEnum, + jsonTruncate, + truncate, + timeTag, + checkboxInputTag, +} from './formatters' + +describe('formatEnum', () => { + it('handles nullish values', () => { + expect(formatEnum(null)).toEqual('') + expect(formatEnum('')).toEqual('') + expect(formatEnum(undefined)).toEqual('') + }) + + it('formats a list of values', () => { + expect( + formatEnum(['RED', 'ORANGE', 'YELLOW', 'GREEN', 'BLUE', 'VIOLET']) + ).toEqual('Red, Orange, Yellow, Green, Blue, Violet') + }) + + it('formats a single value', () => { + expect(formatEnum('DARK_BLUE')).toEqual('Dark blue') + }) + + it('returns an empty string for values of the wrong type (for JS projects)', () => { + // @ts-expect-error - Testing JS scenario + expect(formatEnum(5)).toEqual('') + }) +}) + +describe('truncate', () => { + it('truncates really long strings', () => { + expect(truncate('na '.repeat(1000) + 'batman').length).toBeLessThan(1000) + expect(truncate('na '.repeat(1000) + 'batman')).not.toMatch(/batman/) + }) + + it('does not modify short strings', () => { + expect(truncate('Short strinG')).toEqual('Short strinG') + }) + + it('adds ... to the end of truncated strings', () => { + expect(truncate('repeat'.repeat(1000))).toMatch(/\w\.\.\.$/) + }) + + it('accepts numbers', () => { + expect(truncate(123)).toEqual('123') + expect(truncate(0)).toEqual('0') + expect(truncate(0o000)).toEqual('0') + }) + + it('handles arguments of invalid type', () => { + // @ts-expect-error - Testing JS scenario + expect(truncate(false)).toEqual('false') + + expect(truncate(undefined)).toEqual('') + expect(truncate(null)).toEqual('') + }) +}) + +describe('jsonTruncate', () => { + it('truncates large json structures', () => { + expect( + jsonTruncate({ + foo: 'foo', + bar: 'bar', + baz: 'baz', + kittens: 'kittens meow', + bazinga: 'Sheldon', + nested: { + foobar: 'I have no imagination', + two: 'Second nested item', + }, + five: 5, + bool: false, + }) + ).toMatch(/.+\n.+\w\.\.\.$/s) + }) +}) + +describe('timeTag', () => { + it('renders a date', async () => { + render(
{timeTag(new Date('1970-08-20').toUTCString())}
) + + await waitFor(() => screen.getByText(/1970.*00:00:00/)) + }) + + it('can take an empty input string', async () => { + expect(timeTag('')).toEqual('') + }) +}) + +describe('checkboxInputTag', () => { + it('can be checked', () => { + render(checkboxInputTag(true)) + expect(screen.getByRole('checkbox')).toBeChecked() + }) + + it('can be unchecked', () => { + render(checkboxInputTag(false)) + expect(screen.getByRole('checkbox')).not.toBeChecked() + }) + + it('is disabled when checked', () => { + render(checkboxInputTag(true)) + expect(screen.getByRole('checkbox')).toBeDisabled() + }) + + it('is disabled when unchecked', () => { + render(checkboxInputTag(false)) + expect(screen.getByRole('checkbox')).toBeDisabled() + }) +}) diff --git a/packages/cli/src/commands/generate/scaffold/templates/lib/formatters.tsx.template b/packages/cli/src/commands/generate/scaffold/templates/lib/formatters.tsx.template new file mode 100644 index 000000000000..30552293578e --- /dev/null +++ b/packages/cli/src/commands/generate/scaffold/templates/lib/formatters.tsx.template @@ -0,0 +1,50 @@ +import React from 'react' + +import humanize from 'humanize-string' + +const MAX_STRING_LENGTH = 150 + +export const formatEnum = (values: string | string[] | null | undefined) => { + let output = '' + + if (Array.isArray(values)) { + const humanizedValues = values.map((value) => humanize(value)) + output = humanizedValues.join(', ') + } else if (typeof values === 'string') { + output = humanize(values) + } + + return output +} + +export const truncate = (value: string | number) => { + let output = value?.toString() ?? '' + + if (output.length > MAX_STRING_LENGTH) { + output = output.substring(0, MAX_STRING_LENGTH) + '...' + } + + return output +} + +export const jsonTruncate = (obj: unknown) => { + return truncate(JSON.stringify(obj, null, 2)) +} + +export const timeTag = (dateTime?: string) => { + let output: string | JSX.Element = '' + + if (dateTime) { + output = ( + + ) + } + + return output +} + +export const checkboxInputTag = (checked: boolean) => { + return +} diff --git a/packages/cli/src/lib/index.js b/packages/cli/src/lib/index.js index d33cb44c71f0..0e54679b6ba4 100644 --- a/packages/cli/src/lib/index.js +++ b/packages/cli/src/lib/index.js @@ -244,7 +244,7 @@ export const transformTSToJS = (filename, content) => { retainLines: true, }) - return prettify(filename.replace(/\.ts$/, '.js'), code) + return prettify(filename.replace(/\.tsx?$/, '.js'), code) } /** diff --git a/yarn.lock b/yarn.lock index 3880e0f67cdc..c9e2de79418e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6263,7 +6263,6 @@ __metadata: execa: 5.1.1 fast-glob: 3.2.12 fs-extra: 10.1.0 - humanize-string: 2.1.0 jest: 29.0.3 latest-version: 5.1.0 listr: 0.14.3