01.06.2018

Salesforce DX with LDS + TypeScript + Vue.js

Salesforce DXそのものの内容や使用方法については触れていませんので、ここを参照してください(ごめんなさい)。

前準備

yarnsfdxコマンドが実行できる環境を用意してください。 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などのベンダープレフィックスを自動付与してくれる
  • 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>タグを出力してくれる
  • 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;の指示を書き込む。
  • 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のlibraryTargetlibraryを指定し、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の地理位置情報型を使って空間検索を書こうかな。