diff --git a/addons/metadata/README.md b/addons/metadata/README.md index ee8744e98c2..d1a92cfe052 100644 --- a/addons/metadata/README.md +++ b/addons/metadata/README.md @@ -34,6 +34,88 @@ USE_DATASET_IMPORTING = True The "Import Dataset" button is displayed in a toolbar of a file browser if `USE_DATASET_IMPORTING` is true and users can import datasets from external sources. +## Schema UI Hints (`ui` property) + +Metadata schemas (e.g. `e-rad-metadata-1.json`) define both data structure and form rendering. To keep rendering concerns separate from data structure, each question may carry an optional `ui` property — a single JSON object that holds all UI-specific hints. + +### Design rationale + +Schema properties flow through two distinct paths: + +1. **File metadata path** — The schema JSON is stored in `RegistrationSchema.schema` (a JSONField) and served as-is to the browser. The frontend JS (`metadata-fields.js`) reads `question.ui.*` directly. Any new key added to `ui` is automatically available without backend changes. + +2. **Project metadata path** — The migration `map_schemas_to_schemablocks` decomposes each question into `RegistrationSchemaBlock` rows. Each column on that model requires a DB migration, a serializer update, and a corresponding Ember model attribute. Adding a single column touches Python models, migrations, API serializers, and Ember types. + +By consolidating all UI hints into a single `ui` JSONField column on `RegistrationSchemaBlock`, the project metadata path gains the same extensibility as the file metadata path: new hints can be added to the `ui` object without further migrations or model changes. + +### Structure + +Simple ja/en pair: + +```json +{ "qid": "grdm-file:title-ja", + "ui": { + "group": { "id": "title", "title": "データの名称|Title", "tags": ["共通|Common", "リポジトリ|Repository"], + "help": "...", "info": "管理対象データの特徴を示す名称を入力。|..." }, + "sub_label": "(日本語)|(Japanese)", + "item": { "placeholder": "研究データ管理に関する意識調査" } + } } + +{ "qid": "grdm-file:title-en", + "ui": { "group": "title", "sub_label": "(English)|(English)" } } +``` + +Nested group (ja/en pair inside a category): + +```json +{ "qid": "grdm-file:data-policy-free", + "ui": { "group": { "id": "data-policy", "title": "管理対象データの利活用・提供方針|...", "bar": true, + "tags": ["共通|Common"], "info": "ライセンス情報を記載。|..." } } } + +{ "qid": "grdm-file:data-policy-license", + "ui": { "group": "data-policy" } } + +{ "qid": "grdm-file:data-policy-cite-ja", + "ui": { + "group": { "id": "data-policy-cite", "parent": "data-policy", "title": "引用方法等|..." }, + "sub_label": "(日本語)|(Japanese)" + } } + +{ "qid": "grdm-file:data-policy-cite-en", + "ui": { "group": "data-policy-cite", "sub_label": "(English)|(English)" } } +``` + +The `ui` object has three scopes: + +**Page scope** (`page.ui`) — page-level decoration: + +- **`header`** — Introductory HTML text displayed above all questions on the page (e.g. format notes, tag legend). + +**Group scope** (`ui.group`) — section heading and grouping: + +- **`id`** — Group identifier. The first question in a group carries the full definition (object); subsequent questions reference the group by `id` string only (e.g. `"group": "title"`). +- **`parent`** — Parent group reference. A string ID when the parent is already defined by an earlier question. An object (`{ "id": "...", "title": "...", ... }`) to simultaneously define the parent group — useful when no earlier question belongs directly to the parent. +- **`title`** — Section heading text. +- **`help`** — Help text (supports HTML) displayed under the section heading. +- **`bar`** — If `true`, draw a continuous vertical bar on the left side of group members (for semantic category groups). +- **`tags`** — Badge labels for the section (e.g. `["共通|Common"]`, `["共通|Common", "リポジトリ|Repository"]`). Pipe-delimited for localization. +- **`info`** — Detailed explanation shown in a popover when the ⓘ mark next to the group heading is clicked. + +**Item scope** (`ui.item`) — individual input field: + +- **`placeholder`** — Placeholder text for the input field. +- **`width`** — Abstract width category (e.g. `"narrow"`, `"half"`). +- **`widget`** — Override the default widget (e.g. `"radio"` instead of pulldown for `singleselect`). +- **`enabled_if`** — Conditional disabled display. An object with a `disabled` key whose value is either `true` (unconditionally disabled) or a condition object (e.g. `{ "disabled": { "grdm-file:file-type": "dataset" } }`). When the question's `enabled_if` is false and the `disabled` condition is met, the field is shown greyed out instead of hidden. +- **`info`** — Detailed explanation shown in a popover when the ⓘ mark next to the field label is clicked. For grouped fields, prefer `ui.group.info` over `ui.item.info`. +- **`tags`** — Badge labels for standalone fields (fields not belonging to a group). Pipe-delimited for localization. + +**Top-level** (`ui.*`): + +- **`sub_label`** — Label within a group (e.g. "(日本語)|(Japanese)" / "(English)|(English)" for ja/en pairs). + +This list is not exhaustive; new keys can be added as needed without schema migration. + ## Suggestion Policies (ERAD/KAKEN) This addon provides researcher/project suggestions sourced from ERAD and KAKEN. Ordering and deduplication follow simple, explicit policies so results are predictable and easy to reason about. diff --git a/addons/metadata/packages.py b/addons/metadata/packages.py index bb25c8a0099..aec258c1366 100644 --- a/addons/metadata/packages.py +++ b/addons/metadata/packages.py @@ -1283,11 +1283,26 @@ def to_creators_metadata(users): ] def _to_user_metadata(user): - middle_names_en = '' if not user.middle_names else f'{user.middle_names} ' + affiliation_ja = '' + affiliation_en = '' + current_jobs = [job for job in user.jobs if job.get('ongoing')] + if current_jobs: + affiliation_ja = current_jobs[0].get('institution_ja') or '' + affiliation_en = current_jobs[0].get('institution') or '' return { 'number': user.erad, - 'name_ja': f'{user.family_name_ja}{user.middle_names_ja}{user.given_name_ja}', - 'name_en': f'{user.given_name} {middle_names_en}{user.family_name}', + 'name-ja': { + 'last': user.family_name_ja, + 'middle': user.middle_names_ja, + 'first': user.given_name_ja, + }, + 'name-en': { + 'last': user.family_name, + 'middle': user.middle_names, + 'first': user.given_name, + }, + 'affiliation-name-ja': affiliation_ja, + 'affiliation-name-en': affiliation_en, } def _snake_to_camel(name): diff --git a/addons/metadata/report_format/report_en.csv.j2 b/addons/metadata/report_format/report_en.csv.j2 index a0fceb0a29b..e7963035551 100644 --- a/addons/metadata/report_format/report_en.csv.j2 +++ b/addons/metadata/report_format/report_en.csv.j2 @@ -7,6 +7,6 @@ Funder,Funding stream code in Japan Grant Number,Program name,Japan Grant Number {%- else -%} {{grdm_file.data_research_field_tooltip_1 | quotecsv}} {%- endif -%} -,{{grdm_file.data_type_tooltip_1 | quotecsv}},{{grdm_file.file_size | quotecsv}},{{grdm_file.data_policy_free_tooltip_1 | quotecsv}},{{grdm_file.data_policy_license | quotecsv}},{{grdm_file.data_policy_cite_en | quotecsv}},{{grdm_file.access_rights_tooltip_1 | quotecsv}},{{grdm_file.available_date | quotecsv}},{{grdm_file.repo_information_en | quotecsv}},{{grdm_file.repo_url_doi_link | quotecsv}},{{grdm_file.creators | map(attribute="name_en") | join(";") | quotecsv}},{{grdm_file.creators | map(attribute="number") | join(";") | quotecsv}},{{grdm_file.hosting_inst_en | quotecsv}},{{grdm_file.hosting_inst_id | quotecsv}},{{grdm_file.data_man_name_en | quotecsv}},{{grdm_file.data_man_number | quotecsv}},{{grdm_file.data_man_org_en | quotecsv}},{{grdm_file.data_man_address_en | quotecsv}},{{grdm_file.data_man_tel | quotecsv}},{{grdm_file.data_man_email | quotecsv}},{{grdm_file.remarks_en | quotecsv}} +,{{grdm_file.data_type_tooltip_1 | quotecsv}},{{grdm_file.file_size | quotecsv}},{{grdm_file.data_policy_free_tooltip_1 | quotecsv}},{{grdm_file.data_policy_license | quotecsv}},{{grdm_file.data_policy_cite_en | quotecsv}},{{grdm_file.access_rights_tooltip_1 | quotecsv}},{{grdm_file.available_date | quotecsv}},{{grdm_file.repo_information_en | quotecsv}},{{grdm_file.repo_url_doi_link | quotecsv}},{{grdm_file.creators | map(attribute="name_en") | map("namestr_en") | select | join(";") | quotecsv}},{{grdm_file.creators | map(attribute="number") | join(";") | quotecsv}},{{grdm_file.hosting_inst_en | quotecsv}},{{grdm_file.hosting_inst_id | quotecsv}},{{grdm_file.data_man_name_en | namestr_en | quotecsv}},{{grdm_file.data_man_number | quotecsv}},{{grdm_file.data_man_org_en | quotecsv}},{{grdm_file.data_man_address_en | quotecsv}},{{grdm_file.data_man_tel | quotecsv}},{{grdm_file.data_man_email | quotecsv}},{{grdm_file.remarks_en | quotecsv}} {%- endif -%} {% endfor %} diff --git a/addons/metadata/report_format/report_ja.csv.j2 b/addons/metadata/report_format/report_ja.csv.j2 index 54e26f1f1c8..6414a0934ba 100644 --- a/addons/metadata/report_format/report_ja.csv.j2 +++ b/addons/metadata/report_format/report_ja.csv.j2 @@ -7,6 +7,6 @@ {%- else -%} {{grdm_file.data_research_field_tooltip_0 | quotecsv}} {%- endif -%} -,{{grdm_file.data_type_tooltip_0 | quotecsv}},{{grdm_file.file_size | quotecsv}},{{grdm_file.data_policy_free_tooltip_0 | quotecsv}},{{grdm_file.data_policy_license | quotecsv}},{{grdm_file.data_policy_cite_ja | quotecsv}},{{grdm_file.access_rights_tooltip_0 | quotecsv}},{{grdm_file.available_date | quotecsv}},{{grdm_file.repo_information_ja | quotecsv}},{{grdm_file.repo_url_doi_link | quotecsv}},{{grdm_file.creators | map(attribute="name_ja") | join(";") | quotecsv}},{{grdm_file.creators | map(attribute="number") | join(";") | quotecsv}},{{grdm_file.hosting_inst_ja | quotecsv}},{{grdm_file.hosting_inst_id | quotecsv}},{{grdm_file.data_man_name_ja | quotecsv}},{{grdm_file.data_man_number | quotecsv}},{{grdm_file.data_man_org_ja | quotecsv}},{{grdm_file.data_man_address_ja | quotecsv}},{{grdm_file.data_man_tel | quotecsv}},{{grdm_file.data_man_email | quotecsv}},{{grdm_file.remarks_ja | quotecsv}} +,{{grdm_file.data_type_tooltip_0 | quotecsv}},{{grdm_file.file_size | quotecsv}},{{grdm_file.data_policy_free_tooltip_0 | quotecsv}},{{grdm_file.data_policy_license | quotecsv}},{{grdm_file.data_policy_cite_ja | quotecsv}},{{grdm_file.access_rights_tooltip_0 | quotecsv}},{{grdm_file.available_date | quotecsv}},{{grdm_file.repo_information_ja | quotecsv}},{{grdm_file.repo_url_doi_link | quotecsv}},{{grdm_file.creators | map(attribute="name_ja") | map("namestr_ja") | select | join(";") | quotecsv}},{{grdm_file.creators | map(attribute="number") | join(";") | quotecsv}},{{grdm_file.hosting_inst_ja | quotecsv}},{{grdm_file.hosting_inst_id | quotecsv}},{{grdm_file.data_man_name_ja | namestr_ja | quotecsv}},{{grdm_file.data_man_number | quotecsv}},{{grdm_file.data_man_org_ja | quotecsv}},{{grdm_file.data_man_address_ja | quotecsv}},{{grdm_file.data_man_tel | quotecsv}},{{grdm_file.data_man_email | quotecsv}},{{grdm_file.remarks_ja | quotecsv}} {%- endif -%} {% endfor %} diff --git a/addons/metadata/routes.py b/addons/metadata/routes.py index ad519dcb459..e18d9c416df 100644 --- a/addons/metadata/routes.py +++ b/addons/metadata/routes.py @@ -61,6 +61,8 @@ Rule([ '/project//{}/file_metadata/suggestions/files/'.format(SHORT_NAME), '/project//node//{}/file_metadata/suggestions/files/'.format(SHORT_NAME), + '/project//{}/suggestions'.format(SHORT_NAME), + '/project//node//{}/suggestions'.format(SHORT_NAME), ], 'get', views.metadata_file_metadata_suggestions, json_renderer), Rule([ '/{}/packages/projects/'.format(SHORT_NAME), diff --git a/addons/metadata/static/files.js b/addons/metadata/static/files.js index efc8f2422f8..1d252850d6a 100644 --- a/addons/metadata/static/files.js +++ b/addons/metadata/static/files.js @@ -13,6 +13,7 @@ const _ = rdmGettext._; const ImportDatasetButton = require('./metadataImportDatasetButton.js'); const QuestionPage = require('./metadata-fields.js').QuestionPage; +const getLocalizedText = require('./util.js').getLocalizedText; const WaterButlerCache = require('./wbcache.js').WaterButlerCache; const registrations = require('./registration.js'); const RegistrationSchemas = registrations.RegistrationSchemas; @@ -293,9 +294,7 @@ function MetadataButtons() { ); self.lastFields = self.lastQuestionPage.fields; container.empty(); - self.lastFields.forEach(function(field) { - container.append(field.element); - }); + container.append(self.lastQuestionPage.container); self.lastQuestionPage.validateAll(); } @@ -334,8 +333,9 @@ function MetadataButtons() { } self.createSchemaSelector = function(targetItem) { - const label = $('').text(_('Metadata Schema:')); - const schema = $(''); + const label = $('').text(_('Schema:')) + .css({ 'margin-right': '8px', 'margin-bottom': 0, 'white-space': 'nowrap', 'min-width': '8em', 'text-align': 'right' }); + const schema = $('').addClass('form-control'); const activeSchemas = (self.registrationSchemas.schemas || []) .filter(function(s) { return s.attributes.active; @@ -346,7 +346,7 @@ function MetadataButtons() { activeSchemas.forEach(function(s) { schema.append($('') .attr('value', s.id) - .text(s.attributes.name)); + .text(getLocalizedText(s.attributes.schema.ui && s.attributes.schema.ui.label) || s.attributes.name)); }); var currentSchemaId = null; const activeSchemaIds = activeSchemas.map(function(s) { @@ -363,6 +363,7 @@ function MetadataButtons() { schema.val(currentSchemaId); } const group = $('
').addClass('form-group') + .css({ 'margin-bottom': 0, display: 'flex', 'align-items': 'center' }) .append(label) .append(schema); return { @@ -521,10 +522,12 @@ function MetadataButtons() { self.findSchemaById(self.currentSchemaId), filepath, item, - {} + { variantContainer: variantSlot } ); }); - dialog.toolbar.append(selector.group); + var variantSlot = $('
'); + var selectorColumn = $('
').append(selector.group).append(variantSlot); + dialog.toolbar.append(selectorColumn); if ((context.projectMetadata || {}).editable && !extraMetadata) { const pasteButton = $('') .addClass('btn btn-default') @@ -534,9 +537,7 @@ function MetadataButtons() { .append(_('Paste from Clipboard')) .attr('type', 'button') .on('click', self.pasteFromClipboard); - dialog.toolbar.append($('
') - .css('display', 'flex') - .append(pasteButton)); + dialog.toolbar.append(pasteButton); } if (dialog.customHandler) { dialog.customHandler.empty(); @@ -566,7 +567,7 @@ function MetadataButtons() { self.findSchemaById(self.currentSchemaId), filepath, item, - {} + { variantContainer: variantSlot } ); dialog.container.append(fieldContainer); dialog.dialog.one('shown.bs.modal', function() { @@ -656,11 +657,13 @@ function MetadataButtons() { self.findSchemaById(self.currentSchemaId), filepaths, items, - Object.assign({multiple: true}, computeValuesForMultipleEdit(self.currentSchemaId)) + Object.assign({multiple: true, variantContainer: variantSlot}, computeValuesForMultipleEdit(self.currentSchemaId)) ); }); dialog.toolbar.empty(); - dialog.toolbar.append(selector.group); + var variantSlot = $('
'); + var selectorColumn = $('
').append(selector.group).append(variantSlot); + dialog.toolbar.append(selectorColumn); // container dialog.container.empty(); @@ -671,7 +674,7 @@ function MetadataButtons() { self.findSchemaById(self.currentSchemaId), filepaths, items, - Object.assign({multiple: true}, computeValuesForMultipleEdit(self.currentSchemaId)) + Object.assign({multiple: true, variantContainer: variantSlot}, computeValuesForMultipleEdit(self.currentSchemaId)) ); dialog.container.append(fieldContainer); dialog.dialog.one('shown.bs.modal', function() { @@ -1131,9 +1134,7 @@ function MetadataButtons() { ); self.lastFields = self.lastQuestionPage.fields; container.empty(); - self.lastFields.forEach(function(field) { - container.append(field.element); - }); + container.append(self.lastQuestionPage.container); self.lastQuestionPage.validateAll(); const message = $('
'); if (self.lastQuestionPage.hasValidationError) { @@ -2208,7 +2209,10 @@ function MetadataButtons() { copyToClipboard.on('click', function(event) { self.copyToClipboard(event, copyStatus); }); - const toolbar = $('
'); + const toolbar = $('
') + .css('display', 'flex') + .css('align-items', 'flex-end') + .css('margin-bottom', '10px'); const customHandler = $(''); const container = $('
    ').css('padding', '0 20px'); var notice = $(''); @@ -2232,6 +2236,8 @@ function MetadataButtons() { .append($('') .css('overflow-y', 'scroll') .css('height', '66vh') + .css('background-color', '#fff') + .css('padding-top', '12px') .append(container)))) .append($('') .css('display', 'flex') @@ -2274,7 +2280,10 @@ function MetadataButtons() { $(dialog).modal('hide'); }); }); - const toolbar = $('
    '); + const toolbar = $('
    ') + .css('display', 'flex') + .css('align-items', 'flex-end') + .css('margin-bottom', '10px'); const container = $('
      ').css('padding', '0 20px'); dialog .append($('') @@ -2289,6 +2298,8 @@ function MetadataButtons() { .append($('') .css('overflow-y', 'scroll') .css('height', '70vh') + .css('background-color', '#fff') + .css('padding-top', '12px') .append(container)))) .append($('') .css('display', 'flex') diff --git a/addons/metadata/static/metadata-fields.js b/addons/metadata/static/metadata-fields.js index 3aa277629f4..c496959e39f 100644 --- a/addons/metadata/static/metadata-fields.js +++ b/addons/metadata/static/metadata-fields.js @@ -11,9 +11,12 @@ ******************************************************************************************/ const $ = require('jquery'); +require('./metadata.css'); // Style definitions const AUTOFILLED_BG_COLOR = '#fffbf0'; +const INPUT_BG_COLOR = '#f9fbfd'; +const INPUT_BORDER_COLOR = '#cad9e6'; const $osf = require('js/osfHelpers'); const fangorn = require('js/fangorn'); const rdmGettext = require('js/rdmGettext'); @@ -28,9 +31,176 @@ const util = require('./util'); const sizeofFormat = require("./util").sizeofFormat; const getLocalizedText = util.getLocalizedText; const normalizeText = util.normalizeText; +const template = require('./template'); + +var tagDefs = {}; // id -> { info: "..." } + +function appendTagBadges(container, tags) { + tags.forEach(function(tag) { + var id, info; + if (typeof tag === 'object') { + id = tag.id; + tagDefs[id] = { info: tag.info }; + info = tag.info; + } else { + id = tag; + if (!tagDefs[id]) { + throw new Error('Tag definition not found: ' + id); + } + info = tagDefs[id].info; + } + var badge = $('') + .addClass('label label-default metadata-group-tag') + .css('margin-left', '6px') + .text(getLocalizedText(id)); + if (info) { + badge.css('cursor', 'pointer') + .popover({ + content: getLocalizedText(info), + html: true, + trigger: 'focus', + placement: 'right', + container: 'body' + }).attr('tabindex', '-1'); + } + container.append(badge); + }); +} var filteredPages = []; +function resolveUI(ui, questionFields) { + if (!Array.isArray(ui)) { + return ui; + } + var fallback = null; + for (var i = 0; i < ui.length; i++) { + var entry = ui[i]; + if (!entry.condition) { + fallback = entry; + continue; + } + if (evaluateCond(entry.condition, questionFields)) return entry; + } + return fallback; +} + +function PageHeader(ui) { + this.ui = ui; + this.element = $('
      ') + .addClass('metadata-page-header') + .css('margin-bottom', '12px'); +} + +PageHeader.prototype.refresh = function(questionFields) { + var resolved = resolveUI(this.ui, questionFields); + if (resolved && resolved.header) { + this.element.html(getLocalizedText(resolved.header)); + this.element.find('ul').css('padding-left', '18px'); + this.element.show(); + } else { + this.element.hide(); + } +}; + +function GroupContainer(def, isChild) { + this.id = def.id; + this.parent = def.parent || null; + this.isChild = isChild; + this.enabledIf = def.enabled_if || null; + this.children = []; + this.checkMark = isChild ? null : $('') + .addClass('fa fa-check') + .css({ color: '#5cb85c', 'margin-left': '8px' }) + .hide(); + this.element = $('
      ').addClass('metadata-group'); + this._heading = $('
      ') + .addClass('metadata-group-heading') + .css('margin-bottom', '6px') + .css('margin-top', '18px'); + this.element.append(this._heading); + this.content = $('
      '); + if (def.bar) { + this.content.addClass('metadata-group-bar'); + } + this.element.append(this.content); + this._renderHeading(def); +} + +GroupContainer.prototype._renderHeading = function(def) { + this._heading.empty(); + if (def.marker === 'circle') { + this._heading.append($('').text('◯').css('margin-right', '6px')); + } + var title = $('').text(getLocalizedText(def.title)); + if (this.isChild) { + title.css('font-size', '13px'); + } + this._heading.append(title); + if (def.tags) { + appendTagBadges(this._heading, def.tags); + } + if (def.info) { + var infoMark = $('') + .addClass('fa fa-info-circle metadata-info-mark'); + infoMark.popover({ + content: getLocalizedText(def.info), + html: true, + trigger: 'focus', + placement: 'right', + container: 'body' + }).attr('tabindex', '-1'); + this._heading.append(infoMark); + } + if (this.checkMark) this._heading.append(this.checkMark); + if (def.help) { + this._heading.append($('

      ') + .addClass('text-muted') + .css('margin', '4px 0 0') + .css('font-size', '12px') + .html(getLocalizedText(def.help))); + } +}; + +GroupContainer.prototype.refresh = function(questionFields) { + // Update conditional heading + if (this._sourceUI) { + var resolved = resolveUI(this._sourceUI, questionFields); + if (resolved && resolved.group) { + var groupRef = resolved.group; + var def = typeof groupRef === 'object' ? groupRef : null; + if (def) { + this._renderHeading(def); + } + } + } + // Update enabled state (dim heading and bar when disabled) + if (this.enabledIf) { + var isEnabled = evaluateCond(this.enabledIf, questionFields); + this.element.toggleClass('metadata-group-disabled', !isEnabled); + } + // Update visibility + var hasVisibleChild = this.content.children().toArray().some(function(child) { + return $(child).css('display') !== 'none'; + }); + if (hasVisibleChild) { + this.element.show(); + } else { + this.element.hide(); + } + // Update check mark (top-level groups only) + if (this.checkMark) { + var hasValidChild = this.children.some(function(field) { + return field.hasValidValue(); + }); + if (hasValidChild) { + this.checkMark.show(); + } else { + this.checkMark.hide(); + } + } +}; + const logPrefix = '[metadata] '; const QuestionPage = oop.defclass({ @@ -68,7 +238,9 @@ const QuestionPage = oop.defclass({ if (!self.questionFilter(question)) { return; } - const value = (fileItemData[question.qid] || {}).value; + const value = self.options.multiple + ? self.options.commonValues[question.qid] + : (fileItemData[question.qid] || {}).value; const field = createQuestionField( question, value, @@ -86,9 +258,89 @@ const QuestionPage = oop.defclass({ self.fields.push(field); }); }); + self._buildContainer(); return self.fields; }, + _buildContainer: function() { + const self = this; + self.container = $('
      ').addClass('metadata-form'); + + // Page-level header note (A-7) + self.headers = []; + (self.schema.pages || []).forEach(function(page) { + if (!page.ui) return; + var header = new PageHeader(page.ui); + self.container.append(header.element); + self.headers.push(header); + }); + + const groupDefs = {}; // groupId -> groupDef + self.groupContainers = {}; // groupId -> GroupContainer + + var variantQid = self.schema.ui && self.schema.ui.variant; + var variantContainer = self.options.variantContainer; + if (variantContainer) variantContainer.empty(); + + function ensureGroup(groupId) { + if (self.groupContainers[groupId]) return; + var def = groupDefs[groupId]; + if (def.parent && !self.groupContainers[def.parent]) { + ensureGroup(def.parent); + } + var group = new GroupContainer(def, !!def.parent); + self.groupContainers[groupId] = group; + if (def.parent) { + self.groupContainers[def.parent].content.append(group.element); + } else { + self.container.append(group.element); + } + } + + self.fields.forEach(function(field) { + if (variantContainer && field.question.qid === variantQid) { + var label = $('