Salesforce DX with LDS + TypeScript + Vue.js
Salesforce DXそのものの内容や使用方法については触れていませんので、ここを参照してください(ごめんなさい)。
前準備
yarn
とsfdx
コマンドが実行できる環境を用意してください。
Homebrewがある場合は、下記のコマンドでインストール可能です。
brew install node yarn
brew cask install sfdx
Salesforce 組織は必ず組織での Dev Hub の有効化を行ってください。 その後、下記コマンドで上記のDevHub組織にログインしスクラッチ組織を作成できるようにします。
sfdx force:auth:web:login -d -a DevHub
また、TypeScriptの静的解析を行なっているのでエラーなどを見るのにエディタはVisual Studio Codeを使用することをおすすめします(拡張機能にTSLint
Vetur
必須)。
デモ
ここからソースをダウンロードするか、
git clone https://github.com/kenichi-odo/sfdx-lds-vue-typescript.git
でソースをクローンしてください。
次に、プロジェクトルートでyarn setup
を行うとスクラッチ組織を作成しソースをアップロード、かつアクセス権限の付与と必要データをインポートして動作確認できる状況まで準備します。
あとは、/apex/convenience_stores
にアクセスすると下記のキャプチャの様な動作が確認できると思います。
スクリーンキャプチャ
ソースをいじる際は、yarn
でパッケージインストールしてnode_modules
フォルダがプロジェクトルートに作成確認できたあと、yarn watch_ConvenienceStores
を実行することで、.ts
や.vue
などがファイル監視されるので編集すればビルドが走ると思います。
ただ、それだけではソースそのものは反映されないのでyarn u
を実行してスクラッチ組織にソースをアップロードすることを忘れないでください。
Salesforce DXプロジェクト以外の場合
色々な事情からSalesforce DXをまだ導入できないこともあると思います。
webpack-sfdc-deploy-plugin
を利用すれば、ビルド後のソースを.zipに纏めて静的リソースとして直接アップロードすることが可能です。
salesforce.config.js
module.exports = {
username: 'username',
password: 'password',
url: 'https://test.salesforce.com/'
};
上記のようなログイン情報を記述したファイルをプロジェクトルートに作成し、webpack設定ファイルののpluginsに下記のオプションを設定することでソースのビルド後、自動的に上記ログイン情報で設定した組織の静的リソースにアップロードします。
new (require('webpack-sfdc-deploy-plugin'))({
credentialsPath: `${__dirname}/salesforce.config.js`,
filesFolderPath: `${__dirname}/force-app/main/default/staticresources/${env_.resource_name}`,
staticResourceName: env_.resource_name,
isPublic: true,
})
- credentialsPath
- ログイン情報ファイルのパス
- filesFolderPath
- 静的リソースに
.zip
としてアップロードするフォルダのパス
- 静的リソースに
- staticResourceName
- 静的リソース名
- isPublic
- キャッシュコントロールの設定
解説
設定ファイル
package.json
{
"dependencies": {
"@salesforce-ux/design-system": "^2.4.5",
"@types/node": "^9.3.0",
"autoprefixer": "^7.2.4",
"babel-core": "^6.26.0",
"babel-loader": "^7.1.2",
"babel-polyfill": "^6.26.0",
"babel-preset-env": "^1.6.1",
"css-loader": "^0.28.8",
"postcss-loader": "^2.0.10",
"pug": "^2.0.0-rc.4",
"style-loader": "^0.19.1",
"ts-loader": "^3.2.0",
"ts-node": "^4.1.0",
"tslint": "^5.8.0",
"typescript": "^2.6.2",
"url-loader": "^0.6.2",
"vue": "^2.5.13",
"vue-loader": "^13.7.0",
"vue-property-decorator": "^6.0.0",
"vue-template-compiler": "^2.5.13",
"webpack": "^3.10.0",
"webpack-build-notifier": "^0.1.21",
"webpack-sfdc-deploy-plugin": "^1.1.11"
},
"license": "MIT",
"name": "sfdx-lds-vue-typescript",
"scripts": {
"c": "sfdx force:org:create -f ./config/project-scratch-def.json -s -a test-sfdx-project_$(date +%Y%m%d-%H%M%S)",
"clean": "rm -dfr ./node_modules && yarn",
"d": "sfdx force:source:pull",
"df": "sfdx force:source:pull -f",
"ics": "sfdx force:data:bulk:upsert -s convenience_stores__c -f ./csvs/convenience_stores__c.csv -i Id",
"o": "sfdx force:org:open",
"p": "sfdx force:user:permset:assign -n user",
"setup": "yarn c && yarn u && yarn p && yarn ics && yarn o",
"u": "sfdx force:source:push",
"uf": "sfdx force:source:push -f",
"update": "rm -dfr ./node_modules && rm -f yarn.lock && ncu -a && yarn",
"watch_ConvenienceStores": "webpack -w --env.resource_name=ConvenienceStores --progress --hide-modules"
},
"version": "1.0.0"
}
dependencies
- @salesforce-ux/design-system
- SalesforceのLightning Design Systemのパッケージ
- @types/node
require
などのNode.jsが持つ型定義情報(webpack.config.ts内の型解析に使用)
- autoprefixer
- CSSの
-webkit
-moz
などのベンダープレフィックスを自動付与してくれる
- CSSの
- babel-core
- 最新のECMAScriptの記述を下位バージョンに変換する
- babel-loader
- webpackでBabelを実行できるようにする
- babel-polyfill
- ES5以上の新構文を未対応のブラウザでも動作できるようにする
- babel-preset-env
- Babelで変換する際にバージョン指定して変換する
- css-loader
- 画像・フォント・外部CSSの依存関係処理、CSSをローカルスコープになるように変換
- pug
- インデントで記述するHTMLを書くためのテンプレートエンジン
- style-loader
- webpackでビルド後に出力される
bundle.js
にCSS情報を埋め込み、ロードする際<style>
タグを出力してくれる
- webpackでビルド後に出力される
- ts-loader
- TypeScriptコードをECMAScriptに変換する
- ts-node
- ビルド管理外の
.ts
の実行(webpack.config.ts用)
- ビルド管理外の
- tslint
- Visual Studio Code上でのTypeScriptの静的解析
- typescript
- TypeScript本体
- url-loader
- ソース内で参照されているローカルファイル(画像など)をbase64に変換する
- url-search-params
URLSearchParams
を未対応ブラウザでも動くようにするPolyfill
- vue
- Vue.js本体
- vue-loader
.vue
で書かれた単一ファイルコンポーネントの変換
- vue-property-decorator
- Vue.jsでクラスの書き方を可能にする
- vue-template-compiler
- 単一ファイルコンポーネントに記述されている
<style>
<template>
を処理する
- 単一ファイルコンポーネントに記述されている
- webpack
- モジュールをバンドルする
- webpack-build-notifier
- ソースのビルド状況をmacOSの通知センターに通知する
- webpack-sfdc-deploy-plugin
- ソースのビルド後、Salesforce環境の静的リソースにアップロードする(sfdx未対応環境用)
scripts
律儀に打つのがめんどくさいsfdxコマンドを主にまとめています。
- c(createの略)
- スクラッチ組織を作成する
- d(downloadの略)
- スクラッチ組織からソースをプルする
- df(force downloadの略)
- スクラッチ組織からソースをプルしてコンフリクトを上書きする
- ics(import convenience storesの略)
- コンビニ情報オブジェクトのデータをインポートする
- o(openの略)
- スクラッチ組織をブラウザで表示する
- p(permsetの略)
- 権限セットを適用する
- u(uploadの略)
- スクラッチ組織へソースをプッシュする
- uf(force uploadの略)
- スクラッチ組織へソースをプッシュしてコンフリクトを上書きする
- watch_ConvenienceStores
./client/src/ConvenienceStores
フォルダ配下のソースファイルの監視、ビルドを行う
tslint.json
チームのルールに従ってここを参考にしながら設定すると良いと思います。
{
"defaultSeverity": "error",
"extends": [
"tslint:recommended"
],
"rules": {
"arrow-parens": [
true,
"ban-single-arg-parens"
],
"interface-name": false,
"max-classes-per-file": false,
"max-line-length": false,
"member-ordering": false,
"no-console": [
true,
"log"
],
"no-unused-expression": [
true,
"allow-new"
],
"no-unused-variable": [
true,
"check-parameters"
],
"no-var-requires": false,
"object-literal-sort-keys": false,
"ordered-imports": false,
"prefer-template": true,
"quotemark": [
true,
"single"
],
"semicolon": [
true,
"never"
],
"user-ordering": false,
"variable-name": false
}
}
tsconfig.json
{
"compilerOptions": {
"allowSyntheticDefaultImports": true,
"alwaysStrict": true,
"experimentalDecorators": true,
"forceConsistentCasingInFileNames": true,
"lib": [
"dom",
"esnext"
],
"module": "esnext",
"moduleResolution": "node",
"noFallthroughCasesInSwitch": true,
"noImplicitReturns": true,
"noImplicitThis": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"pretty": true,
"removeComments": true,
"sourceMap": true,
"strictFunctionTypes": true,
"strictNullChecks": true,
"target": "esnext",
"traceResolution": true,
"typeRoots": [
"./node_modules/@types"
]
},
"include": [
"client",
"index.d.ts"
]
}
compilerOptions
下記以外の設定は、ここを参照してください。
- allowSyntheticDefaultImports
export default
を使用していないモジュールのコンパイル時にエラーを出力しない
- alwaysStrict
- 全てのコードがstrictモードで分析し、生成された全てのファイルの先頭に
use strict;
の指示を書き込む。
- 全てのコードがstrictモードで分析し、生成された全てのファイルの先頭に
- experimentalDecorators
- から始まるデコレータ構文を有効にする
- forceConsistentCasingInFileNames
- 大文字小文字を区別してファイルパス参照を解決する
- lib
- コンパイルに含めるライブラリファイルを指定する
- module
- モジュール形式の指定
- moduleResolution
- インポートの解決方法
- noFallthroughCasesInSwitch
- switch分のcaseにbreakがない場合エラーにする
- noImplicitReturns
- 返り値の型チェックをする
- noImplicitThis
- thisに型を指定していない場合エラーにする
- noUnusedLocals
- 未使用のローカル変数を許可しない
- noUnusedParameters
- 未使用の引数を許可しない
- pretty
- エラーの箇所を色付きで表示する
- removeComments
- ビルド時にコメントを除去する
- sourceMap
- ソースマップを出力する
- strictFunctionTypes
- 関数の型チェックを厳密にする
- strictNullChecks
- nullやundefinedを明示しない限り、null非許容型になる
- target
- 出力するECMAScriptのバージョン
- traceResolution
- モジュールのファイルの解決のプロセスを表示する
- typeRoots
- 型定義ファイルのパスを指定する
webpack.config.ts
基本的に特別な記述はしていません。
yarnのscripts実行時に--env.resource_name
でビルド・ウォッチ対象のフォルダ名を指定しているので、それに従いcontext
の部分でパスを設定し、output
の部分でforce-app配下のstaticresourcesにbundle.js
が吐き出されるようにしています。
また、bundle.js
は基本HTML(SalesforceならVisualforce)に<script>
タグを書いてすぐにロードするのが基本になると思いますが、Visualforceなどカスタム表示ラベル、カスタム設定を元にソースの処理を分岐したいといった使い方もあると思うので、outputのlibraryTarget
とlibrary
を指定し、bundle.js
ロード後はグローバルに展開されるようにしそこからnewで任意にビルド内容を読み込みできるようにしています(カスタム表示ラベルやカスタム設定の値の渡し方はまた次回に)。
最後の方では、yarnのscripts実行時にプロダクションフラグの--env.production
が有るか無いかで出力されるbundle.js
の難読化を行なったりソースマップを吐き出さなかったりをコントロールしています。
const Webpack = require('webpack')
const BuildNotifier = require('webpack-build-notifier')
const Autoprefixer = require('autoprefixer')
// const SFDCDeployPlugin = require('webpack-sfdc-deploy-plugin')
module.exports = env_ => {
if (env_ == null) {
env_ = {}
}
const config = {
context: `${__dirname}/client/src/${env_.resource_name}`,
entry: { bundle: ['babel-polyfill', './index.ts'] },
module: {
rules: [
{
test: /\.ts$/,
use: [
{
loader: 'babel-loader',
options: { presets: [['env', { targets: { browsers: ['ie >= 10', 'last 2 versions'] }, useBuiltIns: true }]] },
},
{ loader: 'ts-loader', options: { appendTsSuffixTo: [/\.vue$/], silent: true } },
],
},
{ test: /\.vue$/, use: ['vue-loader'] },
{
test: /\.css$/, use: [
'style-loader',
{ loader: 'css-loader', options: { modules: true } },
{ loader: 'postcss-loader', options: { plugins: Autoprefixer({ browsers: ['ie >= 10', 'last 2 versions'] }) } },
],
},
{ test: /(\.woff|\.woff2|\.svg)$/, use: ['url-loader'] },
],
},
plugins: [
// new SFDCDeployPlugin({
// credentialsPath: `${__dirname}/salesforce.config.js`,
// filesFolderPath: `${__dirname}/force-app/main/default/staticresources/${env_.resource_name}`,
// staticResourceName: env_.resource_name,
// isPublic: true,
// }),
new BuildNotifier({
title: 'sfdx-lds-vue-typescript',
successSound: false,
suppressCompileStart: false,
onClick: () => null,
}),
],
output: {
path: `${__dirname}/force-app/main/default/staticresources/${env_.resource_name}`,
filename: '[name].js',
sourceMapFilename: '[file].map',
libraryTarget: 'umd',
library: env_.resource_name,
},
resolve: { extensions: ['.ts', '.js'], alias: { vue: 'vue/dist/vue.js' } },
} as any
if (env_.production) {
config.plugins.push(new Webpack.DefinePlugin({ 'process.env': { NODE_ENV: '"production"' } }))
config.plugins.push(new Webpack.optimize.UglifyJsPlugin({ compress: { warnings: false } }))
config.plugins.push(new Webpack.optimize.OccurrenceOrderPlugin())
config.plugins.push(new Webpack.optimize.AggressiveMergingPlugin())
} else {
config.devtool = 'source-map'
}
return config
}
ソース
s-object-model.ts
今回オブジェクトの取得は、Visualforceリモートオブジェクトを使ってApexレスでデータを取得しています。
SObjectModel
は1回につき100件までしかデータを取得できなので、async/await
を使いながらlimit, offsetをコントロールして2000件まで一気に取得します。
class ConvenienceStores {
private static _convenience_stores = new window.SObjectModel.convenience_stores__c()
public static Gets({ where_ }: { where_?}) {
return new Promise(async (Resolve_: (_: any[]) => void, Reject_: (_) => void) => {
try {
let rs = []
let offset = 1
while (true) {
if (offset > 2000) {
break
}
const cs = await this.Retrieve({ offset_: offset, where_ })
rs = rs.concat(cs)
if (cs.length === 100) {
offset += 100
continue
}
break
}
Resolve_(rs)
} catch (_) {
Reject_(_)
}
}).catch(_ => { throw _ })
}
private static Retrieve({ offset_, where_ }: { offset_: number, where_ }) {
return new Promise((Resolve_: (_) => void, Reject_: (_) => void) => {
const c = { limit: 100, offset: offset_, where: where_ }
if (where_ != null) {
c.where = where_
}
this._convenience_stores.retrieve(c, (error_, records_: any[]) => {
if (error_ != null) {
Reject_(error_)
return
}
Resolve_(records_)
})
}).catch(_ => { throw _ })
}
}
export default { ConvenienceStores }
app.vue
LDSは<style>
タグ内でインポートすることで適用されます(<script>
内でも可)。
HTML部分は、pug
形式で記述しています(何にせよLDSは属性が多くてHTMLのままだと読むのが辛い)。
<style scoped>
@import url('../../../node_modules/@salesforce-ux/design-system/assets/styles/salesforce-lightning-design-system.css');
</style>
<template lang="pug">
div
.slds-page-header.slds-has-bottom-magnet.slds-is-fixed.slds-size_1-of-1(:style="{ zIndex: 1 }")
.slds-grid
.slds-col.slds-has-flexi-truncate
.slds-media.slds-no-space.slds-grow
.slds-media__figure
span.slds-icon_container.slds-icon-standard-home(title="コンビニ情報")
img.slds-icon(src="../../../node_modules/@salesforce-ux/design-system/assets/icons/standard/home.svg")
.slds-media__body
nav
ol.slds-breadcrumb.slds-line-height_reset
li.slds-breadcrumb__item
span コンビニ情報
h1.slds-page-header__title.slds-p-right_x-small
span.slds-grid.slds-has-flexi-truncate.slds-grid_vertical-align-center
span.slds-truncate(title="Recently Viewed") すべて表示
table.slds-table.slds-table_bordered.slds-table_cell-buffer(:style="{ position: 'absolute', top: '68px' }")
thead
tr.slds-text-title_caps
th(scope="col")
.slds-truncate(title="名前") 名前
th(scope="col")
.slds-truncate(title="住所") 住所
th(scope="col")
.slds-truncate(title="緯度") 緯度
th(scope="col")
.slds-truncate(title="経度") 経度
tbody
tr(v-for="cs_ in convenience_stores")
th(scope="row", data-label="名前")
.slds-truncate(:title="cs_.name") {{ cs_.name }}
td(data-label="住所")
.slds-truncate(:title="cs_.location_name") {{ cs_.location_name }}
td(data-label="緯度")
.slds-truncate(:title="cs_.lat") {{ cs_.lat }}
td(data-label="経度")
.slds-truncate(:title="cs_.lng") {{ cs_.lng }}
.slds-spinner.slds-spinner_brand.slds-spinner_large(v-if="is_loading")
.slds-spinner__dot-a
.slds-spinner__dot-b
</template>
<script lang="ts">
import Vue from 'vue'
import { Component } from 'vue-property-decorator'
import SObjectModel from './s-object-model'
@Component
export default class extends Vue {
is_loading = false
convenience_stores = [] as any[]
async mounted() {
try {
this.is_loading = true
const css = await SObjectModel.ConvenienceStores.Gets({})
this.is_loading = false
this.convenience_stores = css.map(_ => {
return { location_name: _.get('location_name__c'), name: _.get('Name'), lat: _.get('point__Latitude__s'), lng: _.get('point__Longitude__s') }
})
} catch (_) {
console.error(_)
}
}
}
</script>
index.ts
Vueのインスタンス作成で、el
で読み込み先要素のid指定、components
で最初に呼び出す単一ファイルコンポーネントを指定し、template
でその名前をタグとして読み込みます。
import Vue from 'vue'
import App from './app.vue'
Vue.config.productionTip = false
export default class {
constructor() {
new Vue({ el: '#app', components: { app: App }, template: '<app />' })
}
}
convenience_stores.page
Visualforceでは<apex:remoteObjects>
タグでリモートオブジェクトの定義を忘れずに、Vueを読み込むベースとなる<div id="app"></div>
を記載し、その後でbundle.js
を読み込みます。
最後に、グローバルに展開されたConvenienceStoresをnewして読み込み完了です(.default
が付くのはindex.ts
でdefaultでエクスポートしているため)。
<apex:page
sidebar="false"
showHeader="false"
standardStylesheets="false"
applyBodyTag="false"
applyHtmlTag="false"
docType="html-5.0">
<apex:remoteObjects>
<apex:remoteObjectModel
name="convenience_stores__c"
fields="location_name__c, Name, point__Latitude__s, point__Longitude__s" />
</apex:remoteObjects>
<div id="app"></div>
<script src="{!URLFOR($Resource.ConvenienceStores, '/bundle.js')}"></script>
<script>
var _vtss = new ConvenienceStores.default();
</script>
</apex:page>
いやー書くの疲れました。 次回は、Salesforceの地理位置情報型を使って空間検索を書こうかな。