diff --git a/.gitignore b/.gitignore index 829760b096..234cbf325f 100644 --- a/.gitignore +++ b/.gitignore @@ -16,7 +16,8 @@ jsapp/js/refactored.es6 whoosh_index tmp staticfiles +.cache .sass-cache webpack-stats.json sensitive_data.json - +test/compiled/* diff --git a/jsapp/fonts/.gitignore b/jsapp/fonts/.gitignore index c3022ba016..14ac882592 100644 --- a/jsapp/fonts/.gitignore +++ b/jsapp/fonts/.gitignore @@ -3,3 +3,9 @@ *.ttf *.woff *.woff2 +*.scss +*.css +*.md +*.ijmap +*.otf +codepoints \ No newline at end of file diff --git a/jsapp/js/editorMixins/editableForm.es6 b/jsapp/js/editorMixins/editableForm.es6 index 0b4754e401..c657fecf30 100644 --- a/jsapp/js/editorMixins/editableForm.es6 +++ b/jsapp/js/editorMixins/editableForm.es6 @@ -450,6 +450,8 @@ export default assign({ saveButtonText, } = this.buttonStates(); + let translations = this.state.translations || []; + return ( @@ -550,6 +552,21 @@ export default assign({ : null } + + { + (translations.length < 2) ? +

+ {translations[0]} +

+ : +

+ {translations[0]} + + {translations[1]} + +

+ } +
{ + let translations = (asset.content && asset.content.translations + && asset.content.translations.slice(0)) || []; this.launchAppForSurveyContent(asset.content, { name: asset.name, + translations: translations, settings__style: asset.settings__style, asset_uid: asset.uid, asset_type: asset.asset_type, @@ -32,9 +35,11 @@ export default { if (isLibrary(this.context.router)) { routeName = 'library'; } else { - if (stores.history.previousRoute == 'form-landing') { + if (stores.history.currentRoute === 'form-edit') { routeName = 'form-landing'; - params = {assetid: this.props.params.assetid}; + params = { + assetid: this.props.params.assetid, + }; } } diff --git a/jsapp/js/mixins.es6 b/jsapp/js/mixins.es6 index 9ccd74bfc9..16a869875c 100644 --- a/jsapp/js/mixins.es6 +++ b/jsapp/js/mixins.es6 @@ -135,7 +135,8 @@ var dmix = { }, survey: { innerRender: function () { - var docTitle = this.state.name || t('Untitled'); + let docTitle = this.state.name || t('Untitled'); + let formList = this.makeHref('forms'); return ( @@ -143,6 +144,10 @@ var dmix = { + {t('Form Overview')} {this.renderDeployments()} diff --git a/jsapp/scss/components/_kobo.asset-row.scss b/jsapp/scss/components/_kobo.asset-row.scss index 833fd7e21b..a4acdc6e60 100644 --- a/jsapp/scss/components/_kobo.asset-row.scss +++ b/jsapp/scss/components/_kobo.asset-row.scss @@ -44,13 +44,13 @@ } .asset-row__celllink--name { - display: inline-block; - max-width:96%; + display: block; + max-width: 96%; text-overflow: ellipsis; overflow: hidden; font-size: 15px; vertical-align: top; - padding:4px 0px; + padding: 4px 0px; } .asset-type-icon + .asset-row__celllink--name { diff --git a/jsapp/scss/components/_kobo.form-builder.scss b/jsapp/scss/components/_kobo.form-builder.scss index 42c69250e3..ca7a090624 100644 --- a/jsapp/scss/components/_kobo.form-builder.scss +++ b/jsapp/scss/components/_kobo.form-builder.scss @@ -145,6 +145,22 @@ width:calc(100% - 400px); padding:0px 20px; } + &--translations { + p { + margin: 0 0 0 10px; + line-height: 15px; + padding-top: 10px; + font-size: 11px; + + small { + display: block; + color: #888; + &:before { + content: '+ '; + } + } + } + } &--buttonsTopRight { width:280px; diff --git a/jsapp/scss/components/_kobo.form-view.scss b/jsapp/scss/components/_kobo.form-view.scss index 5240870cde..eaa10322a1 100644 --- a/jsapp/scss/components/_kobo.form-view.scss +++ b/jsapp/scss/components/_kobo.form-view.scss @@ -55,6 +55,12 @@ } } +.form-view__link--close { + @extend .k-icon, .k-icon-close; + float: right; + margin-right: -10px; +} + // FIXME: rename this .form-view__cell { .asset-view__label { diff --git a/jsapp/scss/stylesheets/partials/form_builder/_card.scss b/jsapp/scss/stylesheets/partials/form_builder/_card.scss index e3f02ba3ec..3a94f818e7 100644 --- a/jsapp/scss/stylesheets/partials/form_builder/_card.scss +++ b/jsapp/scss/stylesheets/partials/form_builder/_card.scss @@ -65,6 +65,27 @@ cursor:text; // white-space: pre; } + .card__header-subtitle { + margin: 10px 0 0 0; + color: #aaa; + font-size: 12px; + + &:before { + content: '+ '; + opacity: 0.5; + } + } + .card__header-subtitle-empty-value { + border: 1px solid #ffdbdb; + padding: 2px 3px; + border-radius: 3px; + background-color: #fff8f8; + } + .card__option-translation { + &--empty { + opacity: 0.6; + } + } &.card--shaded .card__header-title { opacity:0.3; } diff --git a/jsapp/xlform/src/model.inputParser.coffee b/jsapp/xlform/src/model.inputParser.coffee index e09726098f..4571e0df27 100644 --- a/jsapp/xlform/src/model.inputParser.coffee +++ b/jsapp/xlform/src/model.inputParser.coffee @@ -33,7 +33,7 @@ module.exports = do -> delete item[key] _.map(translations, (_t, i)-> _translated_val = val[i] - if _t and _t isnt translations.preferred_translation + if _t lang_str = "#{key}::#{_t}" else lang_str = key @@ -95,11 +95,27 @@ module.exports = do -> inputParser.parseArr = parseArr inputParser.parse = (o)-> - translations = o.translations or [null] - if translations and '#null_translation' of o - translations.preferred_translation = o['#null_translation'] - if o['#null_translation'] isnt null and null in translations - translations[translations.indexOf(null)] = 'UNNAMED' + translations = o.translations + if o['#active_translation_name'] + _existing_active_translation_name = o['#active_translation_name'] + delete o['#active_translation_name'] + + if translations + if translations.indexOf(null) is -1 # there is no unnamed translation + if _existing_active_translation_name + throw new Error('active translation set, but cannot be found') + o._active_translation_name = translations[0] + translations[0] = null + else if translations.indexOf(null) > 0 + throw new Error(""" + unnamed translation must be the first (primary) translation + translations need to be reordered or unnamed translation needs + to be given a name + """) + else if _existing_active_translation_name # there is already an active null translation + o._active_translation_name = _existing_active_translation_name + else + translations = [null] # sorts groups and repeats into groups and repeats (recreates the structure) if o.survey @@ -111,6 +127,9 @@ module.exports = do -> # settings is sometimes packaged as an array length=1 if o.settings and _.isArray(o.settings) and o.settings.length is 1 o.settings = o.settings[0] + + o.translations = translations + o inputParser.loadChoiceLists = (passedChoices, choices)-> diff --git a/jsapp/xlform/src/model.row.coffee b/jsapp/xlform/src/model.row.coffee index aaa43f8199..aab9e64758 100644 --- a/jsapp/xlform/src/model.row.coffee +++ b/jsapp/xlform/src/model.row.coffee @@ -399,6 +399,23 @@ module.exports = do -> return newRow + getTranslatedColumnKey: (col, whichone="primary")-> + if whichone is "_2" + _t = @getSurvey()._translation_2 + else + _t = @getSurvey()._translation_1 + _key = "#{col}" + if _t isnt null + _key += "::#{_t}" + _key + + getLabel: (whichone="primary")-> + _col = @getTranslatedColumnKey("label", whichone) + if _col of @attributes + @getValue _col + else + null + finalize: -> existing_name = @getValue("name") unless existing_name diff --git a/jsapp/xlform/src/model.survey.coffee b/jsapp/xlform/src/model.survey.coffee index c7423f626f..828b39b994 100644 --- a/jsapp/xlform/src/model.survey.coffee +++ b/jsapp/xlform/src/model.survey.coffee @@ -32,30 +32,16 @@ module.exports = do -> @choices = new $choices.ChoiceLists([], _parent: @) $inputParser.loadChoiceLists(options.choices || [], @choices) - @translations = options.translations or [null] - if @translations - # if 'preferred_translation' is set, then any reference to a null translation - # is actually a reference to the preferred translation. - if @translations.preferred_translation - _pt_index = @translations.indexOf(@translations.preferred_translation) - if _pt_index is -1 - throw new Error("Translation #{@translations.preferred_translation} " - "not found in list: #{@translations.join(', ')}") - if not @translations.secondary_translation - # the secondary_translation is the next available translation in the list - @translations.secondary_translation = @translations[if _pt_index is 0 then 1 else 0] - else if -1 is @translations.indexOf(null) - # if no null translation exists, set the first two translations as the - # preferred and secondary - @translations.preferred_translation = @translations[0] - @translations.secondary_translation = @translations[1] - else - # if a null translation exists (e.g. with a column "label" in a survey with - # other "label::lang" columns) then it is the "preferred_translation" by default - _null_index = @translations.indexOf(null) - @translations.preferred_translation = @translations[_null_index] - _next_translation_index = (_null_index + 1) % @translations.length - @translations.secondary_translation = @translations[_next_translation_index] + if options.translations + @translations = options.translations + else + @translations = [null] + + if options['_active_translation_name'] + @active_translation_name = options['_active_translation_name'] + + @_translation_1 = @translations[0] + @_translation_2 = @translations[1] if options.survey if !$inputParser.hasBeenParsed(options) @@ -154,15 +140,8 @@ module.exports = do -> addlSheets = choices: new $choices.ChoiceLists() - - # pass interface parameters back to the server - if @translations and @translations.preferred_translation isnt null - obj['#null_translation'] = @translations.preferred_translation - - # in case we had to rename the null translation to "UNNAMED" in order - # to "focus" the form builder on a named translation - if @translations and 'UNNAMED' in @translations - obj['#replace_with_null_translation'] = 'UNNAMED' + if @active_translation_name + obj['#active_translation_name'] = @active_translation_name obj.translations = [].concat(@translations) diff --git a/jsapp/xlform/src/view.choices.coffee b/jsapp/xlform/src/view.choices.coffee index be441c9f3f..27fdbace04 100644 --- a/jsapp/xlform/src/view.choices.coffee +++ b/jsapp/xlform/src/view.choices.coffee @@ -20,9 +20,10 @@ module.exports = do -> if cardText.find('.card__buttons__multioptions.js-expand-multioptions').length is 0 cardText.prepend $.parseHTML($viewTemplates.row.expandChoiceList()) @$el.html (@ul = $("
    ", class: @ulClasses)) + _ts = @model.getSurvey().translations if @row.get("type").get("rowType").specifyChoice for option, i in @model.options.models - new OptionView(model: option, cl: @model).render().$el.appendTo @ul + new OptionView(model: option, cl: @model, translations: _ts).render().$el.appendTo @ul if i == 0 while i < 2 @addEmptyOption("Option #{++i}") @@ -54,7 +55,8 @@ module.exports = do -> addEmptyOption: (label)-> emptyOpt = new $choices.Option(label: label) @model.options.add(emptyOpt) - new OptionView(model: emptyOpt, cl: @model).render().$el.appendTo @ul + _translations = @model.getSurvey().translations + new OptionView(model: emptyOpt, cl: @model, translations: _translations).render().$el.appendTo @ul lis = @ul.find('li') if lis.length == 2 lis.find('.js-remove-option').removeClass('hidden') @@ -73,7 +75,7 @@ module.exports = do -> class OptionView extends $baseView tagName: "li" - className: "multioptions__option xlf-option-view xlf-option-view--depr" + className: "multioptions__option xlf-option-view xlf-option-view--depr" events: "keyup input": "keyupinput" "click .js-remove-option": "remove" @@ -129,15 +131,17 @@ module.exports = do -> @d.append(@t) @d.append(@c) @$el.html(@d) - try - _tt = @model.getSurvey().getSurvey().translations - catch err - _tt = false - if _tt and _tt.secondary_translation - _t_opt = @model.get("label::#{_tt.secondary_translation}") - $("").html(""" - 🌐 -  + _translation_2 = @options.translations[1] + if _translation_2 isnt undefined + _t_opt = @model.get("label::#{_translation_2}") + if !_t_opt + _no_t = _t("No translation") + _klss = ["card__option-translation", + "card__option-translation--empty"].join(" ") + _t_opt = """#{_no_t}""" + $("", {className: 'secondary-translation'}).html(""" + #{_t_opt} """).appendTo(@$el) @ diff --git a/jsapp/xlform/src/view.row.coffee b/jsapp/xlform/src/view.row.coffee index e9650b10b4..deea769c0e 100644 --- a/jsapp/xlform/src/view.row.coffee +++ b/jsapp/xlform/src/view.row.coffee @@ -65,6 +65,7 @@ module.exports = do -> _renderRow: -> @$el.html $viewTemplates.$$render('row.xlfRowView', @surveyView) @$label = @$('.card__header-title') + @$sub_label = @$('.card__header-subtitle') @$card = @$('.card') @$header = @$('.card__header') context = {warnings: []} @@ -75,6 +76,13 @@ module.exports = do -> @listView = new $viewChoices.ListView(model: cl, rowView: @).render() @cardSettingsWrap = @$('.card__settings').eq(0) + _second_translation = @surveyView.survey._translation_2 + if _second_translation isnt undefined + _second_val = @model.getLabel('_2') + if !_second_val + _no_t = _t("No translation") + _second_val = """#{_no_t}""" + @$sub_label.html(_second_val).show() @defaultRowDetailParent = @cardSettingsWrap.find('.card__settings__fields--question-options').eq(0) for [key, val] in @model.attributesArray() when key is 'label' or key is 'type' view = new $viewRowDetail.DetailView(model: val, rowView: @) diff --git a/jsapp/xlform/src/view.row.templates.coffee b/jsapp/xlform/src/view.row.templates.coffee index db8ae92b79..6238909789 100644 --- a/jsapp/xlform/src/view.row.templates.coffee +++ b/jsapp/xlform/src/view.row.templates.coffee @@ -62,6 +62,7 @@ module.exports = do ->
    +
    diff --git a/jsapp/xlform/src/view.surveyApp.templates.coffee b/jsapp/xlform/src/view.surveyApp.templates.coffee index be757e1758..b97e8fe813 100644 --- a/jsapp/xlform/src/view.surveyApp.templates.coffee +++ b/jsapp/xlform/src/view.surveyApp.templates.coffee @@ -29,14 +29,14 @@ module.exports = do -> warnings_html += """

    #{warning}

    """ warnings_html += """
    """ if survey.translations - t0 = survey.translations.preferred_translation - t1 = survey.translations.secondary_translation + t0 = survey._translation_1 + t1 = survey._translation_2 print_translation = (tx)-> if tx is null then "Unnamed translation" else tx translations_content = "#{print_translation(t0)}" if t1 translations_content += " [#{print_translation(t1)}]" else - translations_content = "1×🌐" + translations_content = "" """
    diff --git a/jsapp/xlform/src/view.utils.coffee b/jsapp/xlform/src/view.utils.coffee index 8d421d70e6..8caa1a195a 100644 --- a/jsapp/xlform/src/view.utils.coffee +++ b/jsapp/xlform/src/view.utils.coffee @@ -39,8 +39,8 @@ module.exports = do -> parent_element.find('.error-message').remove() current_value = selector.text().replace new RegExp(String.fromCharCode(160), 'g'), '' - edit_box = $('', type:'text', value:current_value, class:'js-cancel-sort js-blur-on-select-row') - selector.parent().append edit_box + edit_box = $('', type:'text', value: current_value, class:'js-cancel-sort js-blur-on-select-row') + selector.after edit_box selector.hide() edit_box.focus() diff --git a/kpi/management/commands/sync_kobocat_xforms.py b/kpi/management/commands/sync_kobocat_xforms.py index b57f769047..dcd4e6db6c 100644 --- a/kpi/management/commands/sync_kobocat_xforms.py +++ b/kpi/management/commands/sync_kobocat_xforms.py @@ -389,7 +389,10 @@ def handle(self, *args, **options): continue if content_changed or metadata_changed: + # preserve the original "asset.content" asset.save(adjust_content=False) + # save a new version with standardized content + asset.save() if content_changed: asset._mark_latest_version_as_deployed() self._print_tabular( diff --git a/kpi/models/asset.py b/kpi/models/asset.py index dc097931ad..2b51fe02ec 100644 --- a/kpi/models/asset.py +++ b/kpi/models/asset.py @@ -38,6 +38,16 @@ expand_rank_and_score_in_place, replace_with_autofields, remove_empty_expressions_in_place) +from ..utils.asset_translation_utils import ( + compare_translations, + # TRANSLATIONS_EQUAL, + TRANSLATIONS_OUT_OF_ORDER, + TRANSLATION_RENAMED, + TRANSLATION_DELETED, + TRANSLATION_ADDED, + TRANSLATION_CHANGE_UNSUPPORTED, + TRANSLATIONS_MULTIPLE_CHANGES, + ) from ..utils.random_id import random_id from ..deployment_backends.mixin import DeployableMixin from kobo.apps.reports.constants import (SPECIFIC_REPORTS_KEY, @@ -202,10 +212,18 @@ def _unlink_list_items(self, content): if '$kuid' in row: del row['$kuid'] - def _remove_empty_expressions(self, content): remove_empty_expressions_in_place(content) + def _adjust_active_translation(self, content): + # to get around the form builder's way of handling translations where + # the interface focuses on the "null translation" and shows other ones + # in advanced settings, we allow the builder to attach a parameter + # which says what to name the null translation. + _null_translation_as = content.pop('#active_translation_name', None) + if _null_translation_as: + self._rename_translation(content, None, _null_translation_as) + def _strip_empty_rows(self, content, vals=None): if vals is None: vals = { @@ -235,8 +253,89 @@ def _rename_null_translation(self, content, new_name): def _has_translations(self, content, min_count=1): return len(content.get('translations', [])) >= min_count + def update_translation_list(self, translation_list): + existing_ts = self.content.get('translations', []) + params = compare_translations(existing_ts, + translation_list) + if None in translation_list and translation_list[0] is not None: + raise ValueError('Unnamed translation must be first in ' + 'list of translations') + if TRANSLATIONS_OUT_OF_ORDER in params: + self._reorder_translations(self.content, translation_list) + elif TRANSLATION_RENAMED in params: + _change = params[TRANSLATION_RENAMED]['changes'][0] + self._rename_translation(self.content, _change['from'], + _change['to']) + elif TRANSLATION_ADDED in params: + if None in existing_ts: + raise ValueError('cannot add translation if an unnamed translation exists') + self._prepend_translation(self.content, params[TRANSLATION_ADDED]) + elif TRANSLATION_DELETED in params: + if params[TRANSLATION_DELETED] != existing_ts[-1]: + raise ValueError('you can only delete the last translation of the asset') + self._remove_last_translation(self.content) + else: + for chg in [ + TRANSLATIONS_MULTIPLE_CHANGES, + TRANSLATION_CHANGE_UNSUPPORTED, + ]: + if chg in params: + raise ValueError( + 'Unsupported change: "{}": {}'.format( + chg, + params[chg] + ) + ) + + def _prioritize_translation(self, content, translation_name, is_new=False): + _translations = content.get('translations') + _translated = content.get('translated', []) + if is_new and (translation_name in _translations): + raise ValueError('cannot add existing translation') + elif (not is_new) and (translation_name not in _translations): + raise ValueError('translation cannot be found') + _tindex = -1 if is_new else _translations.index(translation_name) + if is_new or (_tindex > 0): + for row in content.get('survey', []): + for col in _translated: + if is_new: + val = '{}'.format(row[col][0]) + else: + val = row[col].pop(_tindex) + row[col].insert(0, val) + for row in content.get('choices', []): + for col in _translated: + if is_new: + val = '{}'.format(row[col][0]) + else: + val = row[col].pop(_tindex) + row[col].insert(0, val) + if is_new: + _translations.insert(0, translation_name) + else: + _translations.insert(0, _translations.pop(_tindex)) + + def _remove_last_translation(self, content): + content.get('translations').pop() + _translated = content.get('translated', []) + for row in content.get('survey', []): + for col in _translated: + row[col].pop() + for row in content.get('choices', []): + for col in _translated: + row[col].pop() + + def _prepend_translation(self, content, translation_name): + self._prioritize_translation(content, translation_name, is_new=True) + + def _reorder_translations(self, content, translations): + _ts = translations[:] + _ts.reverse() + for _tname in _ts: + self._prioritize_translation(content, _tname) + def _rename_translation(self, content, _from, _to): - _ts = self.content.get('translations') + _ts = content.get('translations') if _to in _ts: raise ValueError('Duplicate translation: {}'.format(_to)) _ts[_ts.index(_from)] = _to @@ -394,14 +493,8 @@ def adjust_content_on_save(self): asset.save(adjust_content=False) ''' self._standardize(self.content) - # to get around the form builder's way of handling translations where - # the interface focuses on the "null translation" and shows other ones - # in advanced settings, we allow the builder to attach a parameter - # which says what to name the null translation. - _null_translation_as = self.content.pop('#null_translation', None) - if _null_translation_as: - self._rename_translation(self.content, None, _null_translation_as) + self._adjust_active_translation(self.content) self._strip_empty_rows(self.content) self._assign_kuids(self.content) self._autoname(self.content) @@ -480,7 +573,12 @@ def to_clone_dict(self, version_uid=None): def clone(self, version_uid=None): # not currently used, but this is how "to_clone_dict" should work - Asset.objects.create(**self.to_clone_dict(version_uid)) + return Asset.objects.create(**self.to_clone_dict(version_uid)) + + def revert_to_version(self, version_uid): + av = self.asset_versions.get(uid=version_uid) + self.content = av.version_content + self.save() def _populate_report_styles(self): default = self.report_styles.get(DEFAULT_REPORTS_KEY, {}) @@ -612,6 +710,7 @@ def save(self, *args, **kwargs): if _source is None: _source = {} self._standardize(_source) + self._adjust_active_translation(_source) self._strip_empty_rows(_source) self._autoname(_source) self._remove_empty_expressions(_source) diff --git a/kpi/serializers.py b/kpi/serializers.py index 9db1940e1e..cb7bb058b8 100644 --- a/kpi/serializers.py +++ b/kpi/serializers.py @@ -30,6 +30,7 @@ from .forms import USERNAME_REGEX, USERNAME_MAX_LENGTH from .forms import USERNAME_INVALID_MESSAGE from .utils.gravatar_url import gravatar_url + from .deployment_backends.kc_reader.utils import get_kc_profile_data from .deployment_backends.kc_reader.utils import set_kc_require_auth @@ -48,7 +49,7 @@ class WritableJSONField(serializers.Field): """ Serializer for JSONField -- required to make field writable""" def __init__(self, **kwargs): - self.allow_blank= kwargs.pop('allow_blank', False) + self.allow_blank = kwargs.pop('allow_blank', False) super(WritableJSONField, self).__init__(**kwargs) def to_internal_value(self, data): @@ -445,6 +446,20 @@ class Meta: }, } + def update(self, asset, validated_data): + asset_content = asset.content + _req_data = self.context['request'].data + _has_translations = 'translations' in _req_data + _has_content = 'content' in _req_data + if _has_translations and not _has_content: + translations_list = json.loads(_req_data['translations']) + try: + asset.update_translation_list(translations_list) + except ValueError as err: + raise serializers.ValidationError(err.message) + validated_data['content'] = asset_content + return super(AssetSerializer, self).update(asset, validated_data) + def get_fields(self, *args, **kwargs): fields = super(AssetSerializer, self).get_fields(*args, **kwargs) user = self.context['request'].user diff --git a/kpi/templates/rest_framework/api.html b/kpi/templates/rest_framework/api.html index 6de6258388..9b6b90bff0 100644 --- a/kpi/templates/rest_framework/api.html +++ b/kpi/templates/rest_framework/api.html @@ -1,24 +1,9 @@ {% extends "rest_framework/base.html" %} -{# Override this template in your own templates directory to customize #} -{% block title %}KoBoForm API{% endblock %} +{% block title %}KoBoToolbox Form Building API{% endblock %} {% block branding %} - KoBoForm API 0.1 + KoBoToolbox Form Building API {% endblock %} - -{% block head %} - - {{ block.super }} -{% endblock %} -{% block bootstrap_theme %} - {% load staticfiles %} - {% load render_bundle from webpack_loader %} - {% render_bundle 'app' 'css' %} -{% endblock %} - -{% block script %} -{% endblock %} - diff --git a/kpi/templates/rest_framework/horizontal/muted_readonly_content_field.html b/kpi/templates/rest_framework/horizontal/muted_readonly_content_field.html deleted file mode 100644 index bbe7fa7ce0..0000000000 --- a/kpi/templates/rest_framework/horizontal/muted_readonly_content_field.html +++ /dev/null @@ -1,13 +0,0 @@ -
    - - -
    - - {{ field.value }} - - - {% if field.errors %} - {% for error in field.errors %}{{ error }}{% endfor %} - {% endif %} -
    -
    diff --git a/kpi/tests/test_asset_content.py b/kpi/tests/test_asset_content.py index efdd5722ef..e328ce9ba2 100644 --- a/kpi/tests/test_asset_content.py +++ b/kpi/tests/test_asset_content.py @@ -226,11 +226,12 @@ def _name_to_autoname(rows): # if there is a name conflict we should throw a meaningful error # however, since this behavior is already present, it might be - with pytest.raises(ValueError): - _name_to_autoname([ - {'name': LONG_NAME}, - {'name': LONG_NAME}, - ]) + # impossible to transition existing valid forms + # with pytest.raises(ValueError): + # _name_to_autoname([ + # {'name': LONG_NAME}, + # {'name': LONG_NAME}, + # ]) long_label = ('Four score and seven years ago, our fathers brought forth' ' on this contintent') diff --git a/kpi/tests/test_asset_translation_utils.py b/kpi/tests/test_asset_translation_utils.py new file mode 100644 index 0000000000..0006180f8d --- /dev/null +++ b/kpi/tests/test_asset_translation_utils.py @@ -0,0 +1,59 @@ +from django.test import TestCase + + +from kpi.utils.asset_translation_utils import ( + compare_translations, + TRANSLATIONS_EQUAL, + TRANSLATIONS_OUT_OF_ORDER, + TRANSLATIONS_MULTIPLE_CHANGES, + TRANSLATION_RENAMED, + ) + + +class AssetTranslationTests(TestCase): + def test_equality(self): + self.assertTrue(TRANSLATIONS_EQUAL in + compare_translations( + ['a', 'b', 'c'], + ['a', 'b', 'c'], + ), + ) + self.assertTrue(TRANSLATIONS_EQUAL in + compare_translations( + [None, 'a', 'b', 'c'], + [None, 'a', 'b', 'c'], + ), + ) + + def test_out_of_order(self): + self.assertTrue(TRANSLATIONS_OUT_OF_ORDER in + compare_translations( + [None, 'a', 'b', 'c'], + ['a', 'b', 'c', None], + ), + ) + self.assertTrue(TRANSLATIONS_OUT_OF_ORDER in + compare_translations( + [None, 'a', 'b', 'c'], + ['a', None, 'b', 'c'], + ), + ) + + def test_translation_renamed(self): + _renamed_params = compare_translations( + [None, 'a', 'b', 'c'], + [None, 'a', 'b', 'Z'], + ) + + self.assertTrue(TRANSLATION_RENAMED in _renamed_params) + first_change = _renamed_params[TRANSLATION_RENAMED]['changes'][0] + self.assertEqual(first_change['index'], 3) + self.assertEqual(first_change['from'], 'c') + self.assertEqual(first_change['to'], 'Z') + + def test_translations_too_changed(self): + _params = compare_translations( + [None, 'a', 'b', 'c'], + [None, 'a', 'y', 'z'], + ) + self.assertTrue(TRANSLATIONS_MULTIPLE_CHANGES in _params) diff --git a/kpi/tests/test_assets.py b/kpi/tests/test_assets.py index adab9f30ee..1c09d8e239 100644 --- a/kpi/tests/test_assets.py +++ b/kpi/tests/test_assets.py @@ -2,7 +2,9 @@ # -*- coding: utf-8 -*- import re +import json from collections import OrderedDict +from copy import deepcopy from django.contrib.auth.models import User, AnonymousUser from django.core.exceptions import ValidationError @@ -60,7 +62,6 @@ def setUp(self): ]}, owner=self.user, asset_type='survey') self.sa = self.asset - class CreateAssetVersions(AssetsTestCase): def test_asset_with_versions(self): @@ -84,6 +85,42 @@ def _list_tag_names(): self.asset.tags.add('tag2') self.assertEqual(_list_tag_names(), ['tag1', 'tag2']) + def test_asset_can_be_reverted(self): + # TODO: figure out why kuids are changing + # note: this is fixed by calling `self.asset.save()` + # at the beginning of this method + _content = deepcopy(self.asset.content) + # _kuid1 = _content['survey'][0]['$kuid'] + _content_copy = deepcopy(_content) + # remove this next line when todo is fixed + self.asset._strip_kuids(_content_copy) + _c1 = json.dumps(_content_copy, sort_keys=True) + surv_l = len(_content['survey']) + self.assertEqual(surv_l, 2) + self.asset.content['survey'].append({ + 'type': 'integer', + 'label': 'Number' + }) + av1_uid = self.asset.asset_versions.all()[0].uid + self.asset.save() + aa = Asset.objects.get(uid=self.asset.uid) + surv_l_2 = len(aa.content['survey']) + self.assertEqual(surv_l_2, 3) + aa.revert_to_version(av1_uid) + + aa = Asset.objects.get(uid=self.asset.uid) + _content_copy2 = deepcopy(aa.content) + # remove this next line when todo is fixed + self.asset._strip_kuids(_content_copy2) + _c3 = json.dumps(_content_copy2, sort_keys=True) + # _kuid3 = aa.content['survey'][0]['$kuid'] + surv_l_3 = len(aa.content['survey']) + + # self.assertEqual(_kuid1, _kuid3) + self.assertEqual(surv_l_3, 2) + self.assertEqual(_c1, _c3) + + def test_asset_can_be_anonymous(self): anon_asset = Asset.objects.create(content=self.asset.content) self.assertEqual(anon_asset.owner, None) @@ -116,9 +153,10 @@ def test_rename_null_translation(self): {'label': ['lang1', 'lang2'], 'type': 'text', 'name': 'q1'}, ], 'translations': ['lang1', None], - '#null_translation': 'lang2', + '#active_translation_name': 'lang2', }) self.assertEqual(self.asset.content['translations'], ['lang1', 'lang2']) + self.assertTrue('#active_translation_name' not in self.asset.content) self.assertTrue('#null_translation' not in self.asset.content) def test_rename_translation(self): @@ -131,6 +169,10 @@ def test_rename_translation(self): ], 'translations': ['lang1', None], }) + _content = self.asset.content + self.assertTrue('translated' in _content) + self.assertEqual(_content['translated'], ['label']) + self.asset.rename_translation(None, 'lang2') self.assertEqual(self.asset.content['translations'], ['lang1', 'lang2']) diff --git a/kpi/utils/asset_translation_utils.py b/kpi/utils/asset_translation_utils.py new file mode 100644 index 0000000000..333911cd12 --- /dev/null +++ b/kpi/utils/asset_translation_utils.py @@ -0,0 +1,66 @@ +import json + +TRANSLATIONS_EQUAL = 'equal' +TRANSLATIONS_OUT_OF_ORDER = 'out_of_order' +TRANSLATION_RENAMED = 'translation_renamed' +TRANSLATION_ADDED = 'translation_added' +TRANSLATION_CHANGE_UNSUPPORTED = 'translation_change_unsupported' +TRANSLATION_DELETED = 'translation_deleted' +TRANSLATIONS_MULTIPLE_CHANGES = 'multiple_changes' + + +def _track_changes(t1, t2): + _num = 0 + params = { + 'diff_count': 0, + 'changes': [] + } + for (i, vs) in enumerate(zip(t1, t2)): + (v1, v2) = vs + if v1 != v2: + params['changes'].append({ + 'index': i, + 'from': v1, + 'to': v2 + }) + params['diff_count'] += 1 + _num += 1 + return params + + +def compare_translations(t1, t2): + _s1 = set(t1) + _s2 = set(t2) + if len(t1) == len(t2): + params = _track_changes(t1, t2) + if params['diff_count'] == 0: + return { + TRANSLATIONS_EQUAL: True, + } + if _s1 == _s2: + return { + TRANSLATIONS_OUT_OF_ORDER: True, + } + if params['diff_count'] == 1: + return { + TRANSLATION_RENAMED: params + } + if params['diff_count'] > 1: + return { + TRANSLATIONS_MULTIPLE_CHANGES: params + } + if len(t1) == len(t2) - 1: + _added = list(_s2 - _s1) + if len(_added) == 1: + return { + TRANSLATION_ADDED: _added[0], + } + if len(t1) == len(t2) + 1: + _removed = list(set(t1) - set(t2)) + if len(_removed) == 1: + return { + TRANSLATION_DELETED: _removed[0] + } + return { + TRANSLATION_CHANGE_UNSUPPORTED: True + } diff --git a/kpi/utils/autoname.py b/kpi/utils/autoname.py index a89e3c3f35..127be2b7c7 100644 --- a/kpi/utils/autoname.py +++ b/kpi/utils/autoname.py @@ -110,6 +110,12 @@ def _assign_row_to_name(row, suggested_name): row['$given_name'] = _name _name = sluggify_label(_name, other_names=other_names.keys()) + # We might be able to remove these next 4 lines because + # sluggify_label shouldn't be returning an empty string + # and these fields already have names (_has_name(r)==True). + # However, these lines were added when testing a large set + # of forms so it's possible some edge cases (e.g. arabic) + # still permit it if _name == '' and '$kuid' in row: _name = '{}_{}'.format(row['type'], row['$kuid']) elif _name == '': diff --git a/package.json b/package.json index d06e6b125e..6194d99508 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "coffeeify": "^1.1.0", "css-loader": "^0.18.0", "diff": "^1.4.0", + "es6-map": "^0.1.4", "es6-promise": "^3.3.1", "eslint": "^1.3.1", "eslint-loader": "^1.0.0", diff --git a/test/index.js b/test/index.js index f9f9795d71..2be7c4fb67 100644 --- a/test/index.js +++ b/test/index.js @@ -8,6 +8,7 @@ require('./xlform/csv.tests') require('./xlform/deserializer.tests') require('./xlform/group.tests') require('./xlform/inputParser.tests') +require('./xlform/translations.tests') // require('./xlform/integration.tests') require('./xlform/model.tests') require('./xlform/survey.tests') diff --git a/test/xlform/translations.tests.coffee b/test/xlform/translations.tests.coffee new file mode 100644 index 0000000000..84d071b7a5 --- /dev/null +++ b/test/xlform/translations.tests.coffee @@ -0,0 +1,134 @@ +{expect} = require('../helper/fauxChai') + +$inputParser = require("../../jsapp/xlform/src/model.inputParser") +$survey = require("../../jsapp/xlform/src/model.survey") + +describe " translations set proper values ", -> + process = (src)-> + parsed = $inputParser.parse(src) + new $survey.Survey(parsed) + + it 'example 0', -> + survey1 = process( + survey: [ + type: "text" + label: "VAL1", + name: "val1", + ] + ) + survey2 = process( + survey: [ + type: "text" + label: ["VAL1"], + name: "val1", + ] + translations: [null] + ) + + expect(survey1._translation_1).toEqual(null) + expect(survey1._translation_2).toEqual(undefined) + + expect(survey2._translation_1).toEqual(null) + expect(survey2._translation_2).toEqual(undefined) + + it 'does not have active_translation_name value when none set', -> + survey_json = process( + survey: [type: "text", label: ["VAL1"], name: "val1"] + translations: [null] + ).toJSON() + expect(survey_json['#active_translation_name']).toBeUndefined() + + it 'passes thru active_translation_name', -> + survey = process( + survey: [ + type: "text" + label: ["VAL1_NULL", "VAL2_L2"], + name: "val1", + ] + translations: [null, "L2"] + '#active_translation_name': 'XYZ' + ) + expect(survey.active_translation_name).toEqual('XYZ') + _json = survey.toJSON() + expect(_json['#active_translation_name']).toEqual('XYZ') + + it 'fails with invalid active_translation_name', -> + run = -> + survey = process( + survey: [ + type: "text" + label: ["VAL1_NULL", "VAL2_L2"], + name: "val1", + ] + translations: ["L1", "L2"] + '#active_translation_name': 'XYZ' + ) + # "#active_translation_name" is set, but refers to a value in "translations" + # but in this case there is no null in the translations list so it should + # throw an error + expect(run).toThrow() + + it 'example 1', -> + survey = process( + survey: [ + type: "text" + label: ["VAL1_NULL", "VAL2_L2"], + name: "val1", + ] + translations: [null, "L2"] + ) + expect(survey._translation_1).toEqual(null) + expect(survey._translation_2).toEqual("L2") + r0 = survey.rows.at(0) + expect(r0.getLabel('_1')).toEqual('VAL1_NULL') + expect(r0.getLabel('_2')).toEqual('VAL2_L2') + + rj0 = survey.toJSON().survey[0] + expect(rj0['label']).toBeDefined() + expect(rj0['label::L2']).toBeDefined() + + it 'example 2', -> + survey = process( + survey: [ + type: "text" + label: ["VAL1_L1", "VAL2_L2"], + name: "val1", + ] + translations: ["L1", "L2"] + ) + src = $inputParser.parse( + survey: [ + type: "text" + label: ["VAL1_L1", "VAL2_L2"], + name: "val1", + ] + translations: ["L1", "L2"] + ) + expect(src['_active_translation_name']).toEqual("L1") + expect(src.translations[0]).toEqual(null) + + expect(survey._translation_2).toEqual("L2") + _sjson = survey.toJSON() + + r0 = survey.rows.at(0) + expect(r0.getLabel('_1')).toEqual('VAL1_L1') + expect(r0.getLabel('_2')).toEqual('VAL2_L2') + + rj0 = _sjson.survey[0] + expect(rj0['label']).toBeDefined() + expect(rj0['label::L2']).toBeDefined() + expect(_sjson['#active_translation_name']).toEqual('L1') + + it 'example 3', -> + run = -> + survey = process( + survey: [ + type: "text" + label: ["VAL1_L2", "VAL2_NULL"], + name: "val1", + ] + translations: ["L2", null] + ) + # run() + expect(run).toThrow('translations need to be reordered') +