抛开权限等常见原因,如果你狂点翻译却无法进入编辑状态,有一种可能性是被翻译控制台上世纪的UI设计坑害了。
当待翻译的内容超过一屏的时候,这个古老的UI的表格Title不会跟随屏幕滚动浮动,并且列之间没有分割线,导致看起来一整行只有一列。

而实际上却包含三列

只有点击在翻译列区域才可以进入编辑状态。
P.S. for 跳了坑的小伙伴
Salesforce, Javascript, PHP and Others
抛开权限等常见原因,如果你狂点翻译却无法进入编辑状态,有一种可能性是被翻译控制台上世纪的UI设计坑害了。
当待翻译的内容超过一屏的时候,这个古老的UI的表格Title不会跟随屏幕滚动浮动,并且列之间没有分割线,导致看起来一整行只有一列。
而实际上却包含三列
只有点击在翻译列区域才可以进入编辑状态。
P.S. for 跳了坑的小伙伴
如果看不到quote的新建按钮,原因是多种多样的。
There’re so many reasons cause Quote’s new button missing.
其中最普遍的原因不过如下:
The most common reasons as below:
但是,之前在项目上遇到了另外一个原因,在上述三点都做到的前提下,也会导致Quote的New Button消失。
Thus, there is one more reason to cause Quote’s new button not visible although all above conditions had achieved.
这个原因就是Opportunity的Account字段权限。如果profile没有赋予Opportunity的Account字段任何权限,Quote的new button将不会显示。
It’s not grant field permission to Opportunity’s Account field. This will cause the missing of Quote’s new button globally.
去年有人问了我一个问题————为什么清理干净的Profile部署到新环境会多出很多东西。比如说Custom Application Setting, Tab Settings与Object Permissions。明明在源环境相关权限都已经移除,结果到了新环境又阴魂不散的出现了。
这个问题确实略为复杂,这也是为何拖了一年才动笔讨论这个问题。
众所周知,Metadata的部署有两种行为模式,一种是覆盖,一种增量。本文这里不做展开。
相对的,Metadata的取得也有两种模式,一种是独立内容,一种是关联内容。独立内容是指在package.xml中只包含元素自己的时候能将所有内容取出;关联内容是指在package.xml中除了元素本身之外,必须包含关联元素,才能将与其他元素有所关联的内容取出。独立内容的典型代表为ApexClass,CustomField;关联内容的典型代表Object与Profile。如此同时,这两种Metadata也同时存在独立内容。
具体来说,Profile的Metadata内容可以一分为二,第一种是Profile独立内容;第二种是与其他Metadata关联的内容。
✳️具体哪些内容是独立内容,哪些是关联内容,以及关联内容的Retrieve方法参考此思维导图。
部分信息表格版如下:
Category | Source Metadata Type | Support *? |
---|---|---|
Profile Basic Infomation | Profile Include | N/A |
Page Layouts Assignment | RecordType+Layout | Yes+Yes |
Field-Level Security | CustomField | Yes |
Custom App Settings | CustomApplication | Yes |
Connected App Access | ConnectedApp | Yes |
Tab Settings | CustomTab+CustomObject | Custom Yes |
Record Type Settings | RecordType | Yes |
Permissions | Profile Include | N/A |
Object Permissions | CustomObject | Custom Yes |
Session Settings | Independent Metadata Type => ProfileSessionSetting | Yes |
Password Policies | Independent Metadata Type => ProfilePasswordPolicy | Yes |
Login Hours | Profile Include | N/A |
Login IP Ranges | Profile Include | N/A |
Apex Class Access | ApexClass | Yes |
Visualforce Page Access | ApexPage | Yes |
所以如果package.xml中只包含profile本身的话,那么在profile metadata中仅能取出profile基本信息,权限信息,ip range等有限信息。只有将其余关联metadata都包含进package.xml才能取出完整的profile metadata内容。不过,这时候喜欢动手实践的同学会发现,就算是把关联的metadata添加完整,仍然有些profile信息是取不下来的,比如没有任何增删改查的Object Permission。你只能取下来有访问权限的部分,application部分同理。这也是部署到新环境之后希望杀死的权限又死灰复燃的原因之一。
那么有些同学会有疑问,就算是没能将无权限的部分取得下来,那么部署到新环境的时候作为新建的profile,权限是怎么出现的呢?这个默认值是哪里出现的?
所谓实践出真知,如果设计下列实验,就很容易得到答案:
1. 随便找一个环境。
2. package.xml中只包含profile「Minimum Access – Salesforce」。因为此profile为官方提供的权限最少的profile。
3. 取得步骤2的profile,拷贝metadata文件,并改成其他名字,将文件中的false改成true,保存。
4. 将package.xml中的profile名称改为步骤3新建profile的名字,部署。如果使用的是vscode,可以在profile上直接右键deploy。
5. 在环境中打开新建的profile与「Minimum Access – Salesforce」。肉眼可见,在object permissio部分非常不同。
6. 新建的profile与「Standard User」进行对比,发现object permission部分完全相同。
7. 修改「Standard User」的Object Permission,重复步骤1到4再新建一个profile,取得后再次与「Standard User」进行对比,会发现新建的profile与修改后的「Standard User」相同。
由以上实验可以得出结论,通过部署方式新建profile的时候,对标手动创建时必须选择参照哪个profile的流程,系统会默认以「Standard User」作为新建模版,将部署的metadata内容以增量的方式作用到新profile上。未指定的部分以目标系统的「Standard User」设定为准。
那么原因知道了,该如何解决这个问题呢?
目前方式有三。
1. 擒贼先擒王,先人工干掉目标环境的「Standard User」上面所有的权限,让「Standard User」变成「Minimum Access – Salesforce」。这时候再部署的话就不会有额外的权限。
2. 打铁还须自身硬。会被「Standard User」影响本质上还是因为自己没有全面的指定好所有的权限,让它钻了空子。如果能够在profile metadata文件中人工编辑所有未取得的内容,也不会有问题。
3. 中庸方案,人工去目标环境新建好profile,参照profile选择「Minimum Access – Salesforce」,然后再部署。这样既不会影响「Standard User」,也不需要手动编辑profile metadata文件,又保持了新建profile的洁净。不过由于「Minimum Access – Salesforce」也不是绝对的Mininum,比如Custom Application Permission仍然带上了全部的app,所以针对此部分还是要在1与2中进行选择。
有同学又有疑问了,部署的方式有很多种,如果我用changeset还会有相同的问题吗?
答案是一样的,好奇的同学可以自己设计一个实验试一下。
1.查询一百异步双
2.条数五万插万行
3.增删改查一百五
4.呼出一百就用光
5.请求累计两分整
6.发送邮件可十封
7.内存6兆异步倍
8.运算耗时十秒钟
9.异步升级六十秒
10.整体十分别嫌少
11.子查询使用要慎重
12.SOQL等待两分钟
13.batch可查五千万
14.查不出来也白干
解释:
官方文档
行1: SOQL最多100次,在异步环境200次,比如Batch,Future…
行2: SOQL查询最多返回5万条结果,DML操作最多1万条。
行3: DML操作做多是150次。
行4,5: Callout最多100次,累计等待时间120秒。
行6:无需解释
行7:Heapsize最大6MB,异步环境最大12MB。
行8,9:CPU使用时间最大10秒,异步环境一分钟。这里注意,查询或者HTTP请求不是CPU的运算时间。
行10: 所以是各种时间加一起才是事务消耗的总时间。最大十分钟。
行11: 子查询会额外消耗计算查询次数,累计查询次数等指标。能不用最好别用。
行12: SOQL查询最多等待2分钟,如果2分钟没有返回查询结果则异常。(子查询会增加查询复杂度)
行13,14: Batch的start方法里最多可以返回5千万条查询结果。如果数据太多查询执行超过了两分钟,batch也不会启动。
某天,旁边的同事自顾自地嘀咕起来:“如果Trigger里也能用Return该多好啊。。。。”。
我一听这话忍不了啊,使劲儿拍了下他的桌子,“能啊!谁说不能的!”
Trigger中使用Return规则遵从代码块作用域规则。
举个例子,
(注:Return;皆加在Debug Log之前)
如果在位置0写Return;
那么整个Trigger都会被Return跳过去,Debug Log中没有任何输出。
如果在位置1写Return;
那么第一个handler中的内容都会被跳过,Debug Log的输出结果为0,4,5,6。
如果在位置2写Return;
那么第一个handler的第一个方法的内容都会被跳过,Debug Log的输出结果为0,1,3,4,5,6。
同理,如果在位置4写Return;
那么第二个handler被完整跳过,Debug Log的输出结果为0,1,2,3。
通俗的讲,就是从方法级别开始,只影响到花括号范围。
那么有同学肯定会问。虽然代码规范一般不允许一个sObject建多个Trigger。那么万一就是有两个Trigger呢。
然后在其中一个Trigger中使用Return,会影响另一个Trigger执行吗?
答案是:不会。
道理是一样的,把Trigger降级一层,外面再加一层运行环境,那么每个Trigger里的Return就如同Handler里的Return,也只能影响自己而已。
// Update
自从某次Release之后,如果Return; 语句后面还有可执行的语句,保存时会报错,说有unreachable的代码。
如果想做实验,需要使用如下写法
if(true) { return; }
// In helper callServerAction : function(component, action) { return new Promise(function(resolve, reject) { action.setCallback(this, function(response) { let _state = response.getState(); if (_state === "SUCCESS") { resolve(response.getReturnValue()); } else { let errors = response.getError(); let message = "Error"; if (errors && Array.isArray(errors) && errors.length > 0) { message = JSON.stringify(errors); } reject(new Error(message)); } }); $A.enqueueAction(action); }); }, // In Controller doMethod : function(component, event, helper) { let myAction = component.get("c.doSomething"); myAction.setParams({ "param1": param1 }); let myAcitonPromise = helper.callServerAction(component, myAction); myAcitonPromise.then(function(_returnValue) { // DO SOMETHINT OR CONTINUE PROMISE }).catch(function(_error) { console.error(_error); }).finally(function() { }); }
作为Java的高度定制豪华版,Apex的String对象提供了三个与字符串替换相关的函数,replace(), replaceAll(), replaceFirst()。其中replace负责用字符串进行替换,replaceAll()负责用正则表达式进行替换,replaceFisrt负责用正则表达式替换第一个匹配到的结果。
在Apex范畴,这三个方法已经足够日常使用。但是在Javascript领域状况有所不同。
在Javascript中也有个函数叫做replace(),但却复杂很多。
首先引用来自官方文档的例子
const p = 'The quick brown fox jumps over the lazy dog. If the dog reacted, was it really lazy?'; const regex = /dog/gi; console.log(p.replace(regex, 'ferret')); // expected output: "The quick brown fox jumps over the lazy ferret. If the ferret reacted, was it really lazy?" console.log(p.replace('dog', 'monkey')); // expected output: "The quick brown fox jumps over the lazy monkey. If the dog reacted, was it really lazy?"
Javascipt的replace同时支持字符串替换与正则表达式替换,并且支持特殊替换符等高级功能。
但是,此replace却非彼replace。
Apex的replace使用字符串替模式会更加直白一些,直接使用 a.replace(b,c); 就能将a中的b替换成c,注意,是所有的b都被替换c。
而Javascript的字符串替换模式则仅替换第一个匹配项。与Apex的replaceFirst方法效果等同。
这样的话,在Lightning Component中如果打算实现与Apex中的replace一样的效果,就需要启用正则表达式模式。
在Helper添加如下方法:
replace : function(content, reg, replacement, isCaseSensitive) { let mode = 'g'; if(!isCaseSensitive) { mode = mode + 'i'; } let re = new RegExp(reg, mode); if(content !== null && content !== undefined && content !== "") { content = content.replace(re, replacement); } return content; }
虽然无法像原生方法一样直接写成a.replace(b,c); 但好在仍然是一行代码调用。
init : function(component, event, helper) { const p = 'The quick brown fox jumps over the lazy dog. If the dog reacted, was it really lazy?'; const reg = 'Dog'; const replacement = 'ferret'; let result = helper.replace(p, reg, replacement); console.log('result', result); // result The quick brown fox jumps over the lazy ferret. If the ferret reacted, was it really lazy? }
如果需要打开大小写敏感模式,则直接增加第四个参数 helper.replace(p, reg, replacement, true); 即可。
众所周知,Winter‘21在发布此文时已经正式上线。
其中,由于对Aura中使用Apex的权限采取了更严格的标准,从而导致了大批功能崩溃的惨剧。
除此之外,还有另一个Aura的底层变化也值得注意。
在最新的文档中,force:createRecord这个event的文档下面增加了一行小字
Date and time field values must use the ISO 8601 format. For example:
Date: 2017-07-18
Datetime: 2017-07-18T03:00:00Z
While the create record panel presents datetime values in the user’s local time, you must convert datetime values to UTC to prepopulate the field.
这就导致了原来直接向Datatime字段赋值Date值的功能报错。
let createRecordEvent = $A.get("e.force:createRecord"); createRecordEvent .setParams({ "entityApiName": "A__c", "defaultFieldValues": { 'testDatetime__c' : '2020-10-21' } }); createRecordEvent .fire(); // Error on Save : Value for field 'testDatetime__c' is not in ISO 8601 format, Value: 2020-10-21, Runtime class: java.lang.String
保存数据时会报如下错误
Error: Value for field ‘testDatetime__c’ is not in ISO 8601 format, Value: 2020-10-21, Runtime class: java.lang.String
如果有需要把date值写入datetime型字段的同学,可以在helper添加如下方法为date值填充时分秒,以此伪装成datatime值。
// in helper paddingTime : function(date) { if(date != "" && date != null && date != undefined && date != {}) { date = date + "T00:00:00.000"; } return date; } // in controller init : function(component, event, helper) { let createRecordEvent = $A.get("e.force:createRecord"); createRecordEvent .setParams({ "entityApiName": "A__c", "defaultFieldValues": { 'testDatetime__c' : helper.paddingTime('2020-10-21') } }); createRecordEvent .fire(); }
另外需要值得注意的是,虽然aura的controller看起来是前端,但实际上createRecord事件的日期时间赋值行为等同于Apex————既作为UTC时间插入数据库,需要考虑显示时差问题。
那么相比之下,有同学会产生疑问,Apex受影响吗? 还能愉快的直接将date值写入datetime型字段吗?
答案是,放心,Apex一如既往。
// 后记
其实我并不确定官方文档里关于时期格式的描述一定是Winter’21后才加的,但是很确定Winter’21之前可以直接把date值赋给datetime型字段。毕竟aura event的文档无法查看历史版本,所以,就让我主观的视其为Winter’21吧。
众所周知,如果打算做一个纯前端的下载CSV的功能,可以使用如下代码。
let content = "test,test1\nabc,123"; let blob = new Blob([content], { type: 'text/csv' }); let csvUrl = URL.createObjectURL(blob); let elementLink = document.createElement('a'); elementLink.href = csvUrl; elementLink.target = '_blank'; elementLink.download = 'test.csv'; elementLink.click();
在DOM中新建了临时的a标签,并执行点击动作。之后就会把content的内容作为csv文件下载下来。
无论是在VF里还是Lightning Component中皆可使用。
在VF页面中嵌入这段代码之后,使用Chrome在Classic与Lex下使用都是正常的。
但是使用Firefox在Classic下正常,在Lex下却会报错失败。
比如在我的实验环境,错误信息为如下。
Content Security Policy: The page’s settings blocked the loading of a resource at blob:https://snrenv2-dev-ed–c.ap4.visual.force.com/016d4701-1e33-47a1-8099-eaec82c7ff1d (“frame-src”).
唔。。。Content Security Policy。(关于什么是CSP,可以参考阮一峰老师的文章或者MDN文档(什么是内容安全策略,CSP用法)。
而且是来自于frame-src的CSP设定。
那么就一定存在iframe。
还记得在Classic时代,VF页面的主域名和标准页面的主域名是不同的。
VF:https://snrenv2-dev-ed–c.ap4.visual.force.com/
非VF:https://snrenv2-dev-ed.my.salesforce.com/
Lightning时代可能为了解决这个问题,在Lex的时候,居然将VF嵌套进了iframe里???
这样的话,让我们来看看Salesforce的CSP是怎么设定的。F12->网络->respones header。
Content-Security-Policy:
default-src ‘self’; script-src ‘self’ ‘nonce-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx’ chrome-extension: ‘unsafe-inline’ ‘unsafe-eval’ *.canary.lwc.dev *.ap4.visual.force.com https://ssl.gstatic.com/accessibility/; object-src ‘self’ https://snrenv2-dev-ed–c.ap4.content.force.com; style-src ‘self’ blob: chrome-extension: ‘unsafe-inline’ *.ap4.visual.force.com https://snrenv2-dev-ed–c.ap4.content.force.com; img-src ‘self’ http: https: data: blob: *.ap4.visual.force.com; media-src ‘self’ *.ap4.visual.force.com https://snrenv2-dev-ed–c.ap4.content.force.com blob:; frame-ancestors ‘self’; frame-src https: mailto: *.ap4.visual.force.com; font-src ‘self’ https: data: *.ap4.visual.force.com; connect-src ‘self’ https://api.bluetail.salesforce.com https://staging.bluetail.salesforce.com https://preprod.bluetail.salesforce.com *.ap4.visual.force.com https://snrenv2-dev-ed–c.ap4.content.force.com https://ap4.salesforce.com
可以看到针对iframe的设定是允许https, mailto, vf域名。这明明vf域名是合法的啊。
按照最上面的写法,最后生成的url是blob:https://snrenv2-dev-ed–c.ap4.visual.force.com/016d4701-1e33-47a1-8099-eaec82c7ff1d,甚至将https://snrenv2-dev-ed–c.ap4.visual.force.com/016d4701-1e33-47a1-8099-eaec82c7ff1d直接拷贝到浏览器地址栏可以看到csv内容本身。那么为什么Firefox就根据CSP拦截了,而Chrome却没有拦截呢?
其实互联网的世界,并不是那么风平浪静。
以前在一篇文章里谈到过,计算机的世界其实并不是精确如一尘不染的实验室,而是建立在一系列没那么精确,不那么清晰,不是很靠谱的机制上。能跑就行。
虽然对于各种互联网协议的定义都有专门的组织去管理去发展。但是就如法律条文,协议与规范写的再精确也难免因为理解偏差而出现南辕北辙的情况。
而且从当年微软的IE带头不遵守规范伊始,各家浏览器的兼容性问题就始终困扰着广大开发者。就像你规定了IE,Firefox,Chrome都必须戴帽子出门,但是你会发现IE把帽子戴膝盖上了,Firefox斜着戴,Chrome反着戴——————谁让你没规定怎么戴。
这里关于CSP如何拦截我推测也是类似的情况(查阅浏览器代码实在力不从心),Chrome觉得blob: + 合法域名 = 合法,而Firefox觉得blob: 没有在名单里 = 不合法。
所以为了解决这个问题,我们只能干掉Firefox。我们只能绕开CSP,这是一种自己hack自己的行为,也许在某次Firefox更新或者salesforce更新之后就失效了。慎用。
如果说,VF所在的iFrame收到主页面的CSP策略约束,那么,我在iFrame中再造一个iFrame,外层iFrame没有设定CSP,在我造的iFrame里就可以为所欲为了。
代码如下
let iframe = document.createElement('iframe'); document.body.append(iframe); iframe.style.display = 'none'; iframe.addEventListener('load', ()=> { let content = "test,test1\nabc,123"; let blob = new Blob([content], { type: 'text/csv' }); let csvUrl = URL.createObjectURL(blob); let elementLink = document.createElement('a'); elementLink.href = csvUrl; elementLink.target = '_blank'; elementLink.download = 'test.csv'; elementLink.click(); });
原理为,凭空捏造一个iFrame嵌到当前iFrame,然后监听load事件,将下载CSV的代码放入其中。这样当iFrame创建好了之后就会自动开始下载。
慎用。。。慎用。。。慎用
很多年前,我写过一篇文章叫《关于阅读代码 》。是在2014年写的,那时候刚开始写博客。根据后台统计数据显示,这篇文章这些年来也没什么人看。
其实文章分两种,一种是给自己看的,一种是给别人看的。就像写代码一样,有的代码你自己写的爽就行了,也没打算给别人看;还有一种,写出来的目的就是为了给别人看的。
所以回到《关于阅读代码》这篇文章,当时写的时候更多的是因为随着职业经历的积累自己有一些了感悟,觉得自己有一些绝妙的想法,想记录下来。这篇文章也一样。
作为程序员,工作职责中,可能写代码只占很小的一部分,不过因人而异。如果作为Salesfoce从业者,工作职责中80%以上都是写代码的话,我倒是觉得这其实是一件挺可悲的事情。应该考虑换一个平台或者试试别的角色。
但是,单就开发这部分来说,由于标准功能的存在,代码在Salesforce开发中的比重也不再如传统开发的100%。可以说我们的目标就是要干掉所有代码。
虽然说我们要干掉代码,但是代码却极其重要。毕竟所有标准功能无法实现的功能还是要代码去实现。这时候“对人友善”的代码在Salesforce开发活动中就格外的重要。毕竟在梳理业务流程的时候,谁也不想面对方便面一样的代码皱眉头。或者,被问到头上的时候,只能╮(╯▽╰)╭说:“这个功能好用但我不知道为什么。”
这些年我发现一个现象。习惯疯狂抽象分层的Java程序员,在转Salesforce开发之后往往更容易写出散文诗式流水账代码。然后再进行几次需求变动,代码就变成了方便面。
那么,为什么一个优秀的coder做salesforce之后会写出方便面代码?我觉得原因是接受到的信息的不同。
在Java时代,代码写手在动手之前接受到的信息为具体的功能模块,已经有架构师或同等角色的人将功能完全拆解或者绘制好蓝图。代码写手要做的是实现规划好的功能模块,将自己的模块成功嵌入到大框架内。而Salesforce代码写手接受的信息往往是业务描述——只是说了要做哪些事情,而不是严谨的功能模块划分。
举个例子,就像做意大利面,非Salesforce的厨师被提供了一个精确的食谱和严格的验收标准,要做的是用规定的食材严格按照食谱在指定地点把意大利面做好就行。而Salesforce的厨师只是被告知要做一盘什么味道的意大利面,可能连食材都还没买,到做好上桌之前都要自己搞定。
如果有自由发挥的空间,就像在饭店点蛋炒饭,每个厨师都会做出不同的味道。