From fffaf7bea0e2dfed3de5fb73ac17d9d6ecfa1632 Mon Sep 17 00:00:00 2001 From: Eniko Tot <eniko.tot@codebrewery.hu> Date: Thu, 11 Jan 2024 16:51:00 +0100 Subject: [PATCH] [WIP] More refactoring in ApplyForm and Convert checkbox and textarea elements --- .../Bootstrap5/Joblist/ApplyForm.html | 459 ++++++++---------- 1 file changed, 214 insertions(+), 245 deletions(-) diff --git a/Resources/Private/Templates/Bootstrap5/Joblist/ApplyForm.html b/Resources/Private/Templates/Bootstrap5/Joblist/ApplyForm.html index c96a9686..8cc7dbc3 100644 --- a/Resources/Private/Templates/Bootstrap5/Joblist/ApplyForm.html +++ b/Resources/Private/Templates/Bootstrap5/Joblist/ApplyForm.html @@ -223,288 +223,255 @@ </f:if> <f:if condition="!{job.applyExternalLink}"> <div class="row default-content-element"> - <div class="col-md-10 col-sm-10 col-xs-12"> + <f:if condition="{job}"> + <h2> + <f:translate key="frontend.apply.applyAsNow" arguments="{0: '{job.title}'}" /> + </h2> + </f:if> + <f:form action="apply" class="was-validated" id="apply" controller="Joblist" method="post" name="applyData" + object="{applyData}" enctype="multipart/form-data"> <f:if condition="{job}"> - <div class="default-content-element"> - <h2> - <f:translate key="frontend.apply.applyAsNow" arguments="{0: '{job.title}'}" /> - </h2> - </div> + <f:then> + <f:form.hidden property="job" value="{job.uid}" /> + <f:form.hidden property="jobId" value="{job.jobId}" /> + <f:form.hidden property="jobTitle" value="{job.title}" /> + </f:then> </f:if> - <f:form action="apply" class="was-validated" id="apply" controller="Joblist" method="post" - name="applyData" object="{applyData}" enctype="multipart/form-data"> - <f:if condition="{job}"> - <f:then> - <f:form.hidden property="job" value="{job.uid}" /> - <f:form.hidden property="jobId" value="{job.jobId}" /> - <f:form.hidden property="jobTitle" value="{job.title}" /> - </f:then> - </f:if> - <input type="hidden" name="tx_sgjobs_jobapplication[folderName]" value="{folderName}" /> + <input type="hidden" name="tx_sgjobs_jobapplication[folderName]" value="{folderName}" /> - <f:if condition="{internalError}"> - <ul class="sg-jobs-validation-error parsley-errors-list filled"> - <li class="parsley-required"> - <f:translate key="frontend.apply.error.general" /> - : {internalError} - </li> - </ul> - </f:if> - <div class="row"> - <f:if condition="!{job}"> - <div class="col"> - <f:render section="formLabel" - arguments="{label-for: 'company', label-text: 'company'}" /> - <f:form.select property="company" multiple="0" size="1" id="apply-company" - class="form-select" options="{companies}" optionLabelField="city" - optionValueField="uid" - prependOptionLabel="{f:translate(key:'frontend.apply.country.empty')}" - required="required" /> - <f:render section="formValidation" arguments="{form-field: 'company'}" /> - </div> - </f:if> - <div class="col"> - <f:render section="formLabel" arguments="{label-for: 'gender', label-text: 'gender'}" /> - <f:form.select property="gender" id="apply-gender" class="form-select" - options="{male: '{f:translate(key: \'frontend.apply.gender.male\')}', female: '{f:translate(key: \'frontend.apply.gender.female\')}', other: '{f:translate(key: \'frontend.apply.gender.other\')}'}" /> - <f:render section="formValidation" arguments="{form-field: 'gender'}" /> - </div> - </div> - <div class="row"> - <div class="col"> - <f:render section="formTextField" - arguments="{field-id: 'firstName', field-text: 'first_name', required: 'required'}" /> - </div> - - <div class="col"> - <f:render section="formTextField" - arguments="{field-id: 'lastName', field-text: 'last_name', required: 'required'}" /> - </div> - </div> - <div class="row"> - <div class="col-6"> - <f:render section="formTextField" - arguments="{field-id: 'street', field-text: 'street', required: 'required'}" /> - </div> - - <div class="col-4"> - <f:render section="formTextField" - arguments="{field-id: 'city', field-text: 'city', required: 'required'}" /> - </div> - - <div class="col-2"> - <f:render section="formTextField" - arguments="{field-id: 'zip', field-text: 'zip', required: 'required'}" /> - </div> - </div> - <div class="row"> + <f:if condition="{internalError}"> + <ul class="sg-jobs-validation-error parsley-errors-list filled"> + <li class="parsley-required"> + <f:translate key="frontend.apply.error.general" /> + : {internalError} + </li> + </ul> + </f:if> + <div class="row"> + <f:if condition="!{job}"> <div class="col"> - <f:render section="formLabel" arguments="{label-for: 'country', label-text: 'country'}" /> - <f:form.countrySelect value="DE" property="country" id="apply-country" class="form-select" + <f:render section="formLabel" arguments="{label-for: 'company', label-text: 'company'}" /> + <f:form.select property="company" multiple="0" size="1" id="apply-company" + class="form-select" options="{companies}" optionLabelField="city" optionValueField="uid" + prependOptionLabel="{f:translate(key:'frontend.apply.country.empty')}" required="required" /> - <f:render section="formValidation" arguments="{form-field: 'county'}" /> - </div> - - <div class="col"> - <f:render section="formLabel" - arguments="{label-for: 'nationality', label-text: 'nationality'}" /> - <f:form.countrySelect value="DE" property="nationality" id="apply-nationality" - class="form-select" required="required" /> - <f:render section="formValidation" arguments="{form-field: 'nationality'}" /> + <f:render section="formValidation" arguments="{form-field: 'company'}" /> </div> + </f:if> + <div class="col"> + <f:render section="formLabel" arguments="{label-for: 'gender', label-text: 'gender'}" /> + <f:form.select property="gender" id="apply-gender" class="form-select" + options="{male: '{f:translate(key: \'frontend.apply.gender.male\')}', female: '{f:translate(key: \'frontend.apply.gender.female\')}', other: '{f:translate(key: \'frontend.apply.gender.other\')}'}" /> + <f:render section="formValidation" arguments="{form-field: 'gender'}" /> </div> - <div class="row"> - <div class="col"> - <f:render section="formTextField" - arguments="{field-id: 'education', field-text: 'education', required: 'required'}" /> - </div> - - <div class="col"> - <f:render section="formLabel" - arguments="{label-for: 'birthDate', label-text: 'birthDate'}" /> - <f:form.textfield property="birthDate" id="apply-birthDate" class="form-control" - placeholder="{f:translate(key:'frontend.apply.birthDate')}" required="required" /> - <f:render section="formValidation" arguments="{form-field: 'birthDate'}" /> - </div> + </div> + <div class="row"> + <f:render section="formTextField" + arguments="{wrapper-class: 'col', field-id: 'firstName', field-text: 'first_name', required: 'required'}" /> + <f:render section="formTextField" + arguments="{wrapper-class: 'col', field-id: 'lastName', field-text: 'last_name', required: 'required'}" /> + </div> + <div class="row"> + <f:render section="formTextField" + arguments="{wrapper-class: 'col-6', field-id: 'street', field-text: 'street', required: 'required'}" /> + <f:render section="formTextField" + arguments="{wrapper-class: 'col-4', field-id: 'city', field-text: 'city', required: 'required'}" /> + <f:render section="formTextField" + arguments="{wrapper-class: 'col-2', field-id: 'zip', field-text: 'zip', required: 'required'}" /> + </div> + <div class="row"> + <div class="col"> + <f:render section="formLabel" arguments="{label-for: 'country', label-text: 'country'}" /> + <f:form.countrySelect value="DE" property="country" id="apply-country" class="form-select" + required="required" /> + <f:render section="formValidation" arguments="{form-field: 'county'}" /> </div> - <div class="row"> - <div class="col"> - <f:render section="formTextField" - arguments="{field-id: 'phone', field-text: 'phone', required: 'required'}" /> - </div> - <div class="col"> - <f:render section="formTextField" arguments="{field-id: 'mobile', field-text: 'mobile'}" /> - </div> + <div class="col"> + <f:render section="formLabel" + arguments="{label-for: 'nationality', label-text: 'nationality'}" /> + <f:form.countrySelect value="DE" property="nationality" id="apply-nationality" + class="form-select" required="required" /> + <f:render section="formValidation" arguments="{form-field: 'nationality'}" /> </div> - <div class="row"> - <div class="col-6"> - <f:render section="formLabel" arguments="{label-for: 'email', label-text: 'email'}" /> - <f:form.textfield type="email" property="email" id="apply-email" class="form-control" - placeholder="{f:translate(key:'frontend.apply.email')}" required="required" /> - <f:render section="formValidation" arguments="{form-field: 'email'}" /> - </div> + </div> + <div class="row"> + <f:render section="formTextField" + arguments="{wrapper-class: 'col', field-id: 'education', field-text: 'education', required: 'required'}" /> + <div class="col"> + <f:render section="formLabel" arguments="{label-for: 'birthDate', label-text: 'birthDate'}" /> + <f:form.textfield type="date" property="birthDate" id="apply-birthDate" class="form-control" + placeholder="{f:translate(key:'frontend.apply.birthDate')}" required="required" /> + <f:render section="formValidation" arguments="{form-field: 'birthDate'}" /> </div> - <div class="row"> - <div class="col"> - <div class="form-group jobs-upload-group"> - <label for="apply-cover-letter" class="form-label filled"> - <f:translate key="frontend.apply.cover_letter" /> - ( - <f:translate key="frontend.apply.allowed_file_extensions" /> - {allowedFileExtensions}) - </label> - <div class="coverLetter-upload jobs-upload" data-max-file-amount="1" - data-valid-file-extensions="{settings.fileUpload.fileTypes}" - data-max-file-size="{maxFileSize}" data-pid="{storagePid}" - data-inner-text="{f:translate(key: 'frontend.DropFiles', extensionName: 'sg_jobs')}" - data-cancel-upload="{f:translate(key: 'frontend.CancelUpload', extensionName: 'sg_jobs')}" - data-remove-file="{f:translate(key: 'frontend.RemoveFile', extensionName: 'sg_jobs')}" - data-file-type-error="{f:translate(key: 'frontend.FileType', extensionName: 'sg_jobs')}" - data-upload-ajax="{sgajax:uri.ajax(extensionName: 'SgJobs', controller: 'Ajax\\Upload', action: 'uploadCoverletter', format: 'json', parameters: '{pageId: storagePid}')}"> - </div> - <f:if condition="{coverLetter.name}"> - <p class="help-block"> - Aktuell: {coverLetter.name} - <input type="hidden" - name="tx_sgjobs_jobapplication[applyData][coverLetter][submittedFile][name]" - value="{coverLetter.name}" /> - <input type="hidden" - name="tx_sgjobs_jobapplication[applyData][coverLetter][submittedFile][type]" - value="{coverLetter.type}" /> - <input type="hidden" - name="tx_sgjobs_jobapplication[applyData][coverLetter][submittedFile][tmp_name]" - value="{coverLetter.tmp_name}" /> - <input type="hidden" - name="tx_sgjobs_jobapplication[applyData][coverLetter][submittedFile][error]" - value="0" /> - <input type="hidden" - name="tx_sgjobs_jobapplication[applyData][coverLetter][submittedFile][size]" - value="{coverLetter.size}" /> - </p> - </f:if> - <f:render section="formValidation" arguments="{form-field: 'coverLetter'}" /> - </div> - </div> + </div> + <div class="row"> + <f:render section="formTextField" + arguments="{wrapper-class: 'col', field-id: 'phone', field-text: 'phone', required: 'required'}" /> + <f:render section="formTextField" + arguments="{wrapper-class: 'col', field-id: 'mobile', field-text: 'mobile'}" /> + </div> + <div class="row"> + <div class="col-6"> + <f:render section="formLabel" arguments="{label-for: 'email', label-text: 'email'}" /> + <f:form.textfield type="email" property="email" id="apply-email" class="form-control" + placeholder="{f:translate(key:'frontend.apply.email')}" required="required" /> + <f:render section="formValidation" arguments="{form-field: 'email'}" /> </div> - <div class="col-xs-12"> + </div> + <div class="row"> + <div class="col"> <div class="form-group jobs-upload-group"> - <label for="apply-cv" class="form-label filled"> - <f:translate key="frontend.apply.cv" /> + <label for="apply-cover-letter" class="form-label filled"> + <f:translate key="frontend.apply.cover_letter" /> ( <f:translate key="frontend.apply.allowed_file_extensions" /> {allowedFileExtensions}) </label> - - <div class="cv-upload jobs-upload" data-max-file-amount="1" + <div class="coverLetter-upload jobs-upload" data-max-file-amount="1" data-valid-file-extensions="{settings.fileUpload.fileTypes}" data-max-file-size="{maxFileSize}" data-pid="{storagePid}" data-inner-text="{f:translate(key: 'frontend.DropFiles', extensionName: 'sg_jobs')}" data-cancel-upload="{f:translate(key: 'frontend.CancelUpload', extensionName: 'sg_jobs')}" data-remove-file="{f:translate(key: 'frontend.RemoveFile', extensionName: 'sg_jobs')}" data-file-type-error="{f:translate(key: 'frontend.FileType', extensionName: 'sg_jobs')}" - data-upload-ajax="{sgajax:uri.ajax(extensionName: 'SgJobs', controller: 'Ajax\\Upload', action: 'uploadCv', format: 'json', parameters: '{pageId: storagePid}')}"> + data-upload-ajax="{sgajax:uri.ajax(extensionName: 'SgJobs', controller: 'Ajax\\Upload', action: 'uploadCoverletter', format: 'json', parameters: '{pageId: storagePid}')}"> </div> - <f:if condition="{cv.name}"> + <f:if condition="{coverLetter.name}"> <p class="help-block"> - Aktuell: {cv.name} - - <f:comment> - <!-- Important, due to a fluid cache issue with the fluid syntax--> - </f:comment> + Aktuell: {coverLetter.name} <input type="hidden" - name="tx_sgjobs_jobapplication[applyData][cv][submittedFile][name]" - value="{cv.name}" /> + name="tx_sgjobs_jobapplication[applyData][coverLetter][submittedFile][name]" + value="{coverLetter.name}" /> <input type="hidden" - name="tx_sgjobs_jobapplication[applyData][cv][submittedFile][type]" - value="{cv.type}" /> + name="tx_sgjobs_jobapplication[applyData][coverLetter][submittedFile][type]" + value="{coverLetter.type}" /> <input type="hidden" - name="tx_sgjobs_jobapplication[applyData][cv][submittedFile][tmp_name]" - value="{cv.tmp_name}" /> + name="tx_sgjobs_jobapplication[applyData][coverLetter][submittedFile][tmp_name]" + value="{coverLetter.tmp_name}" /> <input type="hidden" - name="tx_sgjobs_jobapplication[applyData][cv][submittedFile][error]" + name="tx_sgjobs_jobapplication[applyData][coverLetter][submittedFile][error]" value="0" /> <input type="hidden" - name="tx_sgjobs_jobapplication[applyData][cv][submittedFile][size]" - value="{cv.size}" /> + name="tx_sgjobs_jobapplication[applyData][coverLetter][submittedFile][size]" + value="{coverLetter.size}" /> </p> </f:if> - - <f:render section="formValidation" arguments="{form-field: 'cv'}" /> + <f:render section="formValidation" arguments="{form-field: 'coverLetter'}" /> </div> </div> + </div> + <div class="col-xs-12"> + <div class="form-group jobs-upload-group"> + <label for="apply-cv" class="form-label filled"> + <f:translate key="frontend.apply.cv" /> + ( + <f:translate key="frontend.apply.allowed_file_extensions" /> + {allowedFileExtensions}) + </label> - <div class="col-xs-12"> - <div class="form-group jobs-upload-group"> - <label for="apply-certificate" class="form-label filled"> - <f:translate key="frontend.apply.certificate" /> - ( - <f:translate key="frontend.apply.allowed_file_extensions" /> - {allowedFileExtensions}) - </label> - - <div class="certificate-upload jobs-upload" data-max-file-amount="1" - data-valid-file-extensions="{settings.fileUpload.fileTypes}" - data-max-file-size="{maxFileSize}" data-pid="{storagePid}" - data-inner-text="{f:translate(key: 'frontend.DropFiles', extensionName: 'sg_jobs')}" - data-cancel-upload="{f:translate(key: 'frontend.CancelUpload', extensionName: 'sg_jobs')}" - data-remove-file="{f:translate(key: 'frontend.RemoveFile', extensionName: 'sg_jobs')}" - data-file-type-error="{f:translate(key: 'frontend.FileType', extensionName: 'sg_jobs')}" - data-upload-ajax="{sgajax:uri.ajax(extensionName: 'SgJobs', controller: 'Ajax\\Upload', action: 'uploadCv', format: 'json', parameters: '{pageId: storagePid}')}"> - </div> - <f:if condition="{certificate.name}"> - <p class="help-block"> - Aktuell: {certificate.name} + <div class="cv-upload jobs-upload" data-max-file-amount="1" + data-valid-file-extensions="{settings.fileUpload.fileTypes}" + data-max-file-size="{maxFileSize}" data-pid="{storagePid}" + data-inner-text="{f:translate(key: 'frontend.DropFiles', extensionName: 'sg_jobs')}" + data-cancel-upload="{f:translate(key: 'frontend.CancelUpload', extensionName: 'sg_jobs')}" + data-remove-file="{f:translate(key: 'frontend.RemoveFile', extensionName: 'sg_jobs')}" + data-file-type-error="{f:translate(key: 'frontend.FileType', extensionName: 'sg_jobs')}" + data-upload-ajax="{sgajax:uri.ajax(extensionName: 'SgJobs', controller: 'Ajax\\Upload', action: 'uploadCv', format: 'json', parameters: '{pageId: storagePid}')}"> + </div> + <f:if condition="{cv.name}"> + <p class="help-block"> + Aktuell: {cv.name} - <f:comment> - <!-- Important, due to a fluid cache issue with the fluid syntax--> - </f:comment> - <input type="hidden" - name="tx_sgjobs_jobapplication[applyData][certificate][submittedFile][name]" - value="{certificate.name}" /> - <input type="hidden" - name="tx_sgjobs_jobapplication[applyData][certificate][submittedFile][type]" - value="{certificate.type}" /> - <input type="hidden" - name="tx_sgjobs_jobapplication[applyData][certificate][submittedFile][tmp_name]" - value="{certificate.tmp_name}" /> - <input type="hidden" - name="tx_sgjobs_jobapplication[applyData][certificate][submittedFile][error]" - value="0" /> - <input type="hidden" - name="tx_sgjobs_jobapplication[applyData][certificate][submittedFile][size]" - value="{certificate.size}" /> - </p> - </f:if> + <f:comment> + <!-- Important, due to a fluid cache issue with the fluid syntax--> + </f:comment> + <input type="hidden" name="tx_sgjobs_jobapplication[applyData][cv][submittedFile][name]" + value="{cv.name}" /> + <input type="hidden" name="tx_sgjobs_jobapplication[applyData][cv][submittedFile][type]" + value="{cv.type}" /> + <input type="hidden" + name="tx_sgjobs_jobapplication[applyData][cv][submittedFile][tmp_name]" + value="{cv.tmp_name}" /> + <input type="hidden" + name="tx_sgjobs_jobapplication[applyData][cv][submittedFile][error]" value="0" /> + <input type="hidden" name="tx_sgjobs_jobapplication[applyData][cv][submittedFile][size]" + value="{cv.size}" /> + </p> + </f:if> - <f:render section="formValidation" arguments="{form-field: 'certificate'}" /> - </div> + <f:render section="formValidation" arguments="{form-field: 'cv'}" /> </div> + </div> - <div class="col-xs-12"> - <div class="form-group"> - <label for="apply-message" class="form-label"> - <f:translate key="frontend.apply.message" /> - </label> - <f:form.textarea class="form-control" rows="10" property="message" id="apply-message" - placeholder="{f:translate(key:'frontend.apply.message')}" /> - </div> + <div class="col-xs-12"> + <div class="form-group jobs-upload-group"> + <label for="apply-certificate" class="form-label filled"> + <f:translate key="frontend.apply.certificate" /> + ( + <f:translate key="frontend.apply.allowed_file_extensions" /> + {allowedFileExtensions}) + </label> - <div class="form-group"> - <label> - <f:form.checkbox id="privacy-policy" property="privacyPolicy" value="1" /> - <f:format.raw> - <f:translate key="frontend.apply.privacyPolicy" - arguments="{0: '{f:render(section:\'privacyPolicyCheckboxLink\')}'}" /> - </f:format.raw> - </label> - <f:render section="formValidation" arguments="{form-field: 'privacyPolicy'}" /> + <div class="certificate-upload jobs-upload" data-max-file-amount="1" + data-valid-file-extensions="{settings.fileUpload.fileTypes}" + data-max-file-size="{maxFileSize}" data-pid="{storagePid}" + data-inner-text="{f:translate(key: 'frontend.DropFiles', extensionName: 'sg_jobs')}" + data-cancel-upload="{f:translate(key: 'frontend.CancelUpload', extensionName: 'sg_jobs')}" + data-remove-file="{f:translate(key: 'frontend.RemoveFile', extensionName: 'sg_jobs')}" + data-file-type-error="{f:translate(key: 'frontend.FileType', extensionName: 'sg_jobs')}" + data-upload-ajax="{sgajax:uri.ajax(extensionName: 'SgJobs', controller: 'Ajax\\Upload', action: 'uploadCv', format: 'json', parameters: '{pageId: storagePid}')}"> </div> + <f:if condition="{certificate.name}"> + <p class="help-block"> + Aktuell: {certificate.name} + + <f:comment> + <!-- Important, due to a fluid cache issue with the fluid syntax--> + </f:comment> + <input type="hidden" + name="tx_sgjobs_jobapplication[applyData][certificate][submittedFile][name]" + value="{certificate.name}" /> + <input type="hidden" + name="tx_sgjobs_jobapplication[applyData][certificate][submittedFile][type]" + value="{certificate.type}" /> + <input type="hidden" + name="tx_sgjobs_jobapplication[applyData][certificate][submittedFile][tmp_name]" + value="{certificate.tmp_name}" /> + <input type="hidden" + name="tx_sgjobs_jobapplication[applyData][certificate][submittedFile][error]" + value="0" /> + <input type="hidden" + name="tx_sgjobs_jobapplication[applyData][certificate][submittedFile][size]" + value="{certificate.size}" /> + </p> + </f:if> + + <f:render section="formValidation" arguments="{form-field: 'certificate'}" /> </div> - <button type="submit" class="btn btn-lg btn-warning">{f:translate(key:'frontend.applyNow')} <i - class="fa fa-paper-plane" aria-hidden="true"></i></button> - </f:form> - </div> + </div> + + <div class="row mb-4"> + <div class="col"> + <f:render section="formLabel" arguments="{label-for: 'message', label-text: 'message'}" /> + <f:form.textarea class="form-control" rows="10" property="message" id="apply-message" + placeholder="{f:translate(key:'frontend.apply.message')}" /> + </div> + </div> + <div class="form-check"> + <f:form.checkbox class="form-check-input" id="privacy-policy" property="privacyPolicy" value="1" + additionalAttributes="{required: 'required'}" /> + <label class="form-check-label" for="privacy-policy"> + <f:format.raw> + <f:translate key="frontend.apply.privacyPolicy" + arguments="{0: '{f:render(section:\'privacyPolicyCheckboxLink\')}'}" /> + </f:format.raw> + </label> + <f:render section="formValidation" arguments="{form-field: 'privacyPolicy'}" /> + </div> + <button type="submit" class="btn btn-lg btn-warning">{f:translate(key:'frontend.applyNow')} <i + class="fa fa-paper-plane" aria-hidden="true"></i></button> + </f:form> </div> </f:if> @@ -598,12 +565,14 @@ <f:section name="formTextField"> - <label for="apply-{field-id}" class="form-label"> - <f:translate key="frontend.apply.{field-text}" /> - </label> - <f:form.textfield property="{field-id}" id="apply-{field-id}" class="form-control" - placeholder="{f:translate(key:'frontend.apply.{field-text}')}" required="{required}" /> - <f:render section="formValidation" arguments="{form-field: '{field-id}'}" /> + <div class="{wrapper-class}"> + <label for="apply-{field-id}" class="form-label"> + <f:translate key="frontend.apply.{field-text}" /> + </label> + <f:form.textfield property="{field-id}" id="apply-{field-id}" class="form-control" + placeholder="{f:translate(key:'frontend.apply.{field-text}')}" required="{required}" /> + <f:render section="formValidation" arguments="{form-field: '{field-id}'}" /> + </div> </f:section> <f:section name="formValidation"> -- GitLab