Skip to main content

Add-on App

개요#

  • G. Suite의 부가기능을 위한 UI및 기능을 제공하는 App
  • Spreadsheet, Docs, Forms, Gmail등의 전용 Add-on으로 구현
  • Add-on 프로젝트는 Server를 담당하는 스크립트(gs)와 Client를 담당하는 View(html)가 하나로 묶여진 형태
  • Script는 Cloud Platform상에서 동작하고 View는 Browser상에서 동작
  • Script는 문서 입출력및 View 생성, View는 UI및 그에 관련된 javascript 수행

Project 구현#

프로젝트 생성 방법은 Interface 참고

Script (Server-side)#

Trigger#

  • onInstall 사용자가 Add-on을 설치했을 때 발생하는 이벤트
  • onOpen 사용자가 Add-on에 관계된 문서를 열었을때 발생하는 이벤트
  • onEdit 사용자가 문서를 편집했을 때 발생하는 이벤트
  • onInstall 과 onOpen에서 Add-ons 메뉴에 추가하는 코드를 수행

Add-ons 메뉴 등록#

  • Ui Class의 createAddonMenu() 함수를 통해 Add-ons 하위에 메뉴 항목을 추가
  • Ui Class는 ~App.getUi() 함수에서 반환
  • Example
SpreadsheetApp.getUi()
.createAddonMenu()
.addItem('Menu Title', 'CallbackFunction')
.addToUi();

HTML UI 생성#

  • HtmlService 의 create~ 함수들을 사용하여 HtmlOutput을 생성 _ 일반적인 Html의 경우 createHtmlOutputFromFile와 같이 HtmlOutput 객체를 반환하는 함수 사용
  • Html에 Template 태그(<?= ?>)을 사용한 경우 createTemplateFromFile와 같이 HtmlTemplate 객체를 반환 후 evaluate() 함수를 통해 HtmlOutput으로 변환하여 사용
  • Ui Class의 showSidebar 함수에 HtmlOutput객체를 전달하면 Html이 Sidebar로 표시
  • Ui Class의 showModalDialog, showModalessDialog 함수에 HtmlOutput 객체와 Title을 전달하면 Html이 Dialog로 표시

연결된 문서 가져오기#

  • Docs: DocumentApp.getActiveDocument()
  • Sheets: SpreadsheetApp.getActiveSheet()
  • Forms: FormApp.getActiveForm()

Spreadsheet 셀 처리#

  • Spreadsheet는 셀에 대한 처리를 Range 기반으로 수행
  • Sheet.getDataRange() : 현재 시트에 데이터가 존재하는 모든 영역 반환
  • Sheet.getRange(row, col) : 특정 행, 컬럼의 인덱스(1로 시작) 로 단일셀의 Range를 반환
  • Sheet.getRange(row, col, rowcount, colcount): 특정 범위의 복수셀의 Range를 반환
  • Range.getValue(), Range.setValue(Object): 단일 셀 Range일 때 해당 셀의 값 read, write
  • Range.getValues(), Range.setValues(Object[][]): 복수 셀 Range일 때 셀들의 값 read, write

View (Client-side)#

Sidebar, Dialog와 같은 UI와 그에 따른 Stylesheet및 Javascript를 Html파일로 구현

Template#

  • Scriptlet: <?= ?> Tag는 evaluate시에 해당 Server-side 값으로 변환
  • Force-printing Scriptlet: <?!= ?> Tag는 일반적인 Scriptlet과 달리 Escape처리를 하지 않고 원본 그대로 출력. 다른 파일을 Embed할 때 사용
  • Example
var fields = `<?= fieldInfo ?>`;
<?!= HtmlService.createHtmlOutputFromFile('Stylesheet').getContent(); ?>

Server 함수 호출#

Client의 Javascript에서 Server-side의 함수를 호출할 때 google.script.run Api를 사용

  • withFailureHandler(function): 오류 처리 핸들러
  • withSuccessHandler(function): 성공 처리 핸들러
  • withUserObject: handler의 두번째 인수로 전달할 값 지정
  • myFunction: 실제 호출할 server-side 함수
  • Example
google.script.run
.withSuccessHandler(function (msg, userObject) {
// succcess code
})
.withFailureHandler(function (msg, userObject) {
// failure code
})
.withUserObject(this) // this를 callback userObject로 전달
.generateGridInfo(); // 실제 호출되는 server-side 함수

Publish#

앱스토어에서 등록시 Cloud Platform을 참고하므로 아래와 같은 순서로 진행

Cloud Platform 등록#

  1. Editor의 Resources - Cloud Platform Project 선택
  2. 상단의 Project ID 링크로 Cloud Platform으로 등록
  3. APIs & Services의 Dashboard에서 ENABLE APIS AND SERVICES 선택
  4. G Suite Marketplace SDK를 검색하여 ENABLE 처리
  5. G Suite Marketplace SDK 페이지에서 Configuration 선택
  6. Application Info및 Icon 등록
  7. OAuth 2.0 scopes Section에서 해당 프로젝트에서 사용하는 인증영역 등록 (Editor File-Project Properties-Scopes에서 확인)
  8. Editor Add-on extensions Section에서 Add-on으로 붙일 대상 체크
  9. Project Key 입력 (Editor File-Project Properties-Info에서 확인)
  10. Script Version 입력 초기값 1, 이후 업데이트시 Version 일련번호(Version이 5(0.3)일 때 5)
  • Deploy Update이후 Cloud Platform에서 Version값을 수정하지 않으면 이전 버전으로 동작

Deploy#

  1. Editor에서 Publish-Deploy as add-on선택
  2. 기본 정보 입력
  3. 앱스토어 페이지에서 상세설명, 아이콘, 캡쳐화면, 프로모션 이미지, 카테고리, 지역, 언어등을 입력
  4. 공개설정에서 공개는 모든 사용자가 Add-ons-Get add-ons메뉴에서 설치 가능, 미등록은 검색은 링크를 통해서만 설치가능, 비공개는 개발자 대시보드에 초대된 사용자만 설치 가능한 영역

::: warning 2019. 4. 19 추가 GCP가 Update되면서 Editor에서 자동 생성되는 기능이 차단됨. GCP에서 수동으로 Project를 생성 후 Auth Consent screen 설정 후 Resources - Cloud Platform project에서 생성된 프로젝트의 번호를 등록해야 함. :::

에제 프로젝트 개요#

  • 리얼그리드 그룹및 컬럼 설정 코드를 빠르게 생성하기 위한 Add-on
  • Spreadsheet 전용 Add-on으로 설치 후 Add-ons 메뉴에서 실행시 Sidebar 표시

Sidebar UI#

  • 시트 양식화 버튼: 현재 시트를 최초 양식을 적용
  • 필드및 컬럼 생성 버튼: 현재 컬럼 정보를 기반으로 필드및 컬럼정보를 생성
  • 미리보기 : 생성된 컬럼정보를 Dialog UI의 그리드에 표시

Dialog UI#

  • 리얼그리드: 컬럼정보를 미리보기하는 그리드
  • textarea: 리얼그리드 컬럼 설정 관련 Full source

예제 프로젝트 소스#

Server-side#

  • Code.gs
// Title
// Title
var SIDEBAR_TITLE = 'Column Generator';
var DIALOG_TITLE = 'Preview';
// Property Count
var PROP_COUNT = 8;
// Property Names
var HEAD_NAME = '필드명';
var HEAD_TYPE = '형태';
var HEAD_ALIGN = '정렬';
var HEAD_EDIT = '편집 여부';
var HEAD_NUMB = '숫자 형식';
var HEAD_DATE = '날짜 형식';
var HEAD_VALS = '선택 값';
var HEAD_LBLS = '선택 라벨';
// 실제 데이터가 시작하는 행 번호
var START_COL = 2;
var TYPE_VALUES = [
'line',
'multiline',
'dropdown',
'search',
'multicheck',
'number',
'date',
];
var TYPE_LABELS = [
'텍스트',
'다중 라인',
'드랍다운',
'부분검색',
'복수 선택',
'숫자',
'날짜',
];
var ALIGN_VALUES = ['near', 'center', 'far'];
var ALIGN_LABELS = ['왼쪽', '가운데', '오른쪽'];
var NFMT_VALUES = ['', '#,##0', '#,##0.0', '#,##0.00'];
var NFMT_LABELS = [
'-',
'정수(#,##0)',
'소숫점 1자리(#,##0.0)',
'소숫점 2자리(#,##0.00)',
];
var DFMT_VALUES = [
'',
'yyyy/MM/dd',
'yyyy-MM-dd',
'yyyy/MM/dd hh:nn:ss',
'yyyy-MM-dd hh:nn:ss',
];
var DFMT_LABELS = [
'-',
'날짜(구분자 "/")',
'날짜(구분자 "-")',
'날짜 + 시간(구분자 "/")',
'날짜 + 시간(구분자 "-")',
];
function onInstall(e) {
onOpen(e);
}
function onOpen(e) {
SpreadsheetApp.getUi()
.createAddonMenu()
.addItem(SIDEBAR_TITLE, 'showColumnSidebar')
.addToUi();
}
function showColumnSidebar() {
var sidebar = HtmlService.createTemplateFromFile('ColumnSidebar')
.evaluate()
.setTitle(SIDEBAR_TITLE)
.setSandboxMode(HtmlService.SandboxMode.IFRAME);
SpreadsheetApp.getUi().showSidebar(sidebar);
}
function showColumnPreivew(fields, columns) {
var template = HtmlService.createTemplateFromFile('ColumnPreview');
template.fieldInfo = fields;
template.columnInfo = columns;
var preview = template
.evaluate()
.setWidth(600)
.setHeight(500)
.setSandboxMode(HtmlService.SandboxMode.IFRAME);
SpreadsheetApp.getUi().showModalDialog(preview, DIALOG_TITLE);
}
// 영삭 초기화
function buildForm(colDepth) {
colDepth = parseInt(colDepth);
var sheet = SpreadsheetApp.getActiveSheet();
sheet.getDataRange().setDataValidation(null);
sheet.clear().clearNotes();
var colCount = 4;
var colPerGroup = 2;
// column group
if (colDepth > 1) {
colCount = Math.pow(colPerGroup, colDepth);
for (var r = 1; r < colDepth; r++) {
for (var c = 0; c < colCount; c++) {
var groupCols = Math.pow(colPerGroup, r);
if (c % groupCols == 0) {
sheet
.getRange(colDepth - r, c + 2, 1, groupCols)
.mergeAcross()
.setValue('그룹');
}
}
}
colCount++;
}
sheet
.getRange(1, 2, colDepth, colCount)
.setHorizontalAlignment('center')
.setVerticalAlignment('middle')
.setBackground('silver')
.setBorder(true, true, true, true, true, true);
// column
for (var i = 0; i < colCount; i++) {
sheet.getRange(colDepth, i + 2).setValue('컬럼' + (i + 1));
sheet.getRange(colDepth + 1, i + 2).setValue('column' + i);
}
// left header
var row = 1 + colDepth;
var fieldNameRow = row;
var editTypeRow = row + 1;
var alignmentRow = row + 2;
var editableRow = row + 3;
var numberFmtRow = row + 4;
var dateFmtRow = row + 5;
var valuesRow = row + 6;
var labelsRow = row + 7;
sheet
.getRange(fieldNameRow, 1)
.setValue(HEAD_NAME)
.setFontColor('#cc0000')
.setFontWeight('bold');
sheet
.getRange(editTypeRow, 1)
.setValue(HEAD_TYPE)
.setFontColor('#cc0000')
.setFontWeight('bold');
sheet.getRange(alignmentRow, 1).setValue(HEAD_ALIGN);
sheet.getRange(editableRow, 1).setValue(HEAD_EDIT);
sheet.getRange(numberFmtRow, 1).setValue(HEAD_NUMB);
sheet.getRange(dateFmtRow, 1).setValue(HEAD_DATE);
sheet
.getRange(valuesRow, 1)
.setValue(HEAD_VALS)
.setNote(
'드랍다운 또는 복수선택일 경우\n항목들의 값을 쉼표(,)로 나누어 입력'
);
sheet
.getRange(labelsRow, 1)
.setValue(HEAD_LBLS)
.setNote(
'드랍다운 또는 복수선택일 경우\n항목들의 라벨을 쉼표(,)로 나누어 입력\n미입력시 값이 표시'
);
sheet.getRange(row, 2, PROP_COUNT, colCount).setBackground('#ffff77');
sheet.setFrozenColumns(1);
// 형태
sheet
.getRange(editTypeRow, 2, 1, colCount)
.setDataValidation(
SpreadsheetApp.newDataValidation()
.setAllowInvalid(false)
.requireValueInList(TYPE_LABELS, true)
.build()
)
.setValue('텍스트');
// 정렬
sheet
.getRange(alignmentRow, 2, 1, colCount)
.setDataValidation(
SpreadsheetApp.newDataValidation()
.setAllowInvalid(true)
.requireValueInList(ALIGN_LABELS, true)
.build()
)
.setValue('왼쪽');
// 편집 가능
sheet
.getRange(editableRow, 2, 1, colCount)
.setDataValidation(
SpreadsheetApp.newDataValidation()
.setAllowInvalid(true)
.requireCheckbox()
.build()
)
.setValue('TRUE');
// 숫자 형식
sheet
.getRange(numberFmtRow, 2, 1, colCount)
.setDataValidation(
SpreadsheetApp.newDataValidation()
.setAllowInvalid(true)
.requireValueInList(NFMT_LABELS, true)
.build()
)
.setValue('');
// 날짜 형식
sheet
.getRange(dateFmtRow, 2, 1, colCount)
.setDataValidation(
SpreadsheetApp.newDataValidation()
.setAllowInvalid(true)
.requireValueInList(DFMT_LABELS, true)
.build()
)
.setValue('');
}
// 정보 생성: 시트 값 parsing
function generateGridInfo() {
var sheet = SpreadsheetApp.getActiveSheet();
var values = sheet.getDataRange().getValues();
if (!values || values.length < 2 || values[0].length < 2) {
throw new Error('시트 데이터가 존재하지 않습니다.');
}
var srow = -1;
var nameRow,
typeRow,
alignRow,
editableRow,
numberRow,
dateRow,
valuesRow,
labelsRow;
for (var r = 0; r < values.length; r++) {
var head = values[r][0];
if (head && srow == -1) {
srow = r;
}
if (head == HEAD_NAME) {
nameRow = r;
} else if (head == HEAD_TYPE) {
typeRow = r;
} else if (head == HEAD_ALIGN) {
alignRow = r;
} else if (head == HEAD_EDIT) {
editableRow = r;
} else if (head == HEAD_NUMB) {
numberRow = r;
} else if (head == HEAD_DATE) {
dateRow = r;
} else if (head == HEAD_VALS) {
valuesRow = r;
} else if (head == HEAD_LBLS) {
labelsRow = r;
}
}
if (!nameRow) {
throw new Error(HEAD_NAME + '행을 찾지 못했습니다.');
}
if (!typeRow) {
throw new Error(HEAD_TYPE + '행을 찾지 못했습니다.');
}
var fields = [];
var columns = [];
for (var c = 1; c < values[0].length; c++) {
var fname = values[nameRow][c];
if (fname) {
var field = {};
var column = {};
field.fieldName = column.fieldName = column.name = fname;
column.width = Math.round(sheet.getColumnWidth(c + 1));
column.header = { text: values[srow - 1][c] };
var typeText = values[typeRow][c];
var typeIndex = TYPE_LABELS.indexOf(typeText);
if (typeIndex == -1) {
throw new Error('존재하지 않은 형태(' + typeText + ') 입니다.');
}
var ftype = TYPE_VALUES[typeIndex];
column.editor = { type: ftype };
if (ftype == 'number' || ftype == 'datetime') {
field.dataType = ftype;
}
var align = ALIGN_VALUES[ALIGN_LABELS.indexOf(values[alignRow][c])];
if (align != 'near') {
column.styles = { textAlignment: align };
}
values[editableRow][c] === false &&
SpreadsheetApp.getActiveSpreadsheet().toast('editable false');
if (!values[editableRow][c]) {
column.editable = false;
}
if (ftype == 'date') {
var fmt = values[dateRow][c];
var formatIndex = DFMT_LABELS.indexOf(fmt);
var dateformat =
formatIndex == -1 ? fmt || 'yyyy/MM/dd' : DFMT_VALUES[formatIndex];
field.datetimeFormat = dateformat;
column.editor.datetimeFormat = dateformat;
}
if (ftype == 'number') {
var fmt = values[numberRow][c];
var formatIndex = NFMT_LABELS.indexOf(fmt);
if (!column.styles) {
column.styles = {};
}
var numberformat =
formatIndex == -1 ? fmt || null : NFMT_VALUES[formatIndex];
column.styles.numberFormat = numberformat;
column.editor.editFormat = numberformat;
column.editor.textAlignment = 'far';
}
if (ftype == 'multicheck' || ftype == 'dropdown') {
column.lookupDisplay = true;
column.editor.showButtons = true;
var vals = values[valuesRow][c].split(',');
var lbls = values[labelsRow][c].split(',');
column.values = vals;
if (lbls.length > 1 || lbls[0] != '') column.labels = lbls;
if (ftype == 'multicheck') column.valueSeparator = ',';
}
fields.push(field);
columns.push(column);
}
}
if (srow > 1) {
var groups = [];
buildColumnGroups(groups, sheet, 1, srow, 2, columns.length + 1, columns);
return { fields: fields, columns: groups };
} else {
return { fields: fields, columns: columns };
}
}
// MergeCell과 일반Cell을 array로 가져오기 위한 utility 함수
function getCells(sheet, row, startcol, lastcol) {
var cells = [];
for (var i = startcol; i <= lastcol; i++) {
var range = sheet.getRange(row, i);
var merges = range.getMergedRanges();
if (merges.length > 0) {
cells.push(merges[0]);
i += merges[0].getLastColumn() - merges[0].getColumn();
} else {
cells.push(range);
}
}
return cells;
}
// 컬럼의 그룹을 생성하는 재귀 함수
function buildColumnGroups(
list,
sheet,
row,
lastRow,
startCol,
lastCol,
columns
) {
var cells = getCells(sheet, row, startCol, lastCol);
var totwidth = 0;
for (var i = 0; i < cells.length; i++) {
var cell = cells[i];
var cellStartCol = cell.getColumn();
var cellLastCol = cell.getLastColumn();
if (cellStartCol == cellLastCol) {
// single column group
var col = columns[cellStartCol - START_COL];
list.push(col);
totwidth += col.width;
} else {
var group = {};
group.header = { text: cell.getValue() };
group.columns = [];
group.width = buildColumnGroups(
group.columns,
sheet,
row + 1,
lastRow,
cellStartCol,
cellLastCol,
columns
);
totwidth += group.width;
list.push(group);
}
}
return totwidth;
}

Client-side#

  • ColumnSidebar.html
<!DOCTYPE html>
<html>
<head>
<base target="_top">
<?!= HtmlService.createHtmlOutputFromFile('Stylesheet').getContent(); ?>
</head>
<body>
<div class="sidebar branding-below">
<p>
시트의 컬럼 형태및 입력 값으로 리얼그리드 컬럼 정보를 생성합니다.
</p>
<div class="block" id="sidebar-button-bar">
<button id="sidebar-setform-button">시트 양식화</button>
<button id="sidebar-generate-button">필드/컬럼 정보 생성</button>
</div>
<div class="block form-group" id="sidebar-value-block">
<label for="sidebar-field">필드정보</label>
<textarea id="sidebar-field"></textarea>
</div>
<div class="block form-group" id="sidebar-value-block">
<label for="sidebar-field">컬럼정보</label>
<textarea id="sidebar-column"></textarea>
</div>
<div class="block" id="sidebar-button-bar">
<button id="sidebar-preview-button">미리보기</button>
</div>
<div id="sidebar-status"></div>
</div>
<?!= HtmlService.createHtmlOutputFromFile('ColumnSidebarJS').getContent(); ?>
</body>
</html>
  • ColumnSidebarJS.html
<script src="//ajax.googleapis.com/ajax/libs/jquery/1.9.1/jquery.min.js"></script>
<script>
$(function() {
$('#sidebar-setform-button').click(setFormClick);
$('#sidebar-generate-button').click(generateClick);
$('#sidebar-preview-button').click(previewClick);
});
function setFormClick() {
this.disabled = true;
var depth = prompt("컬럼을 몇 계층으로 생성하시겠습니까?", "1");
if (!depth || isNaN(depth)) {
return;
}
depth = parseInt(depth);
google.script.run
.withSuccessHandler(
function(msg, element) {
//showStatus("setForm success");
element.disabled = false;
})
.withFailureHandler(
function(msg, element) {
showStatus("setForm failed.");
element.disabled = false;
throw new Error(msg);
})
.withUserObject(this)
.buildForm(depth);
}
function outputJson(obj) {
return JSON.stringify(obj, null, 2);
}
function generateClick() {
this.disabled = true;
google.script.run
.withSuccessHandler(
function(msg, element) {
// Respond to success conditions here.
var values = msg;
if (typeof values != "object") {
showStatus("컬럼 정보가 없습니다.", error);
} else {
$("#sidebar-field").val(outputJson(values.fields));
$("#sidebar-column").val(outputJson(values.columns));
showStatus("success");
}
element.disabled = false;
})
.withFailureHandler(
function(msg, element) {
// Respond to failure conditions here.
showStatus(msg, 'error');
element.disabled = false;
})
.withUserObject(this)
.generateGridInfo();
}
function previewClick() {
var fields = document.getElementById('sidebar-field').value;
var columns = document.getElementById('sidebar-column').value;
this.disabled = true;
google.script.run
.withSuccessHandler(
function(msg, element) {
element.disabled = false;
})
.withFailureHandler(
function(msg, element) {
showStatus(msg, 'error');
element.disabled = false;
})
.withUserObject(this)
.showColumnPreivew(fields, columns);
}
function showStatus(msg, classId) {
$('#sidebar-status').removeClass().html(msg);
if (classId) {
$('#sidebar-status').addClass(classId);
}
}
</script>
  • ColumnPreview.html
<!DOCTYPE html>
<html>
<head>
<title>미리보기</title>
<style>
html, body {
height: 100%;
}
</style>
<script>
realGridJsLic =
"upVcPE+wPOksRHztag2HLtau1G7NGLZh+p2VI6Q9GDOthfRXc6VEa68faT5n8QEp6VZcxcGgRV+vGYFB+5+ms78Lqq5rJJ015ohX793h61SSf8kgSSJFuQ=="
</script>
<?!= HtmlService.createHtmlOutputFromFile('realgridjs').getContent(); ?>
<?!= HtmlService.createHtmlOutputFromFile('realgridapi').getContent(); ?>
<script>
var dataProvider;
var gridView;
function createGrid() {
var divElementId = "realgrid";
RealGridJS.setRootContext("//demo.realgrid.com/lib/realgrid/realgridjs_eval.1.1.31");
dataProvider = new RealGridJS.LocalDataProvider();
var fields = JSON.parse("<?= fieldInfo ?>");
dataProvider.setFields(fields);
dataProvider.setRowCount(10);
gridView = new RealGridJS.GridView(divElementId);
gridView.setDataSource(dataProvider);
var columns = JSON.parse("<?= columnInfo ?>");
console.log(columns);
gridView.setColumns(columns);
};
</script>
</head>
<body onload="createGrid();">
<div id="realgrid" style="width:99%; height:300px">
</div>
<textarea style="width:99%; height:100px">
var divElementId = "realgrid";
dataProvider = new RealGridJS.LocalDataProvider();
var fields = <?= fieldInfo ?>;
dataProvider.setFields(fields);
dataProvider.setRowCount(10);
gridView = new RealGridJS.GridView(divElementId);
var columns = <?= columnInfo ?>;
gridView.setDataSource(dataProvider);
gridView.setColumns(columns);
</textarea>
</body>
</html>
  • Stylesheet.html
<link rel="stylesheet" href="https://ssl.gstatic.com/docs/script/css/add-ons.css">
<style>
label {
font-weight: bold;
}
.branding-below {
bottom: 54px;
top: 0;
}
.branding-text {
left: 7px;
position: relative;
top: 3px;
}
.logo {
vertical-align: middle;
}
.width-100 {
width: 100%;
box-sizing: border-box;
-webkit-box-sizing : border-box;‌
-moz-box-sizing : border-box;
}
#sidebar-value-block,
#dialog-elements {
background-color: #eee;
border-color: #eee;
border-width: 5px;
border-style: solid;
}
#sidebar-button-bar,
#dialog-button-bar {
margin-bottom: 10px;
}
textarea {
width:100%;
height:100px;
overflow-x: scroll;
white-space: nowrap;
}
</style>