From 243a4d28ccac13bef3baec4af9c28249a537f6d8 Mon Sep 17 00:00:00 2001 From: Thomas Jacob Date: Tue, 27 Mar 2018 13:08:56 +0200 Subject: [PATCH] feat: asset properties (fixes #274) --- package.json | 4 + src/component/container/property-list.tsx | 34 ++++++ .../property-items/asset-item/demo.tsx | 38 ++++++ .../property-items/asset-item/index.tsx | 111 ++++++++++++++++++ .../property-items/asset-item/pattern.json | 8 ++ src/store/page/page-element.ts | 24 ++-- .../styleguide/property/asset-property.ts | 102 ++++++++++++++++ .../styleguide/property/boolean-property.ts | 2 +- .../styleguide/property/enum-property.ts | 6 +- .../property/number-array-property.ts | 2 +- .../styleguide/property/number-property.ts | 2 +- .../styleguide/property/object-property.ts | 2 +- .../styleguide/property/pattern-property.ts | 2 +- src/store/styleguide/property/property.ts | 4 +- .../property/string-array-property.ts | 2 +- .../styleguide/property/string-property.ts | 6 +- .../property-analyzer.ts | 57 ++++----- 17 files changed, 351 insertions(+), 55 deletions(-) create mode 100644 src/lsg/patterns/property-items/asset-item/demo.tsx create mode 100644 src/lsg/patterns/property-items/asset-item/index.tsx create mode 100644 src/lsg/patterns/property-items/asset-item/pattern.json create mode 100644 src/store/styleguide/property/asset-property.ts diff --git a/package.json b/package.json index 68827d737..0dafcb203 100644 --- a/package.json +++ b/package.json @@ -93,8 +93,10 @@ "@types/deep-assign": "^0.1.1", "@types/electron-devtools-installer": "^2.0.2", "@types/fs-extra": "^5.0.1", + "@types/isomorphic-fetch": "0.0.34", "@types/js-yaml": "^3.10.1", "@types/lodash": "^4.14.104", + "@types/mime-types": "^2.1.0", "@types/node": "^9.4.0", "@types/object-path": "^0.9.29", "@types/react": "^16.0.0", @@ -120,8 +122,10 @@ "electron-log": "2.2.14", "electron-updater": "2.21.0", "fs-extra": "^5.0.0", + "isomorphic-fetch": "^2.2.1", "js-yaml": "3.11.0", "lodash": "^4.17.5", + "mime-types": "^2.1.18", "mobx": "3.4.1", "mobx-react": "4.4.3", "object-path": "^0.11.4", diff --git a/src/component/container/property-list.tsx b/src/component/container/property-list.tsx index f5426d3ff..691d1c914 100644 --- a/src/component/container/property-list.tsx +++ b/src/component/container/property-list.tsx @@ -1,4 +1,7 @@ +import { AssetItem } from '../../lsg/patterns/property-items/asset-item'; +import { AssetProperty } from '../../store/styleguide/property/asset-property'; import { BooleanItem } from '../../lsg/patterns/property-items/boolean-item'; +import { remote } from 'electron'; import Element from '../../lsg/patterns/element'; import { EnumItem, Values } from '../../lsg/patterns/property-items/enum-item'; import { EnumProperty, Option } from '../../store/styleguide/property/enum-property'; @@ -66,6 +69,21 @@ class PropertyTree extends React.Component { Store.getInstance().execute(this.lastCommand); } + protected handleChooseAsset(id: string, context?: ObjectContext): void { + remote.dialog.showOpenDialog( + { + title: 'Select an image', + properties: ['openFile'] + }, + filePaths => { + if (filePaths && filePaths.length) { + const dataUrl = AssetProperty.getValueFromFile(filePaths[0]); + this.handleChange(id, dataUrl, context); + } + } + ); + } + @action protected handleClick(): void { this.isOpen = !this.isOpen; @@ -149,6 +167,22 @@ class PropertyTree extends React.Component { /> ); + case 'asset': + const src = value as string | undefined; + return ( + + this.handleChange(id, event.currentTarget.value, context) + } + handleChooseClick={event => this.handleChooseAsset(id, context)} + handleClearClick={event => this.handleChange(id, undefined, context)} + /> + ); + case 'object': const objectProperty = property as ObjectProperty; const newPath = (context && `${context.path}.${id}`) || id; diff --git a/src/lsg/patterns/property-items/asset-item/demo.tsx b/src/lsg/patterns/property-items/asset-item/demo.tsx new file mode 100644 index 000000000..a5d0a5a6e --- /dev/null +++ b/src/lsg/patterns/property-items/asset-item/demo.tsx @@ -0,0 +1,38 @@ +import AssetItem from './index'; +import * as React from 'react'; +import styled from 'styled-components'; + +const NOOP = () => {}; // tslint:disable-line no-empty + +const StyledDemo = styled.div` + width: 200px; + margin-bottom: 20px; +`; + +const AssetItemDemo: React.StatelessComponent = (): JSX.Element => ( +
+ + + + + + + + + +
+); + +export default AssetItemDemo; diff --git a/src/lsg/patterns/property-items/asset-item/index.tsx b/src/lsg/patterns/property-items/asset-item/index.tsx new file mode 100644 index 000000000..d0454829a --- /dev/null +++ b/src/lsg/patterns/property-items/asset-item/index.tsx @@ -0,0 +1,111 @@ +import { colors } from '../../colors'; +import { fonts } from '../../fonts'; +import * as React from 'react'; +import { getSpace, Size } from '../../space'; +import styled from 'styled-components'; + +export interface AssetItemProps { + className?: string; + handleChooseClick?: React.MouseEventHandler; + handleClearClick?: React.MouseEventHandler; + handleInputChange?: React.ChangeEventHandler; + imageSrc?: string; + inputValue?: string; + label: string; +} + +const StyledAssetItem = styled.div` + width: 100%; +`; + +const StyledPreview = styled.div` + display: flex; + flex-direction: row; + align-items: center; + margin-bottom: ${getSpace(Size.XS)}px; +`; + +const StyledLabel = styled.span` + display: block; + margin-bottom: ${getSpace(Size.XS)}px; + font-size: 12px; + font-family: ${fonts().NORMAL_FONT}; + color: ${colors.grey36.toString()}; +`; + +const StyledInput = styled.input` + display: inline-block; + box-sizing: border-box; + max-width: 75%; + text-overflow: ellipsis; + border: none; + border-bottom: 1px solid transparent; + background: transparent; + font-family: ${fonts().NORMAL_FONT}; + font-size: 15px; + color: ${colors.grey36.toString()}; + transition: all 0.2s; + + ::-webkit-input-placeholder { + color: ${colors.grey60.toString()}; + } + + &:hover { + color: ${colors.black.toString()}; + border-color: ${colors.grey60.toString()}; + } + + &:focus { + outline: none; + border-color: ${colors.blue40.toString()}; + color: ${colors.black.toString()}; + } +`; + +const StyledImageBox = styled.div` + box-sizing: border-box; + border-radius: 3px; + width: 42px; + height: 42px; + background-color: ${colors.white.toString()}; + border: 0.5px solid ${colors.grey90.toString()}; + padding: 3px; + margin-right: 6px; + flex-shrink: 0; +`; + +const StyledImage = styled.img` + width: 100%; +`; + +const StyledButton = styled.button` + max-width: 50%; + margin-right: 3px; + border: 0.5px solid ${colors.grey90.toString()}; + border-radius: 3px; + background-color: ${colors.white.toString()}; + padding: ${getSpace(Size.XS)}px ${getSpace(Size.S)}px; +`; + +export const AssetItem: React.StatelessComponent = props => ( + + + Open + Clear + +); + +export default AssetItem; diff --git a/src/lsg/patterns/property-items/asset-item/pattern.json b/src/lsg/patterns/property-items/asset-item/pattern.json new file mode 100644 index 000000000..b64f72290 --- /dev/null +++ b/src/lsg/patterns/property-items/asset-item/pattern.json @@ -0,0 +1,8 @@ +{ + "name": "asset-item", + "displayName": "Asset Item", + "version": "1.0.0", + "tags": [ + "property" + ] +} diff --git a/src/store/page/page-element.ts b/src/store/page/page-element.ts index 1f43da6aa..388c35f30 100644 --- a/src/store/page/page-element.ts +++ b/src/store/page/page-element.ts @@ -408,16 +408,20 @@ export class PageElement { return; } - value = property.coerceValue(value); - - if (!path) { - this.propertyValues.set(id, value); - return; - } - - const rootPropertyValue = this.propertyValues.get(id) || {}; - ObjectPath.set<{}, PropertyValue>(rootPropertyValue, path, value); - this.propertyValues.set(id, deepAssign({}, rootPropertyValue)); + (async () => { + const coercedValue: string = await property.coerceValue(value); + if (path) { + const rootPropertyValue = this.propertyValues.get(id) || {}; + ObjectPath.set<{}, PropertyValue>(rootPropertyValue, path, coercedValue); + this.propertyValues.set(id, deepAssign({}, rootPropertyValue)); + } else { + this.propertyValues.set(id, coercedValue); + } + })().catch(reason => { + console.log( + `Failed to coerce property value of property ${this.getId()} of pattern ${this.getPattern()}: ${reason}` + ); + }); } /** diff --git a/src/store/styleguide/property/asset-property.ts b/src/store/styleguide/property/asset-property.ts new file mode 100644 index 000000000..5dc782503 --- /dev/null +++ b/src/store/styleguide/property/asset-property.ts @@ -0,0 +1,102 @@ +import * as FileUtils from 'fs'; +import * as fetch from 'isomorphic-fetch'; +import * as MimeTypes from 'mime-types'; +import * as PathUtils from 'path'; +import { Property } from './property'; + +/** + * An asset property is a property that takes an uploaded file (e.g. an image) + * as a data-URL string to output it as src of an img tag or alike. + * As designer content value (raw value), the asset property accepts data-URL strings and + * undefined (as empty src) only. All other values are invalid and converted into undefined. + * To convert a given file, Buffer, or HTTP URL into a data-URL string, + * use getValueFromFile, getValueFromBuffer, or getValueFromUrl. + * @see Property + * @see AssetProperty.getValueFromFile + * @see AssetProperty.getValueFromBuffer + * @see AssetProperty.getValueFromUrl + */ +export class AssetProperty extends Property { + /** + * Creates a new asset property. + * @param id The technical ID of this property (e.g. the property name + * in the TypeScript props interface). + */ + public constructor(id: string) { + super(id); + } + + /** + * Converts a given buffer and mime type into a data-URL string, a valid value for this property. + * @param buffer The buffer to read. + * @param mimeType The mime type of the buffer content. + * @return The data-URL string. + */ + public static getValueFromBuffer(buffer: Buffer, mimeType: string): string { + return `data:${mimeType};base64,${buffer.toString('base64')}`; + } + + /** + * Reads a given file and converts it into a data-URL string, a valid value for this property. + * @param path The OS-specific path to the file to read. + * @return The data-URL string. + */ + public static getValueFromFile(path: string): string { + const fileName: string | undefined = path.split(PathUtils.sep).pop(); + if (fileName === undefined) { + throw new Error(`Invalid asset path ${path}`); + } + + const mimeType = MimeTypes.lookup(fileName) || 'application/octet-stream'; + const buffer: Buffer = FileUtils.readFileSync(path); + return this.getValueFromBuffer(buffer, mimeType); + } + + /** + * Downloads a given file from HTTP and converts it into a data-URL string, + * a valid value for this property. + * @param url The URL of the file to download. + * @param callback A callback that gets the downloaded file as data-URL. + */ + public static async getValueFromUrl(url: string): Promise { + const response = await fetch(url); + if (response.status >= 400) { + throw new Error(`Failed to load file from server: HTTP ${response.status}`); + } + if (!response.body) { + throw new Error('Server returned no content'); + } + + const mimeType = response.headers.get('Content-Type') || 'application/octet-stream'; + + // tslint:disable-next-line:no-any + const buffer: Buffer = await (response as any).buffer(); + return AssetProperty.getValueFromBuffer(buffer, mimeType); + } + + /** + * @inheritdoc + */ + // tslint:disable-next-line:no-any + public async coerceValue(value: any): Promise { + if (typeof value === 'string') { + return value; + } + + return undefined; + } + + /** + * @inheritdoc + */ + public getType(): string { + return 'asset'; + } + + /** + * @inheritdoc + */ + public toString(): string { + return `AssetProperty(${super.toString()})`; + } +} diff --git a/src/store/styleguide/property/boolean-property.ts b/src/store/styleguide/property/boolean-property.ts index 07ed478b6..882c49d42 100644 --- a/src/store/styleguide/property/boolean-property.ts +++ b/src/store/styleguide/property/boolean-property.ts @@ -20,7 +20,7 @@ export class BooleanProperty extends Property { * @inheritdoc */ // tslint:disable-next-line:no-any - public coerceValue(value: any): any { + public async coerceValue(value: any): Promise { return value === true || value === 'true' || value === 1; } diff --git a/src/store/styleguide/property/enum-property.ts b/src/store/styleguide/property/enum-property.ts index 1da03f505..299b35177 100644 --- a/src/store/styleguide/property/enum-property.ts +++ b/src/store/styleguide/property/enum-property.ts @@ -33,9 +33,9 @@ export class EnumProperty extends Property { * @inheritdoc */ // tslint:disable-next-line:no-any - public coerceValue(value: any): any { + public async coerceValue(value: any): Promise { if (value === null || value === undefined || value === '') { - return undefined; + return; } for (const option of this.options) { @@ -80,7 +80,7 @@ export class EnumProperty extends Property { } } - return undefined; + return; } /** diff --git a/src/store/styleguide/property/number-array-property.ts b/src/store/styleguide/property/number-array-property.ts index 2fdeb60ea..bc1b01a1b 100644 --- a/src/store/styleguide/property/number-array-property.ts +++ b/src/store/styleguide/property/number-array-property.ts @@ -21,7 +21,7 @@ export class NumberArrayProperty extends Property { * @inheritdoc */ // tslint:disable-next-line:no-any - public coerceValue(value: any): any { + public async coerceValue(value: any): Promise { // tslint:disable-next-line:no-any return this.coerceArrayValue(value, (element: any) => parseFloat(value)); } diff --git a/src/store/styleguide/property/number-property.ts b/src/store/styleguide/property/number-property.ts index 7bf3a0c3e..0a5df3a7b 100644 --- a/src/store/styleguide/property/number-property.ts +++ b/src/store/styleguide/property/number-property.ts @@ -21,7 +21,7 @@ export class NumberProperty extends Property { * @inheritdoc */ // tslint:disable-next-line:no-any - public coerceValue(value: any): any { + public async coerceValue(value: any): Promise { const result: number = parseFloat(value); return isNaN(result) ? undefined : result; } diff --git a/src/store/styleguide/property/object-property.ts b/src/store/styleguide/property/object-property.ts index bdb52bf82..4fc160c59 100644 --- a/src/store/styleguide/property/object-property.ts +++ b/src/store/styleguide/property/object-property.ts @@ -26,7 +26,7 @@ export class ObjectProperty extends Property { * @inheritdoc */ // tslint:disable-next-line:no-any - public coerceValue(value: any): any { + public async coerceValue(value: any): Promise { if (value === null || value === undefined || value === '') { return undefined; } diff --git a/src/store/styleguide/property/pattern-property.ts b/src/store/styleguide/property/pattern-property.ts index 1e1eaeef2..576e867e0 100644 --- a/src/store/styleguide/property/pattern-property.ts +++ b/src/store/styleguide/property/pattern-property.ts @@ -28,7 +28,7 @@ export class PatternProperty extends Property { * @inheritdoc */ // tslint:disable-next-line:no-any - public coerceValue(value: any): any { + public async coerceValue(value: any): Promise { // Page elements coerce their properties themselves return value; } diff --git a/src/store/styleguide/property/property.ts b/src/store/styleguide/property/property.ts index 7319c5c08..34896d164 100644 --- a/src/store/styleguide/property/property.ts +++ b/src/store/styleguide/property/property.ts @@ -117,10 +117,10 @@ export abstract class Property { * See Property sub-classes documentation for a description of allowed raw values * and their conversion. * @param value The raw value. - * @return The resulting, property-compatible value. + * @param callback A callback to be called with the resulting, property-compatible value. */ // tslint:disable-next-line:no-any - public abstract coerceValue(value: any): any; + public abstract coerceValue(value: any): Promise; /** * Converts a given value into the form required by the component's props' property. diff --git a/src/store/styleguide/property/string-array-property.ts b/src/store/styleguide/property/string-array-property.ts index b68ce4fd6..22d3d3267 100644 --- a/src/store/styleguide/property/string-array-property.ts +++ b/src/store/styleguide/property/string-array-property.ts @@ -22,7 +22,7 @@ export class StringArrayProperty extends Property { * @inheritdoc */ // tslint:disable-next-line:no-any - public coerceValue(value: any): any { + public async coerceValue(value: any): Promise { // tslint:disable-next-line:no-any return this.coerceArrayValue(value, (element: any) => String(value)); } diff --git a/src/store/styleguide/property/string-property.ts b/src/store/styleguide/property/string-property.ts index 815f7cae2..9e3c0013e 100644 --- a/src/store/styleguide/property/string-property.ts +++ b/src/store/styleguide/property/string-property.ts @@ -26,12 +26,12 @@ export class StringProperty extends Property { * @inheritdoc */ // tslint:disable-next-line:no-any - public coerceValue(value: any): any { + public async coerceValue(value: any): Promise { if (value === null || value === undefined || value === '') { return ''; + } else { + return String(value); } - - return String(value); } /** diff --git a/src/styleguide-analyzer/typescript-react-analyzer/property-analyzer.ts b/src/styleguide-analyzer/typescript-react-analyzer/property-analyzer.ts index eb2d59551..c853a5a52 100644 --- a/src/styleguide-analyzer/typescript-react-analyzer/property-analyzer.ts +++ b/src/styleguide-analyzer/typescript-react-analyzer/property-analyzer.ts @@ -1,18 +1,17 @@ // tslint:disable:no-bitwise -import { Property } from '../../store/styleguide/property/property'; -import * as ts from 'typescript'; - +import { AssetProperty } from '../../store/styleguide/property/asset-property'; import { BooleanProperty } from '../../store/styleguide/property/boolean-property'; import { EnumProperty, Option } from '../../store/styleguide/property/enum-property'; import { NumberArrayProperty } from '../../store/styleguide/property/number-array-property'; import { NumberProperty } from '../../store/styleguide/property/number-property'; import { ObjectProperty } from '../../store/styleguide/property/object-property'; +import { Property } from '../../store/styleguide/property/property'; import { StringArrayProperty } from '../../store/styleguide/property/string-array-property'; import { StringProperty } from '../../store/styleguide/property/string-property'; +import * as ts from 'typescript'; interface PropertyFactoryArgs { - id: string; symbol: ts.Symbol; type: ts.Type; typechecker: ts.TypeChecker; @@ -25,6 +24,15 @@ type PropertyFactory = (args: PropertyFactoryArgs) => Property | undefined; * Alva supported pattern properties. */ export class PropertyAnalyzer { + private static PROPERTY_FACTORIES: PropertyFactory[] = [ + PropertyAnalyzer.createBooleanProperty, + PropertyAnalyzer.createEnumProperty, + PropertyAnalyzer.createStringProperty, + PropertyAnalyzer.createNumberProperty, + PropertyAnalyzer.createArrayProperty, + PropertyAnalyzer.createObjectProperty + ]; + /** * Analyzes a given Props type and returns all Alva-supported properties found. * @param type The TypeScript Props type. @@ -74,30 +82,15 @@ export class PropertyAnalyzer { type = (type as ts.UnionType).types[0]; } - const PROPERTY_FACTORIES: PropertyFactory[] = [ - this.createBooleanProperty, - this.createEnumProperty, - this.createStringProperty, - this.createNumberProperty, - this.createArrayProperty, - this.createObjectProperty - ]; - - for (const propertyFactory of PROPERTY_FACTORIES) { - const property: Property | undefined = propertyFactory({ - id: symbol.name, - symbol, - type, - typechecker - }); - + for (const propertyFactory of this.PROPERTY_FACTORIES) { + const property: Property | undefined = propertyFactory({ symbol, type, typechecker }); if (property) { this.setPropertyMetaData(property, symbol); return property; } } - return undefined; + return; } /** @@ -118,12 +111,12 @@ export class PropertyAnalyzer { const itemType = arrayType.typeArguments[0]; if ((itemType.flags & ts.TypeFlags.String) === ts.TypeFlags.String) { - const property = new StringArrayProperty(args.id); + const property = new StringArrayProperty(args.symbol.name); return property; } if ((itemType.flags & ts.TypeFlags.Number) === ts.TypeFlags.Number) { - const property = new NumberArrayProperty(args.id); + const property = new NumberArrayProperty(args.symbol.name); return property; } } @@ -143,7 +136,7 @@ export class PropertyAnalyzer { (args.type.flags & ts.TypeFlags.BooleanLiteral) === ts.TypeFlags.BooleanLiteral || (args.type.symbol && args.type.symbol.name === 'Boolean') ) { - return new BooleanProperty(args.id); + return new BooleanProperty(args.symbol.name); } return; @@ -185,7 +178,7 @@ export class PropertyAnalyzer { return new Option(enumMemberId, enumMemberName, enumMemberOrdinal); }); - const property = new EnumProperty(args.id); + const property = new EnumProperty(args.symbol.name); property.setOptions(options); return property; } @@ -202,8 +195,7 @@ export class PropertyAnalyzer { */ private static createNumberProperty(args: PropertyFactoryArgs): NumberProperty | undefined { if ((args.type.flags & ts.TypeFlags.Number) === ts.TypeFlags.Number) { - const property = new NumberProperty(args.id); - return property; + return new NumberProperty(args.symbol.name); } return; @@ -221,7 +213,7 @@ export class PropertyAnalyzer { const objectType = args.type as ts.ObjectType; if (objectType.objectFlags & ts.ObjectFlags.Interface) { - const property = new ObjectProperty(args.id); + const property = new ObjectProperty(args.symbol.name); property.setProperties(PropertyAnalyzer.analyze(args.type, args.typechecker)); return property; } @@ -239,8 +231,11 @@ export class PropertyAnalyzer { */ private static createStringProperty(args: PropertyFactoryArgs): StringProperty | undefined { if ((args.type.flags & ts.TypeFlags.String) === ts.TypeFlags.String) { - const property = new StringProperty(args.id); - return property; + if (PropertyAnalyzer.getJsDocValueFromSymbol(args.symbol, 'asset') !== undefined) { + return new AssetProperty(args.symbol.name); + } else { + return new StringProperty(args.symbol.name); + } } return;