From b4f77fa503fbab3980916693c308ac59fb5854ed Mon Sep 17 00:00:00 2001 From: David Lopez Date: Mon, 24 Oct 2022 10:01:43 -0700 Subject: [PATCH] feat: expander init --- ...studio-ui-codegen-react-views.test.ts.snap | 48 ++++- .../__utils__/amplify-renderer-generator.ts | 17 ++ .../studio-ui-codegen-react-views.test.ts | 22 +- .../amplify-view-renderer.ts | 10 + packages/codegen-ui-react/lib/index.ts | 1 + .../lib/react-expander-renderer.ts | 204 ++++++++++++++++++ .../lib/react-table-renderer-helper.ts | 1 + .../lib/views/react-view-renderer.ts | 58 +++-- .../views/expander-with-binding-prop.json | 47 ++++ .../views/expander-with-component-slot.json | 56 +++++ .../lib/__tests__/validation-helper.test.ts | 2 + .../generate-table-definition.ts | 3 + .../codegen-ui/lib/types/view/collection.ts | 51 +++++ packages/codegen-ui/lib/types/view/view.ts | 15 +- .../cypress/e2e/generate-spec.cy.ts | 1 + .../cypress/e2e/views-spec.cy.ts | 37 ++++ .../integration-test-templates/src/App.tsx | 5 + .../src/ViewTests.tsx | 71 ++++++ .../lib/generators/BrowserTestGenerator.ts | 8 + .../lib/generators/NodeTestGenerator.ts | 19 ++ .../lib/generators/TestGenerator.ts | 39 +++- packages/test-generator/lib/index.ts | 1 + packages/test-generator/lib/views/index.ts | 16 ++ .../listing-expander-with-component-slot.json | 47 ++++ 24 files changed, 745 insertions(+), 34 deletions(-) create mode 100644 packages/codegen-ui-react/lib/react-expander-renderer.ts create mode 100644 packages/codegen-ui/example-schemas/views/expander-with-binding-prop.json create mode 100644 packages/codegen-ui/example-schemas/views/expander-with-component-slot.json create mode 100644 packages/codegen-ui/lib/types/view/collection.ts create mode 100644 packages/test-generator/integration-test-templates/cypress/e2e/views-spec.cy.ts create mode 100644 packages/test-generator/integration-test-templates/src/ViewTests.tsx create mode 100644 packages/test-generator/lib/views/index.ts create mode 100644 packages/test-generator/lib/views/listing-expander-with-component-slot.json diff --git a/packages/codegen-ui-react/lib/__tests__/__snapshots__/studio-ui-codegen-react-views.test.ts.snap b/packages/codegen-ui-react/lib/__tests__/__snapshots__/studio-ui-codegen-react-views.test.ts.snap index 0cfe0c320..8608d9deb 100644 --- a/packages/codegen-ui-react/lib/__tests__/__snapshots__/studio-ui-codegen-react-views.test.ts.snap +++ b/packages/codegen-ui-react/lib/__tests__/__snapshots__/studio-ui-codegen-react-views.test.ts.snap @@ -1,5 +1,30 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`amplify expander renderer tests should generate expander with an expander item that has a component as a child 1`] = ` +" + {items.map((item) => ( + + + + ))} +; +" +`; + +exports[`amplify expander renderer tests should generate expander with an expander item that has a data binding as a child 1`] = ` +" + {items.map((item) => ( + + {item.questionAnswer} + + ))} +; +" +`; + exports[`amplify table renderer tests should generate a non-datastore table element 1`] = ` " {!disableHeaders && ( @@ -90,6 +115,7 @@ exports[`amplify view renderer tests should call util file if rendered 1`] = ` "/* eslint-disable */ import * as React from \\"react\\"; import { formatter } from \\"./utils\\"; +import { Post } from \\"../models\\"; import { createDataStorePredicate, useDataStoreBinding, @@ -104,7 +130,7 @@ import { } from \\"@aws-amplify/ui-react\\"; export default function MyPostTable(props) { const { - items: itemsProps, + items: itemsProp, predicateOverride, formatOverride, highlightOnHover, @@ -120,13 +146,13 @@ export default function MyPostTable(props) { }; const postPredicate = createDataStorePredicate(postFilter); const postPagination = { sort: (s) => s.username(SortDirection.ASCENDING) }; - const MyPostTableDataStore = useDataStoreBinding({ + const postDataStore = useDataStoreBinding({ type: \\"collection\\", model: Post, criteria: predicateOverride || postPredicate, pagination: postPagination, }).items; - const items = itemsProp !== undefined ? itemsProp : MyPostTableDataStore; + const items = itemsProp !== undefined ? itemsProp : postDataStore; return (
{!disableHeaders && ( @@ -192,9 +218,10 @@ export default function MyPostTable(props) { exports[`amplify view renderer tests should call util file if rendered 2`] = ` "import * as React from \\"react\\"; -import { EscapeHatchProps } from \\"@aws-amplify/ui-react/internal\\"; +import { EscapeHatchProps, createDataStorePredicate } from \\"@aws-amplify/ui-react/internal\\"; export declare type MyPostTableProps = React.PropsWithChildren<{ overrides?: EscapeHatchProps | undefined | null; + predicateOverride?: ReturnType | undefined | null; }>; export default function MyPostTable(props: MyPostTableProps): React.ReactElement; " @@ -259,9 +286,10 @@ export default function CustomTable(props) { exports[`amplify view renderer tests should render view with custom datastore 2`] = ` "import * as React from \\"react\\"; -import { EscapeHatchProps } from \\"@aws-amplify/ui-react/internal\\"; +import { EscapeHatchProps, createDataStorePredicate } from \\"@aws-amplify/ui-react/internal\\"; export declare type CustomTableProps = React.PropsWithChildren<{ overrides?: EscapeHatchProps | undefined | null; + predicateOverride?: ReturnType | undefined | null; }>; export default function CustomTable(props: CustomTableProps): React.ReactElement; " @@ -270,6 +298,7 @@ export default function CustomTable(props: CustomTableProps): React.ReactElement exports[`amplify view renderer tests should render view with passed in predicate and sort 1`] = ` "/* eslint-disable */ import * as React from \\"react\\"; +import { Post } from \\"../models\\"; import { createDataStorePredicate, useDataStoreBinding, @@ -284,7 +313,7 @@ import { } from \\"@aws-amplify/ui-react\\"; export default function MyPostTable(props) { const { - items: itemsProps, + items: itemsProp, predicateOverride, formatOverride, highlightOnHover, @@ -300,13 +329,13 @@ export default function MyPostTable(props) { }; const postPredicate = createDataStorePredicate(postFilter); const postPagination = { sort: (s) => s.username(SortDirection.ASCENDING) }; - const MyPostTableDataStore = useDataStoreBinding({ + const postDataStore = useDataStoreBinding({ type: \\"collection\\", model: Post, criteria: predicateOverride || postPredicate, pagination: postPagination, }).items; - const items = itemsProp !== undefined ? itemsProp : MyPostTableDataStore; + const items = itemsProp !== undefined ? itemsProp : postDataStore; return (
{!disableHeaders && ( @@ -369,9 +398,10 @@ export default function MyPostTable(props) { exports[`amplify view renderer tests should render view with passed in predicate and sort 2`] = ` "import * as React from \\"react\\"; -import { EscapeHatchProps } from \\"@aws-amplify/ui-react/internal\\"; +import { EscapeHatchProps, createDataStorePredicate } from \\"@aws-amplify/ui-react/internal\\"; export declare type MyPostTableProps = React.PropsWithChildren<{ overrides?: EscapeHatchProps | undefined | null; + predicateOverride?: ReturnType | undefined | null; }>; export default function MyPostTable(props: MyPostTableProps): React.ReactElement; " diff --git a/packages/codegen-ui-react/lib/__tests__/__utils__/amplify-renderer-generator.ts b/packages/codegen-ui-react/lib/__tests__/__utils__/amplify-renderer-generator.ts index 6a2e67286..624274a8b 100644 --- a/packages/codegen-ui-react/lib/__tests__/__utils__/amplify-renderer-generator.ts +++ b/packages/codegen-ui-react/lib/__tests__/__utils__/amplify-renderer-generator.ts @@ -118,3 +118,20 @@ export const genericPrinter = (node: Node): string => { }); return printer.printNode(EmitHint.Unspecified, node, file); }; + +export const renderExpanderJsxElement = ( + filePath: string, + dataSchemaFilePath: string | undefined, + snapshotFileName: string, + renderConfig: ReactRenderConfig = defaultCLIRenderConfig, +): string => { + const expander = loadSchemaFromJSONFile(filePath); + const dataSchema = dataSchemaFilePath ? loadSchemaFromJSONFile(dataSchemaFilePath) : undefined; + const expanderJsx = new AmplifyViewRenderer(expander, dataSchema, renderConfig).renderJsx(); + + const file = createSourceFile(snapshotFileName, '', ScriptTarget.ES2015, true, ScriptKind.TS); + const printer = createPrinter(); + const expanderNode = printer.printNode(EmitHint.Unspecified, expanderJsx, file); + + return transpile(expanderNode, {}).componentText; +}; diff --git a/packages/codegen-ui-react/lib/__tests__/studio-ui-codegen-react-views.test.ts b/packages/codegen-ui-react/lib/__tests__/studio-ui-codegen-react-views.test.ts index ac36b8c38..e4fcf8f04 100644 --- a/packages/codegen-ui-react/lib/__tests__/studio-ui-codegen-react-views.test.ts +++ b/packages/codegen-ui-react/lib/__tests__/studio-ui-codegen-react-views.test.ts @@ -13,7 +13,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { renderTableJsxElement, renderWithAmplifyViewRenderer } from './__utils__'; +import { renderExpanderJsxElement, renderTableJsxElement, renderWithAmplifyViewRenderer } from './__utils__'; describe('amplify table renderer tests', () => { test('should generate a table element', () => { @@ -27,6 +27,26 @@ describe('amplify table renderer tests', () => { }); }); +describe('amplify expander renderer tests', () => { + test('should generate expander with an expander item that has a component as a child', () => { + const expander = renderExpanderJsxElement( + 'views/expander-with-component-slot', + undefined, + 'test-expander-component-slot.ts', + ); + expect(expander).toMatchSnapshot(); + }); + + test('should generate expander with an expander item that has a data binding as a child', () => { + const expander = renderExpanderJsxElement( + 'views/expander-with-binding-prop', + undefined, + 'test-expander-binding-prop.ts', + ); + expect(expander).toMatchSnapshot(); + }); +}); + describe('amplify view renderer tests', () => { test('should render view with passed in predicate and sort', () => { const { componentText, declaration } = renderWithAmplifyViewRenderer( diff --git a/packages/codegen-ui-react/lib/amplify-ui-renderers/amplify-view-renderer.ts b/packages/codegen-ui-react/lib/amplify-ui-renderers/amplify-view-renderer.ts index eed37e3cf..93c6d019d 100644 --- a/packages/codegen-ui-react/lib/amplify-ui-renderers/amplify-view-renderer.ts +++ b/packages/codegen-ui-react/lib/amplify-ui-renderers/amplify-view-renderer.ts @@ -18,6 +18,7 @@ import { JsxElement, factory, JsxFragment } from 'typescript'; import { ReactViewTemplateRenderer } from '../views/react-view-renderer'; import { Primitive } from '../primitive'; import { ReactTableRenderer } from '../react-table-renderer'; +import { ReactExpanderRenderer } from '../react-expander-renderer'; export class AmplifyViewRenderer extends ReactViewTemplateRenderer { renderJsx(): JsxElement | JsxFragment { @@ -29,6 +30,15 @@ export class AmplifyViewRenderer extends ReactViewTemplateRenderer { this.viewMetadata, this.importCollection, ).renderElement(); + case 'Collection': + switch (this.viewComponent.viewConfiguration.collection.collectionType) { + case 'expander': + return new ReactExpanderRenderer(this.viewComponent, this.importCollection).renderElement(); + default: + throw new Error( + `Collection type ${this.viewComponent.viewConfiguration.collection.collectionType} is not supported`, + ); + } default: return factory.createJsxFragment(factory.createJsxOpeningFragment(), [], factory.createJsxJsxClosingFragment()); } diff --git a/packages/codegen-ui-react/lib/index.ts b/packages/codegen-ui-react/lib/index.ts index 25fd4db47..2b978dbed 100644 --- a/packages/codegen-ui-react/lib/index.ts +++ b/packages/codegen-ui-react/lib/index.ts @@ -14,6 +14,7 @@ limitations under the License. */ export * from './react-component-renderer'; +export * from './react-expander-renderer'; export * from './react-table-renderer'; export * from './imports'; export * from './react-studio-template-renderer'; diff --git a/packages/codegen-ui-react/lib/react-expander-renderer.ts b/packages/codegen-ui-react/lib/react-expander-renderer.ts new file mode 100644 index 000000000..bc6079612 --- /dev/null +++ b/packages/codegen-ui-react/lib/react-expander-renderer.ts @@ -0,0 +1,204 @@ +/* + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"). + You may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +import { StudioView } from '@aws-amplify/codegen-ui/lib/types'; +import { factory, JsxChild, JsxElement, SyntaxKind } from 'typescript'; +import { ImportCollection, ImportSource } from './imports'; +import { Primitive } from './primitive'; + +export class ReactExpanderRenderer { + private requiredUIReactImports = [Primitive.Expander, Primitive.ExpanderItem]; + + protected expander: StudioView; + + constructor(expander: StudioView, private imports: ImportCollection) { + this.expander = expander; + + this.requiredUIReactImports.forEach((importName) => { + imports.addImport(ImportSource.UI_REACT, importName); + }); + } + + renderElement(): JsxElement { + return factory.createJsxElement( + this.createOpeningExpanderElement(), + [this.createExpanderChildren()], + factory.createJsxClosingElement(factory.createIdentifier(Primitive.Expander)), + ); + } + + createExpanderChildren() { + const itemParam = factory.createParameterDeclaration( + undefined, + undefined, + undefined, + factory.createIdentifier('item'), + undefined, + undefined, + undefined, + ); + return factory.createJsxExpression( + undefined, + factory.createCallExpression( + factory.createPropertyAccessExpression(factory.createIdentifier('items'), factory.createIdentifier('map')), + undefined, + [ + factory.createArrowFunction( + undefined, + undefined, + [itemParam], + undefined, + factory.createToken(SyntaxKind.EqualsGreaterThanToken), + factory.createParenthesizedExpression(this.createExpanderItemRow()), + ), + ], + ), + ); + } + + createExpanderItemRow() { + const child = this.createExpanderItemChild(); + if (!child) throw new Error('could not determine ExpanderItem child to render'); + return factory.createJsxElement( + factory.createJsxOpeningElement( + factory.createIdentifier(Primitive.ExpanderItem), + undefined, + this.createExpanderItemAttributes(), + ), + [child], + factory.createJsxClosingElement(factory.createIdentifier(Primitive.ExpanderItem)), + ); + } + + createExpanderItemChild(): JsxChild | undefined { + const { viewConfiguration } = this.expander; + if (viewConfiguration.type !== 'Collection') { + throw new Error(`Cannot create ExpanderItem child for view type: ${viewConfiguration.type}`); + } + switch (viewConfiguration.collection.body.type) { + case 'Amplify.ComponentSlot': { + if (!viewConfiguration.collection.body.content.componentSlot.componentName) { + throw new Error('componentName must be defined'); + } + const { componentSlot } = viewConfiguration.collection.body.content; + this.imports.addImport(ImportSource.UI_REACT, componentSlot.componentName); + + const attributes = Object.entries(componentSlot.bindingProperties).map(([key, value]) => { + const attributeValue = value.field + ? factory.createPropertyAccessExpression( + factory.createIdentifier(value.property), + factory.createIdentifier(value.field), + ) + : factory.createIdentifier(value.property); + + return factory.createJsxAttribute( + factory.createIdentifier(key), + factory.createJsxExpression(undefined, attributeValue), + ); + }); + + return factory.createJsxSelfClosingElement( + factory.createIdentifier(viewConfiguration.collection.body.content.componentSlot.componentName), + undefined, + factory.createJsxAttributes(attributes), + ); + } + case 'Amplify.Binding': { + if (!viewConfiguration.collection.body.content.bindingProperty) { + throw new Error('bindingProperty must be defined'); + } + const { property, field } = viewConfiguration.collection.body.content.bindingProperty; + if (!(property && field)) { + throw new Error('property and field must be defined'); + } + return factory.createJsxExpression( + undefined, + factory.createPropertyAccessExpression(factory.createIdentifier(property), factory.createIdentifier(field)), + ); + } + default: { + // "viewConfiguration.collection.body.type" should be a "never" TS type here, so we can't reference it. + throw new Error(`Unrecognized value for field type in viewConfiguration.collection.body`); + } + } + } + + createExpanderItemAttributes() { + const { viewConfiguration } = this.expander; + if (viewConfiguration.type === 'Collection') { + switch (viewConfiguration.collection.title?.type) { + case 'Amplify.Binding': + return factory.createJsxAttributes([ + factory.createJsxAttribute( + factory.createIdentifier('title'), + factory.createJsxExpression( + undefined, + factory.createPropertyAccessExpression( + /** + * leaving "item" hard coded since it's controlled by the code in this package + * and it should never be something else in this context, + * but the user can still set this in the schema. + */ + factory.createIdentifier('item'), + factory.createIdentifier(viewConfiguration.collection.title.content.bindingProperty.field), + ), + ), + ), + /** + * Value is needed for the expander item to expand, + * the user doesn't need it + */ + factory.createJsxAttribute( + factory.createIdentifier('value'), + factory.createJsxExpression( + undefined, + factory.createPropertyAccessExpression( + factory.createIdentifier('item'), + factory.createIdentifier('id'), + ), + ), + ), + /** + * Key here is for React to render a list of items + */ + factory.createJsxAttribute( + factory.createIdentifier('key'), + factory.createJsxExpression( + undefined, + factory.createPropertyAccessExpression( + factory.createIdentifier('item'), + factory.createIdentifier('id'), + ), + ), + ), + ]); + default: + throw new Error(`Unsupported Expander title type, ${viewConfiguration.collection.title.type}`); + } + } + return factory.createJsxAttributes([]); + } + + createOpeningExpanderElement() { + return factory.createJsxOpeningElement( + factory.createIdentifier(Primitive.Expander), + undefined, + // TODO: updating "type" here is handled in a future task + factory.createJsxAttributes([ + factory.createJsxAttribute(factory.createIdentifier('type'), factory.createStringLiteral('multiple')), + ]), + ); + } +} diff --git a/packages/codegen-ui-react/lib/react-table-renderer-helper.ts b/packages/codegen-ui-react/lib/react-table-renderer-helper.ts index b52ea9d17..fc5b221c0 100644 --- a/packages/codegen-ui-react/lib/react-table-renderer-helper.ts +++ b/packages/codegen-ui-react/lib/react-table-renderer-helper.ts @@ -19,6 +19,7 @@ import { CallExpression, factory, ObjectLiteralExpression, SyntaxKind } from 'ty export const getFilterName = (model: string) => `${model.toLowerCase()}Filter`; export const getPredicateName = (model: string) => `${model.toLowerCase()}Predicate`; export const getPaginationName = (model: string) => `${model.toLowerCase()}Pagination`; +export const getDataStoreName = (model: string) => `${model.toLowerCase()}DataStore`; /* checks table to see if there is a formatter for stringFormat diff --git a/packages/codegen-ui-react/lib/views/react-view-renderer.ts b/packages/codegen-ui-react/lib/views/react-view-renderer.ts index 7b1a482f0..f5e9e4a34 100644 --- a/packages/codegen-ui-react/lib/views/react-view-renderer.ts +++ b/packages/codegen-ui-react/lib/views/react-view-renderer.ts @@ -25,6 +25,7 @@ import { handleCodegenErrors, validateViewSchema, StudioComponentPredicate, + DEFAULT_TABLE_DEFINITION, } from '@aws-amplify/codegen-ui'; import { addSyntheticLeadingComment, @@ -60,6 +61,7 @@ import { ReactRenderConfig, scriptKindToFileExtension } from '../react-render-co import { RequiredKeys } from '../utils/type-utils'; import { buildDataStoreCollectionCall, + getDataStoreName, getFilterName, getPaginationName, getPredicateName, @@ -79,7 +81,7 @@ export abstract class ReactViewTemplateRenderer extends StudioTemplateRenderer< protected renderConfig: RequiredKeys; - protected viewDefinition: TableDefinition; + protected viewDefinition: TableDefinition = DEFAULT_TABLE_DEFINITION; protected viewComponent: StudioView; @@ -101,18 +103,20 @@ export abstract class ReactViewTemplateRenderer extends StudioTemplateRenderer< switch (component.viewConfiguration.type) { case 'Table': this.viewDefinition = generateTableDefinition(component, dataSchema); + // find if formatter is required + if (needsFormatter(component.viewConfiguration)) { + this.importCollection.addMappedImport(ImportValue.FORMATTER); + } + break; + case 'Collection': + // 'Collection' type doesn't need a viewDefinition break; default: - throw new Error(`Type: ${component.viewConfiguration.type} is not supported.`); + throw new Error(`Encountered a viewConfiguration type that is not supported.`); } this.viewComponent = component; - // find if formatter is required - if (needsFormatter(component.viewConfiguration)) { - this.importCollection.addMappedImport(ImportValue.FORMATTER); - } - this.viewMetadata = { id: component.id, name: component.name, @@ -252,15 +256,16 @@ export abstract class ReactViewTemplateRenderer extends StudioTemplateRenderer< const statements: Statement[] = []; const elements: BindingElement[] = []; const { type, model, predicate, sort } = this.viewComponent.dataSource; + const itemsProp = 'itemsProp'; const isDataStoreEnabled = type === 'DataStore' && model; if (isDataStoreEnabled) { - this.importCollection.addImport(ImportSource.LOCAL_MODELS, this.component.dataSource.type); + this.importCollection.addImport(ImportSource.LOCAL_MODELS, model); this.importCollection.addMappedImport(ImportValue.USE_DATA_STORE_BINDING); elements.push( factory.createBindingElement( undefined, factory.createIdentifier('items'), - factory.createIdentifier('itemsProps'), + factory.createIdentifier(itemsProp), undefined, ), factory.createBindingElement(undefined, undefined, factory.createIdentifier('predicateOverride'), undefined), @@ -272,13 +277,15 @@ export abstract class ReactViewTemplateRenderer extends StudioTemplateRenderer< // add base Props // props - const props = [ - factory.createBindingElement(undefined, undefined, factory.createIdentifier('formatOverride'), undefined), - factory.createBindingElement(undefined, undefined, factory.createIdentifier('highlightOnHover'), undefined), - factory.createBindingElement(undefined, undefined, factory.createIdentifier('onRowClick'), undefined), - factory.createBindingElement(undefined, undefined, factory.createIdentifier('disableHeaders'), undefined), - ]; - elements.push(...props); + if (this.viewComponent.viewConfiguration.type === 'Table') { + const props = [ + factory.createBindingElement(undefined, undefined, factory.createIdentifier('formatOverride'), undefined), + factory.createBindingElement(undefined, undefined, factory.createIdentifier('highlightOnHover'), undefined), + factory.createBindingElement(undefined, undefined, factory.createIdentifier('onRowClick'), undefined), + factory.createBindingElement(undefined, undefined, factory.createIdentifier('disableHeaders'), undefined), + ]; + elements.push(...props); + } // get rest of props to pass to top level component elements.push( @@ -385,7 +392,7 @@ export abstract class ReactViewTemplateRenderer extends StudioTemplateRenderer< if custom enabled uses regular items array for formatting */ - const dsItemsName = factory.createIdentifier(`${this.viewComponent.name}DataStore`); + const dsItemsName = factory.createIdentifier(getDataStoreName(model)); statements.push( buildBaseCollectionVariableStatement( dsItemsName, @@ -406,12 +413,12 @@ export abstract class ReactViewTemplateRenderer extends StudioTemplateRenderer< undefined, factory.createConditionalExpression( factory.createBinaryExpression( - factory.createIdentifier('itemsProp'), + factory.createIdentifier(itemsProp), factory.createToken(SyntaxKind.ExclamationEqualsEqualsToken), factory.createIdentifier('undefined'), ), factory.createToken(SyntaxKind.QuestionToken), - factory.createIdentifier('itemsProp'), + factory.createIdentifier(itemsProp), factory.createToken(SyntaxKind.ColonToken), dsItemsName, ), @@ -455,10 +462,23 @@ export abstract class ReactViewTemplateRenderer extends StudioTemplateRenderer< factory.createLiteralTypeNode(factory.createNull()), ]), ), + factory.createPropertySignature( + undefined, + factory.createIdentifier('predicateOverride'), + factory.createToken(SyntaxKind.QuestionToken), + factory.createUnionTypeNode([ + factory.createTypeReferenceNode(factory.createIdentifier('ReturnType'), [ + factory.createTypeQueryNode(factory.createIdentifier('createDataStorePredicate')), + ]), + factory.createKeywordTypeNode(SyntaxKind.UndefinedKeyword), + factory.createLiteralTypeNode(factory.createNull()), + ]), + ), ]); const formPropType = getComponentPropName(this.component.name); this.importCollection.addMappedImport(ImportValue.ESCAPE_HATCH_PROPS); + this.importCollection.addMappedImport(ImportValue.CREATE_DATA_STORE_PREDICATE); return [ factory.createTypeAliasDeclaration( diff --git a/packages/codegen-ui/example-schemas/views/expander-with-binding-prop.json b/packages/codegen-ui/example-schemas/views/expander-with-binding-prop.json new file mode 100644 index 000000000..56bd088b5 --- /dev/null +++ b/packages/codegen-ui/example-schemas/views/expander-with-binding-prop.json @@ -0,0 +1,47 @@ +{ + "appId": "d1234", + "dataSource": { + "model": "Faq", + "predicate": { + "field": "enabled", + "operand": "true", + "operator": "eq" + }, + "sort": [ + { + "field": "updatedAt", + "direction": "DESC" + } + ], + "type": "DataStore" + }, + "environmentName": "staging", + "id": "v-123456", + "name": "FaqExpander", + "schemaVersion": "1.0", + "style": {}, + "viewConfiguration": { + "type": "Collection", + "collection": { + "collectionType": "expander", + "title": { + "type": "Amplify.Binding", + "content": { + "bindingProperty": { + "property": "item", + "field": "questionTitle" + } + } + }, + "body": { + "type": "Amplify.Binding", + "content": { + "bindingProperty": { + "property": "item", + "field": "questionAnswer" + } + } + } + } + } +} \ No newline at end of file diff --git a/packages/codegen-ui/example-schemas/views/expander-with-component-slot.json b/packages/codegen-ui/example-schemas/views/expander-with-component-slot.json new file mode 100644 index 000000000..367576966 --- /dev/null +++ b/packages/codegen-ui/example-schemas/views/expander-with-component-slot.json @@ -0,0 +1,56 @@ +{ + "appId": "d1234", + "dataSource": { + "model": "Faq", + "predicate": { + "field": "enabled", + "operand": "true", + "operator": "eq" + }, + "sort": [ + { + "field": "updatedAt", + "direction": "DESC" + } + ], + "type": "DataStore" + }, + "environmentName": "staging", + "id": "v-123456", + "name": "FaqExpander", + "schemaVersion": "1.0", + "style": {}, + "viewConfiguration": { + "type": "Collection", + "collection": { + "collectionType": "expander", + "title": { + "type": "Amplify.Binding", + "content": { + "bindingProperty": { + "property": "item", + "field": "questionTitle" + } + } + }, + "body": { + "type": "Amplify.ComponentSlot", + "content": { + "componentSlot": { + "componentName": "Ampligram", + "bindingProperties": { + "title": { + "property": "item", + "field": "answerTitle" + }, + "description": { + "property": "item", + "field": "answerDescription" + } + } + } + } + } + } + } +} \ No newline at end of file diff --git a/packages/codegen-ui/lib/__tests__/validation-helper.test.ts b/packages/codegen-ui/lib/__tests__/validation-helper.test.ts index 4f635d80c..2b3975bd6 100644 --- a/packages/codegen-ui/lib/__tests__/validation-helper.test.ts +++ b/packages/codegen-ui/lib/__tests__/validation-helper.test.ts @@ -431,5 +431,7 @@ describe('validation-helper', () => { }); }).toThrowErrorMatchingSnapshot(); }); + + // TODO: add tests for validateFormSchema and validateViewSchema }); }); diff --git a/packages/codegen-ui/lib/generate-view-definition/generate-table-definition.ts b/packages/codegen-ui/lib/generate-view-definition/generate-table-definition.ts index 40d0bad0d..4609c9783 100644 --- a/packages/codegen-ui/lib/generate-view-definition/generate-table-definition.ts +++ b/packages/codegen-ui/lib/generate-view-definition/generate-table-definition.ts @@ -34,6 +34,9 @@ import { orderAndFilterVisibleColumns } from './helpers'; * @returns a definition that translates to rendered JSX elements. */ export function generateTableDefinition(table: StudioView, dataSchema?: GenericDataSchema): TableDefinition { + if (table.viewConfiguration.type !== 'Table') { + throw new Error(`Cannot generate a Table definition for viewConfiguration type ${table.viewConfiguration.type}`); + } const definition = DEFAULT_TABLE_DEFINITION; definition.tableStyle = { diff --git a/packages/codegen-ui/lib/types/view/collection.ts b/packages/codegen-ui/lib/types/view/collection.ts new file mode 100644 index 000000000..935902f3b --- /dev/null +++ b/packages/codegen-ui/lib/types/view/collection.ts @@ -0,0 +1,51 @@ +/* + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"). + You may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +export interface CollectionBindingProperty { + property: string; + field: string; +} + +export interface CollectionBodyComponentSlot { + componentName: string; + bindingProperties: { + [propertyName: string]: CollectionBindingProperty; + }; +} + +export interface CollectionBodyContent { + bindingProperty?: CollectionBindingProperty; + componentSlot?: CollectionBodyComponentSlot; +} + +export interface CollectionBindingTitle { + type: 'Amplify.Binding'; + content: { + bindingProperty: CollectionBindingProperty; + }; +} + +export interface CollectionComponentSlotBody { + type: 'Amplify.ComponentSlot'; + content: { + componentSlot: CollectionBodyComponentSlot; + }; +} +export interface CollectionBindingBody { + type: 'Amplify.Binding'; + content: { + bindingProperty: CollectionBindingProperty; + }; +} diff --git a/packages/codegen-ui/lib/types/view/view.ts b/packages/codegen-ui/lib/types/view/view.ts index b0027ee01..65e52b98f 100644 --- a/packages/codegen-ui/lib/types/view/view.ts +++ b/packages/codegen-ui/lib/types/view/view.ts @@ -14,6 +14,7 @@ limitations under the License. */ import { StudioComponentPredicate, StudioComponentSort } from '../bindings'; +import { CollectionBindingBody, CollectionComponentSlotBody, CollectionBindingTitle } from './collection'; import { ViewStyle } from './style'; import { ColumnsMap } from './table'; @@ -29,6 +30,8 @@ export interface StudioView { viewConfiguration: ViewConfiguration; } +export declare type ViewType = 'Table' | 'Collection'; + export interface BaseViewConfiguration { type: ViewType; } @@ -42,9 +45,17 @@ export interface TableConfiguration extends BaseViewConfiguration { enableOnRowClick?: boolean; }; } +export interface CollectionConfiguration extends BaseViewConfiguration { + type: 'Collection'; + collection: { + body: CollectionComponentSlotBody | CollectionBindingBody; + collectionType: 'expander'; + title: CollectionBindingTitle; + }; +} // Append other configuration types here -export type ViewConfiguration = TableConfiguration; +export type ViewConfiguration = TableConfiguration | CollectionConfiguration | CollectionConfiguration; export interface ViewDataTypeConfig { identifiers?: string[]; @@ -66,5 +77,3 @@ export interface ViewSummary { } export declare type ViewSummaryList = ViewSummary[]; - -export declare type ViewType = 'Table'; diff --git a/packages/test-generator/integration-test-templates/cypress/e2e/generate-spec.cy.ts b/packages/test-generator/integration-test-templates/cypress/e2e/generate-spec.cy.ts index dbd961c07..590dbde9a 100644 --- a/packages/test-generator/integration-test-templates/cypress/e2e/generate-spec.cy.ts +++ b/packages/test-generator/integration-test-templates/cypress/e2e/generate-spec.cy.ts @@ -28,6 +28,7 @@ const EXPECTED_SUCCESSFUL_CASES = new Set([ 'BasicComponentImage', 'BasicComponentText', 'BasicComponentCustomRating', + 'ListingExpanderWithComponentSlot', 'CustomFormCreateDog', 'DataStoreFormCreateAllSupportedFormFields', 'ComponentWithDataBindingWithPredicate', diff --git a/packages/test-generator/integration-test-templates/cypress/e2e/views-spec.cy.ts b/packages/test-generator/integration-test-templates/cypress/e2e/views-spec.cy.ts new file mode 100644 index 000000000..bf346698f --- /dev/null +++ b/packages/test-generator/integration-test-templates/cypress/e2e/views-spec.cy.ts @@ -0,0 +1,37 @@ +/* + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"). + You may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +describe('Expander', () => { + before(() => { + cy.visit('http://localhost:3000/view-tests'); + }); + + it('renders expander with component slot', () => { + cy.get('#expanderWithSlot').within(() => { + const description = 'ut labore et dolore magna aliqua. Ut enim ad minim veniam'; + cy.contains(description).should('not.exist'); + // Open expander item to show description + cy.contains('Quiet Cottage').click(); + cy.contains(description); + }); + }); + + it('renders expander with predicateOverride prop', () => { + cy.get('#expanderWithPredicateOverride').within(() => { + // predicateOverride filters out all items but one. + cy.get(`[data-testid=expander-item]`).should('have.length', 1); + }); + }); +}); diff --git a/packages/test-generator/integration-test-templates/src/App.tsx b/packages/test-generator/integration-test-templates/src/App.tsx index a394b2bfb..ee9d503b3 100644 --- a/packages/test-generator/integration-test-templates/src/App.tsx +++ b/packages/test-generator/integration-test-templates/src/App.tsx @@ -19,6 +19,7 @@ import ComponentTests from './ComponentTests'; import GenerateTests from './GenerateTests'; import PrimitivesTests from './PrimitivesTests'; import ComplexTests from './ComplexTests'; +import ViewTests from './ViewTests'; import SnippetTests from './SnippetTests'; // eslint-disable-line import/extensions import WorkflowTests from './WorkflowTests'; import TwoWayBindingTests from './TwoWayBindingTests'; @@ -55,6 +56,9 @@ const HomePage = () => {
  • Workflow Tests
  • +
  • + View Tests +
  • Two Way Binding Tests
  • @@ -79,6 +83,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/packages/test-generator/integration-test-templates/src/ViewTests.tsx b/packages/test-generator/integration-test-templates/src/ViewTests.tsx new file mode 100644 index 000000000..117ef414d --- /dev/null +++ b/packages/test-generator/integration-test-templates/src/ViewTests.tsx @@ -0,0 +1,71 @@ +/* + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"). + You may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +import '@aws-amplify/ui-react/styles.css'; +import { AmplifyProvider, View, Heading } from '@aws-amplify/ui-react'; +import { createDataStorePredicate } from '@aws-amplify/ui-react/internal'; +import { useState, useRef, useEffect } from 'react'; +import { ListingExpanderWithComponentSlot, MyTheme } from './ui-components'; // eslint-disable-line import/extensions +import { initializeListingTestData } from './mock-utils'; + +export default function FormTests() { + const [isInitialized, setInitialized] = useState(false); + const initializeStarted = useRef(false); + + useEffect(() => { + const initializeTestUserData = async () => { + if (initializeStarted.current) { + return; + } + // DataStore.clear() doesn't appear to reliably work in this scenario. + const ddbRequest = indexedDB.deleteDatabase('amplify-datastore'); + await new Promise((res, rej) => { + ddbRequest.onsuccess = () => { + res(true); + }; + ddbRequest.onerror = () => { + rej(ddbRequest.error); + }; + }); + await Promise.all([initializeListingTestData()]); + setInitialized(true); + }; + + initializeTestUserData(); + initializeStarted.current = true; + }, []); + + if (!isInitialized) { + return null; + } + return ( + + + Expander with Component Slot + + + + Expander with Component Slot + + + + ); +} diff --git a/packages/test-generator/lib/generators/BrowserTestGenerator.ts b/packages/test-generator/lib/generators/BrowserTestGenerator.ts index b5d83a67f..a5ef7fb45 100644 --- a/packages/test-generator/lib/generators/BrowserTestGenerator.ts +++ b/packages/test-generator/lib/generators/BrowserTestGenerator.ts @@ -21,6 +21,7 @@ import { StudioComponent, StudioForm, StudioTheme, + StudioView, } from '@aws-amplify/codegen-ui'; import { AmplifyRenderer, @@ -29,6 +30,7 @@ import { ReactUtilsStudioTemplateRenderer, AmplifyFormRenderer, UtilTemplateType, + AmplifyViewRenderer, } from '@aws-amplify/codegen-ui-react'; import schema from '../models/schema'; import { TestGenerator } from './TestGenerator'; @@ -42,6 +44,8 @@ export class BrowserTestGenerator extends TestGenerator { return { formMetadata: {} as FormMetadata }; } // no-op + writeViewToDisk() {} // no-op + writeIndexFileToDisk() {} // no-op writeUtilsFileToDisk() {} // no-op @@ -64,6 +68,10 @@ export class BrowserTestGenerator extends TestGenerator { return new AmplifyFormRenderer(form, getGenericFromDataStore(schema), this.renderConfig).renderComponentOnly(); } + renderView(view: StudioView) { + return new AmplifyViewRenderer(view, getGenericFromDataStore(schema), this.renderConfig).renderComponentOnly(); + } + renderTheme(theme: StudioTheme) { return new ReactThemeStudioTemplateRenderer(theme, this.renderConfig).renderComponent(); } diff --git a/packages/test-generator/lib/generators/NodeTestGenerator.ts b/packages/test-generator/lib/generators/NodeTestGenerator.ts index 1cdca03d5..390872da0 100644 --- a/packages/test-generator/lib/generators/NodeTestGenerator.ts +++ b/packages/test-generator/lib/generators/NodeTestGenerator.ts @@ -25,6 +25,7 @@ import { StudioForm, getGenericFromDataStore, StudioSchema, + StudioView, } from '@aws-amplify/codegen-ui'; import { AmplifyRenderer, @@ -33,6 +34,7 @@ import { ReactUtilsStudioTemplateRenderer, AmplifyFormRenderer, UtilTemplateType, + AmplifyViewRenderer, } from '@aws-amplify/codegen-ui-react'; import schema from '../models/schema'; import { TestGenerator, TestGeneratorParams } from './TestGenerator'; @@ -44,6 +46,8 @@ export class NodeTestGenerator extends TestGenerator { private readonly formRendererFactory: any; + private readonly viewRendererFactory: any; + private readonly indexRendererFactory: any; private readonly utilsRendererFactory: any; @@ -52,6 +56,8 @@ export class NodeTestGenerator extends TestGenerator { private readonly formRendererManager: any; + private readonly viewRendererManager: any; + private readonly themeRendererManager: any; private readonly indexRendererManager: any; @@ -70,6 +76,9 @@ export class NodeTestGenerator extends TestGenerator { this.formRendererFactory = new StudioTemplateRendererFactory( (form: StudioForm) => new AmplifyFormRenderer(form, getGenericFromDataStore(schema), this.renderConfig), ); + this.viewRendererFactory = new StudioTemplateRendererFactory( + (view: StudioView) => new AmplifyViewRenderer(view, getGenericFromDataStore(schema), this.renderConfig), + ); this.indexRendererFactory = new StudioTemplateRendererFactory( (schemas: StudioSchema[]) => new ReactIndexStudioTemplateRenderer(schemas, this.renderConfig), ); @@ -78,6 +87,7 @@ export class NodeTestGenerator extends TestGenerator { ); this.componentRendererManager = new StudioTemplateRendererManager(this.componentRendererFactory, this.outputConfig); this.formRendererManager = new StudioTemplateRendererManager(this.formRendererFactory, this.outputConfig); + this.viewRendererManager = new StudioTemplateRendererManager(this.viewRendererFactory, this.outputConfig); this.themeRendererManager = new StudioTemplateRendererManager(this.themeRendererFactory, this.outputConfig); this.indexRendererManager = new StudioTemplateRendererManager(this.indexRendererFactory, this.outputConfig); this.utilsRendererManager = new StudioTemplateRendererManager(this.utilsRendererFactory, this.outputConfig); @@ -96,11 +106,20 @@ export class NodeTestGenerator extends TestGenerator { return this.formRendererManager.renderSchemaToTemplate(form); } + writeViewToDisk(view: StudioView) { + return this.viewRendererManager.renderSchemaToTemplate(view); + } + renderForm(form: StudioForm) { const buildRenderer = this.formRendererFactory.buildRenderer(form); return buildRenderer.renderComponentOnly(); } + renderView(view: StudioView) { + const buildRenderer = this.viewRendererFactory.buildRenderer(view); + return buildRenderer.renderComponentOnly(); + } + writeSnippetToDisk(components: StudioComponent[]) { const { importsText, compText } = this.renderSnippet(components); fs.writeFileSync(path.join(this.outputConfig.outputPathDir, '..', 'SnippetTests.jsx'), importsText + compText); diff --git a/packages/test-generator/lib/generators/TestGenerator.ts b/packages/test-generator/lib/generators/TestGenerator.ts index 8d6f08587..f568c1ce9 100644 --- a/packages/test-generator/lib/generators/TestGenerator.ts +++ b/packages/test-generator/lib/generators/TestGenerator.ts @@ -13,7 +13,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { FormMetadata, StudioComponent, StudioForm, StudioTheme } from '@aws-amplify/codegen-ui'; +import { FormMetadata, StudioComponent, StudioForm, StudioTheme, StudioView } from '@aws-amplify/codegen-ui'; import { ModuleKind, ScriptTarget, @@ -26,6 +26,7 @@ import log from 'loglevel'; import * as ComponentSchemas from '../components'; import * as ThemeSchemas from '../themes'; import * as FormSchemas from '../forms'; +import * as ViewSchemas from '../views'; const DEFAULT_RENDER_CONFIG = { module: ModuleKind.CommonJS, @@ -42,7 +43,7 @@ log.setLevel('info'); export type TestCase = { name: string; schema: any; - testType: 'Component' | 'Theme' | 'Form' | 'Snippet'; + testType: 'Component' | 'Theme' | 'Form' | 'Snippet' | 'View'; }; export type TestGeneratorParams = { @@ -143,6 +144,30 @@ export abstract class TestGenerator { } }; + const generateView = (testCase: TestCase) => { + const { name, schema } = testCase; + try { + if (this.params.writeToDisk) { + this.writeViewToDisk(schema as StudioView); + } + + if (this.params.writeToLogger) { + const { importsText, compText } = this.renderView(schema as StudioView); + log.info(`# ${name}`); + log.info('## View Only Output'); + log.info('### viewImports'); + log.info(this.decorateTypescriptWithMarkdown(importsText)); + log.info('### viewText'); + log.info(this.decorateTypescriptWithMarkdown(compText)); + } + } catch (err) { + if (this.params.immediatelyThrowGenerateErrors) { + throw err; + } + renderErrors[name] = err; + } + }; + const generateIndexFile = (indexFileTestCases: TestCase[]) => { const schemas = indexFileTestCases.map((testCase) => testCase.schema); try { @@ -217,6 +242,9 @@ export abstract class TestGenerator { case 'Snippet': generateSnippet([testCase]); break; + case 'View': + generateView(testCase); + break; default: throw new Error('Expected either a `Component`, `Theme`, `Form` test case type'); } @@ -251,12 +279,16 @@ export abstract class TestGenerator { abstract writeFormToDisk(form: StudioForm): { formMetadata: FormMetadata }; + abstract writeViewToDisk(view: StudioView): void; + abstract renderComponent(component: StudioComponent): { compText: string; importsText: string }; abstract renderTheme(theme: StudioTheme): { componentText: string }; abstract renderForm(form: StudioForm): { compText: string; importsText: string }; + abstract renderView(view: StudioView): { compText: string; importsText: string }; + abstract writeIndexFileToDisk(schemas: (StudioComponent | StudioForm | StudioTheme)[]): void; abstract renderIndexFile(schemas: (StudioComponent | StudioForm | StudioTheme)[]): { componentText: string }; @@ -281,6 +313,9 @@ export abstract class TestGenerator { ...Object.entries(FormSchemas).map(([name, schema]) => { return { name, schema, testType: 'Form' } as TestCase; }), + ...Object.entries(ViewSchemas).map(([name, schema]) => { + return { name, schema, testType: 'View' } as TestCase; + }), ].filter((testCase) => !disabledSchemaSet.has(testCase.name)); } } diff --git a/packages/test-generator/lib/index.ts b/packages/test-generator/lib/index.ts index 28c71e4b9..a1bdc4c62 100644 --- a/packages/test-generator/lib/index.ts +++ b/packages/test-generator/lib/index.ts @@ -17,3 +17,4 @@ export * from './components'; export * from './generators'; export * from './themes'; export * from './forms'; +export * from './views'; diff --git a/packages/test-generator/lib/views/index.ts b/packages/test-generator/lib/views/index.ts new file mode 100644 index 000000000..24cd2264b --- /dev/null +++ b/packages/test-generator/lib/views/index.ts @@ -0,0 +1,16 @@ +/* + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"). + You may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +export { default as ListingExpanderWithComponentSlot } from './listing-expander-with-component-slot.json'; diff --git a/packages/test-generator/lib/views/listing-expander-with-component-slot.json b/packages/test-generator/lib/views/listing-expander-with-component-slot.json new file mode 100644 index 000000000..cedd9ca53 --- /dev/null +++ b/packages/test-generator/lib/views/listing-expander-with-component-slot.json @@ -0,0 +1,47 @@ +{ + "appId": "d1234", + "dataSource": { + "model": "Listing", + "sort": [ + { + "field": "title", + "direction": "DESC" + } + ], + "type": "DataStore" + }, + "environmentName": "staging", + "id": "v-123456", + "name": "ListingExpanderWithComponentSlot", + "schemaVersion": "1.0", + "style": {}, + "viewConfiguration": { + "type": "Collection", + "collection": { + "collectionType": "expander", + "title": { + "type": "Amplify.Binding", + "content": { + "bindingProperty": { + "property": "item", + "field": "title" + } + } + }, + "body": { + "type": "Amplify.ComponentSlot", + "content": { + "componentSlot": { + "componentName": "Button", + "bindingProperties": { + "children": { + "property": "item", + "field": "description" + } + } + } + } + } + } + } +} \ No newline at end of file