diff --git a/app/assets/images/language-levels/a1.svg b/app/assets/images/language-levels/a1.svg new file mode 100644 index 000000000..c151d5b83 --- /dev/null +++ b/app/assets/images/language-levels/a1.svg @@ -0,0 +1,22 @@ + + + + a1 + Created with Sketch. + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/assets/images/language-levels/a2.svg b/app/assets/images/language-levels/a2.svg new file mode 100644 index 000000000..d97fafd33 --- /dev/null +++ b/app/assets/images/language-levels/a2.svg @@ -0,0 +1,22 @@ + + + + a2 + Created with Sketch. + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/assets/images/language-levels/b1.svg b/app/assets/images/language-levels/b1.svg new file mode 100644 index 000000000..312fb0cfb --- /dev/null +++ b/app/assets/images/language-levels/b1.svg @@ -0,0 +1,22 @@ + + + + b1 + Created with Sketch. + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/assets/images/language-levels/b2.svg b/app/assets/images/language-levels/b2.svg new file mode 100644 index 000000000..0a7a87f34 --- /dev/null +++ b/app/assets/images/language-levels/b2.svg @@ -0,0 +1,22 @@ + + + + b2 + Created with Sketch. + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/assets/images/language-levels/c1.svg b/app/assets/images/language-levels/c1.svg new file mode 100644 index 000000000..aa38db56e --- /dev/null +++ b/app/assets/images/language-levels/c1.svg @@ -0,0 +1,22 @@ + + + + c1 + Created with Sketch. + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/assets/images/language-levels/c2.svg b/app/assets/images/language-levels/c2.svg new file mode 100644 index 000000000..54650360b --- /dev/null +++ b/app/assets/images/language-levels/c2.svg @@ -0,0 +1,22 @@ + + + + c2 + Created with Sketch. + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/assets/images/language-levels/icon_certificate.svg b/app/assets/images/language-levels/icon_certificate.svg new file mode 100644 index 000000000..b5025625c --- /dev/null +++ b/app/assets/images/language-levels/icon_certificate.svg @@ -0,0 +1,11 @@ + + + + icon_certificate + Created with Sketch. + + + + + + \ No newline at end of file diff --git a/app/assets/images/language-levels/keine.svg b/app/assets/images/language-levels/keine.svg new file mode 100644 index 000000000..8f5c70785 --- /dev/null +++ b/app/assets/images/language-levels/keine.svg @@ -0,0 +1,18 @@ + + + + a0 + Created with Sketch. + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/assets/images/language-levels/muttersprache.svg b/app/assets/images/language-levels/muttersprache.svg new file mode 100644 index 000000000..a9460a04d --- /dev/null +++ b/app/assets/images/language-levels/muttersprache.svg @@ -0,0 +1,21 @@ + + + + muttersprache copy + Created with Sketch. + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/assets/stylesheets/styles.scss b/app/assets/stylesheets/styles.scss index 815cc64df..d704d1438 100644 --- a/app/assets/stylesheets/styles.scss +++ b/app/assets/stylesheets/styles.scss @@ -109,6 +109,9 @@ pzsh-topbar { background-color: #69b978 } +.w-min-content { + width: min-content; +} .rate { float: left; diff --git a/app/controllers/concerns/people_controller_concerns.rb b/app/controllers/concerns/people_controller_concerns.rb new file mode 100644 index 000000000..bb6fbafcc --- /dev/null +++ b/app/controllers/concerns/people_controller_concerns.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module PeopleControllerConcerns + private + + def set_nationality2 + if params.include?(:has_nationality2) && false?(params[:has_nationality2][:checked]) + params[:person][:nationality2] = nil + end + end +end diff --git a/app/controllers/people_controller.rb b/app/controllers/people_controller.rb index 142c273d7..d4638fd87 100644 --- a/app/controllers/people_controller.rb +++ b/app/controllers/people_controller.rb @@ -3,12 +3,15 @@ class PeopleController < CrudController include ExportController include ParamConverters + include PeopleControllerConcerns self.permitted_attrs = [:birthdate, :location, :marital_status, :updated_by, :name, :nationality, :nationality2, :title, :competence_notes, :company_id, :email, :department_id, :shortname, :picture, :picture_cache, - { person_roles_attributes: [:role_id, :person_role_level_id, - :percent, :id, :_destroy] }] + { person_roles_attributes: + [:role_id, :person_role_level_id, :percent, :id, :_destroy] }, + { language_skills_attributes: + [:language, :level, :certificate, :id, :_destroy] }] def show return export if format_odt? @@ -19,7 +22,7 @@ def show end def update - params[:person][:nationality2] = nil if false?(params[:has_nationality2]&.[](:checked)) + set_nationality2 super end diff --git a/app/helpers/role_form_helper.rb b/app/helpers/dynamic_form_helper.rb similarity index 82% rename from app/helpers/role_form_helper.rb rename to app/helpers/dynamic_form_helper.rb index 1b06d48b2..a78bb7d79 100644 --- a/app/helpers/role_form_helper.rb +++ b/app/helpers/dynamic_form_helper.rb @@ -1,9 +1,9 @@ # frozen_string_literal: true -module RoleFormHelper +module DynamicFormHelper # This method creates a link with `data-id` `data-fields` attributes. These attributes are used # to create new instances of the nested fields through Javascript. - def link_to_add_role(name, form, association) + def link_to_add_field(name, form, association) # Takes an object and creates a new instance of its associated model new_role = form.object.send(association).klass.new # Saves the unique ID of the object into a variable. @@ -13,7 +13,7 @@ def link_to_add_role(name, form, association) # child_index` is used to ensure the key of the associated array is unique, and that it matched # the value in the `data-id` attribute. fields = form.fields_for(association, new_role, child_index: id) do |builder| - render("#{association.to_s.singularize}_fields", person_role: builder) + render("#{association.to_s.singularize}_fields", f: builder) end @@ -23,7 +23,8 @@ def link_to_add_role(name, form, association) # We use `gsub("\n", "")` to remove anywhite space from the rendered partial. # The `id:` value needs to match the value used in `child_index: id`. link_to(name, '#', - { class: 'add_fields', 'data-action' => 'person-roles#addField', + { class: 'add_fields', 'data-action' => + 'dynamic-fields#addField lang-selection#setNewLangOption', data: { id: id, fields: fields.gsub("\n", '') } }) end end diff --git a/app/helpers/person_helper.rb b/app/helpers/person_helper.rb index 9b38823ad..fe8a3e6a9 100644 --- a/app/helpers/person_helper.rb +++ b/app/helpers/person_helper.rb @@ -37,4 +37,27 @@ def group_person_skills_by_category(person) PeopleSkill.core_competence.where(person_id: person.id) .group_by { |ps| ps.skill.category.parent } end + + def common_languages_translated + I18nData.languages('DE').collect do |language| + if LanguageList::LanguageInfo.find(language[0])&.common? + [language.first, "#{language.last} (#{language.first})"] + end + end.compact.sort_by(&:last) + end + + def sort_languages(languages) + mandatory_langs, optional_langs = languages.partition do |language| + uneditable_language?(language.language) + end + mandatory_langs.sort_by(&:language) + optional_langs.sort_by(&:language) + end + + def uneditable_language?(lang) + %w[DE EN FR].include?(lang) + end + + def language_skill_levels + %w[Keine A1 A2 B1 B2 C1 C2 Muttersprache] + end end diff --git a/app/javascript/controllers/person_roles_controller.js b/app/javascript/controllers/dynamic_fields_controller.js similarity index 100% rename from app/javascript/controllers/person_roles_controller.js rename to app/javascript/controllers/dynamic_fields_controller.js diff --git a/app/javascript/controllers/index.js b/app/javascript/controllers/index.js index 3b51ebc59..d08858d9b 100644 --- a/app/javascript/controllers/index.js +++ b/app/javascript/controllers/index.js @@ -7,15 +7,18 @@ import { application } from "./application" import DropdownController from "./dropdown_controller" application.register("dropdown", DropdownController) +import DynamicFieldsController from "./dynamic_fields_controller" +application.register("dynamic-fields", DynamicFieldsController) + import ImageUploadController from "./image_upload_controller" application.register("image-upload", ImageUploadController) +import LangSelectionController from "./lang_selection_controller" +application.register("lang-selection", LangSelectionController) + import NationalityTwoController from "./nationality_two_controller" application.register("nationality-two", NationalityTwoController) -import PersonRolesController from "./person_roles_controller" -application.register("person-roles", PersonRolesController) - import RemoteModalController from "./remote_modal_controller" application.register("remote-modal", RemoteModalController) diff --git a/app/javascript/controllers/lang_selection_controller.js b/app/javascript/controllers/lang_selection_controller.js new file mode 100644 index 000000000..2432c2a24 --- /dev/null +++ b/app/javascript/controllers/lang_selection_controller.js @@ -0,0 +1,42 @@ +import { Controller } from "@hotwired/stimulus" + +// Connects to data-controller="lang-selection" +export default class extends Controller { + connect() { + this.setOptionState(); + } + setOptionState() { + const languageSelects= document.getElementsByClassName("language-select"); + const selectedLanguages = getSelectedLanguages(); + for(const languageSelect of languageSelects) { + for(const option of languageSelect.options) { + //Deactivate option if it is not the current option of the dropdown but is selected in another dropdown + option.disabled = (selectedLanguages.includes(option.value)) && (languageSelect.selectedOptions[0].value !== option.value); + } + } + } + + setNewLangOption() { + const selectedLanguages = getSelectedLanguages(); + const language_selects = Array.from(document.getElementsByClassName("language-select")); + const language_select = language_selects[language_selects.length - 1] + //Remove newly added language since we dont want to skip that + selectedLanguages.pop(); + while(selectedLanguages.includes(language_select.selectedOptions[0].value)) { + language_select.selectedIndex++; + } + this.setOptionState(); + } +} + +function getSelectedLanguages() { + let selectedLanguages = []; + + const languageSelects= document.getElementsByClassName("language-select"); + for(const languageSelect of languageSelects) { + if(!["1", "true"].includes(languageSelect.closest(".nested-fields").querySelector('input[type="hidden"]').value)) { + selectedLanguages.push(languageSelect.selectedOptions[0].value) + } + } + return selectedLanguages; +} \ No newline at end of file diff --git a/app/models/person.rb b/app/models/person.rb index 410db5c80..65eb5dac8 100644 --- a/app/models/person.rb +++ b/app/models/person.rb @@ -38,6 +38,7 @@ class Person < ApplicationRecord has_many :expertise_topic_skill_values, dependent: :destroy has_many :expertise_topics, through: :expertise_topic_skill_values has_many :language_skills, dependent: :delete_all + accepts_nested_attributes_for :language_skills, allow_destroy: true has_many :person_roles, dependent: :destroy accepts_nested_attributes_for :person_roles, allow_destroy: true has_many :people_skills, dependent: :destroy diff --git a/app/views/people/_form.html.haml b/app/views/people/_form.html.haml index e3ede064e..628ffa2a6 100644 --- a/app/views/people/_form.html.haml +++ b/app/views/people/_form.html.haml @@ -10,25 +10,25 @@ %div.visually-hidden{"data-controller"=>"image-upload"}= form.file_field :picture, { accept: "image/", "data-action" => "image-upload#changeImage", id: "avatar-uploader" } = form.hidden_field :picture_cache %div.pe-5.col-xl-3.col-12 - %table + %table.w-100 %tbody %th.fw-normal Name %tr %td= form.text_field :name, class: "mw-100 form-control" %th.fw-normal Email %tr - %td= form.text_field :email, class: "mw-100, form-control" + %td= form.text_field :email, class: "mw-100 form-control" %th.fw-normal Abschluss %tr - %td= form.text_field :title, class: "mw-100, form-control" + %td= form.text_field :title, class: "mw-100 form-control" %th.fw-normal Funktionen %div = form.fields_for :person_roles do |person_role| %tr - %td= render "person_role_fields", person_role: person_role + %td= render "person_role_fields", f: person_role %tr - %td{"data-controller"=>"person-roles"} - = link_to_add_role "Neue Funktion", form, :person_roles + %td{"data-controller"=>"dynamic-fields"} + = link_to_add_field "Neue Funktion", form, :person_roles %th.fw-normal Organisationseinheit %tr %td= form.collection_select :department_id, Department.order(:name), :id, :name, {}, class: "form-select mw-100" @@ -40,7 +40,7 @@ %td= form.text_field :location, class: "form-control mw-100" %div.pe-5.col-xl-3.col-12 - %table + %table.w-100 %tbody %th.fw-normal Geburtsdatum %tr @@ -60,11 +60,14 @@ %th.fw-normal Kürzel %tr %td= form.text_field :shortname, class: "mw-100 form-control" - %div.col-xl-3.col-12.mw-100 + %div.col-xl-3.col-12{"data-controller"=>"lang-selection"} %div.fw-normal Sprachen - %div.border.border-dark-subtle.mt-1.p-2.rounded - - @person.language_skills.each do |language| - %div.mb-1= "#{language.language}: #{language.level} - #{language.certificate}" + %div.border.border-dark-subtle.mt-1.p-2.rounded.w-100 + = form.fields_for :language_skills, sort_languages(@person.language_skills) do |language_skill| + = render "language_skill_fields", f: language_skill + %div{"data-controller"=>"dynamic-fields"} + = link_to_add_field "Add Language", form, :language_skills + %div.mt-3 = form.submit :Speichern, { class: "btn btn-primary me-3 bg-skills-blue", id: "save-button" } = link_to "Abbrechen", person_path, { id: "cancel-button" } \ No newline at end of file diff --git a/app/views/people/_language_skill_fields.html.haml b/app/views/people/_language_skill_fields.html.haml new file mode 100644 index 000000000..b0a5205cb --- /dev/null +++ b/app/views/people/_language_skill_fields.html.haml @@ -0,0 +1,12 @@ +%div.mb-3.pb-3.border-bottom.border-black.nested-fields + = f.hidden_field :_destroy + %div= f.collection_select :language, common_languages_translated, :first, :last, {}, { class: "form-select mw-100 language-select", disabled: uneditable_language?(f.object.language), "data-action" => "lang-selection#setOptionState" } + %div.d-flex + %div.w-25.me-2 + %div Level + %div= f.collection_select :level, language_skill_levels, :itself, :itself, {}, class: "form-select mw-100 language-level-select" + %div.w-75 + %div Zertifikat + %div= f.text_field :certificate, class: "mw-100 form-control language-certificate-input" + - unless uneditable_language?(f.object.language) + %div{"data-controller"=>"dynamic-fields"}= link_to "Remove", "#", { class: "remove_fields", 'data-action' => 'dynamic-fields#removeField lang-selection#setOptionState' } \ No newline at end of file diff --git a/app/views/people/_person_role_fields.html.haml b/app/views/people/_person_role_fields.html.haml index 9b4b24cca..149bc546d 100644 --- a/app/views/people/_person_role_fields.html.haml +++ b/app/views/people/_person_role_fields.html.haml @@ -1,10 +1,10 @@ %div.border.border-dark-subtle.rounded.p-1.fw-light.nested-fields - = person_role.hidden_field :_destroy + = f.hidden_field :_destroy Rolle - = person_role.collection_select :role_id, Role.order(:name), :id, :name, {}, class: "form-select w-100 role-select" + = f.collection_select :role_id, Role.order(:name), :id, :name, {}, class: "form-select w-100 role-select" %div Stufe %div.d-flex.fw-light - = person_role.collection_select :person_role_level_id, PersonRoleLevel.order(:level), :id, :level, {}, class: "form-select w-50 me-1 role-level-select" - = person_role.number_field :percent, in: 0..200, step: 1, class: "form-control w-50 person-role-percent" - %div{"data-controller"=>"person-roles"}= link_to "Remove", "#", { class: "remove_fields", 'data-action' => 'person-roles#removeField' } \ No newline at end of file + = f.collection_select :person_role_level_id, PersonRoleLevel.order(:level), :id, :level, {}, class: "form-select w-50 me-1 role-level-select" + = f.number_field :percent, in: 0..200, step: 1, class: "form-control w-50 person-role-percent" + %div{"data-controller"=>"dynamic-fields"}= link_to "Remove", "#", { class: "remove_fields", 'data-action' => 'dynamic-fields#removeField' } \ No newline at end of file diff --git a/app/views/people/_profile.html.haml b/app/views/people/_profile.html.haml index 8b765db15..9d1b99fae 100644 --- a/app/views/people/_profile.html.haml +++ b/app/views/people/_profile.html.haml @@ -54,7 +54,13 @@ %div.col-xl-3.col-12 %div.fw-normal.text-gray Sprachen - %div.border.border-dark-subtle.mt-1.p-2.rounded - - @person.language_skills.each do |language| - %div.mb-1= "#{language.language}: #{language.level} - #{language.certificate}" + %div.border.border-dark-subtle.mt-1.p-2.rounded.w-min-content + %table + %tbody + - sort_languages(@person.language_skills).each do |language| + %tr + %td.p-2.pe-3.text-gray= language.language + %td.p-2.pe-3= image_tag("language-levels/#{language.level.downcase}.svg", alt: "Language level indicator") + - unless language.certificate.blank? + %td.p-2= image_tag("language-levels/icon_certificate.svg", alt: "Language certificate icon", title: language.certificate) = link_to "Show all", people_path, {"data-turbo"=>false} \ No newline at end of file diff --git a/spec/exporters/odt/cv_spec.rb b/spec/exporters/odt/cv_spec.rb index 34d46d7d8..93a14b3a2 100644 --- a/spec/exporters/odt/cv_spec.rb +++ b/spec/exporters/odt/cv_spec.rb @@ -55,7 +55,7 @@ it 'formats competence notes' do notes = Odt::Cv.new(people(:bob), {'anon' => 'false'}).send(:competence_notes_list)[:competence] - expect(notes).to eq('Java\n Ruby') + expect(notes).to eq("Java\nRuby") end end diff --git a/spec/features/core_competences_spec.rb b/spec/features/core_competences_spec.rb index 6ce6fa90c..a094c6a63 100644 --- a/spec/features/core_competences_spec.rb +++ b/spec/features/core_competences_spec.rb @@ -15,7 +15,7 @@ it 'should display competence notes and edit link correctly' do visit person_path(people(:alice)) - expect(page).to have_text('LaTex\n Puppet\n Bash') + expect(page).to have_text("LaTex Puppet Bash", normalize_ws: true) expect(page).to have_selector('#edit-link') end @@ -36,12 +36,13 @@ fill_in 'person_competence_notes', with: 'Hello World here' page.find('#cancel').click - expect(page).to have_text('LaTex\n Puppet\n Bash') + expect(page).to have_text("LaTex Puppet Bash", normalize_ws: true) end it 'should display skill with same parent category in same row with divider' do visit person_path(people(:alice)) expect(page).to have_selector('.circle-divider') - expect(page).to have_selector('.core-competence', count: 1, text: "Software-Engineering\nRails\nember") + expect(page).to have_selector('.core-competence', count: 1, + text: "Software-Engineering Rails ember", normalize_ws: true) end end diff --git a/spec/features/people_spec.rb b/spec/features/people_spec.rb index 23292e025..10397f687 100644 --- a/spec/features/people_spec.rb +++ b/spec/features/people_spec.rb @@ -61,6 +61,21 @@ def fill_out_person_form select ISO3166::Country["US"].translations[I18n.locale], from: 'person_nationality2' select I18n.t('marital_statuses.married'), from: 'person_marital_status' fill_in 'person_shortname', with: 'bb' + + page.all(".add_fields").last.click + language_select = page.all('.language-select').last + language_level_select = page.all('.language-level-select').last + language_certificate_input = page.all('.language-certificate-input').last + select 'FI', from: language_select[:id] + select 'B2', from: language_level_select[:id] + fill_in language_certificate_input[:id], with: 'Some Certificate' + end + + def add_language(language) + #Create new language. + page.all(".add_fields").last.click + #Select language from dropdown in newly created language. + select language, from: page.all('.language-select').last[:id] end describe 'Edit person', type: :feature, js: true do @@ -102,6 +117,20 @@ def fill_out_person_form bob.nationality2.nil? ? (expect(page).not_to have_select('person_nationality2')) : (expect(page).to have_select('person_nationality2', selected: ISO3166::Country[bob.nationality2].translations[I18n.locale])) expect(page).to have_select('person_marital_status', selected: I18n.t("marital_statuses.#{bob.marital_status}")) expect(page).to have_field('person_shortname', with: bob.shortname) + + language_skills = bob.language_skills + language_selects = page.all('.language-select') + language_level_selects = page.all('.language-level-select') + language_certificate_inputs = page.all('.language-certificate-input') + language_selects.each_with_index do |language_select, i| + expect(language_select.value).to eql(language_skills[i].language) + end + language_level_selects.each_with_index do |language_level_select, i| + expect(language_level_select.value).to eql(language_skills[i].level) + end + language_certificate_inputs.each_with_index do |language_certificate_input, i| + expect(language_certificate_input.value).to eql(language_skills[i].certificate) + end end it 'should edit and save changes' do @@ -130,6 +159,10 @@ def fill_out_person_form expect(edited_person.nationality2).to eql('US') expect(edited_person.marital_status).to eql('married') expect(edited_person.shortname).to eql('bb') + edited_language_skill = edited_person.language_skills.last + expect(edited_language_skill.language).to eql('FI') + expect(edited_language_skill.level).to eql('B2') + expect(edited_language_skill.certificate).to eql('Some Certificate') end it 'should edit and cancel without saving' do @@ -140,5 +173,43 @@ def fill_out_person_form page.find('#cancel-button').click expect(person.attributes).to eql(Person.first.attributes) end + + it('should correctly disable languages if they are selected, changed, created or deleted') { + bob = people(:bob) + visit person_path(bob) + page.find('#edit-button').click + + add_language('JA') + add_language('ZH') + + lang_selects = page.all('.language-select') + #ZH + lang_select = lang_selects[-1] + #JA + lang_select2 = lang_selects[-2] + + #Check if currently selected language is still enabled + expect(lang_select.find('option', text: 'ZH')).not_to be_disabled + #Check if some other language is enabled + expect(lang_select.find('option', text: 'UR')).not_to be_disabled + #Check if language selected in another dropdown is disabled + expect(lang_select.find('option', text: 'JA')).to be_disabled + + expect(lang_select2.find('option', text: 'JA')).not_to be_disabled + expect(lang_select.find('option', text: 'UR')).not_to be_disabled + expect(lang_select2.find('option', text: 'ZH')).to be_disabled + + #Change language selected in dropdown + select 'KO', from: lang_select[:id] + #Old language selected in dropdown should not be disabled anymore + expect(lang_select2.find('option', text: 'ZH')).not_to be_disabled + #New language selected should be disabled + expect(lang_select2.find('option', text: 'KO')).to be_disabled + + #Delete language + page.all('.remove_fields')[-1].click + #Language should now be re-enabled + expect(lang_select2.find('option', text: 'KO')).not_to be_disabled + } end end diff --git a/spec/fixtures/people.yml b/spec/fixtures/people.yml index ecbdf317c..dff9eb518 100644 --- a/spec/fixtures/people.yml +++ b/spec/fixtures/people.yml @@ -33,7 +33,9 @@ bob: nationality2: SE roles: [software-engineer] title: BSc in Cleaning - competence_notes: Java\n Ruby + competence_notes: | + Java + Ruby company: firma email: bob@example.com department: sys @@ -47,7 +49,10 @@ alice: nationality: AU roles: [system-engineer] title: MSc in writing - competence_notes: LaTex\n Puppet\n Bash + competence_notes: | + LaTex + Puppet + Bash company: partner email: alice@example.com department: sys @@ -61,7 +66,10 @@ ken: nationality: AU roles: [system-engineer] title: MA in sending silly memes - competence_notes: LaTex\n Puppet\n Bash + competence_notes: | + LaTex + Puppet + Bash company: partner email: kenthemaster@testi.com department: ux @@ -74,7 +82,9 @@ charlie: roles: [system-engineer] company: partner title: MSc in Networking - competence_notes: C\n Bash + competence_notes: | + C + Bash email: charlie@example.com department: sys wally: @@ -86,7 +96,13 @@ wally: roles: [software-engineer] company: partner title: Full-Stack Developer - competence_notes: C\n Bash\n Java\n Ruby\n LaTex\n Puppet + competence_notes: | + C + Bash + Java + Ruby + LaTex + Puppet email: wally@example.com department: sys hope: